From 697cd0d4539a4855fae6a12748b138a5a8eeb546 Mon Sep 17 00:00:00 2001 From: Nikki Massaro <5090492+nikkimk@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:35:09 -0400 Subject: [PATCH 1/7] docs: illustrated message a11y migration docs (#6116) --- .../03_components/README.md | 1 + .../accessibility-migration-analysis.md | 162 ++++++++++++++++++ ...endering-and-styling-migration-analysis.md | 3 + 3 files changed, 166 insertions(+) create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index 4106284b062..92a561aa8a1 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -51,6 +51,7 @@ - Help Text - [Help text migration roadmap](help-text/rendering-and-styling-migration-analysis.md) - Illustrated Message + - [Illustrated message accessibility migration analysis](illustrated-message/accessibility-migration-analysis.md) - [Illustrated message migration roadmap](illustrated-message/rendering-and-styling-migration-analysis.md) - Infield Button - [In-field button migration roadmap](infield-button/rendering-and-styling-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md new file mode 100644 index 00000000000..03d6d125997 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md @@ -0,0 +1,162 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Illustrated Message / Illustrated message accessibility migration analysis + + + +# Illustrated message accessibility migration analysis + + + +
+In this doc + +- [Overview](#overview) + - [Also read](#also-read) + - [What `swc-illustrated-message` is](#what-swc-illustrated-message-is) + - [Heading hierarchy and page context](#heading-hierarchy-and-page-context) +- [ARIA and WCAG context](#aria-and-wcag-context) + - [Pattern in the APG](#pattern-in-the-apg) + - [Guidelines that apply](#guidelines-that-apply) +- [Recommendations: ``](#recommendations-swc-illustrated-message) + - [Heading slot, `heading` attribute, and `heading-level`](#heading-slot-heading-attribute-and-heading-level) + - [Other content and slots](#other-content-and-slots) + - [Shadow DOM and cross-root ARIA issues](#shadow-dom-and-cross-root-aria-issues) + - [Accessibility tree expectations](#accessibility-tree-expectations) + - [Keyboard and focus](#keyboard-and-focus) +- [Known 1st-gen issues](#known-1st-gen-issues) +- [Testing](#testing) + - [Automated tests](#automated-tests) +- [Summary checklist](#summary-checklist) +- [References](#references) + +
+ + + +## Overview + +This doc defines how `swc-illustrated-message` should work for accessibility and heading semantics. It targets WCAG 2.2 Level AA. + +### Also read + +- [Illustrated message migration roadmap](./rendering-and-styling-migration-analysis.md) for layout, CSS, DOM, and Spectrum 2 gaps. + +### What `swc-illustrated-message` is + +- A composed empty state or explanatory block: illustration (often SVG), a title-like line, description text, and optionally actions. +- It can appear anywhere on a page—inside a dialog, under a page `h1`, as the only content of a section, nested in cards, etc. + +### Heading hierarchy and page context + +The component cannot know which `h2`–`h6` level is correct for the page; authors must set that explicitly. The only supported pattern is: title text comes from the `heading` attribute and/or the `heading` slot (see below), and the semantic heading element is always created in shadow DOM. + +Do not use `h1` for the illustrated message title. `h1` is for the primary page (or dialog / sheet title outside this block). This component exposes `h2`–`h6` only via `heading-level` (`2`–`6`). + +2nd-gen must implement: + +- `heading-level` property (attribute `heading-level`): integers `2`–`6`, default `2`. The shadow tree renders exactly one `

` … `

` matching that value. Values outside `2`–`6` (including `1`) must be clamped or coerced to `2`–`6` (for example `1` → `2`), or rejected in types with a documented default—pick one policy and document it in Storybook. +- `heading` slot: accepts a `span` only (or equivalent documented phrasing: a single `span` wrapper as the slotted node). Do not allow slotted `

`–`

`; authors must not put heading elements in light DOM for this slot. Implementation may validate in dev and warn or ignore invalid slotted tags. +- Optional `heading` attribute: when used, its text is rendered as the title inside the shadow heading (alongside or instead of slot content per product rules—document the precedence if both exist). + +This differs from putting a real heading in the slot (as in accordion item titles) and from 1st-gen, which always wraps the slot in `

` with no level control. Accordion still allows `level` `1`–`6` on the parent ([SWC-1466](https://jira.corp.adobe.com/browse/SWC-1466), [PR #5969](https://github.com/adobe/spectrum-web-components/pull/5969)); illustrated message uses `heading-level` `2`–`6` only (no `h1`). + +Documentation and Storybook must tell authors to set `heading-level` from document outline, not from visual preference alone. + +--- + +## ARIA and WCAG context + +### Pattern in the APG + +- The APG does not define an “illustrated message” widget. Treat it as structured content: headings, text, optional controls. + +### Guidelines that apply + +| Idea | Plain meaning | +|------|----------------| +| [Info and relationships (WCAG 1.3.1)](https://www.w3.org/TR/WCAG22/#info-and-relationships) | The programmatic heading level must reflect the document outline. Avoid a second `h1` inside this pattern—keep one top-level heading per page view. | +| [Headings and labels (WCAG 2.4.6)](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | The title should describe topic purpose; `heading-level` should match sibling and parent headings (`h2`–`h6`). | +| [Name, role, value (WCAG 4.1.2)](https://www.w3.org/TR/WCAG22/#name-role-value) | Action buttons and links need discernible names; decorative illustrations should not pollute the accessibility tree. | + +Bottom line: Authors choose `heading-level` (`2`–`6`, i.e. `h2`–`h6`) to match the page. The component always emits the corresponding heading in shadow DOM; the slot does not supply the heading tag. + +--- + +## Recommendations: `` + +### Heading slot, `heading` attribute, and `heading-level` + +| Topic | What to do | +|-------|------------| +| No `h1` | Never render `

`. Do not accept `heading-level="1"`. `h1` belongs to the page, shell, or dialog title, not this block. | +| `heading-level` | Required behavior: `2`–`6`, default `2`. Clamp or coerce out-of-range values; document behavior for invalid input (same spirit as accordion `getHeadingLevel()`). | +| `heading` slot | Span only: document that the slot must contain a `span` (or stricter: exactly one root `span`). No slotted `

`–`

`. | +| Shadow heading | Single heading element in shadow DOM; tag is `

`–`

` per `heading-level`. Slot and/or `heading` attribute supply text content inside that element (not the element type). | +| `heading` attribute | Plain-text title when slot is empty or as fallback; document interaction with slotted content if both are present. | +| Docs | Examples across `heading-level` `2`–`6` (for example below page `h1` vs nested under `h3`). Contrast with accordion: accordion allows `level` `1`; illustrated message does not. | + +### Other content and slots + +| Topic | What to do | +|-------|------------| +| Illustration (default slot) | If purely decorative, `aria-hidden="true"` on the SVG (or equivalent). If meaningful, `role="img"` and `aria-label` / `` (see icon and SVG accessibility patterns). | +| Description | Body text; links inside description must be real `<a>` or link components with visible names. | +| Actions (Spectrum 2) | Slotted buttons follow button and action group labeling; order matches visual reading order. | + +### Shadow DOM and cross-root ARIA issues + +- If `aria-labelledby` / `aria-describedby` reference slotted ids, confirm 2nd-gen id forwarding or document limitations. Heading `id` for region labeling should live on the shadow `<h2>`–`<h6>` if needed. + +### Accessibility tree expectations + +Typical open state + +- One heading: correct `h2`–`h6` from `heading-level`, with label text from `heading` attribute and/or `heading` slot (`span` content). +- Description as text content (and focusable links if present). +- Illustration exposed or hidden per decorative vs informative rules. + +### Keyboard and focus + +- The host is not a single tab stop unless design adds interactive chrome; Tab moves to slotted actions and links in DOM order. +- No requirement for arrow-key widget behavior unless actions compose a pattern (for example button group docs). + +--- + +## Known 1st-gen issues + +- `sp-illustrated-message` always wraps the heading slot in `<h2 id="heading">` ([`IllustratedMessage.ts`](https://github.com/adobe/spectrum-web-components/blob/main/1st-gen/packages/illustrated-message/src/IllustratedMessage.ts)) with no `heading-level` API—authors cannot match outline when the block should be `h3`–`h6`. +- The slot accepts any node; slotted heading elements would nest incorrectly inside `<h2>`. 2nd-gen fixes this by owning the heading tag and restricting the slot to `span` only. + +--- + +## Testing + +### Automated tests + +| Kind of test | What to check | +|--------------|----------------| +| Unit | Rendered tag is `h2`–`h6` matching `heading-level`; default `2`; `heading-level` `1` never produces `h1`; invalid values coerce per spec; `heading` slot rejects or ignores non-`span` root if that is the contract. | +| aXe + Storybook | Heading order sane; no `h1` inside illustrated message stories; no empty headings when title required. | +| Integration | Dropzone and dialog demos set `heading-level` appropriately for context. | + +--- + +## Summary checklist + +- [ ] API documented: `heading-level` `2`–`6` (default `2`); `heading` slot span-only; shadow DOM owns `<h2>`–`<h6>`; no `<h1>`. +- [ ] Storybook examples vary `heading-level` by context (not always `2`). +- [ ] 1st-gen fixed `h2` called out as migration motivation; link SWC-1466 / accordion for “configurable level” precedent only (different slot rules). +- [ ] Decorative vs meaningful illustration documented for SVG slot. +- [ ] Actions slot meets button label requirements. + +--- + +## References + +- [WCAG 2.2](https://www.w3.org/TR/WCAG22/) +- [Understanding info and relationships (1.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) +- [Understanding headings and labels (2.4.6)](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) +- [feat(accordion): add `level` property for controlling title heading (PR #5969)](https://github.com/adobe/spectrum-web-components/pull/5969) — precedent for a numeric heading level on a parent; illustrated message uses `heading-level` `2`–`6`, span-only title slot, and shadow-owned heading tag (SWC-1466). +- [Illustrated message migration roadmap](./rendering-and-styling-migration-analysis.md) +- [SWC-1466](https://jira.corp.adobe.com/browse/SWC-1466) (Adobe internal Jira): accordion heading level; analogous motivation for configurable `heading-level` on illustrated message. diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/rendering-and-styling-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/rendering-and-styling-migration-analysis.md index 831945769f5..24297498848 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/rendering-and-styling-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/rendering-and-styling-migration-analysis.md @@ -30,6 +30,8 @@ ## Component specifications +For heading semantics, WCAG outline behavior, and the 2nd-gen API (`heading-level` `2`–`6`, default `2`; `heading` slot span-only; shadow DOM owns `h2`–`h6`), see [Illustrated message accessibility migration analysis](./accessibility-migration-analysis.md). + ### CSS <details> @@ -306,6 +308,7 @@ ## Resources +- [Illustrated message accessibility migration analysis](./accessibility-migration-analysis.md) - [CSS migration](https://github.com/adobe/spectrum-css/pull/3246) - [Spectrum 2 preview](https://spectrumcss.z13.web.core.windows.net/pr-2352/index.html?path=/docs/components-illustrated-message--docs&args=isHorizontal:!true) - [React](https://react-spectrum.adobe.com/IllustratedMessage) From dac31d4fae337c3a1b2b22b56ccdae4eb443e505 Mon Sep 17 00:00:00 2001 From: Miwha Bonini <mbonini@adobe.com> Date: Fri, 3 Apr 2026 09:18:12 -0600 Subject: [PATCH 2/7] docs(illustrated-message): add migration plan for SWC-1834 (#6124) * docs(illustrated-message): add migration plan for SWC-1834 * docs(illustrated-message): update migration plan with reviewer feedback for SWC-1834 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(illustrated-message): add size variants s|m|l with m as default per React Spectrum --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../illustrated-message/migration-plan.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md new file mode 100644 index 00000000000..43c4d3608cb --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md @@ -0,0 +1,237 @@ +# `sp-illustrated-message` Migration Plan + +> **SWC-1834** · Planning output. Must be reviewed before implementation begins. + +--- + +## Table of contents + +- [1st-gen API surface](#1st-gen-api-surface) +- [Dependencies](#dependencies) +- [Changes overview](#changes-overview) +- [2nd-gen API decisions](#2nd-gen-api-decisions) +- [Architecture: core vs SWC split](#architecture-core-vs-swc-split) +- [Migration checklist](#migration-checklist) +- [Blockers and open questions](#blockers-and-open-questions) +- [References](#references) + +--- + +## 1st-gen API surface + +**Source:** [`1st-gen/packages/illustrated-message/src/IllustratedMessage.ts`](../../../../1st-gen/packages/illustrated-message/src/IllustratedMessage.ts) +**Version:** `@spectrum-web-components/illustrated-message@1.11.2` +**Custom element tag:** `sp-illustrated-message` + +### Properties / attributes + +| Property | Type | Default | Attribute | Notes | +|---|---|---|---|---| +| `heading` | `string` | `''` | `heading` | Fallback text if heading slot is empty | +| `description` | `string` | `''` | `description` | Fallback text if description slot is empty | + +### Methods + +None (component is purely presentational). + +### Events + +None dispatched. + +### Slots + +| Slot | Content | Notes | +|---|---|---| +| *(default)* | SVG illustration | No content type restriction; CSS enforces `width: 100%` on `svg[viewBox]` via `::slotted` | +| `heading` | Heading text / markup | Rendered inside a hard-coded `<h2>` shadow element | +| `description` | Body text | Rendered inside a `<div>` with `spectrum-Body spectrum-Body--sizeS` classes | + +### CSS custom properties + +The 1st-gen component imports `spectrum-illustratedmessage.css` (Spectrum 1 tokens) and `illustratedmessage-overrides.css`. The overrides file uses `--mod-*` and `--spectrum-*` chains internally but these were never documented or intended as public consumer API. + +### Shadow DOM output (rendered HTML) + +```html +<div id="illustration"><slot></slot></div> +<h2 id="heading" class="spectrum-Heading spectrum-Heading--sizeL spectrum-Heading--light"> + <slot name="heading">${heading}</slot> +</h2> +<div id="description" class="spectrum-Body spectrum-Body--sizeS"> + <slot name="description">${description}</slot> +</div> +``` + +--- + +## Dependencies + +| Package | Version | Role | +|---|---|---| +| `@spectrum-web-components/base` | `1.11.2` | `SpectrumElement`, `html`, `property` decorator — 1st-gen internal package only | +| `@spectrum-web-components/styles` | `1.11.2` | `bodyStyles`, `headingStyles` (applied via `static get styles()`). Note: 2nd-gen has typography classes but whether they will be importable in the same way is TBD — tracked in SWC-1545. | + +No mixins, no shared utilities, no other SWC components composed inside. No dependency on `@spectrum-web-components/core` (2nd-gen). + +--- + +## Changes overview + +> **Priority framing:** +> - **Accessibility is non-negotiable** — all a11y requirements ship as part of this migration. +> - **Breaking changes** are assessed on merit — some must ship now to avoid a second, more disruptive migration event later (e.g. heading slot restriction per reviewer feedback). +> - **Additive changes** can be deferred and will not cause consumer breakage when they do ship. + +### Must ship — breaking or a11y-required + +| # | What changes | 1st-gen behavior | 2nd-gen behavior | Consumer migration path | +|---|---|---|---|---| +| **B1** | Heading slot content type | Accepts any node inside `<h2>` | Accepts `<span>` only; shadow DOM owns the heading tag | Consumers slotting plain text or `<span>` are unaffected. Consumers who slotted `<h2>`–`<h6>` (incorrect but possible) must switch to `<span>`. Ships now — deferring would cause font property inheritance side-effects and a second migration event. | +| **B2** | CSS token migration (S1 → S2) | Uses `--spectrum-*` base tokens with `--mod-*` override chains (e.g. `var(--mod-illustrated-message-title-color, var(--spectrum-illustrated-message-title-color))`). Forced-colors override applied on `:host`. | `--mod-*` and `--spectrum-*` chains removed; collapsed into `--swc-*` (exposed) or `--_swc-*` (private) properties per [Component Custom Property Exposure](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#component-custom-property-exposure). Forced colors moved to internal `.swc-IllustratedMessage` selector. | Since no `--mod-*` properties were ever documented as public API, there is no consumer breakage. Any new `--swc-*` properties introduced are additive capability. | +| **A11y** | Heading level control | Always `<h2>`, no way for consumers to change level | `heading-level` attribute (`2`–`6`, default `2`); shadow DOM renders the correct `<hN>` tag | Consumers using the default are unaffected. Consumers needing a different level add `heading-level`. Required for WCAG 1.3.1 and 2.4.6 compliance. | +| **A11y** | Illustration accessibility | No handling for decorative vs informative SVGs | Decorative SVGs: `aria-hidden="true"`; informative: `role="img"` + `aria-label` / `<title>` | Slot contract; documented guidance rather than enforced by the component. | + +### Additive — ships when ready, zero breakage for consumers already on 2nd-gen + +| # | What is added | Notes | +|---|---|---| +| **A1** | `size` attribute (`s` \| `m` \| `l`, default `m`) | Net-new t-shirt sizing. `m` is the base style, no extra ruleset needed. Implemented via `:host([size="..."])` attribute selectors, not modifier classes. | +| **A2** | `horizontal` boolean attribute | Net-new layout variant. Consumers not using it are unaffected. | +| **A3** | `actions` slot | Net-new. Leave untyped — a focus group navigation controller will be needed in a future follow-up. | + +--- + +## 2nd-gen API decisions + +These are derived from the a11y analysis and rendering roadmap. Confirmed items are marked; open items are tracked in [Blockers and open questions](#blockers-and-open-questions). + +### Properties / attributes (2nd-gen) + +| Property | Type | Default | Attribute | Notes | +|---|---|---|---|---| +| `heading` | `string` | `''` | `heading` | Carry forward; attribute is fallback, slot takes precedence via `<slot name="heading">${this.heading}</slot>` | +| `description` | `string` | `''` | `description` | Carry forward; same fallback pattern | +| `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | `heading-level` | **New.** Values outside `2`–`6` silently clamped using `Math.max(2, Math.min(6, level))` — same pattern as `AccordionItem.getHeadingLevel()`. | +| `size` | `'s' \| 'm' \| 'l'` | `'m'` | `size` | **New.** `m` is the implicit base style `s` and `l` override via `:host([size="s"])` / `:host([size="l"])` attribute selectors per [selector conventions](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#selector-conventions). | +| `horizontal` | `boolean` | `false` | `horizontal` | **New.** Drives horizontal layout variant. | + +### Slots (2nd-gen) + +| Slot | Content | Notes | +|---|---|---| +| *(default)* | Decorative or informative SVG | Decorative SVGs should have `aria-hidden="true"`; informative need `role="img"` + `aria-label` | +| `heading` | Single `<span>` | Restriction is semantic contract; dev-mode warning for non-`span` root nodes | +| `description` | Phrasing content | Links must be real `<a>` or link components with visible names | +| `actions` | **New.** Button group (untyped) | Leave untyped. Focus group navigation controller to be implemented in a future follow-up. | + +### CSS custom properties (2nd-gen) + +No `--mod-*` properties will be exposed. New `--swc-*` component-level properties may be introduced where needed (especially for size variants) — these are additive and not breaking. See [Component Custom Property Exposure](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#component-custom-property-exposure) for what to expose and how. + +--- + +## Architecture: core vs SWC split + +> The 1st-gen component is a **reference only** — 2nd-gen is built independently. Neither generation imports from the other. + +Follow the [Badge migration reference](../../02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md#reference-badge-migration) as the concrete pattern for the core/SWC split. + +| Layer | Path | Contains | +|---|---|---| +| **Core** | `2nd-gen/packages/core/components/illustrated-message/` | Abstract base class, types, behavior, validation. No rendering. | +| **SWC** | `2nd-gen/packages/swc/components/illustrated-message/` | Extends core base. Rendering, styles, element registration, stories, tests. | + +--- + +## Migration checklist + +### Preparation (this ticket) + +- [x] 1st-gen API surface documented +- [x] Dependencies identified +- [x] Breaking changes documented +- [x] 2nd-gen API decisions drafted +- [ ] Plan reviewed by at least one other engineer + +### Setup + +- [ ] Create `2nd-gen/packages/core/components/illustrated-message/` +- [ ] Create `2nd-gen/packages/swc/components/illustrated-message/` +- [ ] Wire exports in both `package.json` files +- [ ] Check out `spectrum-css` at `spectrum-two` branch as sibling directory + +### API + +- [ ] `IllustratedMessage.types.ts`: `ILLUSTRATED_MESSAGE_VALID_SIZES`, `ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS`, derived types +- [ ] `IllustratedMessage.base.ts`: abstract base class built from 1st-gen as reference; `headingLevel`, `size`, `horizontal`, `heading`, `description` properties; `getHeadingLevel()` clamping helper; `window.__swc?.DEBUG` warnings for invalid `heading-level` and heading-slot content type; new S2 properties go directly in base with correct names (no old-name forwarding) +- [ ] `IllustratedMessage.ts` (SWC): extends base, static `VALID_SIZES`, S2 rendering + +### Styling + +> Follow the [CSS style guide](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/) as the source of truth for all styling work. Key references: [migration steps](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md), [custom properties](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md), [anti-patterns](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md). + +- [ ] Add `.swc-IllustratedMessage` wrapper element in `render()`; move `classMap` onto wrapper, off `:host` +- [ ] Copy S2 source from `spectrum-css` `spectrum-two` branch `index.css` (not `/dist`) into `illustrated-message.css` as baseline +- [ ] Update class and custom property prefixes from `.spectrum-` to `.swc-`; remove all `--mod-*` and `--spectrum-*` chains per [Component Custom Property Exposure](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#component-custom-property-exposure) +- [ ] Remove extra override CSS files (`illustratedmessage-overrides.css`) once combined +- [ ] Verify i18n size modifiers (`:lang(ja)`, `:lang(ko)`, `:lang(zh)`) +- [ ] Check all styling decisions against the [CSS style guide](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/) before opening PR +- [ ] Pass stylelint (property order, `no-descending-specificity`, token validation) + +### Accessibility + +- [ ] Shadow heading renders correct tag (`h2`–`h6`) driven by `heading-level` +- [ ] `heading-level="1"` (and values < 2 or > 6) clamp to valid range — never renders `<h1>` +- [ ] Dev-mode `__swc.warn()` for invalid `heading-level` values +- [ ] Dev-mode `__swc.warn()` if heading slot root is not a `span` +- [ ] Heading slot restricted to `<span>` only; shadow DOM owns the heading tag +- [ ] Decorative illustration guidance documented (`aria-hidden="true"` on SVG); informative illustration guidance documented (`role="img"` + `aria-label`) +- [ ] Actions slot button labels documented + +### Testing + +- [ ] `test/illustrated-message.test.ts`: heading tag matches `heading-level`; default is `h2`; `heading-level="1"` does not produce `<h1>`; `size` and `horizontal` attribute reflection +- [ ] `test/illustrated-message.a11y.spec.ts`: Playwright `toMatchAriaSnapshot` with default story; `heading-level` variants `2`–`5`; no `h1` stories +- [ ] Storybook stories include: default, size `s` / `l`, horizontal, custom `heading-level`, with actions +- [ ] VRT story (`illustrated-message.test-vrt.ts` equivalent) + +### Documentation + +- [ ] JSDoc on all public props, slots, and CSS custom properties +- [ ] Storybook argTypes driven by `ILLUSTRATED_MESSAGE_VALID_SIZES` and `ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS` static arrays +- [ ] Migration notes: `heading-level` replaces hard-coded `h2`; heading slot now `span`-only; new `size`, `horizontal`, `actions` slot +- [ ] Storybook examples vary `heading-level` by context (not always `2`) +- [ ] Decorative vs meaningful illustration guidance in Storybook + +### Review + +- [ ] `yarn lint:2nd-gen` passes (ESLint, Stylelint, Prettier) +- [ ] Status table in workstream doc updated +- [ ] PR created with description referencing SWC-1834 +- [ ] Peer engineer sign-off + +--- + +## Blockers and open questions + +| # | Item | Status | Owner | +|---|---|---|---| +| **Q1** | **`heading` attribute + `heading` slot precedence:** Slot takes precedence via `<slot name="heading">${this.heading}</slot>` — attribute is the fallback, slot content overrides it when present. | **Resolved** | +| **Q2** | **`heading-level` clamping vs type-error:** Silently clamp using `Math.max(2, Math.min(6, level))` — consistent with `AccordionItem.getHeadingLevel()` precedent in the codebase. | **Resolved** | +| **Q3** | **Actions slot type:** Leave untyped — consumer slots any button group content. A focus group navigation controller will need to be implemented in a future follow-up. | **Resolved** | +| **Q4** | **Typography styles dependency (SWC-1545):** 2nd-gen has typography classes but whether they will be importable in the same way as S1's `bodyStyles`/`headingStyles` is TBD. Tracked in SWC-1545. | Open | SWC-1545 | + +--- + +## References + +- [Washing machine workflow](../../02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md) +- [Accessibility migration analysis](./accessibility-migration-analysis.md) +- [Rendering and styling migration analysis](./rendering-and-styling-migration-analysis.md) +- [CSS style guide — Component Custom Property Exposure](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#component-custom-property-exposure) +- [CSS style guide — Selector conventions](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#selector-conventions) +- [1st-gen source](../../../../1st-gen/packages/illustrated-message/src/IllustratedMessage.ts) +- [1st-gen tests](../../../../1st-gen/packages/illustrated-message/test/illustrated-message.test.ts) +- [Badge migration reference](../../02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md#reference-badge-migration) +- [spectrum-css migration PR #3246](https://github.com/adobe/spectrum-css/pull/3246) +- SWC-1834 (this ticket), SWC-1466 (accordion heading level — analogous precedent), SWC-1545 (typography classes) From 780474eda452daec24877b3cf96faafad18a54e9 Mon Sep 17 00:00:00 2001 From: Miwha Bonini <mbonini@adobe.com> Date: Wed, 8 Apr 2026 15:46:53 -0600 Subject: [PATCH 3/7] feat(illustrated-message): file structure and initial API (#6150) * feat(illustrated-message): file structure and initial API pass * test(illustrated-message): add stories, interaction tests, and a11y spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(illustrated-message): update a11y snapshot to match current story heading Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(illustrated-message): extract headingClass const and fix Playground illustration slot * refactor(illustrated-message): remove heading and description attributes, rely on slots only * refactor(illustrated-message): update stories to use template pattern and simplify API docs * refactor(illustrated-message): update stories to use html template slot pattern with inline SVG --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../IllustratedMessage.base.ts | 98 ++++++++ .../IllustratedMessage.types.ts | 18 ++ .../components/illustrated-message/index.ts | 13 + 2nd-gen/packages/swc/.storybook/preview.ts | 28 ++- .../illustrated-message/IllustratedMessage.ts | 84 +++++++ .../illustrated-message.css | 47 ++++ .../components/illustrated-message/index.ts | 24 ++ .../stories/illustrated-message.stories.ts | 162 ++++++++++++ .../test/illustrated-message.a11y.spec.ts | 53 ++++ .../test/illustrated-message.test.ts | 234 ++++++++++++++++++ .../accessibility-migration-analysis.md | 7 +- .../illustrated-message/migration-plan.md | 3 +- 12 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts create mode 100644 2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts create mode 100644 2nd-gen/packages/core/components/illustrated-message/index.ts create mode 100644 2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts create mode 100644 2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css create mode 100644 2nd-gen/packages/swc/components/illustrated-message/index.ts create mode 100644 2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts create mode 100644 2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts create mode 100644 2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts new file mode 100644 index 00000000000..4a3258a35f1 --- /dev/null +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts @@ -0,0 +1,98 @@ +/** + * 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 { + ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS, + type IllustratedMessageHeadingLevel, +} from './IllustratedMessage.types.js'; + +/** + * An illustrated message displays an illustration and a message, typically + * used in empty states or error pages. + * + * @slot - Decorative or informative SVG illustration. Decorative SVGs should include + * `aria-hidden="true"`; informative SVGs should include `role="img"` and `aria-label`. + * @slot heading - Heading text. Must be a single `<span>` element — the shadow DOM owns the + * heading tag and level. Consumers who previously slotted an `<h2>` must switch to `<span>`. + * @slot description - Description text. Links must be real `<a>` elements or link components + * with visible names. + */ +export abstract class IllustratedMessageBase extends SpectrumElement { + // ───────────────────────── + // API TO OVERRIDE + // ───────────────────────── + + /** + * @internal + */ + static readonly VALID_HEADING_LEVELS: readonly IllustratedMessageHeadingLevel[] = + ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS; + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * The heading level rendered in shadow DOM. Accepts 2–6; values outside + * this range are clamped using `Math.max(2, Math.min(6, value))`. + */ + @property({ type: Number, reflect: true, attribute: 'heading-level' }) + public headingLevel: IllustratedMessageHeadingLevel = 2; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + /** + * Returns a valid heading level clamped to 2–6. + * + * @internal + */ + protected getHeadingLevel(): number { + return Math.max(2, Math.min(6, this.headingLevel)); + } + + protected override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (window.__swc?.DEBUG) { + if ( + changedProperties.has('headingLevel') && + (this.headingLevel < 2 || this.headingLevel > 6) + ) { + window.__swc.warn( + this, + `<${this.localName}> received an invalid "heading-level" value of "${this.headingLevel}". Valid values are 2–6. The value has been clamped to ${this.getHeadingLevel()}.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { issues: [`heading-level="${this.headingLevel}"`] } + ); + } + + const headingSlot = this.shadowRoot?.querySelector<HTMLSlotElement>( + 'slot[name="heading"]' + ); + const assigned = headingSlot?.assignedElements() ?? []; + if (assigned.length > 0 && assigned[0].tagName.toLowerCase() !== 'span') { + window.__swc.warn( + this, + `<${this.localName}> expects the "heading" slot to contain a single <span> element. The shadow DOM owns the heading tag. Received: <${assigned[0].tagName.toLowerCase()}>.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { issues: [`heading slot: <${assigned[0].tagName.toLowerCase()}>`] } + ); + } + } + } +} diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts new file mode 100644 index 00000000000..73ac167e89e --- /dev/null +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export const ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS = [ + 2, 3, 4, 5, 6, +] as const; + +export type IllustratedMessageHeadingLevel = + (typeof ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS)[number]; diff --git a/2nd-gen/packages/core/components/illustrated-message/index.ts b/2nd-gen/packages/core/components/illustrated-message/index.ts new file mode 100644 index 00000000000..9ba5dfc60db --- /dev/null +++ b/2nd-gen/packages/core/components/illustrated-message/index.ts @@ -0,0 +1,13 @@ +/** + * 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. + */ +export * from './IllustratedMessage.base.js'; +export * from './IllustratedMessage.types.js'; diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 3b56cf75874..2676cbad6c2 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -314,6 +314,7 @@ const preview = { 'Step by step', [ 'Analyze rendering and styling', + 'Washing machine workflow', 'Factor rendering out of 1st gen component', 'Move base class to 2nd gen core', 'Formalize spectrum data model', @@ -321,6 +322,7 @@ const preview = { 'Migrate rendering and styles', 'Add stories for 2nd gen component', ], + 'Migration project planning', ], 'Accessibility improvements', 'Component improvements', @@ -339,7 +341,10 @@ const preview = { 'Avatar', ['Rendering and styling migration analysis'], 'Badge', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Button', ['Rendering and styling migration analysis'], 'Button group', @@ -349,7 +354,10 @@ const preview = { 'Color field', ['Rendering and styling migration analysis'], 'Divider', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Dropzone', ['Rendering and styling migration analysis'], 'Field group', @@ -359,7 +367,11 @@ const preview = { 'Help text', ['Rendering and styling migration analysis'], 'Illustrated message', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Migration plan', + 'Rendering and styling migration analysis', + ], 'Infield button', ['Rendering and styling migration analysis'], 'Infield progress circle', @@ -377,7 +389,10 @@ const preview = { 'Progress bar', ['Rendering and styling migration analysis'], 'Progress circle', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Radio', ['Rendering and styling migration analysis'], 'Search', @@ -385,7 +400,10 @@ const preview = { 'Slider', ['Rendering and styling migration analysis'], 'Status light', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Swatch', ['Rendering and styling migration analysis'], 'Swatch group', diff --git a/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts b/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts new file mode 100644 index 00000000000..1937cec1dfa --- /dev/null +++ b/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts @@ -0,0 +1,84 @@ +/** + * 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 { CSSResultArray, html, TemplateResult } from 'lit'; + +import { IllustratedMessageBase } from '@spectrum-web-components/core/components/illustrated-message'; + +import styles from './illustrated-message.css'; + +/** + * @element swc-illustrated-message + * @status preview + * @since 0.0.1 + * + * @example + * <swc-illustrated-message> + * <svg slot="" aria-hidden="true" viewBox="0 0 200 160"><!-- illustration --></svg> + * <span slot="heading">Create your first asset.</span> + * <span slot="description">Get started by uploading or importing some assets.</span> + * </swc-illustrated-message> + * + * @example + * <swc-illustrated-message heading-level="3"> + * <svg slot="" aria-hidden="true" viewBox="0 0 200 160"><!-- illustration --></svg> + * <span slot="heading">No results found.</span> + * <span slot="description">Try adjusting your search or filters.</span> + * </swc-illustrated-message> + */ +export class IllustratedMessage extends IllustratedMessageBase { + // ────────────────────────────── + // RENDERING & STYLING + // ────────────────────────────── + + public static override get styles(): CSSResultArray { + return [styles]; + } + + protected override render(): TemplateResult { + const level = this.getHeadingLevel(); + const headingClass = 'swc-IllustratedMessage-heading'; + const heading = html`<slot name="heading"></slot>`; + + return html` + <div class="swc-IllustratedMessage"> + <div class="swc-IllustratedMessage-illustration"> + <slot></slot> + </div> + <div class="swc-IllustratedMessage-content"> + ${level === 2 + ? html` + <h2 class=${headingClass}>${heading}</h2> + ` + : level === 3 + ? html` + <h3 class=${headingClass}>${heading}</h3> + ` + : level === 4 + ? html` + <h4 class=${headingClass}>${heading}</h4> + ` + : level === 5 + ? html` + <h5 class=${headingClass}>${heading}</h5> + ` + : html` + <h6 class=${headingClass}>${heading}</h6> + `} + <div class="swc-IllustratedMessage-description"> + <slot name="description"></slot> + </div> + </div> + </div> + `; + } +} diff --git a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css new file mode 100644 index 00000000000..f36f037b336 --- /dev/null +++ b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css @@ -0,0 +1,47 @@ +/** + * 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. + */ + +/* @todo SWC-1838 — replace with full S2 token-based styles */ + +:host { + display: block; +} + +* { + box-sizing: border-box; +} + +.swc-IllustratedMessage { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.swc-IllustratedMessage-illustration { + display: flex; + align-items: center; + justify-content: center; +} + +.swc-IllustratedMessage-content { + display: flex; + flex-direction: column; + align-items: center; +} + +/* @todo SWC-1838 — heading typography (size, weight, line-height, color) */ +.swc-IllustratedMessage-heading { + margin: 0; +} + +/* @todo SWC-1838 — description typography (size, weight, line-height, color) */ diff --git a/2nd-gen/packages/swc/components/illustrated-message/index.ts b/2nd-gen/packages/swc/components/illustrated-message/index.ts new file mode 100644 index 00000000000..cc7a09144c9 --- /dev/null +++ b/2nd-gen/packages/swc/components/illustrated-message/index.ts @@ -0,0 +1,24 @@ +/** + * 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 { defineElement } from '@spectrum-web-components/core/element/index.js'; + +import { IllustratedMessage } from './IllustratedMessage.js'; + +export * from './IllustratedMessage.js'; + +declare global { + interface HTMLElementTagNameMap { + 'swc-illustrated-message': IllustratedMessage; + } +} + +defineElement('swc-illustrated-message', IllustratedMessage); diff --git a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts new file mode 100644 index 00000000000..6c9f1973ae3 --- /dev/null +++ b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts @@ -0,0 +1,162 @@ +/** + * 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 { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; +import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; + +import { IllustratedMessage } from '@adobe/spectrum-wc/illustrated-message'; + +import '../../icon'; + +// ──────────────── +// METADATA +// ──────────────── + +const { args, argTypes, template } = getStorybookHelpers( + 'swc-illustrated-message' +); + +argTypes['heading-level'] = { + ...argTypes['heading-level'], + control: { type: 'select' }, + options: IllustratedMessage.VALID_HEADING_LEVELS, + table: { + category: 'attributes', + defaultValue: { summary: '2' }, + }, +}; + +/** + * An illustrated message displays an illustration and a message, typically + * used in empty states or error pages. + * + * ### Heading level + * + * The `heading-level` attribute controls the semantic heading level rendered + * in shadow DOM (`h2`–`h6`) to match the document outline. It does not affect + * the visual appearance — all levels render at the same size. + * + * Set `heading-level` based on the heading hierarchy of the page, not visual + * preference. + */ +export const meta: Meta = { + title: 'Illustrated Message', + component: 'swc-illustrated-message', + args, + argTypes, + render: (args) => template(args), + parameters: { + docs: { + subtitle: 'Display an illustration with a heading and description.', + }, + }, + tags: ['migrated'], +}; + +export default { + ...meta, + title: 'Illustrated Message', + excludeStories: ['meta'], +} as Meta; + +// ──────────────────── +// HELPERS +// ──────────────────── + +const cloudIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="95" height="95" viewBox="0 0 160 160" fill="none"> +<path d="M89.3301 28.5C108.54 28.5001 124.013 43.9629 125.408 63.2666C140.627 66.0007 152 79.7946 152 96.1348C152 114.396 137.782 129.5 119.883 129.5C119.845 129.5 119.81 129.499 119.78 129.498C119.762 129.498 119.744 129.5 119.726 129.5H31.8809C31.7135 129.5 31.5486 129.489 31.3867 129.469C18.2497 129.009 8.00002 117.812 8 104.385C8 94.2106 13.8624 85.3674 22.4043 81.4414C22.3044 80.4799 22.2481 79.4992 22.248 78.5C22.248 62.9098 34.3925 49.9717 49.7344 49.9717C51.9927 49.9717 54.1852 50.2598 56.2822 50.7949C61.8951 37.7322 74.5088 28.5 89.3301 28.5ZM89.3301 36.5C76.8952 36.5 66.2004 45.0053 62.5117 57.0029C62.1777 58.0892 61.397 58.9825 60.3652 59.459C59.3335 59.9354 58.148 59.95 57.1045 59.5C54.8246 58.5167 52.3407 57.9717 49.7344 57.9717C39.1347 57.9717 30.248 66.997 30.248 78.5C30.2481 80.0858 30.4383 81.6436 30.7773 83.1748C31.2346 85.2397 30.006 87.3041 27.9727 87.8857C21.1679 89.8324 16 96.3956 16 104.385C16 113.898 23.2731 121.333 31.9502 121.482C32.0505 121.484 32.1497 121.491 32.248 121.5H119.548C119.614 121.497 119.681 121.496 119.747 121.496C119.805 121.496 119.854 121.497 119.889 121.498C119.901 121.498 119.912 121.499 119.923 121.499C133.063 121.477 144 110.295 144 96.1348C144 82.4618 133.788 71.5543 121.259 70.8145C119.114 70.6878 117.452 68.8891 117.495 66.7412C117.504 66.2932 117.512 66.3311 117.512 66.1104C117.512 49.5915 104.732 36.5001 89.3301 36.5Z" fill="#292929"/> +</svg>`; + +// ──────────────────── +// STORIES +// ──────────────────── + +const defaultSlots = html` + <span slot="">${unsafeHTML(cloudIcon)}</span> + <span slot="heading">Illustrated message title</span> + <span slot="description"> + Illustrated message description. Give more information about what a user can + do, expect, or how to make items appear. + </span> +`; + +export const Playground: Story = { + render: (args) => template(args, defaultSlots), + tags: ['autodocs', 'dev'], +}; + +export const Overview: Story = { + render: (args) => template(args, defaultSlots), + tags: ['overview'], +}; + +/** + * The `heading-level` attribute controls the semantic heading level rendered + * in shadow DOM (`h2`–`h6`) to match the document outline. It does not affect + * the visual appearance — all levels render at the same size. + * + * Set `heading-level` based on the heading hierarchy of the page, not visual + * preference. + */ +export const HeadingLevels: Story = { + render: (args) => html` + ${IllustratedMessage.VALID_HEADING_LEVELS.map((level) => + template({ ...args, 'heading-level': level }, defaultSlots) + )} + `, + tags: ['options'], +}; + +/** + * SVGs slotted into the illustration slot should declare their accessibility + * intent explicitly: + * + * - **Decorative** (most common): add `aria-hidden="true"` so screen readers + * skip the graphic entirely. + * - **Informative**: add `role="img"` and `aria-label` (or an inline `<title>`) + * so screen readers announce the illustration's meaning. + */ +export const IllustrationAccessibility: Story = { + render: (args) => html` + ${template( + args, + html` + <span slot="">${unsafeHTML(cloudIcon)}</span> + <span slot="heading">Illustrated message title</span> + <span slot="description"> + The icon above uses + <code>aria-hidden="true"</code> + — screen readers skip the illustration entirely and move on to the + heading and description. + </span> + ` + )} + ${template( + args, + html` + <span slot="">${unsafeHTML(cloudIcon)}</span> + <span slot="heading">Illustrated message title</span> + <span slot="description"> + The icon above uses + <code>role="img"</code> + and + <code>aria-label</code> + — screen readers announce its meaning before reading the heading and + description. + </span> + ` + )} + `, + tags: ['a11y'], +}; diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts new file mode 100644 index 00000000000..ef9c5724e34 --- /dev/null +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts @@ -0,0 +1,53 @@ +/** + * 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 { expect, test } from '@playwright/test'; + +import { gotoStory } from '../../../utils/a11y-helpers.js'; + +/** + * Accessibility tests for IllustratedMessage component (2nd Generation) + * + * ARIA snapshot tests validate the accessibility tree structure. + * aXe WCAG compliance and color contrast validation are run via + * test-storybook (see .storybook/test-runner.ts). Both are included + * in the `test:a11y` command. + */ + +test.describe('IllustratedMessage - ARIA Snapshots', () => { + test('should have correct accessibility tree for overview', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-illustrated-message--overview', + 'swc-illustrated-message' + ); + await expect(root).toMatchAriaSnapshot(` + - heading "Illustrated message title" [level=2] + `); + }); + + test('should render correct heading levels for heading-levels story', async ({ + page, + }) => { + await gotoStory( + page, + 'components-illustrated-message--heading-levels', + 'swc-illustrated-message' + ); + const headings = page.locator('swc-illustrated-message'); + await expect(headings.nth(0).locator('h2')).toBeVisible(); + await expect(headings.nth(1).locator('h3')).toBeVisible(); + await expect(headings.nth(2).locator('h4')).toBeVisible(); + }); +}); diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts new file mode 100644 index 00000000000..1c614fef1e8 --- /dev/null +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts @@ -0,0 +1,234 @@ +/** + * 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 { html } from 'lit'; +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { IllustratedMessage } from '@adobe/spectrum-wc/illustrated-message'; + +import '@adobe/spectrum-wc/illustrated-message'; + +import { getComponent, withWarningSpy } from '../../../utils/test-utils.js'; +import meta from '../stories/illustrated-message.stories.js'; +import { Overview } from '../stories/illustrated-message.stories.js'; + +// This file defines dev-only test stories that reuse the main story metadata. +export default { + ...meta, + title: 'Illustrated Message/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// TEST: Defaults +// ────────────────────────────────────────────────────────────── + +export const OverviewTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('renders with default heading-level 2', async () => { + expect(illustratedMessage.headingLevel, 'default heading level').toBe(2); + }); + + await step('renders an h2 element in shadow DOM by default', async () => { + expect( + illustratedMessage.shadowRoot?.querySelector('h2'), + 'h2 in shadow DOM' + ).not.toBeNull(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Properties / Attributes +// ────────────────────────────────────────────────────────────── + +export const AllHeadingLevelsTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Heading level test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + for (const level of [3, 4, 5, 6] as const) { + await step( + `renders h${level} when heading-level is set to ${level}`, + async () => { + illustratedMessage.setAttribute('heading-level', String(level)); + await illustratedMessage.updateComplete; + expect( + illustratedMessage.shadowRoot?.querySelector(`h${level}`), + `h${level} in shadow DOM` + ).not.toBeNull(); + expect( + illustratedMessage.shadowRoot?.querySelector(`h${level - 1}`), + `h${level - 1} absent after heading-level change` + ).toBeNull(); + } + ); + } + }, +}; + +export const HeadingLevelClampTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Clamped heading</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('clamps heading-level="1" to h2, never renders h1', async () => { + illustratedMessage.setAttribute('heading-level', '1'); + await illustratedMessage.updateComplete; + expect( + illustratedMessage.shadowRoot?.querySelector('h1'), + 'h1 must not exist in shadow DOM' + ).toBeNull(); + expect( + illustratedMessage.shadowRoot?.querySelector('h2'), + 'h2 rendered after clamping' + ).not.toBeNull(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Slots +// ────────────────────────────────────────────────────────────── + +export const DescriptionSlotTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="description">Description text here.</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('renders description slot content', async () => { + const slotted = illustratedMessage.querySelector('[slot="description"]'); + expect(slotted, 'description slot element').not.toBeNull(); + expect(slotted?.textContent?.trim(), 'description text').toBe( + 'Description text here.' + ); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Dev mode warnings +// ────────────────────────────────────────────────────────────── + +export const InvalidHeadingLevelWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('warns when heading-level is set to an out-of-range value', () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.setAttribute('heading-level', '1'); + await illustratedMessage.updateComplete; + + expect( + warnCalls.length, + 'warning count for invalid heading-level' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message mentions heading-level' + ).toContain('heading-level'); + }) + ); + }, +}; + +export const ValidHeadingLevelNoWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('does not warn when a valid heading-level is set', () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.setAttribute('heading-level', '3'); + await illustratedMessage.updateComplete; + + expect(warnCalls.length, 'no warnings for valid heading-level').toBe(0); + }) + ); + }, +}; + +export const InvalidHeadingSlotWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <h3 slot="heading">Heading as h3</h3> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('warns when heading slot contains a non-span element', () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.requestUpdate(); + await illustratedMessage.updateComplete; + + expect( + warnCalls.length, + 'warning count for non-span heading slot' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message mentions heading slot' + ).toContain('heading'); + }) + ); + }, +}; diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md index 03d6d125997..cf4ca688a8e 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/accessibility-migration-analysis.md @@ -49,7 +49,7 @@ This doc defines how `swc-illustrated-message` should work for accessibility and ### Heading hierarchy and page context -The component cannot know which `h2`–`h6` level is correct for the page; authors must set that explicitly. The only supported pattern is: title text comes from the `heading` attribute and/or the `heading` slot (see below), and the semantic heading element is always created in shadow DOM. +The component cannot know which `h2`–`h6` level is correct for the page; authors must set that explicitly. The only supported pattern is: title text comes from the `heading` slot (see below), and the semantic heading element is always created in shadow DOM. Do not use `h1` for the illustrated message title. `h1` is for the primary page (or dialog / sheet title outside this block). This component exposes `h2`–`h6` only via `heading-level` (`2`–`6`). @@ -57,7 +57,6 @@ Do not use `h1` for the illustrated message title. `h1` is for the primary page - `heading-level` property (attribute `heading-level`): integers `2`–`6`, default `2`. The shadow tree renders exactly one `<h2>` … `<h6>` matching that value. Values outside `2`–`6` (including `1`) must be clamped or coerced to `2`–`6` (for example `1` → `2`), or rejected in types with a documented default—pick one policy and document it in Storybook. - `heading` slot: accepts a `span` only (or equivalent documented phrasing: a single `span` wrapper as the slotted node). Do not allow slotted `<h1>`–`<h6>`; authors must not put heading elements in light DOM for this slot. Implementation may validate in dev and warn or ignore invalid slotted tags. -- Optional `heading` attribute: when used, its text is rendered as the title inside the shadow heading (alongside or instead of slot content per product rules—document the precedence if both exist). This differs from putting a real heading in the slot (as in accordion item titles) and from 1st-gen, which always wraps the slot in `<h2>` with no level control. Accordion still allows `level` `1`–`6` on the parent ([SWC-1466](https://jira.corp.adobe.com/browse/SWC-1466), [PR #5969](https://github.com/adobe/spectrum-web-components/pull/5969)); illustrated message uses `heading-level` `2`–`6` only (no `h1`). @@ -93,7 +92,7 @@ Bottom line: Authors choose `heading-level` (`2`–`6`, i.e. `h2`–`h6`) to mat | `heading-level` | Required behavior: `2`–`6`, default `2`. Clamp or coerce out-of-range values; document behavior for invalid input (same spirit as accordion `getHeadingLevel()`). | | `heading` slot | Span only: document that the slot must contain a `span` (or stricter: exactly one root `span`). No slotted `<h1>`–`<h6>`. | | Shadow heading | Single heading element in shadow DOM; tag is `<h2>`–`<h6>` per `heading-level`. Slot and/or `heading` attribute supply text content inside that element (not the element type). | -| `heading` attribute | Plain-text title when slot is empty or as fallback; document interaction with slotted content if both are present. | +| `heading` slot | Only mechanism for providing heading content; no attribute fallback. | | Docs | Examples across `heading-level` `2`–`6` (for example below page `h1` vs nested under `h3`). Contrast with accordion: accordion allows `level` `1`; illustrated message does not. | ### Other content and slots @@ -112,7 +111,7 @@ Bottom line: Authors choose `heading-level` (`2`–`6`, i.e. `h2`–`h6`) to mat Typical open state -- One heading: correct `h2`–`h6` from `heading-level`, with label text from `heading` attribute and/or `heading` slot (`span` content). +- One heading: correct `h2`–`h6` from `heading-level`, with label text from the `heading` slot (`span` content). - Description as text content (and focusable links if present). - Illustration exposed or hidden per decorative vs informative rules. diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md index 43c4d3608cb..7120f20d4af 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md @@ -88,6 +88,7 @@ No mixins, no shared utilities, no other SWC components composed inside. No depe |---|---|---|---|---| | **B1** | Heading slot content type | Accepts any node inside `<h2>` | Accepts `<span>` only; shadow DOM owns the heading tag | Consumers slotting plain text or `<span>` are unaffected. Consumers who slotted `<h2>`–`<h6>` (incorrect but possible) must switch to `<span>`. Ships now — deferring would cause font property inheritance side-effects and a second migration event. | | **B2** | CSS token migration (S1 → S2) | Uses `--spectrum-*` base tokens with `--mod-*` override chains (e.g. `var(--mod-illustrated-message-title-color, var(--spectrum-illustrated-message-title-color))`). Forced-colors override applied on `:host`. | `--mod-*` and `--spectrum-*` chains removed; collapsed into `--swc-*` (exposed) or `--_swc-*` (private) properties per [Component Custom Property Exposure](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#component-custom-property-exposure). Forced colors moved to internal `.swc-IllustratedMessage` selector. | Since no `--mod-*` properties were ever documented as public API, there is no consumer breakage. Any new `--swc-*` properties introduced are additive capability. | +| **B3** | `heading` and `description` attributes removed | `heading` and `description` were available as plain-text attribute fallbacks when slots were empty. | Both attributes and properties are removed. All content must be provided via slots: `<span slot="heading">` and `<span slot="description">`. This is idiomatic web component API and avoids a dual-path content model. Consumers must switch to slots. | | **A11y** | Heading level control | Always `<h2>`, no way for consumers to change level | `heading-level` attribute (`2`–`6`, default `2`); shadow DOM renders the correct `<hN>` tag | Consumers using the default are unaffected. Consumers needing a different level add `heading-level`. Required for WCAG 1.3.1 and 2.4.6 compliance. | | **A11y** | Illustration accessibility | No handling for decorative vs informative SVGs | Decorative SVGs: `aria-hidden="true"`; informative: `role="img"` + `aria-label` / `<title>` | Slot contract; documented guidance rather than enforced by the component. | @@ -109,8 +110,6 @@ These are derived from the a11y analysis and rendering roadmap. Confirmed items | Property | Type | Default | Attribute | Notes | |---|---|---|---|---| -| `heading` | `string` | `''` | `heading` | Carry forward; attribute is fallback, slot takes precedence via `<slot name="heading">${this.heading}</slot>` | -| `description` | `string` | `''` | `description` | Carry forward; same fallback pattern | | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | `heading-level` | **New.** Values outside `2`–`6` silently clamped using `Math.max(2, Math.min(6, level))` — same pattern as `AccordionItem.getHeadingLevel()`. | | `size` | `'s' \| 'm' \| 'l'` | `'m'` | `size` | **New.** `m` is the implicit base style `s` and `l` override via `:host([size="s"])` / `:host([size="l"])` attribute selectors per [selector conventions](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#selector-conventions). | | `horizontal` | `boolean` | `false` | `horizontal` | **New.** Drives horizontal layout variant. | From 40e18621f10bbcc2b0cc8e6fba94495c8c74ac49 Mon Sep 17 00:00:00 2001 From: Miwha Bonini <mbonini@adobe.com> Date: Mon, 13 Apr 2026 12:46:49 -0600 Subject: [PATCH 4/7] feat(illustrated-message) - new size and orientation API (#6155) * feat(illustrated-message): file structure and initial API pass * test(illustrated-message): add stories, interaction tests, and a11y spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(illustrated-message): update a11y snapshot to match current story heading Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(illustrated-message): add size and orientation additive features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(illustrated-message): re-add size and orientation additive API * feat(illustrated-message): add size and orientation additive API and css * fix(illustrated-message): add runtime validation and tighten const types * test(illustrated-message): add size and orientation attribute/property tests --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../IllustratedMessage.base.ts | 55 ++++++- .../IllustratedMessage.types.ts | 21 ++- .../illustrated-message.css | 24 +++ .../test/illustrated-message.test.ts | 141 ++++++++++++++++++ .../illustrated-message/migration-plan.md | 12 +- 5 files changed, 244 insertions(+), 9 deletions(-) diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts index 4a3258a35f1..c3eb7837149 100644 --- a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts @@ -17,7 +17,11 @@ import { SpectrumElement } from '@spectrum-web-components/core/element/index.js' import { ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS, + ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS, + ILLUSTRATED_MESSAGE_VALID_SIZES, type IllustratedMessageHeadingLevel, + type IllustratedMessageOrientation, + type IllustratedMessageSize, } from './IllustratedMessage.types.js'; /** @@ -42,17 +46,40 @@ export abstract class IllustratedMessageBase extends SpectrumElement { static readonly VALID_HEADING_LEVELS: readonly IllustratedMessageHeadingLevel[] = ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS; + /** + * @internal + */ + static readonly VALID_SIZES: readonly IllustratedMessageSize[] = + ILLUSTRATED_MESSAGE_VALID_SIZES; + + /** + * @internal + */ + static readonly VALID_ORIENTATIONS: readonly IllustratedMessageOrientation[] = + ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS; + // ────────────────── // SHARED API // ────────────────── /** - * The heading level rendered in shadow DOM. Accepts 2–6; values outside - * this range are clamped using `Math.max(2, Math.min(6, value))`. + * The heading level of the illustrated message title. Accepts 2–6. */ @property({ type: Number, reflect: true, attribute: 'heading-level' }) public headingLevel: IllustratedMessageHeadingLevel = 2; + /** + * The size of the illustrated message. + */ + @property({ type: String, reflect: true }) + public size: IllustratedMessageSize = 'm'; + + /** + * The layout orientation of the illustrated message. + */ + @property({ type: String, reflect: true }) + public orientation: IllustratedMessageOrientation = 'vertical'; + // ────────────────────── // IMPLEMENTATION // ────────────────────── @@ -69,6 +96,30 @@ export abstract class IllustratedMessageBase extends SpectrumElement { protected override updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (window.__swc?.DEBUG) { + if ( + changedProperties.has('size') && + !ILLUSTRATED_MESSAGE_VALID_SIZES.includes(this.size) + ) { + window.__swc.warn( + this, + `<${this.localName}> received an invalid "size" value of "${this.size}". Valid values are ${ILLUSTRATED_MESSAGE_VALID_SIZES.join(', ')}.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { issues: [`size="${this.size}"`] } + ); + } + + if ( + changedProperties.has('orientation') && + !ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS.includes(this.orientation) + ) { + window.__swc.warn( + this, + `<${this.localName}> received an invalid "orientation" value of "${this.orientation}". Valid values are ${ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS.join(', ')}.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { issues: [`orientation="${this.orientation}"`] } + ); + } + if ( changedProperties.has('headingLevel') && (this.headingLevel < 2 || this.headingLevel > 6) diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts index 73ac167e89e..5ca2edcaac0 100644 --- a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts @@ -10,9 +10,28 @@ * governing permissions and limitations under the License. */ +import type { ElementSize } from '@spectrum-web-components/core/mixins/index.js'; + export const ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS = [ 2, 3, 4, 5, 6, -] as const; +] as const satisfies readonly number[]; + +export const ILLUSTRATED_MESSAGE_VALID_SIZES = [ + 's', + 'm', + 'l', +] as const satisfies readonly ElementSize[]; + +export const ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS = [ + 'vertical', + 'horizontal', +] as const satisfies readonly string[]; export type IllustratedMessageHeadingLevel = (typeof ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS)[number]; + +export type IllustratedMessageSize = + (typeof ILLUSTRATED_MESSAGE_VALID_SIZES)[number]; + +export type IllustratedMessageOrientation = + (typeof ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS)[number]; diff --git a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css index f36f037b336..ea18fc981ee 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css +++ b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css @@ -45,3 +45,27 @@ } /* @todo SWC-1838 — description typography (size, weight, line-height, color) */ + +/* ── Size ───────────────────────────────────────────────────────────────── */ + +/* @todo SWC-1838 — replace with full S2 token-based size values */ + +:host([size="s"]) .swc-IllustratedMessage-illustration { + /* @todo SWC-1838 */ +} + +:host([size="l"]) .swc-IllustratedMessage-illustration { + /* @todo SWC-1838 */ +} + +/* ── Orientation ─────────────────────────────────────────────────────────── */ + +/* @todo SWC-1838 — replace with full S2 token-based orientation values */ + +:host([orientation="horizontal"]) .swc-IllustratedMessage { + /* @todo SWC-1838 */ +} + +:host([orientation="horizontal"]) .swc-IllustratedMessage-content { + /* @todo SWC-1838 */ +} diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts index 1c614fef1e8..77ab9a2790e 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts @@ -203,6 +203,147 @@ export const ValidHeadingLevelNoWarningTest: Story = { }, }; +// ────────────────────────────────────────────────────────────── +// TEST: Size attribute / property +// ────────────────────────────────────────────────────────────── + +export const ValidSizeNoWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + for (const size of IllustratedMessage.VALID_SIZES) { + await step( + `does not warn and reflects property when size="${size}"`, + () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.setAttribute('size', size); + await illustratedMessage.updateComplete; + + expect(warnCalls.length, `no warnings for size="${size}"`).toBe(0); + expect( + illustratedMessage.size, + `size property reflects "${size}"` + ).toBe(size); + expect( + illustratedMessage.getAttribute('size'), + `size attribute reflects "${size}"` + ).toBe(size); + }) + ); + } + }, +}; + +export const InvalidSizeWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('warns when size is set to an invalid value', () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.setAttribute('size', 'xl'); + await illustratedMessage.updateComplete; + + expect( + warnCalls.length, + 'warning count for invalid size' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message mentions size' + ).toContain('size'); + }) + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Orientation attribute / property +// ────────────────────────────────────────────────────────────── + +export const ValidOrientationNoWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + for (const orientation of IllustratedMessage.VALID_ORIENTATIONS) { + await step( + `does not warn and reflects property when orientation="${orientation}"`, + () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.setAttribute('orientation', orientation); + await illustratedMessage.updateComplete; + + expect( + warnCalls.length, + `no warnings for orientation="${orientation}"` + ).toBe(0); + expect( + illustratedMessage.orientation, + `orientation property reflects "${orientation}"` + ).toBe(orientation); + expect( + illustratedMessage.getAttribute('orientation'), + `orientation attribute reflects "${orientation}"` + ).toBe(orientation); + }) + ); + } + }, +}; + +export const InvalidOrientationWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <span slot="heading">Test</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('warns when orientation is set to an invalid value', () => + withWarningSpy(async (warnCalls) => { + illustratedMessage.setAttribute('orientation', 'diagonal'); + await illustratedMessage.updateComplete; + + expect( + warnCalls.length, + 'warning count for invalid orientation' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message mentions orientation' + ).toContain('orientation'); + }) + ); + }, +}; + export const InvalidHeadingSlotWarningTest: Story = { render: () => html` <swc-illustrated-message> diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md index 7120f20d4af..3a1a84e50e7 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md @@ -97,7 +97,7 @@ No mixins, no shared utilities, no other SWC components composed inside. No depe | # | What is added | Notes | |---|---|---| | **A1** | `size` attribute (`s` \| `m` \| `l`, default `m`) | Net-new t-shirt sizing. `m` is the base style, no extra ruleset needed. Implemented via `:host([size="..."])` attribute selectors, not modifier classes. | -| **A2** | `horizontal` boolean attribute | Net-new layout variant. Consumers not using it are unaffected. | +| **A2** | `orientation` string attribute (`'vertical'` \| `'horizontal'`, default `'vertical'`) | Net-new layout variant. Consumers not using it are unaffected. | | **A3** | `actions` slot | Net-new. Leave untyped — a focus group navigation controller will be needed in a future follow-up. | --- @@ -112,7 +112,7 @@ These are derived from the a11y analysis and rendering roadmap. Confirmed items |---|---|---|---|---| | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | `heading-level` | **New.** Values outside `2`–`6` silently clamped using `Math.max(2, Math.min(6, level))` — same pattern as `AccordionItem.getHeadingLevel()`. | | `size` | `'s' \| 'm' \| 'l'` | `'m'` | `size` | **New.** `m` is the implicit base style `s` and `l` override via `:host([size="s"])` / `:host([size="l"])` attribute selectors per [selector conventions](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#selector-conventions). | -| `horizontal` | `boolean` | `false` | `horizontal` | **New.** Drives horizontal layout variant. | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | `orientation` | **New.** Drives layout variant; `horizontal` places the illustration beside the content. | ### Slots (2nd-gen) @@ -162,7 +162,7 @@ Follow the [Badge migration reference](../../02_workstreams/02_2nd-gen-component ### API - [ ] `IllustratedMessage.types.ts`: `ILLUSTRATED_MESSAGE_VALID_SIZES`, `ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS`, derived types -- [ ] `IllustratedMessage.base.ts`: abstract base class built from 1st-gen as reference; `headingLevel`, `size`, `horizontal`, `heading`, `description` properties; `getHeadingLevel()` clamping helper; `window.__swc?.DEBUG` warnings for invalid `heading-level` and heading-slot content type; new S2 properties go directly in base with correct names (no old-name forwarding) +- [ ] `IllustratedMessage.base.ts`: abstract base class built from 1st-gen as reference; `headingLevel`, `size`, `orientation`, `heading`, `description` properties; `getHeadingLevel()` clamping helper; `window.__swc?.DEBUG` warnings for invalid `heading-level` and heading-slot content type; new S2 properties go directly in base with correct names (no old-name forwarding) - [ ] `IllustratedMessage.ts` (SWC): extends base, static `VALID_SIZES`, S2 rendering ### Styling @@ -189,16 +189,16 @@ Follow the [Badge migration reference](../../02_workstreams/02_2nd-gen-component ### Testing -- [ ] `test/illustrated-message.test.ts`: heading tag matches `heading-level`; default is `h2`; `heading-level="1"` does not produce `<h1>`; `size` and `horizontal` attribute reflection +- [ ] `test/illustrated-message.test.ts`: heading tag matches `heading-level`; default is `h2`; `heading-level="1"` does not produce `<h1>`; `size` and `orientation` attribute reflection - [ ] `test/illustrated-message.a11y.spec.ts`: Playwright `toMatchAriaSnapshot` with default story; `heading-level` variants `2`–`5`; no `h1` stories -- [ ] Storybook stories include: default, size `s` / `l`, horizontal, custom `heading-level`, with actions +- [ ] Storybook stories include: default, size `s` / `l`, orientation horizontal, custom `heading-level`, with actions - [ ] VRT story (`illustrated-message.test-vrt.ts` equivalent) ### Documentation - [ ] JSDoc on all public props, slots, and CSS custom properties - [ ] Storybook argTypes driven by `ILLUSTRATED_MESSAGE_VALID_SIZES` and `ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS` static arrays -- [ ] Migration notes: `heading-level` replaces hard-coded `h2`; heading slot now `span`-only; new `size`, `horizontal`, `actions` slot +- [ ] Migration notes: `heading-level` replaces hard-coded `h2`; heading slot now `span`-only; new `size`, `orientation`, `actions` slot - [ ] Storybook examples vary `heading-level` by context (not always `2`) - [ ] Decorative vs meaningful illustration guidance in Storybook From be639ad1c7d214d1abce55b580a18b6e62c2c65e Mon Sep 17 00:00:00 2001 From: Miwha Bonini <mbonini@adobe.com> Date: Fri, 17 Apr 2026 13:07:34 -0600 Subject: [PATCH 5/7] feat(illustrated-message): slot-based heading API + gen1 deprecation (#6173) * feat(illustrated-message): slot-based heading API + gen1 deprecation * fix(illustrated-message): enable verbose mode for deprecation tests and add heading slot validation * feat(illustrated-message): update status to unsupported (SWC-1944) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(illustrated-message): register custom element in stories to unblock a11y tests * fix(illustrated-message): replace component-level deprecation with property-level warnings * docs(contributor-docs): add deprecation warning guidance to debug-validation guide * docs(contributor-docs): rename "Warning structure" to "Deprecation warning structure" for clarity * fix(illustrated-message): scope heading slot validation to slotchange instead of every updated() * test(illustrated-message): update heading slot test * fix(illustrated-message): register slotchange listener unconditionally, guard warn inside method --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../src/IllustratedMessage.ts | 44 +++- .../test/illustrated-message.test.ts | 90 +++++++- .../IllustratedMessage.base.ts | 69 +++--- .../IllustratedMessage.types.ts | 7 - .../illustrated-message/IllustratedMessage.ts | 32 +-- .../illustrated-message.css | 12 +- .../stories/illustrated-message.stories.ts | 45 +--- .../test/illustrated-message.a11y.spec.ts | 13 +- .../test/illustrated-message.test.ts | 205 +++++++----------- .../02_typescript/17_debug-validation.md | 56 +++++ .../03_components/README.md | 1 + .../illustrated-message/migration-plan.md | 44 ++++ 12 files changed, 359 insertions(+), 259 deletions(-) diff --git a/1st-gen/packages/illustrated-message/src/IllustratedMessage.ts b/1st-gen/packages/illustrated-message/src/IllustratedMessage.ts index 498a40fab3d..737d4d905a8 100644 --- a/1st-gen/packages/illustrated-message/src/IllustratedMessage.ts +++ b/1st-gen/packages/illustrated-message/src/IllustratedMessage.ts @@ -36,11 +36,51 @@ export class IllustratedMessage extends SpectrumElement { return [headingStyles, bodyStyles, messageStyles]; } + /** + * @deprecated Use `<h2 slot="heading">` instead. + */ @property() - public heading = ''; + public get heading(): string { + return this._heading; + } + + public set heading(value: string) { + if (window.__swc?.DEBUG && value) { + window.__swc.warn( + this, + `The "heading" property on <${this.localName}> has been deprecated and will be removed in a future release. Use <h2 slot="heading"> instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { level: 'deprecation' } + ); + } + this._heading = value; + this.requestUpdate('heading', this._heading); + } + + private _heading = ''; + /** + * @deprecated Use `<span slot="description">` instead. + */ @property() - public description = ''; + public get description(): string { + return this._description; + } + + public set description(value: string) { + if (window.__swc?.DEBUG && value) { + window.__swc.warn( + this, + `The "description" property on <${this.localName}> has been deprecated and will be removed in a future release. Use <span slot="description"> instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { level: 'deprecation' } + ); + } + this._description = value; + this.requestUpdate('description', this._description); + } + + private _description = ''; protected override render(): TemplateResult { return html` diff --git a/1st-gen/packages/illustrated-message/test/illustrated-message.test.ts b/1st-gen/packages/illustrated-message/test/illustrated-message.test.ts index e2bc5537590..03054b0f2b8 100644 --- a/1st-gen/packages/illustrated-message/test/illustrated-message.test.ts +++ b/1st-gen/packages/illustrated-message/test/illustrated-message.test.ts @@ -9,13 +9,101 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { expect, fixture, html } from '@open-wc/testing'; +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { stub } from 'sinon'; import '@spectrum-web-components/illustrated-message/sp-illustrated-message.js'; import { IllustratedMessage } from '../'; describe('Illustrated Message', () => { + describe('dev mode', () => { + let consoleWarnStub!: ReturnType<typeof stub>; + before(() => { + window.__swc.verbose = true; + consoleWarnStub = stub(console, 'warn'); + }); + afterEach(() => { + consoleWarnStub.resetHistory(); + }); + after(() => { + window.__swc.verbose = false; + consoleWarnStub.restore(); + }); + it('warns when deprecated "heading" property is used', async () => { + const el = await fixture<IllustratedMessage>(html` + <sp-illustrated-message + heading="Drag and Drop Your File" + ></sp-illustrated-message> + `); + + await elementUpdated(el); + + expect(consoleWarnStub.called).to.be.true; + const spyCall = consoleWarnStub.getCall(0); + expect( + spyCall.args[0].includes('deprecated'), + 'confirm deprecation message' + ).to.be.true; + expect( + spyCall.args[0].includes('heading'), + 'confirm message references heading property' + ).to.be.true; + expect( + spyCall.args[spyCall.args.length - 1], + 'confirm `data` shape' + ).to.deep.equal({ + data: { + localName: 'sp-illustrated-message', + type: 'api', + level: 'deprecation', + }, + }); + }); + + it('warns when deprecated "description" property is used', async () => { + const el = await fixture<IllustratedMessage>(html` + <sp-illustrated-message + description="Additional descriptive text" + ></sp-illustrated-message> + `); + + await elementUpdated(el); + + expect(consoleWarnStub.called).to.be.true; + const spyCall = consoleWarnStub.getCall(0); + expect( + spyCall.args[0].includes('deprecated'), + 'confirm deprecation message' + ).to.be.true; + expect( + spyCall.args[0].includes('description'), + 'confirm message references description property' + ).to.be.true; + expect( + spyCall.args[spyCall.args.length - 1], + 'confirm `data` shape' + ).to.deep.equal({ + data: { + localName: 'sp-illustrated-message', + type: 'api', + level: 'deprecation', + }, + }); + }); + + it('does not warn when slot-based API is used', async () => { + await fixture<IllustratedMessage>(html` + <sp-illustrated-message> + <h2 slot="heading">Drag and Drop Your File</h2> + <span slot="description">Additional descriptive text</span> + </sp-illustrated-message> + `); + + expect(consoleWarnStub.called).to.be.false; + }); + }); + it('loads', async () => { const el = await fixture<IllustratedMessage>(html` <sp-illustrated-message diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts index c3eb7837149..a016b53d3c8 100644 --- a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts @@ -16,10 +16,8 @@ import { property } from 'lit/decorators.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; import { - ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS, ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS, ILLUSTRATED_MESSAGE_VALID_SIZES, - type IllustratedMessageHeadingLevel, type IllustratedMessageOrientation, type IllustratedMessageSize, } from './IllustratedMessage.types.js'; @@ -30,8 +28,10 @@ import { * * @slot - Decorative or informative SVG illustration. Decorative SVGs should include * `aria-hidden="true"`; informative SVGs should include `role="img"` and `aria-label`. - * @slot heading - Heading text. Must be a single `<span>` element — the shadow DOM owns the - * heading tag and level. Consumers who previously slotted an `<h2>` must switch to `<span>`. + * @slot heading - The heading element. Must be an `<h2>`–`<h6>` element. The consumer owns + * the heading tag and level. + * @todo SWC-1943 Add slot constraints once the CEM slot constraints work is complete: + * `{required} {allowedChildren: h2, h3, h4, h5, h6} {maxChildren: 1}` * @slot description - Description text. Links must be real `<a>` elements or link components * with visible names. */ @@ -40,12 +40,6 @@ export abstract class IllustratedMessageBase extends SpectrumElement { // API TO OVERRIDE // ───────────────────────── - /** - * @internal - */ - static readonly VALID_HEADING_LEVELS: readonly IllustratedMessageHeadingLevel[] = - ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS; - /** * @internal */ @@ -62,12 +56,6 @@ export abstract class IllustratedMessageBase extends SpectrumElement { // SHARED API // ────────────────── - /** - * The heading level of the illustrated message title. Accepts 2–6. - */ - @property({ type: Number, reflect: true, attribute: 'heading-level' }) - public headingLevel: IllustratedMessageHeadingLevel = 2; - /** * The size of the illustrated message. */ @@ -84,13 +72,17 @@ export abstract class IllustratedMessageBase extends SpectrumElement { // IMPLEMENTATION // ────────────────────── - /** - * Returns a valid heading level clamped to 2–6. - * - * @internal - */ - protected getHeadingLevel(): number { - return Math.max(2, Math.min(6, this.headingLevel)); + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + const headingSlot = this.shadowRoot?.querySelector<HTMLSlotElement>( + 'slot[name="heading"]' + ); + if (headingSlot) { + headingSlot.addEventListener('slotchange', () => + this.warnInvalidHeadingSlot(headingSlot) + ); + this.warnInvalidHeadingSlot(headingSlot); + } } protected override updated(changedProperties: PropertyValues): void { @@ -119,29 +111,20 @@ export abstract class IllustratedMessageBase extends SpectrumElement { { issues: [`orientation="${this.orientation}"`] } ); } + } + } - if ( - changedProperties.has('headingLevel') && - (this.headingLevel < 2 || this.headingLevel > 6) - ) { - window.__swc.warn( - this, - `<${this.localName}> received an invalid "heading-level" value of "${this.headingLevel}". Valid values are 2–6. The value has been clamped to ${this.getHeadingLevel()}.`, - 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', - { issues: [`heading-level="${this.headingLevel}"`] } - ); - } - - const headingSlot = this.shadowRoot?.querySelector<HTMLSlotElement>( - 'slot[name="heading"]' - ); - const assigned = headingSlot?.assignedElements() ?? []; - if (assigned.length > 0 && assigned[0].tagName.toLowerCase() !== 'span') { - window.__swc.warn( + private warnInvalidHeadingSlot(headingSlot: HTMLSlotElement): void { + if (!window.__swc?.DEBUG) { + return; + } + for (const el of headingSlot.assignedElements()) { + if (!['H2', 'H3', 'H4', 'H5', 'H6'].includes(el.tagName)) { + window.__swc?.warn( this, - `<${this.localName}> expects the "heading" slot to contain a single <span> element. The shadow DOM owns the heading tag. Received: <${assigned[0].tagName.toLowerCase()}>.`, + `<${this.localName}> heading slot received a <${el.tagName.toLowerCase()}> element. Only <h2>–<h6> elements are allowed in the heading slot.`, 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', - { issues: [`heading slot: <${assigned[0].tagName.toLowerCase()}>`] } + { issues: [`heading slot: <${el.tagName.toLowerCase()}>`] } ); } } diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts index 5ca2edcaac0..7a500f4b51b 100644 --- a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.types.ts @@ -12,10 +12,6 @@ import type { ElementSize } from '@spectrum-web-components/core/mixins/index.js'; -export const ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS = [ - 2, 3, 4, 5, 6, -] as const satisfies readonly number[]; - export const ILLUSTRATED_MESSAGE_VALID_SIZES = [ 's', 'm', @@ -27,9 +23,6 @@ export const ILLUSTRATED_MESSAGE_VALID_ORIENTATIONS = [ 'horizontal', ] as const satisfies readonly string[]; -export type IllustratedMessageHeadingLevel = - (typeof ILLUSTRATED_MESSAGE_VALID_HEADING_LEVELS)[number]; - export type IllustratedMessageSize = (typeof ILLUSTRATED_MESSAGE_VALID_SIZES)[number]; diff --git a/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts b/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts index 1937cec1dfa..b261f22628d 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts @@ -18,20 +18,20 @@ import styles from './illustrated-message.css'; /** * @element swc-illustrated-message - * @status preview + * @status unsupported * @since 0.0.1 * * @example * <swc-illustrated-message> * <svg slot="" aria-hidden="true" viewBox="0 0 200 160"><!-- illustration --></svg> - * <span slot="heading">Create your first asset.</span> + * <h2 slot="heading">Create your first asset.</h2> * <span slot="description">Get started by uploading or importing some assets.</span> * </swc-illustrated-message> * * @example - * <swc-illustrated-message heading-level="3"> + * <swc-illustrated-message> * <svg slot="" aria-hidden="true" viewBox="0 0 200 160"><!-- illustration --></svg> - * <span slot="heading">No results found.</span> + * <h3 slot="heading">No results found.</h3> * <span slot="description">Try adjusting your search or filters.</span> * </swc-illustrated-message> */ @@ -45,35 +45,13 @@ export class IllustratedMessage extends IllustratedMessageBase { } protected override render(): TemplateResult { - const level = this.getHeadingLevel(); - const headingClass = 'swc-IllustratedMessage-heading'; - const heading = html`<slot name="heading"></slot>`; - return html` <div class="swc-IllustratedMessage"> <div class="swc-IllustratedMessage-illustration"> <slot></slot> </div> <div class="swc-IllustratedMessage-content"> - ${level === 2 - ? html` - <h2 class=${headingClass}>${heading}</h2> - ` - : level === 3 - ? html` - <h3 class=${headingClass}>${heading}</h3> - ` - : level === 4 - ? html` - <h4 class=${headingClass}>${heading}</h4> - ` - : level === 5 - ? html` - <h5 class=${headingClass}>${heading}</h5> - ` - : html` - <h6 class=${headingClass}>${heading}</h6> - `} + <slot name="heading"></slot> <div class="swc-IllustratedMessage-description"> <slot name="description"></slot> </div> diff --git a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css index ea18fc981ee..436fb3f9e3d 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css +++ b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css @@ -40,8 +40,18 @@ } /* @todo SWC-1838 — heading typography (size, weight, line-height, color) */ -.swc-IllustratedMessage-heading { + +/* + * Reset native heading styles (font-size, font-weight, margin) so component tokens can take over. + * The :not([class]) guard leaves intentionally-classed headings untouched. + */ +::slotted(h2:not([class])), +::slotted(h3:not([class])), +::slotted(h4:not([class])), +::slotted(h5:not([class])), +::slotted(h6:not([class])) { margin: 0; + font: inherit; } /* @todo SWC-1838 — description typography (size, weight, line-height, color) */ diff --git a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts index 6c9f1973ae3..3d5e58c9b57 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts @@ -15,9 +15,7 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; -import { IllustratedMessage } from '@adobe/spectrum-wc/illustrated-message'; - -import '../../icon'; +import '@adobe/spectrum-wc/illustrated-message'; // ──────────────── // METADATA @@ -27,28 +25,14 @@ const { args, argTypes, template } = getStorybookHelpers( 'swc-illustrated-message' ); -argTypes['heading-level'] = { - ...argTypes['heading-level'], - control: { type: 'select' }, - options: IllustratedMessage.VALID_HEADING_LEVELS, - table: { - category: 'attributes', - defaultValue: { summary: '2' }, - }, -}; - /** * An illustrated message displays an illustration and a message, typically * used in empty states or error pages. * * ### Heading level * - * The `heading-level` attribute controls the semantic heading level rendered - * in shadow DOM (`h2`–`h6`) to match the document outline. It does not affect - * the visual appearance — all levels render at the same size. - * - * Set `heading-level` based on the heading hierarchy of the page, not visual - * preference. + * Provide the appropriate `<h2>`–`<h6>` element directly in the `heading` + * slot to match the document outline. The component does not control the heading level. */ export const meta: Meta = { title: 'Illustrated Message', @@ -84,7 +68,7 @@ const cloudIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="95" height="95 const defaultSlots = html` <span slot="">${unsafeHTML(cloudIcon)}</span> - <span slot="heading">Illustrated message title</span> + <h2 slot="heading">Illustrated message title</h2> <span slot="description"> Illustrated message description. Give more information about what a user can do, expect, or how to make items appear. @@ -101,23 +85,6 @@ export const Overview: Story = { tags: ['overview'], }; -/** - * The `heading-level` attribute controls the semantic heading level rendered - * in shadow DOM (`h2`–`h6`) to match the document outline. It does not affect - * the visual appearance — all levels render at the same size. - * - * Set `heading-level` based on the heading hierarchy of the page, not visual - * preference. - */ -export const HeadingLevels: Story = { - render: (args) => html` - ${IllustratedMessage.VALID_HEADING_LEVELS.map((level) => - template({ ...args, 'heading-level': level }, defaultSlots) - )} - `, - tags: ['options'], -}; - /** * SVGs slotted into the illustration slot should declare their accessibility * intent explicitly: @@ -133,7 +100,7 @@ export const IllustrationAccessibility: Story = { args, html` <span slot="">${unsafeHTML(cloudIcon)}</span> - <span slot="heading">Illustrated message title</span> + <h2 slot="heading">Illustrated message title</h2> <span slot="description"> The icon above uses <code>aria-hidden="true"</code> @@ -146,7 +113,7 @@ export const IllustrationAccessibility: Story = { args, html` <span slot="">${unsafeHTML(cloudIcon)}</span> - <span slot="heading">Illustrated message title</span> + <h2 slot="heading">Illustrated message title</h2> <span slot="description"> The icon above uses <code>role="img"</code> diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts index ef9c5724e34..6be85799553 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts @@ -37,17 +37,16 @@ test.describe('IllustratedMessage - ARIA Snapshots', () => { `); }); - test('should render correct heading levels for heading-levels story', async ({ + test('should have heading in light DOM (consumer-owned)', async ({ page, }) => { - await gotoStory( + const root = await gotoStory( page, - 'components-illustrated-message--heading-levels', + 'components-illustrated-message--overview', 'swc-illustrated-message' ); - const headings = page.locator('swc-illustrated-message'); - await expect(headings.nth(0).locator('h2')).toBeVisible(); - await expect(headings.nth(1).locator('h3')).toBeVisible(); - await expect(headings.nth(2).locator('h4')).toBeVisible(); + // The heading is slotted from light DOM — it must be queryable from outside + // the shadow root and must not appear inside the shadow DOM. + await expect(root.locator('h2[slot="heading"]')).toBeVisible(); }); }); diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts index 77ab9a2790e..68c2c75f759 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts @@ -44,27 +44,27 @@ export const OverviewTest: Story = { 'swc-illustrated-message' ); - await step('renders with default heading-level 2', async () => { - expect(illustratedMessage.headingLevel, 'default heading level').toBe(2); - }); + await step('consumer owns the heading element in light DOM', async () => { + const headingInLight = + illustratedMessage.querySelector('[slot="heading"]'); + expect(headingInLight, 'heading element in light DOM').not.toBeNull(); - await step('renders an h2 element in shadow DOM by default', async () => { - expect( - illustratedMessage.shadowRoot?.querySelector('h2'), - 'h2 in shadow DOM' - ).not.toBeNull(); + const headingInShadow = illustratedMessage.shadowRoot?.querySelector( + 'h1, h2, h3, h4, h5, h6' + ); + expect(headingInShadow, 'no heading element in shadow DOM').toBeNull(); }); }, }; // ────────────────────────────────────────────────────────────── -// TEST: Properties / Attributes +// TEST: Heading slot — valid usage // ────────────────────────────────────────────────────────────── -export const AllHeadingLevelsTest: Story = { +export const HeadingSlotValidElementsTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="heading">Heading level test</span> + <h2 slot="heading">Heading test</h2> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -73,61 +73,33 @@ export const AllHeadingLevelsTest: Story = { 'swc-illustrated-message' ); - for (const level of [3, 4, 5, 6] as const) { - await step( - `renders h${level} when heading-level is set to ${level}`, - async () => { - illustratedMessage.setAttribute('heading-level', String(level)); + for (const tag of ['h2', 'h3', 'h4', 'h5', 'h6'] as const) { + await step(`does not warn when heading slot contains <${tag}>`, () => + withWarningSpy(async (warnCalls) => { + const heading = document.createElement(tag); + heading.setAttribute('slot', 'heading'); + heading.textContent = `Heading as ${tag}`; + + illustratedMessage.querySelector('[slot="heading"]')?.remove(); + illustratedMessage.appendChild(heading); + illustratedMessage.requestUpdate(); await illustratedMessage.updateComplete; - expect( - illustratedMessage.shadowRoot?.querySelector(`h${level}`), - `h${level} in shadow DOM` - ).not.toBeNull(); - expect( - illustratedMessage.shadowRoot?.querySelector(`h${level - 1}`), - `h${level - 1} absent after heading-level change` - ).toBeNull(); - } + + expect(warnCalls.length, `no warning for valid <${tag}>`).toBe(0); + }) ); } }, }; -export const HeadingLevelClampTest: Story = { - render: () => html` - <swc-illustrated-message> - <span slot="heading">Clamped heading</span> - </swc-illustrated-message> - `, - play: async ({ canvasElement, step }) => { - const illustratedMessage = await getComponent<IllustratedMessage>( - canvasElement, - 'swc-illustrated-message' - ); - - await step('clamps heading-level="1" to h2, never renders h1', async () => { - illustratedMessage.setAttribute('heading-level', '1'); - await illustratedMessage.updateComplete; - expect( - illustratedMessage.shadowRoot?.querySelector('h1'), - 'h1 must not exist in shadow DOM' - ).toBeNull(); - expect( - illustratedMessage.shadowRoot?.querySelector('h2'), - 'h2 rendered after clamping' - ).not.toBeNull(); - }); - }, -}; - // ────────────────────────────────────────────────────────────── -// TEST: Slots +// TEST: Heading slot — invalid usage // ────────────────────────────────────────────────────────────── -export const DescriptionSlotTest: Story = { +export const HeadingSlotInvalidElementWarningTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="description">Description text here.</span> + <div slot="heading">Not a heading</div> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -136,54 +108,54 @@ export const DescriptionSlotTest: Story = { 'swc-illustrated-message' ); - await step('renders description slot content', async () => { - const slotted = illustratedMessage.querySelector('[slot="description"]'); - expect(slotted, 'description slot element').not.toBeNull(); - expect(slotted?.textContent?.trim(), 'description text').toBe( - 'Description text here.' - ); - }); - }, -}; + await step('warns when heading slot contains a non-heading element', () => + withWarningSpy(async (warnCalls) => { + const headingSlot = + illustratedMessage.shadowRoot?.querySelector<HTMLSlotElement>( + 'slot[name="heading"]' + ); + if (!headingSlot) { + return; + } -// ────────────────────────────────────────────────────────────── -// TEST: Dev mode warnings -// ────────────────────────────────────────────────────────────── + const slotChanged = new Promise<void>((resolve) => + headingSlot.addEventListener('slotchange', () => resolve(), { + once: true, + }) + ); -export const InvalidHeadingLevelWarningTest: Story = { - render: () => html` - <swc-illustrated-message> - <span slot="heading">Test</span> - </swc-illustrated-message> - `, - play: async ({ canvasElement, step }) => { - const illustratedMessage = await getComponent<IllustratedMessage>( - canvasElement, - 'swc-illustrated-message' - ); + // Replace the slotted element to trigger slotchange + const div = document.createElement('div'); + div.setAttribute('slot', 'heading'); + div.textContent = 'Not a heading'; - await step('warns when heading-level is set to an out-of-range value', () => - withWarningSpy(async (warnCalls) => { - illustratedMessage.setAttribute('heading-level', '1'); - await illustratedMessage.updateComplete; + illustratedMessage.querySelector('[slot="heading"]')?.remove(); + illustratedMessage.appendChild(div); + + await slotChanged; expect( warnCalls.length, - 'warning count for invalid heading-level' + 'warning fired for invalid heading slot element' ).toBeGreaterThan(0); expect( - String(warnCalls[0]?.[1] || ''), - 'warning message mentions heading-level' - ).toContain('heading-level'); + String(warnCalls[0]?.[1] ?? ''), + 'warning message mentions heading slot' + ).toContain('heading'); }) ); }, }; -export const ValidHeadingLevelNoWarningTest: Story = { +// ────────────────────────────────────────────────────────────── +// TEST: Slots +// ────────────────────────────────────────────────────────────── + +export const DescriptionSlotTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="heading">Test</span> + <h2 slot="heading">Heading</h2> + <span slot="description">Description text here.</span> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -192,14 +164,13 @@ export const ValidHeadingLevelNoWarningTest: Story = { 'swc-illustrated-message' ); - await step('does not warn when a valid heading-level is set', () => - withWarningSpy(async (warnCalls) => { - illustratedMessage.setAttribute('heading-level', '3'); - await illustratedMessage.updateComplete; - - expect(warnCalls.length, 'no warnings for valid heading-level').toBe(0); - }) - ); + await step('renders description slot content', async () => { + const slotted = illustratedMessage.querySelector('[slot="description"]'); + expect(slotted, 'description slot element').not.toBeNull(); + expect(slotted?.textContent?.trim(), 'description text').toBe( + 'Description text here.' + ); + }); }, }; @@ -210,7 +181,7 @@ export const ValidHeadingLevelNoWarningTest: Story = { export const ValidSizeNoWarningTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="heading">Test</span> + <h2 slot="heading">Test</h2> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -245,7 +216,7 @@ export const ValidSizeNoWarningTest: Story = { export const InvalidSizeWarningTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="heading">Test</span> + <h2 slot="heading">Test</h2> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -264,7 +235,7 @@ export const InvalidSizeWarningTest: Story = { 'warning count for invalid size' ).toBeGreaterThan(0); expect( - String(warnCalls[0]?.[1] || ''), + String(warnCalls[0]?.[1] ?? ''), 'warning message mentions size' ).toContain('size'); }) @@ -279,7 +250,7 @@ export const InvalidSizeWarningTest: Story = { export const ValidOrientationNoWarningTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="heading">Test</span> + <h2 slot="heading">Test</h2> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -317,7 +288,7 @@ export const ValidOrientationNoWarningTest: Story = { export const InvalidOrientationWarningTest: Story = { render: () => html` <swc-illustrated-message> - <span slot="heading">Test</span> + <h2 slot="heading">Test</h2> </swc-illustrated-message> `, play: async ({ canvasElement, step }) => { @@ -336,40 +307,10 @@ export const InvalidOrientationWarningTest: Story = { 'warning count for invalid orientation' ).toBeGreaterThan(0); expect( - String(warnCalls[0]?.[1] || ''), + String(warnCalls[0]?.[1] ?? ''), 'warning message mentions orientation' ).toContain('orientation'); }) ); }, }; - -export const InvalidHeadingSlotWarningTest: Story = { - render: () => html` - <swc-illustrated-message> - <h3 slot="heading">Heading as h3</h3> - </swc-illustrated-message> - `, - play: async ({ canvasElement, step }) => { - const illustratedMessage = await getComponent<IllustratedMessage>( - canvasElement, - 'swc-illustrated-message' - ); - - await step('warns when heading slot contains a non-span element', () => - withWarningSpy(async (warnCalls) => { - illustratedMessage.requestUpdate(); - await illustratedMessage.updateComplete; - - expect( - warnCalls.length, - 'warning count for non-span heading slot' - ).toBeGreaterThan(0); - expect( - String(warnCalls[0]?.[1] || ''), - 'warning message mentions heading slot' - ).toContain('heading'); - }) - ); - }, -}; diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md index c5ee6fed9d6..60e26f64dd4 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md @@ -17,6 +17,9 @@ - [firstUpdated() — One-time setup validation](#firstupdated--one-time-setup-validation) - [updated() — Post-render validation](#updated--post-render-validation) - [connectedCallback() — Environment validation](#connectedcallback--environment-validation) +- [Deprecation warnings](#deprecation-warnings) + - [Deprecation warning structure](#deprecation-warning-structure) + - [Testing deprecation warnings](#testing-deprecation-warnings) - [Warning message format](#warning-message-format) </details> @@ -194,6 +197,59 @@ public override connectedCallback(): void { } ``` +## Deprecation warnings + +Use `{ level: 'deprecation' }` when a property or attribute has been superseded by a new API. This signals to consumers that they need to migrate, not just fix a configuration error. + +**When to use deprecation warnings:** + +- A property is being replaced by a slot (consumer-owned HTML) +- A property value is being renamed (e.g. `variant="cta"` → `variant="accent"`) +- An attribute is being removed, or replaced by a different attribute + + +### Deprecation warning structure + +The key difference from other warnings is `{ level: 'deprecation' }` instead of `{ issues: [...] }`. + +```ts +if (window.__swc?.DEBUG) { + window.__swc.warn( + this, + `The "oldProp" property on <${this.localName}> has been deprecated and will be removed in a future release. Use "newProp" instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/your-component/', + { level: 'deprecation' } + ); +} +``` + +### Testing deprecation warnings + +Write separate test cases that verify: + +1. The warning fires when the deprecated API is used +2. The warning does **not** fire when the recommended API is used + +```ts +it('warns when deprecated "heading" property is used', async () => { + const el = await fixture(html` + <sp-illustrated-message heading="Title"></sp-illustrated-message> + `); + await elementUpdated(el); + expect(consoleWarnStub.called).to.be.true; + expect(consoleWarnStub.getCall(0).args[0]).to.include('heading'); +}); + +it('does not warn when slot-based API is used', async () => { + await fixture(html` + <sp-illustrated-message> + <h2 slot="heading">Title</h2> + </sp-illustrated-message> + `); + expect(consoleWarnStub.called).to.be.false; +}); +``` + ## Warning message format Warnings should be clear, actionable, and include helpful context. Follow this format: diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index c2ebe00238f..90adf70a5c5 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -54,6 +54,7 @@ - [Help text migration roadmap](help-text/rendering-and-styling-migration-analysis.md) - Illustrated Message - [Illustrated message accessibility migration analysis](illustrated-message/accessibility-migration-analysis.md) + - [`sp-illustrated-message` Migration Plan](illustrated-message/migration-plan.md) - [Illustrated message migration roadmap](illustrated-message/rendering-and-styling-migration-analysis.md) - Infield Button - [In-field button migration roadmap](infield-button/rendering-and-styling-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md index 3a1a84e50e7..3a2b3c3f55d 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/illustrated-message/migration-plan.md @@ -1,5 +1,49 @@ +<!-- Generated breadcrumbs - DO NOT EDIT --> + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Illustrated Message / `sp-illustrated-message` Migration Plan + +<!-- Document title (editable) --> + # `sp-illustrated-message` Migration Plan +<!-- Generated TOC - DO NOT EDIT --> + +<details open> +<summary><strong>In this doc</strong></summary> + +- [Table of contents](#table-of-contents) +- [1st-gen API surface](#1st-gen-api-surface) + - [Properties / attributes](#properties--attributes) + - [Methods](#methods) + - [Events](#events) + - [Slots](#slots) + - [CSS custom properties](#css-custom-properties) + - [Shadow DOM output (rendered HTML)](#shadow-dom-output-rendered-html) +- [Dependencies](#dependencies) +- [Changes overview](#changes-overview) + - [Must ship — breaking or a11y-required](#must-ship--breaking-or-a11y-required) + - [Additive — ships when ready, zero breakage for consumers already on 2nd-gen](#additive--ships-when-ready-zero-breakage-for-consumers-already-on-2nd-gen) +- [2nd-gen API decisions](#2nd-gen-api-decisions) + - [Properties / attributes (2nd-gen)](#properties--attributes-2nd-gen) + - [Slots (2nd-gen)](#slots-2nd-gen) + - [CSS custom properties (2nd-gen)](#css-custom-properties-2nd-gen) +- [Architecture: core vs SWC split](#architecture-core-vs-swc-split) +- [Migration checklist](#migration-checklist) + - [Preparation (this ticket)](#preparation-this-ticket) + - [Setup](#setup) + - [API](#api) + - [Styling](#styling) + - [Accessibility](#accessibility) + - [Testing](#testing) + - [Documentation](#documentation) + - [Review](#review) +- [Blockers and open questions](#blockers-and-open-questions) +- [References](#references) + +</details> + +<!-- Document content (editable) --> + > **SWC-1834** · Planning output. Must be reviewed before implementation begins. --- From a1d702c04106b74bce10f0a7836e9264c3136c5f Mon Sep 17 00:00:00 2001 From: Miwha Bonini <mbonini@adobe.com> Date: Mon, 20 Apr 2026 12:58:47 -0600 Subject: [PATCH 6/7] fix(illustrated-message): migrate CSS to S2 tokens (#6181) * feat(illustrated-message): slot-based heading API + gen1 deprecation * fix(illustrated-message): enable verbose mode for deprecation tests and add heading slot validation * feat(illustrated-message): update status to unsupported (SWC-1944) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(illustrated-message): register custom element in stories to unblock a11y tests * fix(illustrated-message): migrate CSS to S2 tokens and fix story illustration scaling * fix(illustrated-message): dry up cloud SVG and revert doc-story selector change * fix(illustrated-message): address CSS PR feedback * refactor(illustrated-message): scope heading slot validation to slotchange event * fix(illustrated-message): add Sizes and Orientation option stories * fix(illustrated-message): minor copy tweaks * fix(illustrated-message): css selector updates and jsdoc --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../IllustratedMessage.base.ts | 45 +++---- .../illustrated-message/IllustratedMessage.ts | 2 +- .../illustrated-message.css | 126 ++++++++++++++---- .../stories/illustrated-message.stories.ts | 119 ++++++++++++++++- 4 files changed, 231 insertions(+), 61 deletions(-) diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts index a016b53d3c8..0efc75f69a5 100644 --- a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts @@ -72,19 +72,6 @@ export abstract class IllustratedMessageBase extends SpectrumElement { // IMPLEMENTATION // ────────────────────── - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - const headingSlot = this.shadowRoot?.querySelector<HTMLSlotElement>( - 'slot[name="heading"]' - ); - if (headingSlot) { - headingSlot.addEventListener('slotchange', () => - this.warnInvalidHeadingSlot(headingSlot) - ); - this.warnInvalidHeadingSlot(headingSlot); - } - } - protected override updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (window.__swc?.DEBUG) { @@ -114,18 +101,26 @@ export abstract class IllustratedMessageBase extends SpectrumElement { } } - private warnInvalidHeadingSlot(headingSlot: HTMLSlotElement): void { - if (!window.__swc?.DEBUG) { - return; - } - for (const el of headingSlot.assignedElements()) { - if (!['H2', 'H3', 'H4', 'H5', 'H6'].includes(el.tagName)) { - window.__swc?.warn( - this, - `<${this.localName}> heading slot received a <${el.tagName.toLowerCase()}> element. Only <h2>–<h6> elements are allowed in the heading slot.`, - 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', - { issues: [`heading slot: <${el.tagName.toLowerCase()}>`] } - ); + /** + * Validates that the heading slot only contains `<h2>`–`<h6>` elements. + * Rendering subclasses must wire this to the heading slot's `slotchange` + * event (e.g. `<slot name="heading" @slotchange=${this.handleHeadingSlotChange}>`) + * for the validation warning to fire. + * + * @internal + */ + protected handleHeadingSlotChange(event: Event): void { + if (window.__swc?.DEBUG) { + const headingSlot = event.target as HTMLSlotElement; + for (const el of headingSlot.assignedElements()) { + if (!['H2', 'H3', 'H4', 'H5', 'H6'].includes(el.tagName)) { + window.__swc.warn( + this, + `<${this.localName}> heading slot received a <${el.tagName.toLowerCase()}> element. Only <h2>–<h6> elements are allowed in the heading slot.`, + 'https://opensource.adobe.com/spectrum-web-components/components/illustrated-message/', + { issues: [`heading slot: <${el.tagName.toLowerCase()}>`] } + ); + } } } } diff --git a/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts b/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts index b261f22628d..da76fd09428 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/IllustratedMessage.ts @@ -51,7 +51,7 @@ export class IllustratedMessage extends IllustratedMessageBase { <slot></slot> </div> <div class="swc-IllustratedMessage-content"> - <slot name="heading"></slot> + <slot name="heading" @slotchange=${this.handleHeadingSlotChange}></slot> <div class="swc-IllustratedMessage-description"> <slot name="description"></slot> </div> diff --git a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css index 436fb3f9e3d..eb90e05a2db 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css +++ b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css @@ -10,8 +10,6 @@ * governing permissions and limitations under the License. */ -/* @todo SWC-1838 — replace with full S2 token-based styles */ - :host { display: block; } @@ -21,61 +19,133 @@ } .swc-IllustratedMessage { + --_swc-illustrated-message-illustration-to-content: var(--swc-illustrated-message-illustration-to-content, token("spacing-200")); + display: flex; flex-direction: column; + gap: var(--_swc-illustrated-message-illustration-to-content); align-items: center; - text-align: center; + max-inline-size: var(--swc-illustrated-message-max-inline-size, token("illustrated-message-vertical-maximum-width")); + margin-inline: auto; } .swc-IllustratedMessage-illustration { + --_swc-illustrated-message-illustration-size: var(--swc-illustrated-message-illustration-size, 96px); + display: flex; - align-items: center; + flex-shrink: 0; justify-content: center; + inline-size: var(--swc-illustrated-message-illustration-inline-size, var(--_swc-illustrated-message-illustration-size)); + block-size: var(--swc-illustrated-message-illustration-block-size, var(--_swc-illustrated-message-illustration-size)); + color: var(--swc-illustrated-message-illustration-color, token("neutral-content-color-default")); } .swc-IllustratedMessage-content { display: flex; flex-direction: column; - align-items: center; + gap: token("spacing-75"); + text-align: center; +} + +.swc-IllustratedMessage-description { + font-size: var(--swc-illustrated-message-description-font-size, token("illustrated-message-medium-body-font-size")); + font-weight: token("regular-font-weight"); + line-height: var(--swc-illustrated-message-description-line-height, token("body-line-height")); + color: token("body-color"); } -/* @todo SWC-1838 — heading typography (size, weight, line-height, color) */ +/* ── Size: small ─────────────────────────────────────────────────────────── */ -/* - * Reset native heading styles (font-size, font-weight, margin) so component tokens can take over. - * The :not([class]) guard leaves intentionally-classed headings untouched. - */ -::slotted(h2:not([class])), -::slotted(h3:not([class])), -::slotted(h4:not([class])), -::slotted(h5:not([class])), -::slotted(h6:not([class])) { - margin: 0; - font: inherit; +:host([size="s"]) { + --swc-illustrated-message-illustration-size: 96px; + --swc-illustrated-message-illustration-to-content: token("spacing-200"); + --swc-illustrated-message-heading-font-size: token("illustrated-message-small-title-font-size"); + --swc-illustrated-message-description-font-size: token("illustrated-message-small-body-font-size"); } -/* @todo SWC-1838 — description typography (size, weight, line-height, color) */ +/* ── Size: large ─────────────────────────────────────────────────────────── */ -/* ── Size ───────────────────────────────────────────────────────────────── */ +:host([size="l"]) { + --swc-illustrated-message-illustration-size: 160px; + --swc-illustrated-message-illustration-to-content: token("spacing-100"); + --swc-illustrated-message-heading-font-size: token("illustrated-message-large-title-font-size"); + --swc-illustrated-message-description-font-size: token("illustrated-message-large-body-font-size"); +} + +/* ── CJK language support ────────────────────────────────────────────────── */ -/* @todo SWC-1838 — replace with full S2 token-based size values */ +:host(:lang(ja)), +:host(:lang(ko)), +:host(:lang(zh)) { + --swc-illustrated-message-heading-font-size: token("illustrated-message-medium-cjk-title-font-size"); + --swc-illustrated-message-heading-line-height: token("cjk-line-height-100"); + --swc-illustrated-message-description-line-height: token("cjk-line-height-200"); +} -:host([size="s"]) .swc-IllustratedMessage-illustration { - /* @todo SWC-1838 */ +:host([size="s"]:lang(ja)), +:host([size="s"]:lang(ko)), +:host([size="s"]:lang(zh)) { + --swc-illustrated-message-heading-font-size: token("illustrated-message-small-cjk-title-font-size"); } -:host([size="l"]) .swc-IllustratedMessage-illustration { - /* @todo SWC-1838 */ +:host([size="l"]:lang(ja)), +:host([size="l"]:lang(ko)), +:host([size="l"]:lang(zh)) { + --swc-illustrated-message-heading-font-size: token("illustrated-message-large-cjk-title-font-size"); } -/* ── Orientation ─────────────────────────────────────────────────────────── */ +/* ── Orientation: horizontal ─────────────────────────────────────────────── */ -/* @todo SWC-1838 — replace with full S2 token-based orientation values */ +:host([orientation="horizontal"]) { + --swc-illustrated-message-max-inline-size: token("illustrated-message-horizontal-maximum-width"); +} :host([orientation="horizontal"]) .swc-IllustratedMessage { - /* @todo SWC-1838 */ + flex-direction: row; + align-items: center; } :host([orientation="horizontal"]) .swc-IllustratedMessage-content { - /* @todo SWC-1838 */ + align-items: flex-start; + text-align: start; +} + +/* ── Slotted content ─────────────────────────────────────────────────────── */ + +/* + * Heading styles live on the slot element itself. Slotted elements inherit + * from their assigned slot per the spec, so these properties flow into the + * heading without needing ::slotted() for each individual property. + */ +slot[name="heading"] { + font-size: var(--swc-illustrated-message-heading-font-size, token("illustrated-message-medium-title-font-size")); + font-style: token("title-sans-serif-font-style"); + font-weight: token("title-sans-serif-font-weight"); + line-height: var(--swc-illustrated-message-heading-line-height, token("title-line-height")); + color: token("heading-color"); +} + +/* + * Direct slotted headings to inherit from the slot element. + * The :not([class]) guard leaves intentionally-classed headings untouched. + * !important is required to win over any light DOM styles targeting + * the slotted heading (e.g. global h2 resets from the host document). + */ +::slotted([slot="heading"]:not([class])) { + margin: 0 !important; + font: inherit !important; + /* stylelint-disable-next-line scale-unlimited/declaration-strict-value */ + color: inherit !important; +} + +/* + * Ensure slotted SVG illustrations fill the illustration container + * and use the component illustration color via currentcolor. + */ +::slotted(svg) { + display: block; + inline-size: 100%; + block-size: 100%; + fill: currentcolor; + stroke: currentcolor; } diff --git a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts index 3d5e58c9b57..e600c869d41 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts @@ -15,7 +15,7 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; -import '@adobe/spectrum-wc/illustrated-message'; +import { IllustratedMessage } from '@adobe/spectrum-wc/illustrated-message'; // ──────────────── // METADATA @@ -25,6 +25,18 @@ const { args, argTypes, template } = getStorybookHelpers( 'swc-illustrated-message' ); +argTypes.size = { + ...argTypes.size, + control: { type: 'select' }, + options: IllustratedMessage.VALID_SIZES, +}; + +argTypes.orientation = { + ...argTypes.orientation, + control: { type: 'select' }, + options: IllustratedMessage.VALID_ORIENTATIONS, +}; + /** * An illustrated message displays an illustration and a message, typically * used in empty states or error pages. @@ -58,16 +70,17 @@ export default { // HELPERS // ──────────────────── -const cloudIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="95" height="95" viewBox="0 0 160 160" fill="none"> -<path d="M89.3301 28.5C108.54 28.5001 124.013 43.9629 125.408 63.2666C140.627 66.0007 152 79.7946 152 96.1348C152 114.396 137.782 129.5 119.883 129.5C119.845 129.5 119.81 129.499 119.78 129.498C119.762 129.498 119.744 129.5 119.726 129.5H31.8809C31.7135 129.5 31.5486 129.489 31.3867 129.469C18.2497 129.009 8.00002 117.812 8 104.385C8 94.2106 13.8624 85.3674 22.4043 81.4414C22.3044 80.4799 22.2481 79.4992 22.248 78.5C22.248 62.9098 34.3925 49.9717 49.7344 49.9717C51.9927 49.9717 54.1852 50.2598 56.2822 50.7949C61.8951 37.7322 74.5088 28.5 89.3301 28.5ZM89.3301 36.5C76.8952 36.5 66.2004 45.0053 62.5117 57.0029C62.1777 58.0892 61.397 58.9825 60.3652 59.459C59.3335 59.9354 58.148 59.95 57.1045 59.5C54.8246 58.5167 52.3407 57.9717 49.7344 57.9717C39.1347 57.9717 30.248 66.997 30.248 78.5C30.2481 80.0858 30.4383 81.6436 30.7773 83.1748C31.2346 85.2397 30.006 87.3041 27.9727 87.8857C21.1679 89.8324 16 96.3956 16 104.385C16 113.898 23.2731 121.333 31.9502 121.482C32.0505 121.484 32.1497 121.491 32.248 121.5H119.548C119.614 121.497 119.681 121.496 119.747 121.496C119.805 121.496 119.854 121.497 119.889 121.498C119.901 121.498 119.912 121.499 119.923 121.499C133.063 121.477 144 110.295 144 96.1348C144 82.4618 133.788 71.5543 121.259 70.8145C119.114 70.6878 117.452 68.8891 117.495 66.7412C117.504 66.2932 117.512 66.3311 117.512 66.1104C117.512 49.5915 104.732 36.5001 89.3301 36.5Z" fill="#292929"/> -</svg>`; +const cloudPath = `<path d="M89.3301 28.5C108.54 28.5001 124.013 43.9629 125.408 63.2666C140.627 66.0007 152 79.7946 152 96.1348C152 114.396 137.782 129.5 119.883 129.5C119.845 129.5 119.81 129.499 119.78 129.498C119.762 129.498 119.744 129.5 119.726 129.5H31.8809C31.7135 129.5 31.5486 129.489 31.3867 129.469C18.2497 129.009 8.00002 117.812 8 104.385C8 94.2106 13.8624 85.3674 22.4043 81.4414C22.3044 80.4799 22.2481 79.4992 22.248 78.5C22.248 62.9098 34.3925 49.9717 49.7344 49.9717C51.9927 49.9717 54.1852 50.2598 56.2822 50.7949C61.8951 37.7322 74.5088 28.5 89.3301 28.5ZM89.3301 36.5C76.8952 36.5 66.2004 45.0053 62.5117 57.0029C62.1777 58.0892 61.397 58.9825 60.3652 59.459C59.3335 59.9354 58.148 59.95 57.1045 59.5C54.8246 58.5167 52.3407 57.9717 49.7344 57.9717C39.1347 57.9717 30.248 66.997 30.248 78.5C30.2481 80.0858 30.4383 81.6436 30.7773 83.1748C31.2346 85.2397 30.006 87.3041 27.9727 87.8857C21.1679 89.8324 16 96.3956 16 104.385C16 113.898 23.2731 121.333 31.9502 121.482C32.0505 121.484 32.1497 121.491 32.248 121.5H119.548C119.614 121.497 119.681 121.496 119.747 121.496C119.805 121.496 119.854 121.497 119.889 121.498C119.901 121.498 119.912 121.499 119.923 121.499C133.063 121.477 144 110.295 144 96.1348C144 82.4618 133.788 71.5543 121.259 70.8145C119.114 70.6878 117.452 68.8891 117.495 66.7412C117.504 66.2932 117.512 66.3311 117.512 66.1104C117.512 49.5915 104.732 36.5001 89.3301 36.5Z" fill="currentColor"/>`; + +const cloudSvg = (a11yAttrs: string) => + `<svg slot="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" ${a11yAttrs}>\n${cloudPath}\n</svg>`; // ──────────────────── // STORIES // ──────────────────── const defaultSlots = html` - <span slot="">${unsafeHTML(cloudIcon)}</span> + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} <h2 slot="heading">Illustrated message title</h2> <span slot="description"> Illustrated message description. Give more information about what a user can @@ -76,15 +89,105 @@ const defaultSlots = html` `; export const Playground: Story = { + args: { + orientation: 'vertical', + }, render: (args) => template(args, defaultSlots), tags: ['autodocs', 'dev'], }; +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + export const Overview: Story = { render: (args) => template(args, defaultSlots), tags: ['overview'], }; +// ────────────────────────── +// OPTIONS STORIES +// ────────────────────────── + +/** + * Illustrated messages come in three sizes: + * + * - **Small (s)**: 96px illustration, compact spacing — for space-constrained contexts + * - **Medium (m)**: 96px illustration, standard spacing — the default + * - **Large (l)**: 160px illustration, reduced spacing — for prominent empty states + */ +export const Sizes: Story = { + render: (args) => html` + ${template( + { ...args, size: 's' }, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Small</h2> + <span slot="description">Size s — 96px illustration</span> + ` + )} + ${template( + { ...args, size: 'm' }, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Medium</h2> + <span slot="description">Size m — 96px illustration (default)</span> + ` + )} + ${template( + { ...args, size: 'l' }, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Large</h2> + <span slot="description">Size l — 160px illustration</span> + ` + )} + `, + tags: ['options'], + parameters: { + flexLayout: true, + 'section-order': 1, + }, +}; + +/** + * Illustrated messages support two layout orientations: + * + * - **Vertical** (default): illustration stacked above the heading and description, + * centered — use for full-page or centered empty states + * - **Horizontal**: illustration beside the heading and description in a row, + * left-aligned — use for inline or sidebar empty states + */ +export const Orientation: Story = { + render: (args) => html` + ${template( + { ...args, orientation: 'vertical' }, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Vertical (default)</h2> + <span slot="description">Illustration stacked above the content.</span> + ` + )} + ${template( + { ...args, orientation: 'horizontal' }, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Horizontal</h2> + <span slot="description">Illustration beside the content.</span> + ` + )} + `, + tags: ['options'], + parameters: { + styles: { display: 'flex', 'flex-direction': 'column', gap: '2rem' }, + 'section-order': 2, + }, +}; + +// ──────────────────────────────── +// ACCESSIBILITY STORIES +// ──────────────────────────────── + /** * SVGs slotted into the illustration slot should declare their accessibility * intent explicitly: @@ -99,7 +202,7 @@ export const IllustrationAccessibility: Story = { ${template( args, html` - <span slot="">${unsafeHTML(cloudIcon)}</span> + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} <h2 slot="heading">Illustrated message title</h2> <span slot="description"> The icon above uses @@ -112,7 +215,9 @@ export const IllustrationAccessibility: Story = { ${template( args, html` - <span slot="">${unsafeHTML(cloudIcon)}</span> + ${unsafeHTML( + cloudSvg('role="img" aria-label="Cloud storage illustration"') + )} <h2 slot="heading">Illustrated message title</h2> <span slot="description"> The icon above uses From d1393f210291e99d69abc2aed111d039fe326cd7 Mon Sep 17 00:00:00 2001 From: Miwha Bonini <mbonini@adobe.com> Date: Tue, 21 Apr 2026 08:33:16 -0600 Subject: [PATCH 7/7] chore(illustrated-message): conform to code style guides, storybook and tests (#6191) * style(illustrated-message): align source, stories, and tests with CONTRIBUTOR-DOCS code standards * chore(illustrated-message): add SWC-1992 ticket reference to Stackblitz todo * fix(illustrated-message): scope a11y spec locators to swc-illustrated-message instances --- .../IllustratedMessage.base.ts | 29 +- .../stories/illustrated-message.stories.ts | 196 +++++++-- .../test/illustrated-message.a11y.spec.ts | 67 +++ .../test/illustrated-message.test.ts | 403 +++++++++++++++--- 4 files changed, 583 insertions(+), 112 deletions(-) diff --git a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts index 0efc75f69a5..2af73378a42 100644 --- a/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts +++ b/2nd-gen/packages/core/components/illustrated-message/IllustratedMessage.base.ts @@ -26,14 +26,11 @@ import { * An illustrated message displays an illustration and a message, typically * used in empty states or error pages. * - * @slot - Decorative or informative SVG illustration. Decorative SVGs should include - * `aria-hidden="true"`; informative SVGs should include `role="img"` and `aria-label`. - * @slot heading - The heading element. Must be an `<h2>`–`<h6>` element. The consumer owns - * the heading tag and level. + * @slot - Decorative or informative SVG illustration + * @slot heading - The heading element, h2–h6 * @todo SWC-1943 Add slot constraints once the CEM slot constraints work is complete: * `{required} {allowedChildren: h2, h3, h4, h5, h6} {maxChildren: 1}` - * @slot description - Description text. Links must be real `<a>` elements or link components - * with visible names. + * @slot description - Supporting description text */ export abstract class IllustratedMessageBase extends SpectrumElement { // ───────────────────────── @@ -57,13 +54,13 @@ export abstract class IllustratedMessageBase extends SpectrumElement { // ────────────────── /** - * The size of the illustrated message. + * The size of the message */ @property({ type: String, reflect: true }) public size: IllustratedMessageSize = 'm'; /** - * The layout orientation of the illustrated message. + * The layout orientation */ @property({ type: String, reflect: true }) public orientation: IllustratedMessageOrientation = 'vertical'; @@ -101,14 +98,14 @@ export abstract class IllustratedMessageBase extends SpectrumElement { } } - /** - * Validates that the heading slot only contains `<h2>`–`<h6>` elements. - * Rendering subclasses must wire this to the heading slot's `slotchange` - * event (e.g. `<slot name="heading" @slotchange=${this.handleHeadingSlotChange}>`) - * for the validation warning to fire. - * - * @internal - */ + /** + * @internal + * + * Validates that the heading slot only contains `<h2>`–`<h6>` elements. + * Rendering subclasses must wire this to the heading slot's `slotchange` + * event (e.g. `<slot name="heading" @slotchange=${this.handleHeadingSlotChange}>`) + * for the validation warning to fire. + */ protected handleHeadingSlotChange(event: Event): void { if (window.__swc?.DEBUG) { const headingSlot = event.target as HTMLSlotElement; diff --git a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts index e600c869d41..9f3803c0441 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/stories/illustrated-message.stories.ts @@ -38,13 +38,20 @@ argTypes.orientation = { }; /** - * An illustrated message displays an illustration and a message, typically - * used in empty states or error pages. + * An illustrated message displays an illustration alongside a heading and + * description, typically used in empty states or on error pages. * * ### Heading level * - * Provide the appropriate `<h2>`–`<h6>` element directly in the `heading` - * slot to match the document outline. The component does not control the heading level. + * Provide the appropriate `<h2>`–`<h6>` element directly in the `heading` slot + * to match the document outline. The component validates the slot content in + * development but does not control the heading level. + * + * ### Migration from 1st-gen + * + * The `heading` and `description` plain-text attributes from 1st-gen + * (`sp-illustrated-message`) have been removed. All content must be provided + * via slots. */ export const meta: Meta = { title: 'Illustrated Message', @@ -56,6 +63,11 @@ export const meta: Meta = { docs: { subtitle: 'Display an illustration with a heading and description.', }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/Mngz9H7WZLbrCvGQf3GnsY/S2---Web--Desktop-scale-?node-id=20032-601&p=f&t=v3YDUMXflgtF0NtJ-0', + }, + // @todo SWC-1992 Add Stackblitz link: stackblitz: { url: '<stackblitz-url>' } }, tags: ['migrated'], }; @@ -75,19 +87,16 @@ const cloudPath = `<path d="M89.3301 28.5C108.54 28.5001 124.013 43.9629 125.408 const cloudSvg = (a11yAttrs: string) => `<svg slot="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" ${a11yAttrs}>\n${cloudPath}\n</svg>`; -// ──────────────────── -// STORIES -// ──────────────────── - const defaultSlots = html` ${unsafeHTML(cloudSvg('aria-hidden="true"'))} <h2 slot="heading">Illustrated message title</h2> - <span slot="description"> - Illustrated message description. Give more information about what a user can - do, expect, or how to make items appear. - </span> + <span slot="description">Supporting description text.</span> `; +// ──────────────────── +// AUTODOCS STORY +// ──────────────────── + export const Playground: Story = { args: { orientation: 'vertical', @@ -105,16 +114,58 @@ export const Overview: Story = { tags: ['overview'], }; +// ────────────────────────── +// ANATOMY STORIES +// ────────────────────────── + +/** + * An illustrated message consists of three slots: + * + * 1. **Default slot**: Decorative or informative SVG illustration displayed + * above (vertical) or beside (horizontal) the heading and description. + * Decorative SVGs must have `aria-hidden="true"`. Informative SVGs must have + * `role="img"` and `aria-label`. + * 2. **`heading` slot**: Consumer-provided `<h2>`–`<h6>` element. The component + * does not own the heading tag; the consumer chooses the level to match the + * document outline. The component warns in development if a non-heading element + * is used. + * 3. **`description` slot** (optional): Supporting text that elaborates on the + * heading. May contain phrasing content, links, or action elements. + */ +export const Anatomy: Story = { + render: (args) => html` + ${template( + args, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Heading only</h2> + ` + )} + ${template( + args, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Heading and description</h2> + <span slot="description">Optional supporting description.</span> + ` + )} + `, + tags: ['anatomy'], + parameters: { + styles: { display: 'flex', 'flex-direction': 'column', gap: '3rem' }, + }, +}; + // ────────────────────────── // OPTIONS STORIES // ────────────────────────── /** - * Illustrated messages come in three sizes: + * Illustrated messages come in three sizes to fit various contexts: * - * - **Small (s)**: 96px illustration, compact spacing — for space-constrained contexts - * - **Medium (m)**: 96px illustration, standard spacing — the default - * - **Large (l)**: 160px illustration, reduced spacing — for prominent empty states + * - **Small (`s`)**: 96px illustration, compact spacing, for space-constrained contexts + * - **Medium (`m`)**: 96px illustration, standard spacing, the default + * - **Large (`l`)**: 160px illustration, reduced spacing, for prominent empty states */ export const Sizes: Story = { render: (args) => html` @@ -123,7 +174,7 @@ export const Sizes: Story = { html` ${unsafeHTML(cloudSvg('aria-hidden="true"'))} <h2 slot="heading">Small</h2> - <span slot="description">Size s — 96px illustration</span> + <span slot="description">Size s, 96px illustration</span> ` )} ${template( @@ -131,7 +182,7 @@ export const Sizes: Story = { html` ${unsafeHTML(cloudSvg('aria-hidden="true"'))} <h2 slot="heading">Medium</h2> - <span slot="description">Size m — 96px illustration (default)</span> + <span slot="description">Size m, 96px illustration (default)</span> ` )} ${template( @@ -139,7 +190,7 @@ export const Sizes: Story = { html` ${unsafeHTML(cloudSvg('aria-hidden="true"'))} <h2 slot="heading">Large</h2> - <span slot="description">Size l — 160px illustration</span> + <span slot="description">Size l, 160px illustration</span> ` )} `, @@ -154,9 +205,9 @@ export const Sizes: Story = { * Illustrated messages support two layout orientations: * * - **Vertical** (default): illustration stacked above the heading and description, - * centered — use for full-page or centered empty states + * centered. Use for full-page or centered empty states. * - **Horizontal**: illustration beside the heading and description in a row, - * left-aligned — use for inline or sidebar empty states + * left-aligned. Use for inline or sidebar empty states. */ export const Orientation: Story = { render: (args) => html` @@ -184,18 +235,105 @@ export const Orientation: Story = { }, }; +/** + * The `heading` slot accepts any `<h2>`–`<h6>` element. Choose the level that + * fits the surrounding document outline. The component validates but does not + * restrict the heading level. + * + * Common patterns: + * + * - `<h2>` for page-level empty states with no other headings in scope + * - `<h3>` for empty states nested inside a page section with its own `<h2>` + * - `<h4>` or deeper for empty states inside nested panels or cards + */ +export const HeadingLevels: Story = { + render: (args) => html` + ${template( + args, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Heading h2</h2> + <span slot="description">Can be used for full-page empty states.</span> + ` + )} + ${template( + args, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h3 slot="heading">Heading h3</h3> + <span slot="description">Can be used inside a page section.</span> + ` + )} + ${template( + args, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h4 slot="heading">Heading h4</h4> + <span slot="description">Can be used inside a panel or card.</span> + ` + )} + `, + tags: ['options'], + parameters: { + styles: { display: 'flex', 'flex-direction': 'column', gap: '3rem' }, + 'section-order': 3, + }, +}; +HeadingLevels.storyName = 'Heading levels'; + +// ────────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────────── + +/** + * Place an `<a>` element inside the `description` slot to provide a + * call-to-action link alongside the supporting text. + */ +export const DescriptionWithLink: Story = { + render: (args) => html` + ${template( + args, + html` + ${unsafeHTML(cloudSvg('aria-hidden="true"'))} + <h2 slot="heading">Upload your files</h2> + <span slot="description"> + <a href="#">Select a file</a> + from your computer to get started. + </span> + ` + )} + `, + tags: ['behaviors'], +}; +DescriptionWithLink.storyName = 'Description with link'; + // ──────────────────────────────── // ACCESSIBILITY STORIES // ──────────────────────────────── /** - * SVGs slotted into the illustration slot should declare their accessibility - * intent explicitly: + * ### Features + * + * The `<swc-illustrated-message>` element implements the following + * accessibility features: + * + * 1. **Heading ownership**: The consumer provides the `<h2>`–`<h6>` element + * directly in the `heading` slot, preserving full control over heading + * level and ensuring it participates in the document outline. + * 2. **Illustration intent**: SVGs in the default slot must declare their + * accessibility intent explicitly via `aria-hidden` or `role="img"`. + * 3. **No redundant ARIA**: The component adds no `role` or `aria-*` + * attributes of its own; semantics come entirely from slotted content. + * + * ### Best practices * - * - **Decorative** (most common): add `aria-hidden="true"` so screen readers - * skip the graphic entirely. - * - **Informative**: add `role="img"` and `aria-label` (or an inline `<title>`) - * so screen readers announce the illustration's meaning. + * - Decorative illustrations (most common): add `aria-hidden="true"` so + * screen readers skip the graphic and move directly to the heading. + * - Informative illustrations: add `role="img"` and `aria-label` (or an + * inline `<title>`) so screen readers announce the illustration's meaning + * before reading the heading and description. + * - Choose the heading level (`h2`–`h6`) that fits the surrounding document + * outline, not based on visual size. */ export const IllustrationAccessibility: Story = { render: (args) => html` @@ -207,7 +345,7 @@ export const IllustrationAccessibility: Story = { <span slot="description"> The icon above uses <code>aria-hidden="true"</code> - — screen readers skip the illustration entirely and move on to the + so screen readers skip the illustration entirely and move on to the heading and description. </span> ` @@ -224,7 +362,7 @@ export const IllustrationAccessibility: Story = { <code>role="img"</code> and <code>aria-label</code> - — screen readers announce its meaning before reading the heading and + so screen readers announce its meaning before reading the heading and description. </span> ` diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts index 6be85799553..e912cbdae61 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.a11y.spec.ts @@ -49,4 +49,71 @@ test.describe('IllustratedMessage - ARIA Snapshots', () => { // the shadow root and must not appear inside the shadow DOM. await expect(root.locator('h2[slot="heading"]')).toBeVisible(); }); + + test('should have correct heading for each size variant', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-illustrated-message--sizes', + 'swc-illustrated-message' + ); + await expect(root).toMatchAriaSnapshot(` + - heading "Small" [level=2] + - heading "Medium" [level=2] + - heading "Large" [level=2] + `); + }); + + test('should have correct heading for each orientation variant', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-illustrated-message--orientation', + 'swc-illustrated-message' + ); + await expect(root).toMatchAriaSnapshot(` + - heading "Vertical (default)" [level=2] + - heading "Horizontal" [level=2] + `); + }); + + test('should hide decorative illustration from assistive technology', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-illustrated-message--illustration-accessibility', + 'swc-illustrated-message' + ); + // The first component has aria-hidden="true" on its SVG. + // It must not produce an img role in the accessibility tree. + const first = root.locator('swc-illustrated-message').first(); + await expect(first.getByRole('img')).toHaveCount(0); + // Heading is still accessible. + await expect(first).toMatchAriaSnapshot(` + - heading "Illustrated message title" [level=2] + `); + }); + + test('should expose informative illustration to assistive technology', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-illustrated-message--illustration-accessibility', + 'swc-illustrated-message' + ); + // The second component has role="img" + aria-label on its SVG. + // It must appear as a named image in the accessibility tree. + const second = root.locator('swc-illustrated-message').nth(1); + await expect( + second.getByRole('img', { name: 'Cloud storage illustration' }) + ).toBeVisible(); + await expect(second).toMatchAriaSnapshot(` + - img "Cloud storage illustration" + - heading "Illustrated message title" [level=2] + `); + }); }); diff --git a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts index 68c2c75f759..58eb479fc70 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts +++ b/2nd-gen/packages/swc/components/illustrated-message/test/illustrated-message.test.ts @@ -17,9 +17,17 @@ import { IllustratedMessage } from '@adobe/spectrum-wc/illustrated-message'; import '@adobe/spectrum-wc/illustrated-message'; -import { getComponent, withWarningSpy } from '../../../utils/test-utils.js'; -import meta from '../stories/illustrated-message.stories.js'; -import { Overview } from '../stories/illustrated-message.stories.js'; +import { + getComponent, + getComponents, + withWarningSpy, +} from '../../../utils/test-utils.js'; +import meta, { + IllustrationAccessibility, + Orientation, + Overview, + Sizes, +} from '../stories/illustrated-message.stories.js'; // This file defines dev-only test stories that reuse the main story metadata. export default { @@ -44,7 +52,7 @@ export const OverviewTest: Story = { 'swc-illustrated-message' ); - await step('consumer owns the heading element in light DOM', async () => { + await step('confirms heading element lives in light DOM', async () => { const headingInLight = illustratedMessage.querySelector('[slot="heading"]'); expect(headingInLight, 'heading element in light DOM').not.toBeNull(); @@ -57,10 +65,163 @@ export const OverviewTest: Story = { }, }; +export const DefaultValuesTest: Story = { + render: () => html` + <swc-illustrated-message></swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('reflects default size "m" as attribute', async () => { + expect(illustratedMessage.size, 'default size property').toBe('m'); + expect( + illustratedMessage.getAttribute('size'), + 'default size attribute' + ).toBe('m'); + }); + + await step( + 'reflects default orientation "vertical" as attribute', + async () => { + expect( + illustratedMessage.orientation, + 'default orientation property' + ).toBe('vertical'); + expect( + illustratedMessage.getAttribute('orientation'), + 'default orientation attribute' + ).toBe('vertical'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Properties / Attributes +// ────────────────────────────────────────────────────────────── + +export const PropertyMutationTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step( + 'size reflects to attribute after direct property mutation', + async () => { + illustratedMessage.size = 's'; + await illustratedMessage.updateComplete; + expect( + illustratedMessage.getAttribute('size'), + 'size attribute reflects "s"' + ).toBe('s'); + + illustratedMessage.size = 'l'; + await illustratedMessage.updateComplete; + expect( + illustratedMessage.getAttribute('size'), + 'size attribute reflects "l"' + ).toBe('l'); + + illustratedMessage.size = 'm'; + await illustratedMessage.updateComplete; + expect( + illustratedMessage.getAttribute('size'), + 'size attribute restores to "m"' + ).toBe('m'); + } + ); + + await step( + 'orientation reflects to attribute after direct property mutation', + async () => { + illustratedMessage.orientation = 'horizontal'; + await illustratedMessage.updateComplete; + expect( + illustratedMessage.getAttribute('orientation'), + 'orientation attribute reflects "horizontal"' + ).toBe('horizontal'); + + illustratedMessage.orientation = 'vertical'; + await illustratedMessage.updateComplete; + expect( + illustratedMessage.getAttribute('orientation'), + 'orientation attribute reflects "vertical"' + ).toBe('vertical'); + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── -// TEST: Heading slot — valid usage +// TEST: Slots // ────────────────────────────────────────────────────────────── +export const DefaultSlotIllustrationTest: Story = { + render: () => html` + <swc-illustrated-message> + <svg + aria-hidden="true" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 160 160" + > + <path d="M0 0" /> + </svg> + <h2 slot="heading">Test heading</h2> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('renders illustration content in the default slot', async () => { + const svg = illustratedMessage.querySelector('svg'); + expect(svg, 'svg element present in default slot').not.toBeNull(); + }); + + await step( + 'verifies decorative illustration is hidden from assistive tech', + async () => { + const svg = illustratedMessage.querySelector('svg'); + expect( + svg?.getAttribute('aria-hidden'), + 'aria-hidden on decorative svg' + ).toBe('true'); + } + ); + }, +}; + +export const DescriptionSlotTest: Story = { + render: () => html` + <swc-illustrated-message> + <h2 slot="heading">Heading</h2> + <span slot="description">Description text here.</span> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('renders description slot content', async () => { + const slotted = illustratedMessage.querySelector('[slot="description"]'); + expect(slotted, 'description slot element').not.toBeNull(); + expect(slotted?.textContent?.trim(), 'description text').toBe( + 'Description text here.' + ); + }); + }, +}; + export const HeadingSlotValidElementsTest: Story = { render: () => html` <swc-illustrated-message> @@ -93,89 +254,113 @@ export const HeadingSlotValidElementsTest: Story = { }; // ────────────────────────────────────────────────────────────── -// TEST: Heading slot — invalid usage +// TEST: Variants / States // ────────────────────────────────────────────────────────────── -export const HeadingSlotInvalidElementWarningTest: Story = { - render: () => html` - <swc-illustrated-message> - <div slot="heading">Not a heading</div> - </swc-illustrated-message> - `, +export const SizesTest: Story = { + ...Sizes, play: async ({ canvasElement, step }) => { - const illustratedMessage = await getComponent<IllustratedMessage>( + const elements = await getComponents<IllustratedMessage>( canvasElement, 'swc-illustrated-message' ); - await step('warns when heading slot contains a non-heading element', () => - withWarningSpy(async (warnCalls) => { - const headingSlot = - illustratedMessage.shadowRoot?.querySelector<HTMLSlotElement>( - 'slot[name="heading"]' + await step('renders one component per valid size', async () => { + expect(elements.length, 'number of size variants rendered').toBe( + IllustratedMessage.VALID_SIZES.length + ); + }); + + await step( + 'each component reflects its size as a property and attribute', + async () => { + for (const size of IllustratedMessage.VALID_SIZES) { + const el = elements.find( + (item) => item.getAttribute('size') === size ); - if (!headingSlot) { - return; + expect(el, `component with size="${size}"`).not.toBeUndefined(); + expect(el?.size, `size property is "${size}"`).toBe(size); } + } + ); + }, +}; - const slotChanged = new Promise<void>((resolve) => - headingSlot.addEventListener('slotchange', () => resolve(), { - once: true, - }) - ); - - // Replace the slotted element to trigger slotchange - const div = document.createElement('div'); - div.setAttribute('slot', 'heading'); - div.textContent = 'Not a heading'; - - illustratedMessage.querySelector('[slot="heading"]')?.remove(); - illustratedMessage.appendChild(div); +export const OrientationTest: Story = { + ...Orientation, + play: async ({ canvasElement, step }) => { + const elements = await getComponents<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); - await slotChanged; + await step('renders one component per valid orientation', async () => { + expect(elements.length, 'number of orientation variants rendered').toBe( + IllustratedMessage.VALID_ORIENTATIONS.length + ); + }); - expect( - warnCalls.length, - 'warning fired for invalid heading slot element' - ).toBeGreaterThan(0); - expect( - String(warnCalls[0]?.[1] ?? ''), - 'warning message mentions heading slot' - ).toContain('heading'); - }) + await step( + 'each component reflects its orientation as a property and attribute', + async () => { + for (const orientation of IllustratedMessage.VALID_ORIENTATIONS) { + const el = elements.find( + (item) => item.getAttribute('orientation') === orientation + ); + expect( + el, + `component with orientation="${orientation}"` + ).not.toBeUndefined(); + expect( + el?.orientation, + `orientation property is "${orientation}"` + ).toBe(orientation); + } + } ); }, }; -// ────────────────────────────────────────────────────────────── -// TEST: Slots -// ────────────────────────────────────────────────────────────── - -export const DescriptionSlotTest: Story = { - render: () => html` - <swc-illustrated-message> - <h2 slot="heading">Heading</h2> - <span slot="description">Description text here.</span> - </swc-illustrated-message> - `, +export const IllustrationAccessibilityTest: Story = { + ...IllustrationAccessibility, play: async ({ canvasElement, step }) => { - const illustratedMessage = await getComponent<IllustratedMessage>( + const elements = await getComponents<IllustratedMessage>( canvasElement, 'swc-illustrated-message' ); - await step('renders description slot content', async () => { - const slotted = illustratedMessage.querySelector('[slot="description"]'); - expect(slotted, 'description slot element').not.toBeNull(); - expect(slotted?.textContent?.trim(), 'description text').toBe( - 'Description text here.' - ); - }); + await step( + 'first component has a decorative illustration (aria-hidden)', + async () => { + const [first] = elements; + const decorativeSvg = first?.querySelector('svg[aria-hidden="true"]'); + expect( + decorativeSvg, + 'decorative svg with aria-hidden="true" in first component' + ).not.toBeNull(); + } + ); + + await step( + 'second component has an informative illustration (role="img" + aria-label)', + async () => { + const [, second] = elements; + const informativeSvg = second?.querySelector('svg[role="img"]'); + expect( + informativeSvg, + 'informative svg with role="img" in second component' + ).not.toBeNull(); + expect( + informativeSvg?.getAttribute('aria-label'), + 'informative svg has aria-label' + ).not.toBeNull(); + } + ); }, }; // ────────────────────────────────────────────────────────────── -// TEST: Size attribute / property +// TEST: Dev mode warnings // ────────────────────────────────────────────────────────────── export const ValidSizeNoWarningTest: Story = { @@ -243,10 +428,6 @@ export const InvalidSizeWarningTest: Story = { }, }; -// ────────────────────────────────────────────────────────────── -// TEST: Orientation attribute / property -// ────────────────────────────────────────────────────────────── - export const ValidOrientationNoWarningTest: Story = { render: () => html` <swc-illustrated-message> @@ -314,3 +495,91 @@ export const InvalidOrientationWarningTest: Story = { ); }, }; + +export const HeadingSlotInvalidElementWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <div slot="heading">Not a heading</div> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('warns when heading slot contains a non-heading element', () => + withWarningSpy(async (warnCalls) => { + const headingSlot = + illustratedMessage.shadowRoot?.querySelector<HTMLSlotElement>( + 'slot[name="heading"]' + ); + if (!headingSlot) { + return; + } + + const slotChanged = new Promise<void>((resolve) => + headingSlot.addEventListener('slotchange', () => resolve(), { + once: true, + }) + ); + + const div = document.createElement('div'); + div.setAttribute('slot', 'heading'); + div.textContent = 'Not a heading'; + + illustratedMessage.querySelector('[slot="heading"]')?.remove(); + illustratedMessage.appendChild(div); + + await slotChanged; + + expect( + warnCalls.length, + 'warning fired for invalid heading slot element' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] ?? ''), + 'warning message mentions heading slot' + ).toContain('heading'); + }) + ); + }, +}; + +export const HeadingSlotEmptyNoWarningTest: Story = { + render: () => html` + <swc-illustrated-message> + <h2 slot="heading">Initial heading</h2> + </swc-illustrated-message> + `, + play: async ({ canvasElement, step }) => { + const illustratedMessage = await getComponent<IllustratedMessage>( + canvasElement, + 'swc-illustrated-message' + ); + + await step('does not warn when heading slot is empty', () => + withWarningSpy(async (warnCalls) => { + const headingSlot = + illustratedMessage.shadowRoot?.querySelector<HTMLSlotElement>( + 'slot[name="heading"]' + ); + if (!headingSlot) { + return; + } + + const slotChanged = new Promise<void>((resolve) => + headingSlot.addEventListener('slotchange', () => resolve(), { + once: true, + }) + ); + + illustratedMessage.querySelector('[slot="heading"]')?.remove(); + + await slotChanged; + + expect(warnCalls.length, 'no warning for empty heading slot').toBe(0); + }) + ); + }, +};