import { LitElement, html, css, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { hasSlotContent } from '../../../utils/slot'; export type CardVariant = 'success' | 'info' | 'error' | 'warning' | 'monochrome' | ''; export type CardRounded = 'sm' | 'md' | 'lg' | ''; export type CardMediaPosition = 'top' | 'bottom'; /** * Converter for rounded prop: accepts boolean (true -> 'md') or string values * See https://lit.dev/docs/v1/components/properties/#conversion-converter */ const roundedConverter = { fromAttribute(value: string | null): CardRounded { if (value === '' || value === 'true') return 'md'; if (value === 'false' || value === null) return ''; return (value as CardRounded) || ''; }, toAttribute(value: CardRounded | boolean): string | null { if (typeof value === 'boolean') return value ? 'md' : null; return value || null; } }; export interface CardProps { stacked?: boolean; shadow?: boolean; animated?: boolean; /** Border radius size. Use 'sm', 'md', 'lg' or true (defaults to 'md') */ rounded?: CardRounded | boolean; variant?: CardVariant; /** Enables the media slot for edge-to-edge image/video rendering */ hasMedia?: boolean; /** Whether media renders above or below the header/content/footer */ mediaPosition?: CardMediaPosition; } export class AgCard extends LitElement implements CardProps { @property({ type: Boolean, reflect: true }) declare stacked: boolean; @property({ type: Boolean, reflect: true }) declare shadow: boolean; @property({ type: Boolean, reflect: true }) declare animated: boolean; @property({ converter: roundedConverter, reflect: true }) declare rounded: CardRounded; @property({ type: String, reflect: true }) declare variant: CardVariant; @property({ type: Boolean, reflect: true, attribute: 'has-media' }) declare hasMedia: boolean; @property({ type: String, reflect: true, attribute: 'media-position' }) declare mediaPosition: CardMediaPosition; private _hasHeaderSlotContent = false; private _hasFooterSlotContent = false; constructor() { super(); this.stacked = false; this.shadow = false; this.animated = false; this.rounded = ''; this.variant = ''; this.hasMedia = false; this.mediaPosition = 'top'; } /** * Handle slot changes to detect if header/footer are empty */ private _handleSlotChange(e: Event) { const slot = e.target as HTMLSlotElement; const slotName = slot.name; if (slotName === 'header') { this._hasHeaderSlotContent = hasSlotContent(slot); } else if (slotName === 'footer') { this._hasFooterSlotContent = hasSlotContent(slot); } this.requestUpdate(); } override firstUpdated() { // Initial check for slot content const headerSlot = this.shadowRoot?.querySelector('slot[name="header"]') as HTMLSlotElement; const footerSlot = this.shadowRoot?.querySelector('slot[name="footer"]') as HTMLSlotElement; this._hasHeaderSlotContent = hasSlotContent(headerSlot); this._hasFooterSlotContent = hasSlotContent(footerSlot); this.requestUpdate(); } static styles = css` :host { display: block; position: relative; box-sizing: border-box; width: 100%; /* Use the global token directly for padding */ --card-padding: var(--ag-space-6, --ag-card-padding); background-color: var(--ag-background-primary); border: var(--ag-border-width-1) solid var(--ag-border); } /* Rounded variants - no rounding by default */ :host([rounded="sm"]) { border-radius: var(--ag-radius-sm); } :host([rounded="md"]) { border-radius: var(--ag-radius-md); } :host([rounded="lg"]) { border-radius: var(--ag-radius-lg); } :host([shadow]) { box-shadow: var(--ag-shadow-lg); overflow: hidden; } /* Enhanced hover effect for shadow cards */ :host([shadow]:hover) { box-shadow: var(--ag-shadow-xl); } /* Animated cards - smooth transitions */ :host([animated]) { transition: box-shadow var(--ag-timing-fast, 150ms) ease-out, transform var(--ag-timing-fast, 150ms) cubic-bezier(0.39, 0.575, 0.565, 1); } :host([animated]:hover) { transform: translateY(-3px); transition: box-shadow var(--ag-timing-fast, 150ms) ease-out, transform var(--ag-timing-slow, 300ms) cubic-bezier(0.39, 0.575, 0.565, 1); } /* Respect reduced motion preferences */ @media (prefers-reduced-motion), (update: slow) { :host([animated]), :host([animated]:hover) { transition-duration: 0.001ms !important; transform: none !important; } } /* Variant colors */ :host([variant="success"]) { background-color: var(--ag-success-background); color: var(--ag-success-text); } :host([variant="info"]) { background-color: var(--ag-info-background); color: var(--ag-info-text); } :host([variant="error"]) { background-color: var(--ag-danger-background); color: var(--ag-danger-text); } :host([variant="warning"]) { background-color: var(--ag-warning-background); color: var(--ag-warning-text); } :host([variant="monochrome"]) { background-color: var(--ag-background-primary-inverted); color: var(--ag-text-primary-inverted); } .card-header, .card-footer { color: var(--ag-text-primary); } .card-header { padding: var(--ag-space-4) var(--card-padding); border-bottom: var(--ag-border-width-1) solid var(--ag-border); } .card-footer { padding: var(--ag-space-4) var(--card-padding); border-top: var(--ag-border-width-1) solid var(--ag-border); } /* Hide header/footer when empty class is applied */ .card-header.empty, .card-footer.empty { display: none; } .card-content { padding: var(--card-padding); } :host([stacked]) .card-content > ::slotted(*:not(:last-child)) { margin-block-end: var(--ag-space-8); } /* The accessible clickable card trick */ ::slotted(a.card-primary-action)::after { content: ''; position: absolute; inset: 0; z-index: 1; cursor: pointer; } /* Ensure content and other actions are selectable/clickable */ .card-content, ::slotted(h1), ::slotted(h2), ::slotted(h3), ::slotted(h4), ::slotted(h5), ::slotted(h6), ::slotted(p), ::slotted(div) { position: relative; z-index: 2; } ::slotted(.card-secondary-action) { position: relative; z-index: 2; } /* ── Media slot ─────────────────────────────────────────────────── */ /* Clip image corners to the card's border-radius for non-shadow cards */ :host([has-media]) { overflow: hidden; } .card-media { overflow: hidden; /* belt-and-suspenders for non-rounded cards */ line-height: 0; /* collapses inline whitespace below slotted img */ } /* Top media: clip only the card's top two corners */ :host([rounded="sm"]) .card-media--top { border-radius: var(--ag-radius-sm) var(--ag-radius-sm) 0 0; } :host([rounded="md"]) .card-media--top { border-radius: var(--ag-radius-md) var(--ag-radius-md) 0 0; } :host([rounded="lg"]) .card-media--top { border-radius: var(--ag-radius-lg) var(--ag-radius-lg) 0 0; } /* Bottom media: clip only the card's bottom two corners */ :host([rounded="sm"]) .card-media--bottom { border-radius: 0 0 var(--ag-radius-sm) var(--ag-radius-sm); } :host([rounded="md"]) .card-media--bottom { border-radius: 0 0 var(--ag-radius-md) var(--ag-radius-md); } :host([rounded="lg"]) .card-media--bottom { border-radius: 0 0 var(--ag-radius-lg) var(--ag-radius-lg); } /* Sensible defaults for slotted media elements */ .card-media ::slotted(img), .card-media ::slotted(video) { display: block; width: 100%; height: auto; object-fit: cover; } `; render() { const headerClass = this._hasHeaderSlotContent ? 'card-header' : 'card-header empty'; const footerClass = this._hasFooterSlotContent ? 'card-footer' : 'card-footer empty'; return html`