From 8b9f61dee0e55787f0d7bc17e748b812c45b9e44 Mon Sep 17 00:00:00 2001 From: 5t3ph Date: Thu, 9 Jul 2026 10:57:43 -0500 Subject: [PATCH 1/5] feat(cards): init card base, template, and migration-plan --- .../core/components/card/Card.base.ts | 271 +++++++ .../core/components/card/Card.types.ts | 42 ++ .../packages/core/components/card/index.ts | 13 + 2nd-gen/packages/core/package.json | 7 + 2nd-gen/packages/swc/.storybook/preview.ts | 7 +- .../swc/components/card/card-template.ts | 58 ++ .../components/card/test/card-base.test.ts | 694 ++++++++++++++++++ .../components/card/test/test-card-base.ts | 87 +++ .../stylesheets/_lit-styles/card-template.css | 24 + .../03_components/README.md | 2 + .../03_components/card/migration-plan.md | 295 ++++++++ .../03_project-planning/README.md | 1 + 12 files changed, 1497 insertions(+), 4 deletions(-) create mode 100644 2nd-gen/packages/core/components/card/Card.base.ts create mode 100644 2nd-gen/packages/core/components/card/Card.types.ts create mode 100644 2nd-gen/packages/core/components/card/index.ts create mode 100644 2nd-gen/packages/swc/components/card/card-template.ts create mode 100644 2nd-gen/packages/swc/components/card/test/card-base.test.ts create mode 100644 2nd-gen/packages/swc/components/card/test/test-card-base.ts create mode 100644 2nd-gen/packages/swc/stylesheets/_lit-styles/card-template.css create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/card/migration-plan.md diff --git a/2nd-gen/packages/core/components/card/Card.base.ts b/2nd-gen/packages/core/components/card/Card.base.ts new file mode 100644 index 00000000000..345ae405313 --- /dev/null +++ b/2nd-gen/packages/core/components/card/Card.base.ts @@ -0,0 +1,271 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; +import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; + +import { + CARD_DENSITIES, + CARD_VALID_SIZES, + CARD_VARIANTS, + type CardDensity, + type CardVariant, +} from './Card.types.js'; + +/** + * Abstract base class for all card-family components. Owns the semantics + * shared across every card type: sizing, visual variant, and density. + * + * `swc-card`, `swc-asset-card`, `swc-user-card`, and `swc-product-card` each + * extend this base directly and render the shared anatomy via + * `renderCardTemplate()` in `swc/components/card/card-template.ts`. That + * template also carries a collection placeholder and an avatar/thumbnail + * glyph, each supplied per-component via `renderCollection`/`renderGlyph`. + * + * @slot preview - Primary preview content + * @slot title - Card title + * @slot actions - Optional action controls + * @slot description - Supporting description text + * @slot footer - Optional footer content + * @slot - Additional body content + */ +export abstract class CardBase extends SizedMixin(SpectrumElement, { + validSizes: CARD_VALID_SIZES, +}) { + // ───────────────────────── + // API TO OVERRIDE + // ───────────────────────── + + /** + * @internal + */ + static readonly VARIANTS: readonly CardVariant[] = CARD_VARIANTS; + + /** + * @internal + */ + static readonly DENSITIES: readonly CardDensity[] = CARD_DENSITIES; + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * The visual variant of the card. + * + * @default primary + */ + @property({ type: String, reflect: true }) + public variant: CardVariant = 'primary'; + + /** + * The density of the card, controlling internal spacing and gaps. + * + * @default regular + */ + @property({ type: String, reflect: true }) + public density: CardDensity = 'regular'; + + /** + * Indicates the consumer has wrapped their `title` slot content in a real + * link. Extends that link's hit area to cover the card surface while + * leaving navigation entirely consumer-owned — Card accepts no `href`. + * + * @todo Naming, and the `::slotted(a)::after` vs. click-proxy mechanism, + * are still open. See the card family plan, A11y-3 / Q2. + */ + @property({ type: Boolean, reflect: true, attribute: 'title-as-link' }) + public titleAsLink = false; + + /** + * Makes the card focusable and captures surface clicks (excluding nested + * interactive targets) to dispatch a `swc-card-click` event, independent + * of `titleAsLink`. Lays groundwork for a future `CardView` selection + * model without Card owning selected-state UI itself. + * + * @todo Event name and `tabindex` management details are still open. See + * the card family plan, A11y-3 / Q3. + * @todo No `role` is set when `selectable` is true — deferred rather than + * defaulting to `role="button"`, since the eventual `CardView` selection + * model may call for a different role (e.g. `option`/`gridcell`) that + * `role="button"` would be wrong to have committed to today. See the card + * family plan, A11y-3 / Q8. + */ + @property({ type: Boolean, reflect: true }) + public selectable = false; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + public override connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('click', this.handleSurfaceClick); + } + + public override disconnectedCallback(): void { + this.removeEventListener('click', this.handleSurfaceClick); + this.removeEventListener('keydown', this.handleSelectableKeydown); + super.disconnectedCallback(); + } + + protected override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('selectable')) { + if (this.selectable) { + this.setAttribute('tabindex', '0'); + this.addEventListener('keydown', this.handleSelectableKeydown); + } else { + this.removeAttribute('tabindex'); + this.removeEventListener('keydown', this.handleSelectableKeydown); + } + } + + if (window.__swc?.DEBUG) { + const { VARIANTS, DENSITIES } = this.constructor as typeof CardBase; + + if ( + changedProperties.has('variant') && + !VARIANTS.includes(this.variant) + ) { + window.__swc.warn( + this, + `<${this.localName}> received an invalid "variant" value of "${this.variant}". Valid values are ${VARIANTS.join(', ')}.`, + 'https://opensource.adobe.com/spectrum-web-components/components/card/', + { issues: [`variant="${this.variant}"`] } + ); + } + + if ( + changedProperties.has('density') && + !DENSITIES.includes(this.density) + ) { + window.__swc.warn( + this, + `<${this.localName}> received an invalid "density" value of "${this.density}". Valid values are ${DENSITIES.join(', ')}.`, + 'https://opensource.adobe.com/spectrum-web-components/components/card/', + { issues: [`density="${this.density}"`] } + ); + } + + if ( + changedProperties.has('titleAsLink') && + this.titleAsLink && + !this.getTitleLinkElement() + ) { + window.__swc.warn( + this, + `<${this.localName}> has "title-as-link" set but no link element was found in the "title" slot.`, + 'https://opensource.adobe.com/spectrum-web-components/components/card/', + { issues: ['title-as-link'] } + ); + } + } + } + + /** + * @internal + * The card's own `title` slot element, or `null` before the concrete + * class's `renderCardTemplate()` call has rendered. + */ + protected getTitleSlotElement(): HTMLSlotElement | null { + return this.renderRoot?.querySelector('slot[name="title"]') ?? null; + } + + /** + * @internal + * The `title` slot's link, supporting both forms consumers reasonably + * use: the assigned element itself is the anchor (``), or the anchor is nested inside a wrapper (``). Requires `href` — a link with no + * destination can't usefully be clicked-through. + */ + protected getTitleLinkElement(): HTMLAnchorElement | null { + const assigned = this.getTitleSlotElement()?.assignedElements({ + flatten: true, + }); + for (const element of assigned ?? []) { + if (element instanceof HTMLAnchorElement && element.href) { + return element; + } + const nested = element.querySelector('a[href]'); + if (nested) { + return nested; + } + } + return null; + } + + /** + * @internal + * Whether `node` provides its own interaction/focus semantics. Checking + * the `tabIndex` IDL property (rather than the `tabindex` attribute or a + * tag-name list) correctly covers natively-interactive elements + * (`button`, `input`, `select`, `textarea`, `a[href]`) with no explicit + * attribute, excludes `tabindex="-1"` (focusable for scripting, not a + * click target) and disabled controls, and works for a custom element's + * internal shadow-DOM control too — `composedPath()` already traverses + * into other elements' shadow roots for composed events like `click`, so + * an internal ` + Supporting text + Footer + Default body content + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step('renders assigned content for every shared slot', async () => { + expect( + card.querySelector('[slot="preview"]'), + 'preview slot content' + ).toBeTruthy(); + expect( + card.querySelector('[slot="title"]'), + 'title slot content' + ).toBeTruthy(); + expect( + card.querySelector('[slot="actions"]'), + 'actions slot content' + ).toBeTruthy(); + expect( + card.querySelector('[slot="description"]'), + 'description slot content' + ).toBeTruthy(); + expect( + card.querySelector('[slot="footer"]'), + 'footer slot content' + ).toBeTruthy(); + const defaultSlotContent = card.querySelectorAll(':scope > :not([slot])'); + expect( + defaultSlotContent.length, + 'default slot child count' + ).toBeGreaterThan(0); + }); + }, +}; + +export const MediaExtrasTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-with-media-extras' + ); + + await step('renders the supplied renderCollection content', async () => { + const marker = card.renderRoot.querySelector('.test-collection-marker'); + expect(marker, 'collection marker rendered in shadow DOM').toBeTruthy(); + }); + + await step('renders the supplied renderGlyph content', async () => { + const marker = card.renderRoot.querySelector('.test-glyph-marker'); + expect(marker, 'glyph marker rendered in shadow DOM').toBeTruthy(); + }); + }, +}; + +export const NoMediaExtrasByDefaultTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step( + 'renders no collection or glyph content when not supplied', + async () => { + expect( + card.renderRoot.querySelector('.test-collection-marker'), + 'no collection marker by default' + ).toBeFalsy(); + expect( + card.renderRoot.querySelector('.test-glyph-marker'), + 'no glyph marker by default' + ).toBeFalsy(); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Behaviors +// ────────────────────────────────────────────────────────────── + +export const TitleAsLinkClickProxyTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step( + 'proxies a surface click to a directly-slotted title anchor', + async () => { + const anchor = document.createElement('a'); + anchor.slot = 'title'; + anchor.href = 'https://example.com/profile'; + anchor.textContent = 'Jane Doe'; + let activated = false; + anchor.addEventListener('click', (event) => { + event.preventDefault(); + activated = true; + }); + card.appendChild(anchor); + await card.updateComplete; + + card.click(); + + expect(activated, 'title anchor received a proxied click').toBe(true); + card.removeChild(anchor); + } + ); + + await step( + 'proxies a surface click to an anchor nested inside a title wrapper', + async () => { + const wrapper = document.createElement('span'); + wrapper.slot = 'title'; + const anchor = document.createElement('a'); + anchor.href = 'https://example.com/profile'; + anchor.textContent = 'Jane Doe'; + let activated = false; + anchor.addEventListener('click', (event) => { + event.preventDefault(); + activated = true; + }); + wrapper.appendChild(anchor); + card.appendChild(wrapper); + await card.updateComplete; + + card.click(); + + expect(activated, 'nested title anchor received a proxied click').toBe( + true + ); + card.removeChild(wrapper); + } + ); + }, +}; + +export const SelectableActivationTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + let dispatched = false; + card.addEventListener('swc-card-click', () => { + dispatched = true; + }); + + await step('dispatches swc-card-click for a surface click', async () => { + dispatched = false; + card.click(); + expect(dispatched, 'swc-card-click dispatched for a surface click').toBe( + true + ); + }); + + await step('dispatches swc-card-click on Enter', async () => { + dispatched = false; + card.dispatchEvent( + new KeyboardEvent('keydown', { + code: 'Enter', + bubbles: true, + composed: true, + }) + ); + expect(dispatched, 'swc-card-click dispatched on Enter keydown').toBe( + true + ); + }); + + await step('dispatches swc-card-click on Space', async () => { + dispatched = false; + card.dispatchEvent( + new KeyboardEvent('keydown', { + code: 'Space', + bubbles: true, + composed: true, + }) + ); + expect(dispatched, 'swc-card-click dispatched on Space keydown').toBe( + true + ); + }); + }, +}; + +export const InteractiveTargetFilteringTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + let dispatched = false; + card.addEventListener('swc-card-click', () => { + dispatched = true; + }); + + await step( + 'does not dispatch swc-card-click for a click inside actions', + async () => { + dispatched = false; + const button = document.createElement('button'); + button.type = 'button'; + button.slot = 'actions'; + button.textContent = 'Edit'; + card.appendChild(button); + await card.updateComplete; + + button.click(); + + expect( + dispatched, + 'swc-card-click did not fire for an actions click' + ).toBe(false); + card.removeChild(button); + } + ); + + await step( + 'excludes the actions slot unconditionally, even for a non-focusable child', + async () => { + dispatched = false; + const span = document.createElement('span'); + span.slot = 'actions'; + span.textContent = 'Not actually interactive'; + card.appendChild(span); + await card.updateComplete; + + span.click(); + + expect( + dispatched, + 'actions slot is excluded even without a focusable child' + ).toBe(false); + card.removeChild(span); + } + ); + + await step( + 'dispatches swc-card-click for a non-interactive click outside actions', + async () => { + dispatched = false; + const span = document.createElement('span'); + span.slot = 'description'; + span.textContent = 'Just some text'; + card.appendChild(span); + await card.updateComplete; + + span.click(); + + expect( + dispatched, + 'swc-card-click fires for a non-interactive click outside actions' + ).toBe(true); + card.removeChild(span); + } + ); + + await step( + 'does not treat tabindex="-1" as an interactive target', + async () => { + dispatched = false; + const div = document.createElement('div'); + div.slot = 'description'; + div.tabIndex = -1; + div.textContent = 'Programmatically focusable only'; + card.appendChild(div); + await card.updateComplete; + + div.click(); + + expect( + dispatched, + 'tabindex="-1" is not treated as an interactive target' + ).toBe(true); + card.removeChild(div); + } + ); + + await step( + 'excludes a shadow-DOM button inside actions, regardless of which shadow tree it belongs to', + async () => { + dispatched = false; + const host = document.createElement('test-nested-button-host'); + host.slot = 'actions'; + card.appendChild(host); + await card.updateComplete; + + const innerButton = host.shadowRoot?.querySelector('button'); + expect( + innerButton, + 'nested button rendered inside its own shadow root' + ).toBeTruthy(); + + innerButton?.click(); + + expect( + dispatched, + 'nested shadow-DOM button in actions is excluded' + ).toBe(false); + card.removeChild(host); + } + ); + + await step( + 'excludes a shadow-DOM button outside actions via tabIndex alone', + async () => { + dispatched = false; + const host = document.createElement('test-nested-button-host'); + host.slot = 'description'; + card.appendChild(host); + await card.updateComplete; + + const innerButton = host.shadowRoot?.querySelector('button'); + + innerButton?.click(); + + expect( + dispatched, + 'nested shadow-DOM button outside actions is still excluded via tabIndex' + ).toBe(false); + card.removeChild(host); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Dev mode warnings +// ────────────────────────────────────────────────────────────── + +export const InvalidVariantWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step('warns when an invalid variant is set in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + card.variant = 'not-a-variant' as unknown as TestCardBase['variant']; + await card.updateComplete; + + expect( + warnCalls.length, + 'at least one warning is emitted for invalid variant' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references variant' + ).toContain('variant'); + }) + ); + }, +}; + +export const ValidVariantNoWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step('does not warn when a valid variant is set in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + card.variant = 'tertiary'; + await card.updateComplete; + + expect( + warnCalls.length, + 'no warnings are emitted for a valid variant' + ).toBe(0); + }) + ); + }, +}; + +export const InvalidDensityWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step('warns when an invalid density is set in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + card.density = 'not-a-density' as unknown as TestCardBase['density']; + await card.updateComplete; + + expect( + warnCalls.length, + 'at least one warning is emitted for invalid density' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references density' + ).toContain('density'); + }) + ); + }, +}; + +export const ValidDensityNoWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step('does not warn when a valid density is set in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + card.density = 'spacious'; + await card.updateComplete; + + expect( + warnCalls.length, + 'no warnings are emitted for a valid density' + ).toBe(0); + }) + ); + }, +}; + +export const TitleAsLinkMissingAnchorWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step( + 'warns when title-as-link is set with no anchor in the title slot', + () => + withWarningSpy(async (warnCalls) => { + card.titleAsLink = true; + await card.updateComplete; + + expect( + warnCalls.length, + 'at least one warning is emitted for a missing title anchor' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references title-as-link' + ).toContain('title-as-link'); + }) + ); + }, +}; + +export const TitleAsLinkWithAnchorNoWarningTest: Story = { + render: () => html` + + Jane Doe + + `, + play: async ({ canvasElement, step }) => { + const card = await getComponent( + canvasElement, + 'test-card-base' + ); + + await step( + 'does not warn when title-as-link has a valid anchor in the title slot', + () => + withWarningSpy(async (warnCalls) => { + card.titleAsLink = true; + await card.updateComplete; + + expect( + warnCalls.length, + 'no warnings are emitted when a title anchor is present' + ).toBe(0); + }) + ); + }, +}; diff --git a/2nd-gen/packages/swc/components/card/test/test-card-base.ts b/2nd-gen/packages/swc/components/card/test/test-card-base.ts new file mode 100644 index 00000000000..5525308220a --- /dev/null +++ b/2nd-gen/packages/swc/components/card/test/test-card-base.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Test-only fixtures for exercising `CardBase` before any concrete card + * component (`swc-card`, `swc-user-card`, etc.) exists. Not a real + * component: no public API, no docs, no production use. Once a concrete + * card ships, prefer testing through it instead and retire these fixtures. + */ + +import { html, TemplateResult } from 'lit'; + +import { CardBase } from '@spectrum-web-components/core/components/card/index.js'; + +import { renderCardTemplate } from '../card-template.js'; + +/** + * Minimal concrete card rendering the shared anatomy with no + * `renderCollection`/`renderGlyph` overrides, to exercise `CardBase`'s own + * behavior (variant/density validation, `titleAsLink`, `selectable`, + * interactive-target filtering) against the real `renderCardTemplate()` + * output. + */ +export class TestCardBase extends CardBase { + protected override render(): TemplateResult { + return renderCardTemplate({ cardClass: 'TestCardBase' }); + } +} + +if (!customElements.get('test-card-base')) { + customElements.define('test-card-base', TestCardBase); +} + +/** + * Minimal concrete card that supplies `renderCollection`/`renderGlyph`, to + * verify those `renderCardTemplate()` callback parameters actually render + * when provided (the default-card case above verifies they render nothing + * when omitted). + */ +export class TestCardWithMediaExtras extends CardBase { + protected override render(): TemplateResult { + return renderCardTemplate({ + cardClass: 'TestCardWithMediaExtras', + renderCollection: () => html` + collection + `, + renderGlyph: () => html` + glyph + `, + }); + } +} + +if (!customElements.get('test-card-with-media-extras')) { + customElements.define('test-card-with-media-extras', TestCardWithMediaExtras); +} + +/** + * A custom element with its own shadow-DOM `