import { css, html } from "lit"; import { BaseElement } from "../../core/base-element.ts"; /** * CTCard - Content container with support for header, content, and footer sections * * @element ct-card * * @attr {boolean} clickable - Whether the card responds to click interactions * * @slot header - Card header content * @slot content - Main card content * @slot footer - Card footer content * @slot - Default slot (alternative to using named slots) * * @example * *

Card Title

*

Card content goes here

* Action *
* * Uses JS to detect empty slots (CSS :has() can't distinguish assigned vs fallback content). */ export class CTCard extends BaseElement { static override styles = css` :host { display: block; box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } .card { border-radius: var(--ct-theme-border-radius, 0.5rem); border: 1px solid var(--border, hsl(0, 0%, 89%)); background-color: var(--card, hsl(0, 0%, 100%)); color: var(--card-foreground, hsl(0, 0%, 9%)); overflow: hidden; transition: all var(--ct-theme-animation-duration, 150ms) cubic-bezier(0.4, 0, 0.2, 1); } .card[tabindex="0"] { cursor: pointer; } .card[tabindex="0"]:hover { background-color: var(--accent, hsl(0, 0%, 96%)); transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } .card[tabindex="0"]:focus-visible { outline: 2px solid var(--ring, hsl(212, 100%, 47%)); outline-offset: 2px; } .card[tabindex="0"]:active { transform: translateY(0); } /* Header section */ .card-header { padding: var(--ct-theme-spacing-loose, 1rem); padding-bottom: 0; } /* When header is the only section, add bottom padding */ .card-header:not(.empty):has(+ .card-content.empty) { padding-bottom: var(--ct-theme-spacing-loose, 1rem); } /* Hide header if empty (controlled by JS via .empty class) */ .card-header.empty { display: none; padding: 0; } /* Title wrapper for title and action slots */ .card-title-wrapper { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--ct-theme-spacing-loose, 1rem); } /* Hide title wrapper if empty (controlled by JS via .empty class) */ .card-title-wrapper.empty { display: none; } /* Title slot styling */ ::slotted([slot="title"]) { font-size: 1.5rem; font-weight: 600; line-height: 2rem; letter-spacing: -0.025em; margin: 0; } /* Description slot styling */ ::slotted([slot="description"]) { font-size: 0.875rem; line-height: 1.25rem; color: var(--muted-foreground, hsl(0, 0%, 45%)); margin-top: var(--ct-theme-spacing-tight, 0.25rem); } /* Content section */ .card-content { padding: var(--ct-theme-spacing-loose, 1rem); } /* Hide content if empty (controlled by JS via .empty class) */ .card-content.empty { display: none; padding: 0; } /* Footer section */ .card-footer { padding: var(--ct-theme-spacing-loose, 1rem); padding-top: 0; } /* Hide footer if empty (controlled by JS via .empty class) */ .card-footer.empty { display: none; padding: 0; } /* Adjust spacing when sections are used together */ .card-header:not(:empty) + .card-content:not(:empty) { padding-top: var(--ct-theme-spacing-loose, 1rem); } .card-content:not(:empty) + .card-footer:not(:empty) { padding-top: var(--ct-theme-spacing-loose, 1rem); } `; static override properties = { clickable: { type: Boolean }, }; declare clickable: boolean; constructor() { super(); this.clickable = false; } override connectedCallback() { super.connectedCallback(); if (this.clickable) { this.addEventListener("click", this._handleClick); this.addEventListener("keydown", this._handleKeydown); } } override firstUpdated() { // Set up slot change listeners to detect empty slots this.shadowRoot?.querySelectorAll("slot").forEach((slot) => { slot.addEventListener("slotchange", () => this._updateEmptyStates()); }); // Initial check for empty states this._updateEmptyStates(); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("click", this._handleClick); this.removeEventListener("keydown", this._handleKeydown); } override updated(changedProperties: Map) { if (changedProperties.has("clickable")) { if (this.clickable) { this.addEventListener("click", this._handleClick); this.addEventListener("keydown", this._handleKeydown); } else { this.removeEventListener("click", this._handleClick); this.removeEventListener("keydown", this._handleKeydown); } } } override render() { return html`
`; } /** Check if slot has real content (not just whitespace) */ private _slotHasContent(slot: HTMLSlotElement | null): boolean { if (!slot) return false; return slot.assignedNodes().some((node) => { if (node.nodeType === Node.TEXT_NODE) { return node.textContent?.trim() !== ""; } return true; }); } /** Update empty state classes based on slot content */ private _updateEmptyStates(): void { const getSlot = (name: string) => this.shadowRoot?.querySelector(`slot[name="${name}"]`) as | HTMLSlotElement | null; const headerSlot = getSlot("header"); const contentSlot = getSlot("content"); const defaultSlot = contentSlot?.querySelector("slot:not([name])") as | HTMLSlotElement | null; const footerSlot = getSlot("footer"); const titleSlot = getSlot("title"); const actionSlot = getSlot("action"); const descriptionSlot = getSlot("description"); const hasHeader = this._slotHasContent(headerSlot); const hasContent = this._slotHasContent(contentSlot) || this._slotHasContent(defaultSlot); const hasFooter = this._slotHasContent(footerSlot); const hasTitle = this._slotHasContent(titleSlot); const hasAction = this._slotHasContent(actionSlot); const hasDescription = this._slotHasContent(descriptionSlot); const showHeader = hasHeader || hasTitle || hasAction || hasDescription; const showTitleWrapper = hasTitle || hasAction; this.shadowRoot?.querySelector(".card-header")?.classList.toggle( "empty", !showHeader, ); this.shadowRoot?.querySelector(".card-content")?.classList.toggle( "empty", !hasContent, ); this.shadowRoot?.querySelector(".card-footer")?.classList.toggle( "empty", !hasFooter, ); this.shadowRoot?.querySelector(".card-title-wrapper")?.classList.toggle( "empty", !showTitleWrapper, ); } private _handleClick = (_event: Event): void => { if (!this.clickable) return; // Emit a custom click event this.emit("ct-card-click", { clickable: this.clickable, }); }; private _handleKeydown = (event: KeyboardEvent): void => { if (!this.clickable) return; // Handle Enter and Space keys for accessibility if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this._handleClick(event); } }; /** * Focus the card programmatically (only works when clickable) */ override focus(): void { if (this.clickable) { const card = this.shadowRoot?.querySelector(".card") as HTMLElement; card?.focus(); } } /** * Blur the card programmatically */ override blur(): void { const card = this.shadowRoot?.querySelector(".card") as HTMLElement; card?.blur(); } } globalThis.customElements.define("ct-card", CTCard);