import { css, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { crystalGridEndpoints } from '../generated/endpoint-defaults.js'; import type { GetBirthstonesResponse, GetCrystalsByChakraResponse, GetCrystalsByElementResponse, GetCrystalsByZodiacResponse, ListCrystalsResponse, SearchCrystalsResponse, } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { cssColor } from '../utils/css-color.js'; import { customElement } from '../utils/define.js'; import { formatList, monthName } from '../utils/format.js'; /** * Any crystal list response that carries a `crystals` summary array. Every crystals endpoint that returns more than one stone shares the `{ name, id, imageUrl, colors }` item shape, so one grid renders them all. */ type CrystalGridData = | ListCrystalsResponse | GetCrystalsByChakraResponse | GetCrystalsByElementResponse | GetCrystalsByZodiacResponse | GetBirthstonesResponse | SearchCrystalsResponse; /** * Crystal grid. Renders any crystals list response (/crystals, /crystals/chakra/{chakra}, /crystals/element/{element}, /crystals/zodiac/{sign}, /crystals/birthstone/{month}, /crystals/search) as a responsive gallery of crystal tiles with photo, name, and colour swatches. The heading is derived from the response filter (chakra, element, zodiac sign, or birth month) or set explicitly via the `heading` attribute. A picked tile emits `roxy-symbol-select` ({ id, name }) for a host that pairs its own roxy-crystal-card, and in self-fetch mode opens that card under the grid itself; the full list opens on every stone with its chakra, zodiac, element, colour and planet filters above it and pages on with Show more. */ @customElement('roxy-crystal-grid') export class RoxyCrystalGrid extends RoxyDataElement { static endpoints = crystalGridEndpoints; static styles = [ baseStyles, css` .wrap { display: grid; /* Never an implicit auto column: it floors at min-content, so one long * unbreakable string widens the track past the padded card. */ grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-md, 1rem); } .head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .title { margin: 0; font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-fg, #0a0a0a); } .count { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-sm, 0.875rem); } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); gap: var(--roxy-space-md, 1rem); margin: 0; padding: 0; list-style: none; } .tile { display: grid; gap: var(--roxy-space-xs, 0.25rem); width: 100%; text-align: left; font: inherit; color: inherit; background: var(--roxy-surface, #fff); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-sm, 0.5rem); box-shadow: var(--roxy-shadow-sm); cursor: pointer; transition: border-color 0.12s ease; } .tile[aria-pressed='true'], .tile:hover, .tile:focus-visible { border-color: var(--roxy-accent, #f59e0b); outline: none; } .detail { margin-top: var(--roxy-space-md, 1rem); } .photo { aspect-ratio: 1 / 1; width: 100%; border-radius: var(--roxy-radius-sm, 4px); object-fit: cover; background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 35%, transparent); } .name { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-fg, #0a0a0a); } .colors { display: flex; flex-wrap: wrap; gap: 4px; } .swatch { width: 10px; height: 10px; border-radius: var(--roxy-radius-full, 9999px); border: 1px solid color-mix(in srgb, var(--roxy-fg, #0a0a0a) 18%, transparent); } `, ]; /** * Override the auto-derived grid heading. Empty by default, in which case the heading comes from the response filter (chakra, element, zodiac, or birth month) or falls back to "Crystals". */ @property({ type: String, reflect: true }) heading = ''; protected rowDetail = { tag: 'roxy-crystal-card', path: 'crystals/{id}' }; protected listKey = 'crystals'; protected renderData(d: CrystalGridData) { const crystals = d.crystals ?? []; if (crystals.length === 0) return this.renderEmpty(); const title = this.heading || this.deriveHeading(d); const total = 'total' in d && typeof d.total === 'number' ? d.total : crystals.length; return html`

${title}

${total === 1 ? this.t('1 crystal') : this.t('{{count}} crystals', { count: total })}
${this.renderMore()} ${this.openedRow ? html`
${this.openedRow}
` : nothing}
`; } private deriveHeading(d: CrystalGridData): string { if ('chakra' in d && d.chakra) return this.t('{{chakra}} chakra crystals', { chakra: d.chakra }); if ('element' in d && d.element) return this.t('{{element}} element crystals', { element: d.element }); if ('sign' in d && d.sign) return this.t('Crystals for {{sign}}', { sign: d.sign }); if ('month' in d && typeof d.month === 'number') return this.t('{{month}} birthstones', { month: monthName(this.effectiveLang(), d.month), }); return this.t('Crystals'); } } declare global { interface HTMLElementTagNameMap { 'roxy-crystal-grid': RoxyCrystalGrid; } }