diff --git a/.ai/skills/migration-api/SKILL.md b/.ai/skills/migration-api/SKILL.md index 0d8130eb36f..29a28d3390e 100644 --- a/.ai/skills/migration-api/SKILL.md +++ b/.ai/skills/migration-api/SKILL.md @@ -17,7 +17,11 @@ The plan's architectural sections also govern **where each property and type lan **Deferred items are out of scope.** The plan may explicitly defer features to follow-up tickets (e.g. form-associated behavior, cross-root ARIA). Do not implement deferred items in Phase 3 even if they appear in the API section of the plan — mark them as skipped and note the tracking ticket. -**1st-gen changes are limited to deprecation notices only.** When adding deprecation notices to 1st-gen, restrict changes to: (1) `@deprecated` JSDoc on exported types, consts, and properties; and (2) `window.__swc.warn()` calls added inside already-existing setters or other existing code paths. Do not refactor 1st-gen code structure — no new backing types, no converting simple properties to getter/setter patterns, no new private fields. TypeScript TS6385 hints that arise from deprecated types referencing each other internally are an accepted side effect and do not require structural fixes. +**For each breaking change in the migration plan, you must add both `@deprecated` JSDoc and a `window.__swc.warn()` call to the 1st-gen file.** JSDoc alone is not enough — types and IDE tooling pick it up, but consumers building against compiled output won't see it. Restrict 1st-gen changes to these two things only: do not add new backing types, do not convert plain properties to getter/setter patterns, do not add new private fields. + +For where the warn goes — inside an existing setter, or in the `updated()` lifecycle method with a `changes.has()` guard and a non-default value check for a setter-less `@property` — follow the [Where to place the warning](../../../CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md#where-to-place-the-warning) section of the debug and validation guide. The setter-less pattern matters here because Phase 3 does not allow converting a plain `@property` into a getter/setter just to host the warn, so `updated()` is its only home. + +After writing the deprecation notices, scan every `@deprecated` tag in the 1st-gen file and confirm a `__swc.warn()` call exists for each one before closing Phase 3. TypeScript TS6385 hints that arise from deprecated types referencing each other internally are an accepted side effect and do not require structural fixes. **`@deprecated` JSDoc message format** — the washing machine workflow Phase 3 section on "Deprecating 1st-gen type and const exports" says to document migration to class-level inference (e.g. `typeof Badge.prototype.variant`, `typeof Badge.FIXED_VALUES`) when a class static already exists in 1st-gen. Apply this as follows: @@ -26,11 +30,7 @@ The plan's architectural sections also govern **where each property and type lan - Do not add new class statics to 1st-gen just to provide this alternative — that is a refactor and out of scope. - For **renames**, note the new name in the JSDoc: "...will be replaced by `newName` in a future release." -For the `window.__swc.warn()` call format and the `{ level: 'deprecation' }` option, follow the **Deprecation warnings** section of [`CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md`](../../../CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md). Key rules from that guide: - -- Use `{ level: 'deprecation' }` instead of `{ issues: [...] }`. -- If a 1st-gen replacement exists, include `Use "newProp" instead.` at the end of the message. If there is no 1st-gen alternative, omit the "Use" clause. -- Do not reference 2nd-gen APIs as replacements in runtime warn messages. +For the `window.__swc.warn()` call format, the `{ level: 'deprecation' }` option, and the rules on referencing a replacement in the message, follow the **[Deprecation warnings](../../../CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md#deprecation-warnings)** section of the debug and validation guide. Applying its replacement-reference rule to migration: reference the 1st-gen replacement if one exists, otherwise omit the "Use" clause — and never point a 1st-gen runtime warn at a 2nd-gen API, since consumers on the 1st-gen build cannot use it. ## When to use this skill diff --git a/1st-gen/packages/progress-bar/src/ProgressBar.ts b/1st-gen/packages/progress-bar/src/ProgressBar.ts index 7ffdce7079c..c2c208c9818 100644 --- a/1st-gen/packages/progress-bar/src/ProgressBar.ts +++ b/1st-gen/packages/progress-bar/src/ProgressBar.ts @@ -187,6 +187,33 @@ export class ProgressBar extends SizedMixin( } } + if (window.__swc?.DEBUG) { + if (changes.has('label') && this.label.length > 0) { + window.__swc.warn( + this, + `The "label" attribute on <${this.localName}> has been deprecated and will be removed in a future release. Use a "label" named slot or the "accessible-label" attribute instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/progress-bar/#accessibility', + { level: 'deprecation' } + ); + } + if (changes.has('sideLabel') && this.sideLabel) { + window.__swc.warn( + this, + `The "side-label" attribute on <${this.localName}> has been deprecated and will be removed in a future release. Use label-position="side" instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/progress-bar/', + { level: 'deprecation' } + ); + } + if (changes.has('progress') && this.progress !== 0) { + window.__swc.warn( + this, + `The "progress" property on <${this.localName}> has been deprecated and will be removed in a future release. Use the "value" attribute instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/progress-bar/', + { level: 'deprecation' } + ); + } + } + if (window.__swc?.DEBUG) { if ( !this.label && diff --git a/1st-gen/packages/progress-bar/test/progress-bar.test.ts b/1st-gen/packages/progress-bar/test/progress-bar.test.ts index e55c58c26cb..7e0c570855d 100644 --- a/1st-gen/packages/progress-bar/test/progress-bar.test.ts +++ b/1st-gen/packages/progress-bar/test/progress-bar.test.ts @@ -168,29 +168,6 @@ describe('ProgressBar', () => { consoleWarnStub.restore(); }); - it('warns in Dev Mode when accessible attributes are not leveraged', async () => { - const el = await fixture(html` - - `); - - await elementUpdated(el); - - const spyCall = consoleWarnStub.getCall(0); - expect( - (spyCall.args[0] as string).includes('accessible'), - 'confirm accessibility-centric message' - ).to.be.true; - expect( - spyCall.args[spyCall.args.length - 1], - 'confirm `data` shape' - ).to.deep.equal({ - data: { - localName: 'sp-progress-bar', - type: 'accessibility', - level: 'default', - }, - }); - }); it('resolves a language (en-US)', async () => { const [languageContext] = createLanguageContext('en-US'); const test = await fixture(html` @@ -270,13 +247,18 @@ describe('ProgressBar', () => { 'confirm deprecated over-background warning' ).to.be.true; - const spyCall = consoleWarnStub.getCall(0); + const spyCall = consoleWarnStub + .getCalls() + .find((call: { args: unknown[] }) => + (call.args[0] as string)?.includes('over-background') + ); + expect(spyCall, 'over-background warning emitted').to.not.be.undefined; expect( - (spyCall.args[0] as string).includes('deprecated'), + (spyCall!.args[0] as string).includes('deprecated'), 'confirm deprecated over-background warning' ).to.be.true; expect( - spyCall.args[spyCall.args.length - 1], + spyCall!.args[spyCall!.args.length - 1], 'confirm `data` shape' ).to.deep.equal({ data: { @@ -294,13 +276,21 @@ describe('ProgressBar', () => { await elementUpdated(el); expect(consoleWarnStub.called).to.be.true; - const spyCall = consoleWarnStub.getCall(0); + const spyCall = consoleWarnStub.getCalls().find( + (call: { args: unknown[] }) => + ( + call.args[call.args.length - 1] as { + data?: { type?: string }; + } + )?.data?.type === 'accessibility' + ); + expect(spyCall, 'accessibility warning emitted').to.not.be.undefined; expect( - (spyCall.args[0] as string).includes('accessible'), + (spyCall!.args[0] as string).includes('accessible'), 'confirm accessibility-centric message' ).to.be.true; expect( - spyCall.args[spyCall.args.length - 1], + spyCall!.args[spyCall!.args.length - 1], 'confirm `data` shape' ).to.deep.equal({ data: { diff --git a/2nd-gen/packages/swc/components/meter/stories/meter.stories.ts b/2nd-gen/packages/swc/components/meter/stories/meter.stories.ts index 8093b601f10..c82b355cf0d 100644 --- a/2nd-gen/packages/swc/components/meter/stories/meter.stories.ts +++ b/2nd-gen/packages/swc/components/meter/stories/meter.stories.ts @@ -88,6 +88,9 @@ argTypes.value = { * user actions rather than system progress. The accessible `meter` role and its * `aria-value*` attributes live on the internal bar wrapper; the host carries no * ARIA role. + * + * To show the progression of a system operation such as downloading or processing, + * see [Progress Bar](../?path=/docs/components-progress-bar--docs). */ const meta: Meta = { title: 'Meter', diff --git a/2nd-gen/packages/swc/components/progress-bar/progress-bar.mdx b/2nd-gen/packages/swc/components/progress-bar/progress-bar.mdx new file mode 100644 index 00000000000..8e5fc048cc4 --- /dev/null +++ b/2nd-gen/packages/swc/components/progress-bar/progress-bar.mdx @@ -0,0 +1,117 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import { DocsFooter, DocsHeader } from '../../.storybook/blocks'; + +import * as Stories from './stories/progress-bar.stories'; + + + + + +## Anatomy + +A progress bar consists of: + +1. **Label**: text describing the operation in progress, provided through the `label` named slot +2. **Value**: the formatted current value, rendered from `value` within the `min-value` and `max-value` range (omitted when indeterminate) +3. **Track**: the bar background showing the full range +4. **Fill**: the portion of the track representing the current value (animates continuously when indeterminate) +5. **Description**: optional supporting text below the bar, provided through the `description` named slot + +The `progressbar` role and all `aria-value*`, `aria-labelledby`, and `aria-describedby` attributes live on the internal bar wrapper, not on the host element. + +When there is no visible `label` slot content, set the `accessible-label` attribute to supply an accessible name without showing visible text (for example, a full-page loading animation). + + + +## Options + +### Sizes + +Progress bars come in four sizes: small (`s`), medium (`m`), large (`l`), and extra-large (`xl`). Medium is the default. + + + +### Label Position + +The label can be displayed above the bar or beside it. Set `label-position="top"` (default) or `label-position="side"`. + + + +### Static Colors + +Use the `static-color` attribute when the progress bar is displayed over images or colored backgrounds: + +- **white**: use on dark or colored backgrounds for contrast +- **black**: use on light backgrounds for contrast + + + +## States + +### Values + +The bar fill and the formatted value reflect `value` relative to the sanitized `min-value` and `max-value` range. Values outside the range are clamped to the nearest bound. + + + +### Indeterminate + +Set `indeterminate` when the operation is in progress but the completion time cannot be determined, such as reconnecting to a server. The fill runs a looping animation and all four `aria-value*` attributes are omitted. + + + +## Behaviors + +### Custom range + +Set `min-value` and `max-value` to measure against an arbitrary numeric range rather than the default 0 to 100. The fill and `aria-valuetext` use the sanitized range; if the two bounds are reversed they are swapped, and non-finite bounds fall back to 0 and 100. + + + +### Value label + +Set the `value-label` attribute to replace the auto-formatted value in both the rendered value text and `aria-valuetext` with a custom string, such as `3 of 10 steps`. This attribute is ignored when `indeterminate` is set. + + + +### Format options + +When `value-label` is unset, the value is formatted with `Intl.NumberFormat`. The default style is percent. Set the `formatOptions` property (JavaScript only, no attribute) to any `Intl.NumberFormatOptions` to change the formatting. Percent style formats the fraction of the range; every other style formats the raw clamped value. + + + +## Accessibility + +### Features + +The `` element implements these accessibility features: + +#### ARIA implementation + +1. **ARIA role**: sets `role="progressbar"` on the internal bar wrapper. The host element carries no ARIA role. +2. **Labeling**: when the `label` slot has content, the role element references it via `aria-labelledby`. When there is no `label` slot content, the `accessible-label` value is exposed as `aria-label`. +3. **Description**: when the `description` slot has content, the role element references it via `aria-describedby`. +4. **Determinate value**: sets `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext` from the sanitized range and clamped value. `aria-valuetext` matches the formatted value, including `value-label` and `formatOptions` overrides, and is localized from the document language. +5. **Indeterminate state**: all four `aria-value*` attributes are omitted when `indeterminate` is set, as required by ARIA. + +#### Visual accessibility + +- Progress is communicated through the fill amount and the formatted value text, not through color alone. +- Forced-colors mode is supported with appropriate color overrides. +- Static color variants maintain sufficient contrast on images and colored backgrounds. + +#### Non-interactive element + +- Progress bars are not focusable and are not in the tab order. +- No keyboard interaction is required or expected. + +### Best practices + +- Provide an accessible name through either visible `label` slot content or the `accessible-label` attribute. +- Use specific, meaningful label text that describes the ongoing operation; for example, `"Uploading file"` rather than `"Loading"`. +- When no visible label is appropriate, such as when the surrounding context already names the operation, set `accessible-label` to provide a screen-reader-only name. +- Ensure sufficient color contrast between the bar and its background; use `static-color="white"` on dark backgrounds and `static-color="black"` on light backgrounds. + + + + diff --git a/2nd-gen/packages/swc/components/progress-bar/stories/progress-bar.stories.ts b/2nd-gen/packages/swc/components/progress-bar/stories/progress-bar.stories.ts index 997acdead58..5dc5705446f 100644 --- a/2nd-gen/packages/swc/components/progress-bar/stories/progress-bar.stories.ts +++ b/2nd-gen/packages/swc/components/progress-bar/stories/progress-bar.stories.ts @@ -67,12 +67,22 @@ argTypes.value = { }, }; +/** + * A `` shows the progression of a system operation such as + * downloading, uploading, or processing. Use a determinate bar when progress + * can be calculated against a known total; set `indeterminate` when the + * completion time cannot be determined. + * + * For circular progress indicators, see + * [Progress Circle](../?path=/docs/components-progress-circle--docs) and for read-only + * values use [Meter](../?path=/docs/components-meter--docs). + */ const meta: Meta = { title: 'Progress Bar', component: 'swc-progress-bar', parameters: { docs: { - subtitle: `Non-focusable, read-only bar that shows task progress or an indeterminate loading state.`, + subtitle: `Shows the progress of a task or an indeterminate loading state.`, }, styles: { 'min-inline-size': '250px' }, }, @@ -110,7 +120,7 @@ const staticColorLabels = { // ──────────────────── export const Playground: Story = { - tags: ['autodocs', 'dev'], + tags: ['dev'], args: { size: 'm', value: 60, @@ -216,7 +226,6 @@ export const LabelPosition: Story = { tags: ['options'], args: { value: 50 }, }; -LabelPosition.storyName = 'Label position'; export const StaticColors: Story = { render: (args) => html` @@ -232,7 +241,6 @@ export const StaticColors: Story = { tags: ['options', '!test'], args: { value: 50 }, }; -StaticColors.storyName = 'Static colors'; // ────────────────────────── // STATES STORIES diff --git a/2nd-gen/packages/swc/components/progress-bar/test/progress-bar.test.ts b/2nd-gen/packages/swc/components/progress-bar/test/progress-bar.test.ts new file mode 100644 index 00000000000..55c99739f8c --- /dev/null +++ b/2nd-gen/packages/swc/components/progress-bar/test/progress-bar.test.ts @@ -0,0 +1,446 @@ +/** + * 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, waitFor } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { ProgressBar } from '@adobe/spectrum-wc/progress-bar'; + +import '@adobe/spectrum-wc/components/progress-bar/swc-progress-bar.js'; + +import { + fixture, + getComponent, + getComponents, + withWarningSpy, +} from '../../../utils/test-utils.js'; +import meta from '../stories/progress-bar.stories.js'; +import { Indeterminate, Overview } from '../stories/progress-bar.stories.js'; + +// This file defines dev-only test stories that reuse the main story metadata. +export default { + ...meta, + title: 'Progress Bar/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +/** + * The WAI-ARIA `progressbar` role lives on the shadow `.swc-LinearProgress` + * wrapper, not on the host. Every `aria-value*` and naming attribute is + * read from this element. + */ +const getRoleEl = (host: ProgressBar): HTMLElement => { + const roleEl = host.shadowRoot?.querySelector( + '[role="progressbar"]' + ); + if (!roleEl) { + throw new Error('progressbar role element not found in shadow root'); + } + return roleEl; +}; + +const getFillEl = (host: ProgressBar): HTMLElement => { + const fill = host.shadowRoot?.querySelector( + '.swc-LinearProgress-fill' + ); + if (!fill) { + throw new Error('fill element not found in shadow root'); + } + return fill; +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Role placement and default ARIA +// ────────────────────────────────────────────────────────────── + +export const OverviewTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + + await step('host carries no ARIA role; role lives in shadow', () => { + expect(progressBar.hasAttribute('role')).toBe(false); + expect(roleEl.getAttribute('role')).toBe('progressbar'); + }); + + await step('default range and value bind to the role element', () => { + expect(roleEl.getAttribute('aria-valuemin')).toBe('0'); + expect(roleEl.getAttribute('aria-valuemax')).toBe('100'); + expect(roleEl.getAttribute('aria-valuenow')).toBe('75'); + expect(roleEl.getAttribute('aria-valuetext')).toBeTruthy(); + }); + + await step('label slot is referenced via aria-labelledby', () => { + const labelledBy = roleEl.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + const labelEl = progressBar.shadowRoot?.getElementById( + labelledBy as string + ); + expect(labelEl, 'aria-labelledby points to a real element').toBeTruthy(); + // No aria-label when a visible label slot provides the name. + expect(roleEl.hasAttribute('aria-label')).toBe(false); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Indeterminate suppresses all value attrs + visible text +// ────────────────────────────────────────────────────────────── + +export const IndeterminateTest: Story = { + ...Indeterminate, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + + await step('all four aria-value* attributes are absent', () => { + expect(roleEl.hasAttribute('aria-valuemin')).toBe(false); + expect(roleEl.hasAttribute('aria-valuemax')).toBe(false); + expect(roleEl.hasAttribute('aria-valuenow')).toBe(false); + expect(roleEl.hasAttribute('aria-valuetext')).toBe(false); + }); + + await step('visible value text element is not rendered', () => { + const valueEl = progressBar.shadowRoot?.querySelector( + '.swc-LinearProgress-value' + ); + expect( + valueEl, + 'value text element is absent in indeterminate' + ).toBeNull(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Determinate → indeterminate toggle clears value attrs +// ────────────────────────────────────────────────────────────── + +export const ToggleIndeterminateTest: Story = { + render: () => html` + + Processing + + `, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + + await step('determinate state has all four value attributes', () => { + expect(roleEl.getAttribute('aria-valuenow')).toBe('50'); + expect(roleEl.hasAttribute('aria-valuemin')).toBe(true); + expect(roleEl.hasAttribute('aria-valuemax')).toBe(true); + expect(roleEl.hasAttribute('aria-valuetext')).toBe(true); + }); + + await step( + 'switching to indeterminate removes all four value attributes', + async () => { + progressBar.indeterminate = true; + await progressBar.updateComplete; + + expect(roleEl.hasAttribute('aria-valuenow')).toBe(false); + expect(roleEl.hasAttribute('aria-valuemin')).toBe(false); + expect(roleEl.hasAttribute('aria-valuemax')).toBe(false); + expect(roleEl.hasAttribute('aria-valuetext')).toBe(false); + } + ); + + await step( + 'returning to determinate restores all four value attributes', + async () => { + progressBar.indeterminate = false; + await progressBar.updateComplete; + + expect(roleEl.getAttribute('aria-valuenow')).toBe('50'); + expect(roleEl.hasAttribute('aria-valuemin')).toBe(true); + expect(roleEl.hasAttribute('aria-valuemax')).toBe(true); + expect(roleEl.hasAttribute('aria-valuetext')).toBe(true); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Custom minValue/maxValue clamps value and feeds ARIA + fill +// ────────────────────────────────────────────────────────────── + +export const CustomRangeClampTest: Story = { + render: () => html` + + Steps completed + + `, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + + await step('sanitized range feeds aria-value*', () => { + expect(roleEl.getAttribute('aria-valuemin')).toBe('0'); + expect(roleEl.getAttribute('aria-valuemax')).toBe('10'); + expect(roleEl.getAttribute('aria-valuenow')).toBe('3'); + // 3 of 0..10 → 30% fill. + expect(getFillEl(progressBar).style.inlineSize).toBe('30%'); + }); + + await step('clamps value above max', async () => { + progressBar.value = 30; + await progressBar.updateComplete; + expect(roleEl.getAttribute('aria-valuenow')).toBe('10'); + expect(getFillEl(progressBar).style.inlineSize).toBe('100%'); + }); + + await step('clamps value below min', async () => { + progressBar.value = -5; + await progressBar.updateComplete; + expect(roleEl.getAttribute('aria-valuenow')).toBe('0'); + expect(getFillEl(progressBar).style.inlineSize).toBe('0%'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: accessible-label fallback vs label slot +// ────────────────────────────────────────────────────────────── + +export const AccessibleLabelTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + + await step( + 'accessible-label renders aria-label, not aria-labelledby', + () => { + expect(roleEl.getAttribute('aria-label')).toBe('Screen-reader label'); + expect(roleEl.hasAttribute('aria-labelledby')).toBe(false); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: description slot → aria-describedby present/absent +// ────────────────────────────────────────────────────────────── + +export const DescriptionPresentTest: Story = { + render: () => html` + + Files uploaded + Upload will complete shortly + + `, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + + await step('description slot is referenced via aria-describedby', () => { + const describedBy = roleEl.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect( + progressBar.shadowRoot?.getElementById(describedBy as string) + ).toBeTruthy(); + }); + + await step( + 'no aria-describedby when description slot is empty', + async () => { + // Remove the description slot content to verify the attribute is absent. + progressBar.querySelector('[slot="description"]')?.remove(); + // Presence detection runs through a MutationObserver, which then + // schedules a deferred re-render, so a single `updateComplete` is not + // enough; poll until the attribute is dropped. Timing of the observer + // callback varies across engines (notably WebKit). + await waitFor(() => + expect(roleEl.hasAttribute('aria-describedby')).toBe(false) + ); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: value-label overrides formatted text; suppressed in indeterminate +// ────────────────────────────────────────────────────────────── + +export const ValueLabelOverrideTest: Story = { + render: () => html` + + Files uploaded + + `, + play: async ({ canvasElement, step }) => { + const progressBar = await getComponent( + canvasElement, + 'swc-progress-bar' + ); + const roleEl = getRoleEl(progressBar); + const valueEl = progressBar.shadowRoot?.querySelector( + '.swc-LinearProgress-value' + ); + + await step('value-label drives rendered value and aria-valuetext', () => { + expect(roleEl.getAttribute('aria-valuetext')).toBe('2 of 5 files'); + expect(valueEl?.textContent?.trim()).toBe('2 of 5 files'); + }); + + await step( + 'clearing value-label falls back to percent format', + async () => { + progressBar.valueLabel = undefined; + await progressBar.updateComplete; + expect(roleEl.getAttribute('aria-valuetext')).not.toBe('2 of 5 files'); + expect(roleEl.getAttribute('aria-valuetext')).toContain('%'); + } + ); + + await step('value-label is suppressed when indeterminate', async () => { + progressBar.valueLabel = '2 of 5 files'; + progressBar.indeterminate = true; + await progressBar.updateComplete; + // All aria-value* are absent in indeterminate state. + expect(roleEl.hasAttribute('aria-valuetext')).toBe(false); + expect( + progressBar.shadowRoot?.querySelector('.swc-LinearProgress-value') + ).toBeNull(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Missing accessible-name DEBUG warning +// ────────────────────────────────────────────────────────────── + +export const MissingAccessibleNameTest: Story = { + // The no-accessible-name warning is one-shot per instance (guarded by an + // internal flag in the mixin) and fires on the first render when DEBUG is + // enabled. Mount a fresh element *inside* the warning spy so the spy + // captures that first render, rather than mutating an element that may have + // already warned before the spy was installed. + render: () => html` + + `, + play: async ({ step }) => { + await step('warns when no label slot and no accessible-label', () => + withWarningSpy(async (warnCalls) => { + const progressBar = await fixture(html` + + `); + await progressBar.updateComplete; + const messages = warnCalls.map((c) => String(c?.[1] ?? '')); + expect(messages.some((m) => m.includes('accessible name'))).toBe(true); + progressBar.parentElement?.remove(); + }) + ); + + await step('does not warn when accessible-label is set', () => + withWarningSpy(async (warnCalls) => { + const progressBar = await fixture(html` + + `); + await progressBar.updateComplete; + const messages = warnCalls.map((c) => String(c?.[1] ?? '')); + expect(messages.some((m) => m.includes('accessible name'))).toBe(false); + progressBar.parentElement?.remove(); + }) + ); + + await step('does not warn when label slot has content', () => + withWarningSpy(async (warnCalls) => { + const progressBar = await fixture(html` + + Uploading file + + `); + await progressBar.updateComplete; + const messages = warnCalls.map((c) => String(c?.[1] ?? '')); + expect(messages.some((m) => m.includes('accessible name'))).toBe(false); + progressBar.parentElement?.remove(); + }) + ); + + await step('warning references swc-progress-bar, not progress-circle', () => + withWarningSpy(async (warnCalls) => { + const progressBar = await fixture(html` + + `); + await progressBar.updateComplete; + const elements = warnCalls.map((c) => c?.[0]); + // The first argument is the element itself; its localName must be + // swc-progress-bar, not swc-progress-circle (1st-gen copy-paste bug). + const warnedElement = elements[0] as HTMLElement | undefined; + expect(warnedElement?.localName).toBe('swc-progress-bar'); + progressBar.parentElement?.remove(); + }) + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Static-color reflection (restores coverage for the +// `!test` StaticColors story, which axe cannot evaluate against +// the decorator gradient). +// ────────────────────────────────────────────────────────────── + +export const StaticColorsTest: Story = { + render: () => html` + + Static white + + + Static black + + `, + parameters: { staticColorsDemo: true }, + play: async ({ canvasElement, step }) => { + const bars = await getComponents( + canvasElement, + 'swc-progress-bar' + ); + + await step('each bar reflects its static-color attribute', () => { + const colors = bars.map((bar) => bar.getAttribute('static-color')); + expect(colors).toEqual(['white', 'black']); + }); + }, +}; 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 2da5d96639e..dbaf1ae36a0 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 @@ -19,6 +19,8 @@ - [connectedCallback() — Environment validation](#connectedcallback--environment-validation) - [Deprecation warnings](#deprecation-warnings) - [Deprecation warning structure](#deprecation-warning-structure) + - [Where to place the warning](#where-to-place-the-warning) + - [Referencing a replacement in the message](#referencing-a-replacement-in-the-message) - [Testing deprecation warnings](#testing-deprecation-warnings) - [Warning message format](#warning-message-format) @@ -222,6 +224,8 @@ public override connectedCallback(): void { 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. +Every deprecated API needs a runtime warning, not only a `@deprecated` JSDoc tag. TypeScript and IDE tooling read the JSDoc, but consumers building against compiled output never see it. Pair each `@deprecated` tag with a `window.__swc.warn()` call so the deprecation also surfaces at runtime in development. + **When to use deprecation warnings:** - A property is being replaced by a slot (consumer-owned HTML) @@ -244,6 +248,51 @@ if (window.__swc?.DEBUG) { } ``` +### Where to place the warning + +Place the warning where the deprecated API is actually used, guarded so it fires only when a consumer sets the deprecated value — never on default initialization. + +- **Property with a setter** — put the warning inside the setter, so it fires when the value is assigned: + +```ts +public set overBackground(value: boolean) { + // ...existing setter work... + if (window.__swc?.DEBUG) { + window.__swc.warn( + this, + `The "over-background" attribute on <${this.localName}> has been deprecated and will be removed in a future release. Use "static-color='white'" instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/progress-bar/', + { level: 'deprecation' } + ); + } +} +``` + +- **Plain `@property` field without a setter** — do not add a setter just to host the warning. Warn from the existing `updated()` lifecycle method, guarded by both a `changes.has()` check and a non-default value check so property initialization does not trigger a false positive: + +```ts +protected override updated(changes: PropertyValues): void { + super.updated(changes); + if (window.__swc?.DEBUG) { + if (changes.has('progress') && this.progress !== 0) { + window.__swc.warn( + this, + `The "progress" property on <${this.localName}> has been deprecated and will be removed in a future release. Use the "value" attribute instead.`, + 'https://opensource.adobe.com/spectrum-web-components/components/progress-bar/', + { level: 'deprecation' } + ); + } + } +} +``` + +The non-default guard (`this.progress !== 0`) is what keeps the warning honest: without it, the warning fires at construction on every instance, including consumers who never touched the deprecated property. + +### Referencing a replacement in the message + +- Include `Use "newProp" instead.` at the end of the message only when the replacement API ships in the same package the consumer already has. If there is no same-package alternative, omit the "Use" clause and end with `...will be removed in a future release.` +- Do not point a runtime warning at an API from a different package or a newer major generation. A warning should only name a replacement that is available in the build that emitted it. + ### Testing deprecation warnings Write separate test cases that verify: