import { html, PropertyValues, unsafeCSS } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { BaseElement } from "../../core/base-element.ts";
import { collapsibleStyles } from "./styles.ts";
/**
* CTCollapsible - Single collapsible section with trigger and content
*
* @element ct-collapsible
*
* @attr {boolean} open - Whether the collapsible is open
* @attr {boolean} disabled - Whether the collapsible is disabled
*
* @slot trigger - Clickable trigger element
* @slot - Default slot for collapsible content
*
* @fires ct-toggle - Fired on open/close with detail: { open }
*
* @example
*
*
* Hidden content revealed here
*
*/
export class CTCollapsible extends BaseElement {
static override properties = {
open: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
};
static override styles = unsafeCSS(collapsibleStyles);
declare open: boolean;
declare disabled: boolean;
private _contentWrapper: HTMLElement | null = null;
private _content: HTMLElement | null = null;
private _triggerSlot: HTMLSlotElement | null = null;
private _triggerWrapperEl: HTMLElement | null = null;
constructor() {
super();
this.open = false;
this.disabled = false;
}
get contentWrapper(): HTMLElement | null {
if (!this._contentWrapper) {
this._contentWrapper = this.shadowRoot?.querySelector(
".content-wrapper",
) as HTMLElement | null;
}
return this._contentWrapper;
}
get content(): HTMLElement | null {
if (!this._content) {
this._content = this.shadowRoot?.querySelector(".content") as
| HTMLElement
| null;
}
return this._content;
}
get triggerSlot(): HTMLSlotElement | null {
if (!this._triggerSlot) {
this._triggerSlot = this.shadowRoot?.querySelector(
'slot[name="trigger"]',
) as HTMLSlotElement | null;
}
return this._triggerSlot;
}
get triggerWrapperEl(): HTMLElement | null {
if (!this._triggerWrapperEl) {
this._triggerWrapperEl = this.shadowRoot?.querySelector(
".trigger-wrapper",
) as HTMLElement | null;
}
return this._triggerWrapperEl;
}
override connectedCallback() {
super.connectedCallback();
// Set up event delegation for trigger clicks
this.addEventListener("click", this.handleClick);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("click", this.handleClick);
}
override firstUpdated() {
// Cache references
this._contentWrapper = this.shadowRoot?.querySelector(".content-wrapper") as
| HTMLElement
| null;
this._content = this.shadowRoot?.querySelector(".content") as
| HTMLElement
| null;
this._triggerSlot = this.shadowRoot?.querySelector(
'slot[name="trigger"]',
) as HTMLSlotElement | null;
this._triggerWrapperEl = this.shadowRoot?.querySelector(
".trigger-wrapper",
) as HTMLElement | null;
this.setupTriggerHandlers();
this.updateContentHeight();
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has("open")) {
this.updateContentHeight();
this.updateTriggerAttributes();
}
if (changedProperties.has("disabled")) {
this.updateTriggerAttributes();
}
}
override render() {
const classes = {
collapsible: true,
open: this.open,
disabled: this.disabled,
};
return html`
`;
}
private handleSlotChange = () => {
this.setupTriggerHandlers();
};
private setupTriggerHandlers(): void {
if (!this.triggerSlot) return;
const triggerElements = this.triggerSlot.assignedElements();
triggerElements.forEach((element: Element) => {
// Mark trigger elements with appropriate attributes
if (element instanceof HTMLElement) {
element.setAttribute("role", "button");
element.setAttribute("aria-expanded", String(this.open));
element.setAttribute("aria-controls", "collapsible-content");
if (this.disabled) {
element.setAttribute("aria-disabled", "true");
} else {
element.removeAttribute("aria-disabled");
}
element.style.cursor = this.disabled ? "not-allowed" : "pointer";
}
});
}
private updateTriggerAttributes(): void {
if (!this.triggerSlot) return;
const triggerElements = this.triggerSlot.assignedElements();
triggerElements.forEach((element: Element) => {
if (element instanceof HTMLElement) {
element.setAttribute("aria-expanded", String(this.open));
if (this.disabled) {
element.setAttribute("aria-disabled", "true");
} else {
element.removeAttribute("aria-disabled");
}
element.style.cursor = this.disabled ? "not-allowed" : "pointer";
}
});
}
private handleClick = (event: Event): void => {
// Check if click came from trigger slot or wrapper/indicator
const path = event.composedPath();
if (this.disabled) return;
let clickedTrigger = false;
if (this.triggerSlot) {
const triggerElements = this.triggerSlot.assignedElements();
clickedTrigger = path.some((el) =>
triggerElements.includes(el as Element)
);
}
if (!clickedTrigger && this.triggerWrapperEl) {
clickedTrigger = path.includes(this.triggerWrapperEl);
}
if (clickedTrigger) {
event.preventDefault();
this.toggle();
}
};
private updateContentHeight(): void {
if (!this.contentWrapper || !this.content) return;
if (this.open) {
// Get the actual height of the content
const height = this.content.scrollHeight;
(this.contentWrapper as HTMLElement).style.height = `${height}px`;
} else {
(this.contentWrapper as HTMLElement).style.height = "0px";
}
}
/**
* Toggle the open state
*/
toggle(): void {
if (this.disabled) return;
this.open = !this.open;
// Emit toggle event
this.emit("ct-toggle", {
open: this.open,
});
}
/**
* Open the collapsible
*/
expand(): void {
if (!this.open && !this.disabled) {
this.open = true;
this.emit("ct-toggle", {
open: true,
});
}
}
/**
* Close the collapsible
*/
collapse(): void {
if (this.open && !this.disabled) {
this.open = false;
this.emit("ct-toggle", {
open: false,
});
}
}
}
globalThis.customElements.define("ct-collapsible", CTCollapsible);