diff --git a/2nd-gen/packages/core/components/asset/Asset.base.ts b/2nd-gen/packages/core/components/asset/Asset.base.ts index 97ae5602408..73909cd2bd5 100644 --- a/2nd-gen/packages/core/components/asset/Asset.base.ts +++ b/2nd-gen/packages/core/components/asset/Asset.base.ts @@ -44,7 +44,7 @@ export abstract class AssetBase extends SpectrumElement { /** * Accessible label for the asset’s file or folder variant. */ - @property() + @property({ type: String }) public label = ''; // ────────────────────── diff --git a/2nd-gen/packages/core/components/icon/Icon.base.ts b/2nd-gen/packages/core/components/icon/Icon.base.ts index 6df8ea9919f..b026273300b 100644 --- a/2nd-gen/packages/core/components/icon/Icon.base.ts +++ b/2nd-gen/packages/core/components/icon/Icon.base.ts @@ -40,7 +40,7 @@ export abstract class IconBase extends SizedMixin(SpectrumElement, { /** * Accessible label for the icon. */ - @property() + @property({ type: String }) public label = ''; @queryAssignedElements({ flatten: true }) diff --git a/2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts b/2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts index a87df672664..e1042bc0d23 100644 --- a/2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts +++ b/2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts @@ -9,6 +9,7 @@ * 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'; @@ -24,6 +25,14 @@ import { ProgressCircleStaticColor, } from './ProgressCircle.types.js'; +/** + * @todo SWC-1891 Extract shared progress logic (ARIA, label, clamping, formatting, + * indeterminate derivation) into a `ProgressBase` mixin or abstract class in + * `core/components/progress/` so that both `ProgressCircleBase` and a future + * `ProgressBarBase` can extend it. Also add `formatOptions` support for + * progress-bar's custom value labels (e.g. "3 of 10", "45 MB / 100 MB"). + */ + /** * A progress circle component that visually represents the completion progress of a task. * Can be used in both determinate (with specific progress value) and indeterminate (loading) states. @@ -75,20 +84,15 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { // SHARED API // ────────────────── - /** - * @todo Revisit the default API for `indeterminate` and `progress`. SWC-1891 - * - * Whether the progress circle shows indeterminate progress (loading state). - * - * When true, displays an animated loading indicator instead of a specific progress value. - */ - @property({ type: Boolean, reflect: true }) - public indeterminate = false; - /** * Accessible label for the progress circle. * * Used to provide context about what is loading or progressing. + * When no accessible name is provided (no label, aria-label, or + * aria-labelledby), a default "Loading" label is applied. + * + * @todo Localize the default "Loading" fallback via LanguageResolutionController + * once a runtime i18n system for static strings is available. */ @property({ type: String }) public label = ''; @@ -96,11 +100,15 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { /** * Progress value from 0 to 100. * - * Only relevant when indeterminate is false. Values outside that range or - * non-finite numbers are clamped to 0–100 (non-finite becomes 0). + * When `null` (indeterminate), the component shows a loading animation. + * Setting a number switches to determinate mode. Removing the `progress` + * attribute or setting this property to `null` returns to indeterminate. + * Values outside 0–100 or non-finite numbers are clamped (non-finite becomes 0). + * + * Reflected to the `progress` attribute when set; the attribute is omitted when indeterminate. */ - @property({ type: Number }) - public progress = 0; + @property({ type: Number, reflect: true }) + public progress: number | null = null; private languageResolver = new LanguageResolutionController(this); @@ -108,6 +116,12 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { // IMPLEMENTATION // ────────────────────── + /** + * @todo Localize via LanguageResolutionController once a runtime i18n + * system for static strings is available. + */ + private static readonly DEFAULT_LABEL = 'Loading'; + /** True when light DOM has element nodes or non-whitespace text (no default slot). */ private static hasMeaningfulLightDomChildren(host: HTMLElement): boolean { for (const node of host.childNodes) { @@ -121,6 +135,28 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { return false; } + private hasAccessibleName(): boolean { + return Boolean( + this.label || + this.getAttribute('aria-label') || + this.getAttribute('aria-labelledby') + ); + } + + private static clampProgress(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(100, Math.max(0, value)); + } + + private formatProgress(): string { + return new Intl.NumberFormat(this.languageResolver.language, { + style: 'percent', + unitDisplay: 'narrow', + }).format((this.progress ?? 0) / 100); + } + private warnDeprecatedLightDomChildren(): void { if (!window.__swc?.DEBUG) { return; @@ -136,15 +172,27 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { ); } - private static clampProgress(value: number): number { - if (!Number.isFinite(value)) { - return 0; + private warnMissingAccessibleName(): void { + if (!window.__swc?.DEBUG) { + return; } - return Math.min(100, Math.max(0, value)); + window.__swc?.warn( + this, + `<${this.localName}> requires an accessible name. A default label of "${ProgressCircleBase.DEFAULT_LABEL}" has been applied, but a more specific label should be provided via:`, + 'https://opensource.adobe.com/spectrum-web-components/second-gen/?path=/docs/components-progress-circle--docs', + { + type: 'accessibility', + issues: [ + 'value supplied to the "label" attribute, which will be displayed visually as part of the element, or', + 'value supplied to the "aria-label" attribute, which will only be provided to screen readers, or', + 'an element ID reference supplied to the "aria-labelledby" attribute, which will be provided by screen readers and will need to be managed manually by the parent application.', + ], + } + ); } protected override willUpdate(changes: PropertyValues): void { - if (changes.has('progress')) { + if (changes.has('progress') && this.progress !== null) { const clamped = ProgressCircleBase.clampProgress(this.progress); if (clamped !== this.progress) { this.progress = clamped; @@ -155,40 +203,30 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { protected override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); - if (!this.hasAttribute('role')) { - this.setAttribute('role', 'progressbar'); - } - } - - private formatProgress(): string { - return new Intl.NumberFormat(this.languageResolver.language, { - style: 'percent', - unitDisplay: 'narrow', - }).format(this.progress / 100); + this.setAttribute('role', 'progressbar'); } protected override updated(changes: PropertyValues): void { super.updated(changes); - if (changes.has('indeterminate')) { - if (this.indeterminate) { - this.removeAttribute('aria-valuemin'); - this.removeAttribute('aria-valuemax'); - this.removeAttribute('aria-valuenow'); - this.removeAttribute('aria-valuetext'); - } else { + + if (changes.has('progress')) { + if (this.progress !== null && this.progress >= 0) { this.setAttribute('aria-valuemin', '0'); this.setAttribute('aria-valuemax', '100'); this.setAttribute('aria-valuenow', String(this.progress)); this.setAttribute('aria-valuetext', this.formatProgress()); + } else { + this.removeAttribute('aria-valuemin'); + this.removeAttribute('aria-valuemax'); + this.removeAttribute('aria-valuenow'); + this.removeAttribute('aria-valuetext'); } } - if (!this.indeterminate && changes.has('progress')) { - this.setAttribute('aria-valuenow', String(this.progress)); - this.setAttribute('aria-valuetext', this.formatProgress()); - } - if (!this.indeterminate && changes.has(languageResolverUpdatedSymbol)) { + + if (this.progress !== null && changes.has(languageResolverUpdatedSymbol)) { this.setAttribute('aria-valuetext', this.formatProgress()); } + if (changes.has('label')) { if (this.label.length) { this.setAttribute('aria-label', this.label); @@ -197,31 +235,14 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { } } - const hasAccessibleName = (): boolean => { - return Boolean( - this.label || - this.getAttribute('aria-label') || - this.getAttribute('aria-labelledby') - ); - }; + // Apply default accessible name fallback after handling explicit label changes. + if (changes.has('label') && !this.hasAccessibleName()) { + this.setAttribute('aria-label', ProgressCircleBase.DEFAULT_LABEL); + this.warnMissingAccessibleName(); + } if (window.__swc?.DEBUG) { this.warnDeprecatedLightDomChildren(); - if (!hasAccessibleName() && this.getAttribute('role') === 'progressbar') { - window.__swc?.warn( - this, - `<${this.localName}> elements need one of the following to be accessible:`, - 'https://opensource.adobe.com/spectrum-web-components/second-gen/?path=/docs/components-progress-circle--docs', - { - type: 'accessibility', - issues: [ - 'value supplied to the "label" attribute, which will be displayed visually as part of the element, or', - 'value supplied to the "aria-label" attribute, which will only be provided to screen readers, or', - 'an element ID reference supplied to the "aria-labelledby" attribute, which will be provided by screen readers and will need to be managed manually by the parent application.', - ], - } - ); - } } } } diff --git a/2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts b/2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts index 03caa52603e..b506300e500 100644 --- a/2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts +++ b/2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts @@ -16,8 +16,9 @@ export const PROGRESS_CIRCLE_VALID_SIZES = [ 's', 'm', 'l', -] as const satisfies ElementSize[]; +] as const satisfies readonly ElementSize[]; export const PROGRESS_CIRCLE_STATIC_COLORS = ['white', 'black'] as const; export type ProgressCircleStaticColor = (typeof PROGRESS_CIRCLE_STATIC_COLORS)[number]; +export type ProgressCircleSize = (typeof PROGRESS_CIRCLE_VALID_SIZES)[number]; diff --git a/2nd-gen/packages/core/components/status-light/StatusLight.base.ts b/2nd-gen/packages/core/components/status-light/StatusLight.base.ts index 06be7399e1a..d4b21589ec0 100644 --- a/2nd-gen/packages/core/components/status-light/StatusLight.base.ts +++ b/2nd-gen/packages/core/components/status-light/StatusLight.base.ts @@ -16,7 +16,7 @@ import { SpectrumElement } from '@spectrum-web-components/core/element/index.js' import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; import { - STATUSLIGHT_VALID_SIZES, + STATUS_LIGHT_VALID_SIZES, type StatusLightVariant, } from './StatusLight.types.js'; @@ -27,7 +27,7 @@ import { * @attribute {ElementSize} size - The size of the status light. */ export abstract class StatusLightBase extends SizedMixin(SpectrumElement, { - validSizes: STATUSLIGHT_VALID_SIZES, + validSizes: STATUS_LIGHT_VALID_SIZES, noDefaultSize: true, }) { // ───────────────────────── diff --git a/2nd-gen/packages/core/components/status-light/StatusLight.types.ts b/2nd-gen/packages/core/components/status-light/StatusLight.types.ts index f41ebd4882a..820d58fa66a 100644 --- a/2nd-gen/packages/core/components/status-light/StatusLight.types.ts +++ b/2nd-gen/packages/core/components/status-light/StatusLight.types.ts @@ -10,20 +10,16 @@ * governing permissions and limitations under the License. */ -/** - * @todo Rename STATUSLIGHT_ prefix to STATUS_LIGHT_ to align with type prefix - * naming convention (use underscore separators for multi-word names). - */ import type { ElementSize } from '@spectrum-web-components/core/mixins/index.js'; -export const STATUSLIGHT_VALID_SIZES = [ +export const STATUS_LIGHT_VALID_SIZES = [ 's', 'm', 'l', 'xl', ] as const satisfies readonly ElementSize[]; -export const STATUSLIGHT_VARIANTS_SEMANTIC = [ +export const STATUS_LIGHT_VARIANTS_SEMANTIC = [ 'neutral', 'info', 'positive', @@ -31,7 +27,7 @@ export const STATUSLIGHT_VARIANTS_SEMANTIC = [ 'notice', ] as const; -export const STATUSLIGHT_VARIANTS_COLOR = [ +export const STATUS_LIGHT_VARIANTS_COLOR = [ 'fuchsia', 'indigo', 'magenta', @@ -48,15 +44,15 @@ export const STATUSLIGHT_VARIANTS_COLOR = [ 'silver', ] as const; -export const STATUSLIGHT_VARIANTS = [ - ...STATUSLIGHT_VARIANTS_SEMANTIC, - ...STATUSLIGHT_VARIANTS_COLOR, +export const STATUS_LIGHT_VARIANTS = [ + ...STATUS_LIGHT_VARIANTS_SEMANTIC, + ...STATUS_LIGHT_VARIANTS_COLOR, ] as const; export type StatusLightSemanticVariant = - (typeof STATUSLIGHT_VARIANTS_SEMANTIC)[number]; + (typeof STATUS_LIGHT_VARIANTS_SEMANTIC)[number]; export type StatusLightColorVariant = - (typeof STATUSLIGHT_VARIANTS_COLOR)[number]; + (typeof STATUS_LIGHT_VARIANTS_COLOR)[number]; -export type StatusLightVariant = (typeof STATUSLIGHT_VARIANTS)[number]; +export type StatusLightVariant = (typeof STATUS_LIGHT_VARIANTS)[number]; diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index f26f4005752..6932715d3ed 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -179,7 +179,7 @@ const preview = { }, source: { excludeDecorators: true, - type: 'auto', + type: 'dynamic', language: 'html', transform: async (source: string) => { try { diff --git a/2nd-gen/packages/swc/components/avatar/Avatar.ts b/2nd-gen/packages/swc/components/avatar/Avatar.ts index fbb81d61f67..ffc6dd2ca87 100644 --- a/2nd-gen/packages/swc/components/avatar/Avatar.ts +++ b/2nd-gen/packages/swc/components/avatar/Avatar.ts @@ -36,6 +36,10 @@ import styles from './avatar.css'; * */ export class Avatar extends AvatarBase { + // ────────────────────────────── + // RENDERING & STYLING + // ────────────────────────────── + public static override get styles(): CSSResultArray { return [styles]; } diff --git a/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts b/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts index 04b8677b5d7..5050aea58bb 100644 --- a/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts +++ b/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts @@ -73,9 +73,9 @@ export default { excludeStories: ['meta'], } as Meta; -// ──────────────── -// STORIES -// ──────────────── +// ──────────────────── +// HELPERS +// ──────────────────── const PLACEHOLDER_SRC = 'https://picsum.photos/id/64/500/500'; @@ -86,18 +86,18 @@ const PLACEHOLDER_SRC = 'https://picsum.photos/id/64/500/500'; // alt ?? '' guards against undefined produced by Storybook controls when // the user clears the alt field. Explicit stories use typed args that are always defined. export const Playground: Story = { - render: ({ src, alt, size, outline, disabled }) => html` + render: (args) => html`
`, @@ -112,13 +112,11 @@ export const Playground: Story = { }; // ────────────────────────── -// OVERVIEW STORY +// OVERVIEW STORIES // ────────────────────────── export const Overview: Story = { - render: ({ src, alt, size }) => html` - - `, + render: (args) => template({ ...args }), tags: ['overview'], args: { src: PLACEHOLDER_SRC, @@ -143,9 +141,14 @@ export const Overview: Story = { * - `size`: Numeric size token (50–1500). Defaults to `500` (40 px). */ export const Anatomy: Story = { - render: () => html` - + render: (args) => html` + `, + args: { + src: PLACEHOLDER_SRC, + alt: 'Jane Doe', + size: '500', + }, tags: ['anatomy'], }; @@ -160,17 +163,20 @@ export const Anatomy: Story = { * The default size is `500` (40 px). */ export const Sizes: Story = { - render: () => html` + render: (args) => html` ${AVATAR_VALID_SIZES.map( (size) => html` ` )} `, + args: { + src: PLACEHOLDER_SRC, + }, parameters: { flexLayout: 'row-wrap', 'section-order': 1, @@ -186,15 +192,19 @@ export const Sizes: Story = { * (e.g., their name appears next to the avatar). */ export const Decorative: Story = { - render: () => html` + render: (args) => html` Jane Doe `, + args: { + src: PLACEHOLDER_SRC, + size: '500', + }, parameters: { 'section-order': 2 }, tags: ['options'], }; @@ -211,15 +221,24 @@ export const Decorative: Story = { * migrated to 2nd-gen. */ export const InActionButton: Story = { - render: () => html` + render: (args) => html` `, + args: { + src: PLACEHOLDER_SRC, + alt: 'Jane Doe', + size: '100', + }, tags: ['behaviors'], }; @@ -232,24 +251,28 @@ export const InActionButton: Story = { * defaults to `true` to visually separate stacked avatars. */ export const Outline: Story = { - render: () => html` + render: (args) => html`
`, + args: { + src: PLACEHOLDER_SRC, + alt: 'Jane Doe', + }, parameters: { 'section-order': 3 }, tags: ['options'], }; @@ -261,14 +284,19 @@ export const Outline: Story = { * accessibility tree — `disabled` is purely visual. */ export const Disabled: Story = { - render: () => html` + render: (args) => html` `, + args: { + src: PLACEHOLDER_SRC, + alt: 'Jane Doe', + size: '500', + }, parameters: { 'section-order': 4 }, tags: ['options'], }; @@ -294,9 +322,13 @@ export const Disabled: Story = { * - Keep alt text short and descriptive: prefer `"Jane Doe"` over `"Profile photo of Jane Doe"` */ export const Accessibility: Story = { - render: () => html` - - + render: (args) => html` + + `, + args: { + src: PLACEHOLDER_SRC, + size: '500', + }, tags: ['a11y'], }; diff --git a/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts b/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts index d61043b27c4..7759f86684d 100644 --- a/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts +++ b/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts @@ -438,6 +438,7 @@ export const TextWrapping: Story = { `, tags: ['behaviors'], }; + // ──────────────────────────────── // ACCESSIBILITY STORIES // ──────────────────────────────── diff --git a/2nd-gen/packages/swc/components/icon/stories/icon.internal.stories.ts b/2nd-gen/packages/swc/components/icon/stories/icon.internal.stories.ts index f0a804bd572..b7583da4abe 100644 --- a/2nd-gen/packages/swc/components/icon/stories/icon.internal.stories.ts +++ b/2nd-gen/packages/swc/components/icon/stories/icon.internal.stories.ts @@ -53,6 +53,7 @@ const meta: Meta = { subtitle: `Internal icon renderer for shared SVG templates.`, }, }, + tags: ['migrated'], }; export default meta; diff --git a/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts b/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts index 94248de07ac..7f99fae7a62 100644 --- a/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts +++ b/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts @@ -13,6 +13,7 @@ import { CSSResultArray, html, TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { PROGRESS_CIRCLE_STATIC_COLORS, @@ -25,15 +26,15 @@ import styles from './progress-circle.css'; /** * Progress circles show the progression of a system operation such as downloading, uploading, processing, etc. in a visual way. - * They can represent determinate (with a specific progress value) or indeterminate (loading) progress. + * + * They can represent determinate (with a specific progress value) or indeterminate (loading) progress. If no `progress` value is given, the progress circle is indeterminate. * * @element swc-progress-circle * @status preview * @since 0.0.1 * * @property {string} staticColor - Reflected as the `static-color` attribute. Static color variant for use on different backgrounds. - * @property {number} progress - Progress value between 0 and 100. - * @property {boolean} indeterminate - Indeterminate state for loading. + * @property {number | null} progress - Progress between 0 and 100, reflected as the `progress` attribute when set. When `null` (indeterminate), the attribute is omitted. * @property {string} size - Size of the component. * @property {string} label - Label for the component. * @@ -41,9 +42,7 @@ import styles from './progress-circle.css'; * * * @example - * - * - * Light DOM children are not projected into the shadow tree. Use the `label` attribute or property, or `aria-label` / `aria-labelledby` on the host, for an accessible name. + * */ export class ProgressCircle extends ProgressCircleBase { // ──────────────────── @@ -73,6 +72,27 @@ export class ProgressCircle extends ProgressCircleBase { return [styles]; } + /** + * Compute the SVG stroke-dashoffset for the fill circle. + * + * - **Indeterminate** (`progress` is `null`): returns `undefined` so CSS + * animation keyframes fully control the offset. + * - **0%**: returns 98 instead of 100. A dashoffset of 100 hides the fill + * entirely, which fails WCAG 1.4.11 non-text contrast (the track alone + * may not meet 3:1 against the background). The 2-unit fill keeps the + * graphical element perceivable. `aria-valuenow` stays at 0. + * - **1–100%**: returns `100 - progress`. + */ + private computeDashOffset(): number | undefined { + if (this.progress === null) { + return undefined; + } + if (this.progress === 0) { + return 98; + } + return 100 - this.progress; + } + protected override render(): TemplateResult { const strokeWidth = this.size === 's' ? 2 : this.size === 'l' ? 6 : 4; // SVG strokes are centered, so subtract half the stroke width from the radius to create an inner stroke. @@ -82,14 +102,12 @@ export class ProgressCircle extends ProgressCircleBase {
- + diff --git a/2nd-gen/packages/swc/components/progress-circle/progress-circle.css b/2nd-gen/packages/swc/components/progress-circle/progress-circle.css index f9af7da028c..d83f7a748fd 100644 --- a/2nd-gen/packages/swc/components/progress-circle/progress-circle.css +++ b/2nd-gen/packages/swc/components/progress-circle/progress-circle.css @@ -100,10 +100,17 @@ --swc-progress-circle-fill-border-color: token("static-black-track-indicator-color"); } +/* @todo Come back to this when we discuss reduce motion animations */ +@media (prefers-reduced-motion: reduce) { + .swc-ProgressCircle--indeterminate .swc-ProgressCircle-fill { + stroke-dashoffset: 75px; + animation: none; + } +} + @media (forced-colors: active) { .swc-ProgressCircle { --swc-progress-circle-fill-border-color: Highlight; - --swc-progress-circle-track-color: Canvas; @media (prefers-color-scheme: dark) { --swc-progress-circle-track-border-color: token("static-white-track-color"); diff --git a/2nd-gen/packages/swc/components/progress-circle/stories/progress-circle.stories.ts b/2nd-gen/packages/swc/components/progress-circle/stories/progress-circle.stories.ts index 4278b219f19..256e45ed274 100644 --- a/2nd-gen/packages/swc/components/progress-circle/stories/progress-circle.stories.ts +++ b/2nd-gen/packages/swc/components/progress-circle/stories/progress-circle.stories.ts @@ -15,6 +15,11 @@ import type { Meta, StoryObj as Story } from '@storybook/web-components'; import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; import { ProgressCircle } from '@adobe/spectrum-wc/progress-circle'; +import { + PROGRESS_CIRCLE_STATIC_COLORS, + PROGRESS_CIRCLE_VALID_SIZES, + type ProgressCircleSize, +} from '@spectrum-web-components/core/components/progress-circle'; import '@adobe/spectrum-wc/progress-circle'; @@ -24,16 +29,16 @@ import '@adobe/spectrum-wc/progress-circle'; const { args, argTypes, template } = getStorybookHelpers('swc-progress-circle'); -// @todo Blurring the range control seems to cause a catastrophic Storybook render failure, so using number input for now. React spectrum has the range control working, check their implementation for a solution. -argTypes.progress = { - ...argTypes.progress, - control: { type: 'number', min: 0, max: 100, step: 1 }, -}; - argTypes.size = { ...argTypes.size, control: { type: 'select' }, options: ProgressCircle.VALID_SIZES, + table: { + category: 'attributes', + defaultValue: { + summary: 'm', + }, + }, }; argTypes['static-color'] = { @@ -69,6 +74,16 @@ const meta: Meta = { export default meta; +// ──────────────────── +// HELPERS +// ──────────────────── + +const sizeLabels = { + s: 'Processing small item', + m: 'Processing medium item', + l: 'Processing large item', +} as const satisfies Record; + // ──────────────────── // AUTODOCS STORY // ──────────────────── @@ -76,8 +91,6 @@ export default meta; export const Playground: Story = { tags: ['autodocs', 'dev'], args: { - progress: 50, - size: 'm', label: 'Uploading document', }, }; @@ -89,7 +102,6 @@ export const Playground: Story = { export const Overview: Story = { tags: ['overview'], args: { - progress: 50, label: 'Uploading document', }, }; @@ -103,34 +115,30 @@ export const Overview: Story = { * * 1. **Track** - Background ring showing the full progress range * 2. **Fill** - Colored ring segment showing current progress - * 3. **Label** - Accessible text describing the operation (not visually rendered), provided via the `label` attribute or property, or `aria-label` / `aria-labelledby` on the host + * 3. **Label** - Accessible text describing the operation (not visually rendered). * - * ### Content + * > **A11y Note:** Light DOM children are not projected into the shadow tree, so content between the opening and closing tags does not supply an accessible name. Use the `label` attribute or property, or `aria-label` / `aria-labelledby` on the host. * - * - **Label**: Accessible text describing what is loading or progressing (not visually rendered) */ export const Anatomy: Story = { - render: (args) => html` - ${template({ - ...args, - progress: 0, - size: 'l', - label: 'Starting upload', - })} - ${template({ - ...args, - progress: 50, - size: 'l', - label: 'Uploading document', - })} - ${template({ - ...args, - progress: 100, - size: 'l', - label: 'Upload complete', - })} + render: () => html` + + + `, tags: ['anatomy'], + args: { + size: 'l', + }, }; // ────────────────────────── @@ -146,17 +154,20 @@ export const Anatomy: Story = { */ export const Sizes: Story = { render: (args) => html` - ${template({ ...args, size: 's', label: 'Processing small item' })} - ${template({ ...args, size: 'm', label: 'Processing medium item' })} - ${template({ ...args, size: 'l', label: 'Processing large item' })} + ${PROGRESS_CIRCLE_VALID_SIZES.map( + (size) => html` + ${template({ + ...args, + size, + label: sizeLabels[size], + })} + ` + )} `, tags: ['options'], args: { progress: 25, }, - parameters: { - 'section-order': 1, - }, }; /** @@ -165,9 +176,10 @@ export const Sizes: Story = { * - **white**: Use on dark or colored backgrounds for better contrast * - **black**: Use on light backgrounds for better contrast */ +// @todo: capture the Chromatic VRTs for all sizes of progress circles for both static color options and WHCM. SWC-1848 export const StaticColors: Story = { render: (args) => html` - ${ProgressCircle.STATIC_COLORS.map( + ${PROGRESS_CIRCLE_STATIC_COLORS.map( (color) => html` ${template({ ...args, 'static-color': color })} ` @@ -183,52 +195,53 @@ export const StaticColors: Story = { 'section-order': 2, }, }; -StaticColors.storyName = 'Static colors'; // ────────────────────────── // STATES STORIES // ────────────────────────── /** - * Progress circles can show specific progress values from 0% to 100%. - * Set the `progress` attribute to a value between 0 and 100 to represent determinate progress. - * This automatically sets `aria-valuenow` to the provided value for screen readers. + * When no `progress` value is set, the component displays an animated indeterminate + * loading indicator. This is the default state. + * The `aria-valuenow` attribute is removed, signaling to assistive technologies + * that the operation duration is unknown. + * + * Use indeterminate progress when: + * - The operation duration is unknown + * - Progress cannot be accurately measured + * - Multiple sub-operations are running in parallel */ -export const ProgressValues: Story = { - render: (args) => html` - ${template({ ...args, progress: 0, label: 'Starting download' })} - ${template({ ...args, progress: 25, label: 'Downloading (25%)' })} - ${template({ ...args, progress: 50, label: 'Downloading (50%)' })} - ${template({ ...args, progress: 75, label: 'Downloading (75%)' })} - ${template({ ...args, progress: 100, label: 'Download complete' })} - `, +export const Indeterminate: Story = { tags: ['states'], args: { - size: 'm', + label: 'Processing request', }, parameters: { 'section-order': 1, }, }; -ProgressValues.storyName = 'Progress values'; /** - * The indeterminate state shows an animated loading indicator when progress is unknown or cannot be determined. - * Set the `indeterminate` attribute to `true` to activate this state. - * This removes `aria-valuenow` from the element and provides appropriate feedback to assistive technologies. - * - * Use indeterminate progress when: - * - The operation duration is unknown - * - Progress cannot be accurately measured - * - Multiple sub-operations are running in parallel + * Progress circles can show specific progress values from 0% to 100%. + * Set the `progress` attribute to a value between 0 and 100 to represent determinate progress. + * This automatically sets `aria-valuenow` to the provided value for screen readers. */ -export const Indeterminate: Story = { +export const ProgressValues: Story = { + render: () => html` + + + + `, tags: ['states'], - args: { - indeterminate: true, - size: 'm', - label: 'Processing request', - }, parameters: { 'section-order': 2, }, @@ -250,7 +263,7 @@ export const Indeterminate: Story = { * 3. **Progress state** (determinate): * - Sets `aria-valuenow` with the current `progress` value * 4. **Loading state** (indeterminate): - * - Removes `aria-valuenow` when `indeterminate="true"` + * - When no `progress` value is set, `aria-valuenow` is omitted * - Screen readers understand this as an ongoing operation with unknown duration * 5. **Status communication**: Screen readers announce progress updates as values change * @@ -260,18 +273,25 @@ export const Indeterminate: Story = { * - High contrast mode is supported with appropriate color overrides * - Static color variants ensure sufficient contrast on different backgrounds * + * #### Non-interactive element + * + * - Progress circles have no interactive behavior and are not focusable + * - Screen readers will announce the progress circle content as static text + * - No keyboard interaction is required or expected + * * ### Best practices * * - Always provide a descriptive `label` that explains what the progress represents * - Use specific, meaningful labels (e.g., "Uploading profile photo" instead of "Loading") * - Use determinate progress (`progress="50"`) when possible to give users a clear sense of completion * - For determinate progress, ensure the `progress` value accurately reflects the actual progress - * - Use indeterminate progress only when duration is truly unknown + * - Use indeterminate progress only when duration is truly unknown or when the wait is less than 3 seconds. * - Consider using `size="l"` for primary loading states to improve visibility * - Ensure sufficient color contrast between the progress circle and its background * - Use `static-color="white"` on dark backgrounds or `static-color="black"` on light backgrounds * - Test with screen readers to verify progress announcements are clear and timely * - Avoid updating progress values more frequently than every 1-2 seconds to prevent announcement overload + * - Do not force live region announcements for progress durations that are 3 seconds or less. Instead, consider status messages when progress is complete or there is an error */ export const Accessibility: Story = { tags: ['a11y'], diff --git a/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.a11y.spec.ts b/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.a11y.spec.ts index 8973f9310e6..ac00cfa7754 100644 --- a/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.a11y.spec.ts +++ b/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.a11y.spec.ts @@ -33,8 +33,7 @@ test.describe('Progress Circle - ARIA Snapshots', () => { 'swc-progress-circle' ); await expect(root).toMatchAriaSnapshot(` - - progressbar "Uploading document": - - img + - progressbar "Uploading document" `); }); @@ -47,8 +46,7 @@ test.describe('Progress Circle - ARIA Snapshots', () => { 'swc-progress-circle' ); await expect(root).toMatchAriaSnapshot(` - - progressbar "Starting upload": - - img + - progressbar "Starting upload" `); }); @@ -59,8 +57,7 @@ test.describe('Progress Circle - ARIA Snapshots', () => { 'swc-progress-circle' ); await expect(root).toMatchAriaSnapshot(` - - progressbar "Processing small item": - - img + - progressbar "Processing small item" `); }); @@ -71,8 +68,7 @@ test.describe('Progress Circle - ARIA Snapshots', () => { 'swc-progress-circle' ); await expect(root).toMatchAriaSnapshot(` - - progressbar "Processing media": - - img + - progressbar "Processing media" `); }); @@ -83,8 +79,7 @@ test.describe('Progress Circle - ARIA Snapshots', () => { 'swc-progress-circle' ); await expect(root).toMatchAriaSnapshot(` - - progressbar "Starting download": - - img + - progressbar "Starting download" `); }); @@ -95,8 +90,7 @@ test.describe('Progress Circle - ARIA Snapshots', () => { 'swc-progress-circle' ); await expect(root).toMatchAriaSnapshot(` - - progressbar "Processing request": - - img + - progressbar "Processing request" `); }); }); diff --git a/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts b/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts index 8d26e93ef69..5a137eb37a4 100644 --- a/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts +++ b/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts @@ -61,9 +61,7 @@ export const OverviewTest: Story = { expect(progressCircle.getAttribute('aria-label')).toBe( progressCircle.label ); - expect(progressCircle.getAttribute('aria-valuenow')).toBe( - String(progressCircle.progress) - ); + expect(progressCircle.hasAttribute('progress')).toBe(false); } ); }, @@ -108,22 +106,6 @@ export const StaticColorsTest: Story = { }, }; -export const RoleOverrideTest: Story = { - render: () => html` - - `, - play: async ({ canvasElement, step }) => { - const progressCircle = await getComponent( - canvasElement, - 'swc-progress-circle' - ); - - await step('preserves user-supplied role', async () => { - expect(progressCircle.getAttribute('role')).toBe('status'); - }); - }, -}; - export const LabelClearingTest: Story = { render: () => html` { - progressCircle.label = ''; - await progressCircle.updateComplete; + await step( + 'falls back to default label when label is cleared', + async () => { + progressCircle.label = ''; + await progressCircle.updateComplete; - expect(progressCircle.getAttribute('aria-label')).toBeNull(); - }); + expect(progressCircle.getAttribute('aria-label')).toBe('Loading'); + } + ); }, }; @@ -219,7 +204,7 @@ export const LightDomChildrenDoNotSetLabelTest: Story = { 'does not use light DOM text as the label or accessible name', async () => { expect(progressCircle.label).toBe(''); - expect(progressCircle.getAttribute('aria-label')).toBeNull(); + expect(progressCircle.getAttribute('aria-label')).toBe('Loading'); //check the default label is applied not the light dom text } ); @@ -230,17 +215,11 @@ export const LightDomChildrenDoNotSetLabelTest: Story = { progressCircle.progress = 10; await progressCircle.updateComplete; - expect(warnCalls.length).toBeGreaterThanOrEqual(2); - expect( - warnCalls.some((call) => - String(call[1] ?? '').includes('no longer has a default slot') - ) - ).toBe(true); - expect( - warnCalls.some((call) => - String(call[1] ?? '').includes('accessible') - ) - ).toBe(true); + expect(warnCalls.length).toBe(1); + expect(String(warnCalls[0]?.[1] ?? '')).toContain( + 'no longer has a default slot' + ); + expect(String(warnCalls[0]?.[1] ?? '')).toContain('accessible'); }) ); }, @@ -286,12 +265,28 @@ export const ProgressValuesTest: Story = { 'swc-progress-circle' ); - await step('reflects progress values to aria-valuenow', async () => { - circles.forEach((circle) => { - const progress = String(circle.progress); - expect(circle.getAttribute('aria-valuenow')).toBe(progress); + const expectedValues = [0, 25, 50, 75, 100]; + + await step('renders with the correct initial progress values', async () => { + circles.forEach((circle, i) => { + expect(circle.progress).toBe(expectedValues[i]); + expect(circle.getAttribute('progress')).toBe(String(expectedValues[i])); }); }); + + await step('reflects progress values as progress changes', async () => { + const circle = circles[0]; + expect(circle.progress).toBe(0); + expect(circle.getAttribute('progress')).toBe('0'); + circle.progress = 50; + await circle.updateComplete; + expect(circle.progress).toBe(50); + expect(circle.getAttribute('progress')).toBe('50'); + circle.progress = 100; + await circle.updateComplete; + expect(circle.progress).toBe(100); + expect(circle.getAttribute('progress')).toBe('100'); + }); }, }; @@ -333,7 +328,7 @@ export const IndeterminateTest: Story = { ); await step('removes aria-valuenow in indeterminate state', async () => { - expect(progressCircle.indeterminate).toBe(true); + expect(progressCircle.progress).toBe(null); expect(progressCircle.hasAttribute('aria-valuenow')).toBe(false); }); }, @@ -357,7 +352,7 @@ export const ReturnToIndeterminateTest: Story = { await step( 'clears aria-valuenow when switched to indeterminate', async () => { - progressCircle.indeterminate = true; + progressCircle.progress = null; await progressCircle.updateComplete; expect(progressCircle.hasAttribute('aria-valuenow')).toBe(false); @@ -379,9 +374,19 @@ export const AccessibilityWarningTest: Story = { canvasElement, 'swc-progress-circle' ); - await step('warns when there is no accessible name', () => + + await step( + 'applies default "Loading" label when no accessible name is provided', + async () => { + expect(progressCircle.getAttribute('aria-label')).toBe('Loading'); + } + ); + + await step('warns when accessible name is removed', () => withWarningSpy(async (warnCalls) => { - progressCircle.progress = 10; + progressCircle.label = 'Temporary'; + await progressCircle.updateComplete; + progressCircle.label = ''; await progressCircle.updateComplete; expect(warnCalls.length).toBeGreaterThan(0); diff --git a/2nd-gen/packages/swc/components/status-light/StatusLight.ts b/2nd-gen/packages/swc/components/status-light/StatusLight.ts index f8412b7350a..80f0e895798 100644 --- a/2nd-gen/packages/swc/components/status-light/StatusLight.ts +++ b/2nd-gen/packages/swc/components/status-light/StatusLight.ts @@ -15,9 +15,9 @@ import { property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { - STATUSLIGHT_VARIANTS, - STATUSLIGHT_VARIANTS_COLOR, - STATUSLIGHT_VARIANTS_SEMANTIC, + STATUS_LIGHT_VARIANTS, + STATUS_LIGHT_VARIANTS_COLOR, + STATUS_LIGHT_VARIANTS_SEMANTIC, StatusLightBase, type StatusLightVariant, } from '@spectrum-web-components/core/components/status-light'; @@ -47,17 +47,17 @@ export class StatusLight extends StatusLightBase { /** * @internal */ - static override readonly VARIANTS_COLOR = STATUSLIGHT_VARIANTS_COLOR; + static override readonly VARIANTS_COLOR = STATUS_LIGHT_VARIANTS_COLOR; /** * @internal */ - static override readonly VARIANTS_SEMANTIC = STATUSLIGHT_VARIANTS_SEMANTIC; + static override readonly VARIANTS_SEMANTIC = STATUS_LIGHT_VARIANTS_SEMANTIC; /** * @internal */ - static override readonly VARIANTS = STATUSLIGHT_VARIANTS; + static override readonly VARIANTS = STATUS_LIGHT_VARIANTS; /** * Changes the color of the status dot. The variant list includes both semantic and non-semantic options. diff --git a/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts b/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts index 82ca157bf0b..e79b2b7d353 100644 --- a/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts +++ b/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts @@ -16,10 +16,12 @@ import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; import { StatusLight } from '@adobe/spectrum-wc/status-light'; import { - STATUSLIGHT_VARIANTS_COLOR, - STATUSLIGHT_VARIANTS_SEMANTIC, + STATUS_LIGHT_VALID_SIZES, + STATUS_LIGHT_VARIANTS_COLOR, + STATUS_LIGHT_VARIANTS_SEMANTIC, StatusLightColorVariant, StatusLightSemanticVariant, + type StatusLightSize, } from '@spectrum-web-components/core/components/status-light'; import '@adobe/spectrum-wc/status-light'; @@ -38,20 +40,29 @@ argTypes.variant = { ...argTypes.variant, control: { type: 'select' }, options: StatusLight.VARIANTS, + table: { + category: 'attributes', + defaultValue: { + summary: 'info', + }, + }, }; argTypes.size = { ...argTypes.size, control: { type: 'select' }, options: StatusLight.VALID_SIZES, + table: { + category: 'attributes', + defaultValue: { + summary: 'm', + }, + }, }; /** * Status lights describe the condition of an entity. Much like [badges](../?path=/docs/components-badge--readme), they can be used to convey semantic meaning, such as statuses and categories. */ -args['default-slot'] = 'Status light'; -args.size = 'm'; - export const meta: Meta = { title: 'Status light', component: 'swc-status-light', @@ -109,6 +120,13 @@ const nonSemanticLabels = { silver: 'Version 1.2.10', } as const satisfies Record; +const sizeLabels = { + s: 'Small', + m: 'Medium', + l: 'Large', + xl: 'Extra-large', +} as const satisfies Record; + // ──────────────────── // AUTODOCS STORY // ──────────────────── @@ -116,21 +134,17 @@ const nonSemanticLabels = { export const Playground: Story = { tags: ['autodocs', 'dev'], args: { - size: 'm', - variant: 'info', 'default-slot': 'Active', }, }; // ──────────────────── -// OVERVIEW STORY +// OVERVIEW STORIES // ──────────────────── export const Overview: Story = { tags: ['overview'], args: { - size: 'm', - variant: 'info', 'default-slot': 'Active', }, }; @@ -158,9 +172,6 @@ export const Anatomy: Story = { })} `, tags: ['anatomy'], - args: { - size: 'm', - }, }; // ────────────────────────── @@ -179,10 +190,13 @@ export const Anatomy: Story = { */ export const Sizes: Story = { render: (args) => html` - ${template({ ...args, size: 's', 'default-slot': 'Small' })} - ${template({ ...args, size: 'm', 'default-slot': 'Medium' })} - ${template({ ...args, size: 'l', 'default-slot': 'Large' })} - ${template({ ...args, size: 'xl', 'default-slot': 'Extra-large' })} + ${STATUS_LIGHT_VALID_SIZES.map((size) => + template({ + ...args, + size, + 'default-slot': sizeLabels[size], + }) + )} `, parameters: { 'section-order': 1 }, tags: ['options'], @@ -201,7 +215,7 @@ export const Sizes: Story = { */ export const SemanticVariants: Story = { render: (args) => html` - ${STATUSLIGHT_VARIANTS_SEMANTIC.map((variant: StatusLightSemanticVariant) => + ${STATUS_LIGHT_VARIANTS_SEMANTIC.map((variant: StatusLightSemanticVariant) => template({ ...args, variant, @@ -234,7 +248,7 @@ export const SemanticVariants: Story = { */ export const NonSemanticVariants: Story = { render: (args) => html` - ${STATUSLIGHT_VARIANTS_COLOR.map((variant: StatusLightColorVariant) => + ${STATUS_LIGHT_VARIANTS_COLOR.map((variant: StatusLightColorVariant) => template({ ...args, variant, diff --git a/CONTRIBUTOR-DOCS/01_contributor-guides/07_authoring-contributor-docs/README.md b/CONTRIBUTOR-DOCS/01_contributor-guides/07_authoring-contributor-docs/README.md index d3d1a251827..70f0aa524e8 100644 --- a/CONTRIBUTOR-DOCS/01_contributor-guides/07_authoring-contributor-docs/README.md +++ b/CONTRIBUTOR-DOCS/01_contributor-guides/07_authoring-contributor-docs/README.md @@ -156,11 +156,11 @@ Request generation of breadcrumbs and TOC when you: The script also automatically verifies all internal markdown links and reports any broken links, which an AI agent will typically fix automatically. -You can request an update by asking an AI Agent, pointing it to the [AI Agent Instructions](../../../.cursor/skills/contributor-docs-nav/references/ai-agent-instructions.md). +You can request an update by asking an AI Agent, pointing it to the [AI Agent Instructions](../../../.ai/skills/contributor-docs-nav/references/ai-agent-instructions.md). > If you're using Cursor, you can just ask an agent to "update the contributor docs"; a project-level Cursor rule will help the agent find the applicable instructions. -> If you don't have access to an AI Agent or prefer not to use one, you can also run the script manually. See [the AI Agent Instructions](../../../.cursor/skills/contributor-docs-nav/references/ai-agent-instructions.md) for details. +> If you don't have access to an AI Agent or prefer not to use one, you can also run the script manually. See [the AI Agent Instructions](../../../.ai/skills/contributor-docs-nav/references/ai-agent-instructions.md) for details. ### Verifying and troubleshooting @@ -168,7 +168,7 @@ The logic for generating navigational elements is quite simple, so it's unlikely **Link verification:** The script automatically checks all internal markdown links and reports any broken links. When working with an AI agent, broken links are typically fixed automatically without human intervention. The agent will only ask for guidance if the fix is ambiguous (e.g., when a target file has been completely removed). -If you encounter any issues, try asking an AI agent to help you troubleshoot, pointing the agent to the [AI Agent Instructions](../../../.cursor/skills/contributor-docs-nav/references/ai-agent-instructions.md). +If you encounter any issues, try asking an AI agent to help you troubleshoot, pointing the agent to the [AI Agent Instructions](../../../.ai/skills/contributor-docs-nav/references/ai-agent-instructions.md). ## Committing updates diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_analyze-rendering-and-styling/README.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_analyze-rendering-and-styling/README.md index 8d8db316df7..e78455b222b 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_analyze-rendering-and-styling/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_analyze-rendering-and-styling/README.md @@ -212,6 +212,6 @@ The prompt template lives in the component-migration-analysis skill at `.cursor/ ## Resources -- [Rendering and styling prompt template](../../../../../../.cursor/skills/component-migration-analysis/references/migration-analysis-prompt.md) (used by the component-migration-analysis Cursor skill) +- [Rendering and styling prompt template](../../../../../../.ai/skills/component-migration-analysis/references/migration-analysis-prompt.md) (used by the component-migration-analysis Cursor skill) - [Spectrum CSS repository](https://github.com/adobe/spectrum-css) - [Spectrum Web Components repository](https://github.com/adobe/spectrum-web-components)