diff --git a/2nd-gen/packages/core/components/badge/Badge.base.ts b/2nd-gen/packages/core/components/badge/Badge.base.ts index aa78726dac..f6287ed1a0 100644 --- a/2nd-gen/packages/core/components/badge/Badge.base.ts +++ b/2nd-gen/packages/core/components/badge/Badge.base.ts @@ -102,58 +102,30 @@ export abstract class BadgeBase extends SizedMixin( /** * The fixed position of the badge. - * - * @todo The purpose of the bespoke getter and setter is unclear, as it - * looks like they may be behaving just like a standard Lit reactive - * property. Explore replacing after milestone 2. */ - @property({ reflect: true }) - public get fixed(): FixedValues | undefined { - return this._fixed; - } - - public set fixed(fixed: FixedValues | undefined) { - if (fixed === this.fixed) { - return; - } - const oldValue = this.fixed; - this._fixed = fixed; - if (fixed) { - this.setAttribute('fixed', fixed); - } else { - this.removeAttribute('fixed'); - } - this.requestUpdate('fixed', oldValue); - } - - private _fixed?: FixedValues; + @property({ type: String, reflect: true }) + public fixed?: FixedValues; // ────────────────────── // IMPLEMENTATION // ────────────────────── /** - * Used for rendering gap when the badge has an icon. - * * @internal + * + * Used for rendering gap when the badge has an icon. */ protected get hasIcon(): boolean { return this.slotContentIsPresent; } - /** - * @todo Migrate from update() to updated() for consistency with other - * components. The standard pattern is to use updated() for post-render - * validation (debug warnings). - */ protected override update(changedProperties: PropertyValues): void { - super.update(changedProperties); if (window.__swc?.DEBUG) { const constructor = this.constructor as typeof BadgeBase; if (!constructor.VARIANTS.includes(this.variant)) { window.__swc.warn( this, - `<${this.localName}> element expect the "variant" attribute to be one of the following:`, + `<${this.localName}> element expects the "variant" attribute to be one of the following:`, 'https://opensource.adobe.com/spectrum-web-components/components/badge/#variants', { issues: [...constructor.VARIANTS], @@ -176,5 +148,6 @@ export abstract class BadgeBase extends SizedMixin( ); } } + super.update(changedProperties); } } diff --git a/2nd-gen/packages/core/components/divider/Divider.base.ts b/2nd-gen/packages/core/components/divider/Divider.base.ts index 050cd99eec..d55d952012 100644 --- a/2nd-gen/packages/core/components/divider/Divider.base.ts +++ b/2nd-gen/packages/core/components/divider/Divider.base.ts @@ -29,7 +29,7 @@ import { */ export abstract class DividerBase extends SizedMixin(SpectrumElement, { validSizes: DIVIDER_VALID_SIZES, - /**@todo the design spec says the default size is small but we declare no default size */ + /** @todo Size `s` is noted as the default in Spectrum design documentation, so be aware there is a discrepancy between the t-shirt API, which supports `m` as the default, and this component's use of `noDefaultSize` (visual default via CSS). SWC-1847 */ noDefaultSize: true, }) { // ────────────────── @@ -51,18 +51,34 @@ export abstract class DividerBase extends SizedMixin(SpectrumElement, { /** * The static color variant to use for the divider. - * - * @todo Add runtime validation separately. When implementing, - * access STATIC_COLORS from this.constructor.STATIC_COLORS to ensure - * correct values are used. */ - @property({ reflect: true, attribute: 'static-color' }) + @property({ type: String, reflect: true, attribute: 'static-color' }) public staticColor?: DividerStaticColor; // ────────────────────── // IMPLEMENTATION // ────────────────────── + protected override update(changedProperties: PropertyValues): void { + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof DividerBase; + if ( + typeof this.staticColor !== 'undefined' && + !constructor.STATIC_COLORS.includes(this.staticColor) + ) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "static-color" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/divider/', + { + issues: [...constructor.STATIC_COLORS], + } + ); + } + } + super.update(changedProperties); + } + protected override firstUpdated(changed: PropertyValues): void { super.firstUpdated(changed); this.setAttribute('role', 'separator'); 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 6a845eb0e1..dba02af58e 100644 --- a/2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts +++ b/2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import { PropertyValues } from 'lit'; -import { property, query } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { LanguageResolutionController, @@ -18,7 +18,6 @@ import { } from '@spectrum-web-components/core/controllers/language-resolution.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; -import { getLabelFromSlot } from '@spectrum-web-components/core/utils/get-label-from-slot.js'; import { PROGRESS_CIRCLE_VALID_SIZES, @@ -30,14 +29,6 @@ import { * Can be used in both determinate (with specific progress value) and indeterminate (loading) states. * * @attribute {ElementSize} size - The size of the progress circle. - * - * @todo Why do we support both the slot and the label attribute? Should we deprecate the slot? - * - * @todo Figure out why our tool chain doesn't respect the line breaks in this slot description. - * - * @slot - Accessible label for the progress circle. - * - * Used to provide context about what is loading or progressing. */ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { validSizes: PROGRESS_CIRCLE_VALID_SIZES, @@ -78,6 +69,8 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { // ────────────────── /** + * @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. @@ -96,7 +89,8 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { /** * Progress value from 0 to 100. * - * Only relevant when indeterminate is false. + * Only relevant when indeterminate is false. Values outside that range or + * non-finite numbers are clamped to 0–100 (non-finite becomes 0). */ @property({ type: Number }) public progress = 0; @@ -107,23 +101,49 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { // IMPLEMENTATION // ────────────────────── - /** - * @internal - */ - @query('slot') - private slotEl!: HTMLSlotElement; + /** 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) { + if ( + node.nodeType === Node.ELEMENT_NODE || + (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) + ) { + return true; + } + } + return false; + } + + private warnDeprecatedLightDomChildren(): void { + if (!window.__swc?.DEBUG) { + return; + } + if (!ProgressCircleBase.hasMeaningfulLightDomChildren(this)) { + return; + } + window.__swc.warn( + this, + `<${this.localName}> no longer has a default slot. Light DOM children are not rendered and are not used for an accessible name. Use the "label" attribute or property, or "aria-label" / "aria-labelledby" on the host instead.`, + 'https://opensource.adobe.com/spectrum-web-components/second-gen/?path=/docs/components-progress-circle--docs', + { level: 'deprecation' } + ); + } - protected makeRotation(rotation: number): string | undefined { - return this.indeterminate - ? undefined - : `transform: rotate(${rotation}deg);`; + private static clampProgress(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(100, Math.max(0, value)); } - protected handleSlotchange(): void { - const labelFromSlot = getLabelFromSlot(this.label, this.slotEl); - if (labelFromSlot) { - this.label = labelFromSlot; + protected override willUpdate(changes: PropertyValues): void { + if (changes.has('progress')) { + const clamped = ProgressCircleBase.clampProgress(this.progress); + if (clamped !== this.progress) { + this.progress = clamped; + } } + super.willUpdate(changes); } protected override firstUpdated(changes: PropertyValues): void { @@ -151,12 +171,12 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { } else { this.setAttribute('aria-valuemin', '0'); this.setAttribute('aria-valuemax', '100'); - this.setAttribute('aria-valuenow', '' + this.progress); + this.setAttribute('aria-valuenow', String(this.progress)); this.setAttribute('aria-valuetext', this.formatProgress()); } } if (!this.indeterminate && changes.has('progress')) { - this.setAttribute('aria-valuenow', '' + this.progress); + this.setAttribute('aria-valuenow', String(this.progress)); this.setAttribute('aria-valuetext', this.formatProgress()); } if (!this.indeterminate && changes.has(languageResolverUpdatedSymbol)) { @@ -174,22 +194,21 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { return Boolean( this.label || this.getAttribute('aria-label') || - this.getAttribute('aria-labelledby') || - this.slotEl.assignedNodes().length + this.getAttribute('aria-labelledby') ); }; if (window.__swc?.DEBUG) { + this.warnDeprecatedLightDomChildren(); if (!hasAccessibleName() && this.getAttribute('role') === 'progressbar') { window.__swc?.warn( this, - ' elements need one of the following to be accessible:', - 'https://opensource.adobe.com/spectrum-web-components/components/progress-circle/#accessibility', + `<${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', - 'text content supplied directly to 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 c54535523d..d23b8220ab 100644 --- a/2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts +++ b/2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts @@ -30,3 +30,4 @@ export type ProgressCircleStaticColorS2 = export type ProgressCircleStaticColor = | ProgressCircleStaticColorS1 | ProgressCircleStaticColorS2; +export type ProgressCircleSize = (typeof PROGRESS_CIRCLE_VALID_SIZES)[number]; 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 0058808790..d952d1f7d7 100644 --- a/2nd-gen/packages/core/components/status-light/StatusLight.types.ts +++ b/2nd-gen/packages/core/components/status-light/StatusLight.types.ts @@ -93,3 +93,5 @@ export type StatusLightColorVariant = export type StatusLightVariantS1 = (typeof STATUSLIGHT_VARIANTS_S1)[number]; export type StatusLightVariantS2 = (typeof STATUSLIGHT_VARIANTS_S2)[number]; export type StatusLightVariant = StatusLightVariantS1 | StatusLightVariantS2; + +export type StatusLightSize = (typeof STATUSLIGHT_VALID_SIZES)[number]; diff --git a/2nd-gen/packages/swc/components/badge/Badge.ts b/2nd-gen/packages/swc/components/badge/Badge.ts index 933a97f94d..f8571763e3 100644 --- a/2nd-gen/packages/swc/components/badge/Badge.ts +++ b/2nd-gen/packages/swc/components/badge/Badge.ts @@ -64,9 +64,11 @@ export class Badge extends BadgeBase { /** * The variant of the badge. + * + * @todo - Implement new badge variants (notification, indicator) introduced in S2. Jira ticket: SWC-1831 */ @property({ type: String, reflect: true }) - public override variant: BadgeVariant = 'informative'; + public override variant: BadgeVariant = 'neutral'; // ─────────────────── // API ADDITIONS @@ -104,9 +106,10 @@ export class Badge extends BadgeBase { class=${classMap({ ['swc-Badge']: true, [`swc-Badge--${this.variant}`]: typeof this.variant !== 'undefined', - [`swc-Badge--subtle`]: this.subtle, - [`swc-Badge--outline`]: this.outline, + ['swc-Badge--subtle']: this.subtle, + ['swc-Badge--outline']: this.outline, [`swc-Badge--fixed-${this.fixed}`]: typeof this.fixed !== 'undefined', + [`swc-Badge--no-label`]: !this.slotHasContent, })} > ${when( @@ -114,7 +117,7 @@ export class Badge extends BadgeBase { () => html`
diff --git a/2nd-gen/packages/swc/components/badge/badge.css b/2nd-gen/packages/swc/components/badge/badge.css index 0fa675e3d1..4c30e8c805 100644 --- a/2nd-gen/packages/swc/components/badge/badge.css +++ b/2nd-gen/packages/swc/components/badge/badge.css @@ -11,7 +11,7 @@ */ :host { - display: inline-block; + display: inline-flex; place-self: start; vertical-align: middle; } @@ -20,17 +20,21 @@ box-sizing: border-box; } +/* @todo Size `s` is noted as the default in Spectrum design documentation, so be aware there is a discrepancy between the t-shirt API, which supports `m` as the default. SWC-1847 */ .swc-Badge { --_swc-badge-border-width: token("border-width-200"); - --_swc-badge-border-width-deduction: calc(var(--_swc-badge-border-width) * 2); - --_swc-badge-padding-block: token("component-top-to-text-100") token("component-bottom-to-text-100"); + --_swc-badge-padding-block: var(--swc-badge-padding-block, token("component-padding-vertical-100")); + --_swc-badge-padding-inline-start: var(--swc-badge-padding-inline-start, var(--swc-badge-padding-inline, token("component-edge-to-text-100"))); + --_swc-badge-padding-inline: var(--swc-badge-padding-inline, token("component-edge-to-text-100")); + --_swc-badge-line-height: var(--swc-badge-line-height, token("line-height-font-size-100")); display: inline-flex; gap: var(--swc-badge-gap, token("text-to-visual-100")); align-items: center; min-block-size: var(--swc-badge-height, token("component-height-100")); - padding-block: calc(var(--swc-badge-padding-block, var(--_swc-badge-padding-block)) - var(--_swc-badge-border-width-deduction)); - padding-inline: calc(var(--swc-badge-padding-inline, token("component-edge-to-text-100")) - var(--_swc-badge-border-width-deduction)); + padding-block: calc(var(--_swc-badge-padding-block) - var(--_swc-badge-border-width)); + padding-inline-start: calc(var(--_swc-badge-padding-inline-start) - var(--_swc-badge-border-width)); + padding-inline-end: calc(var(--_swc-badge-padding-inline) - var(--_swc-badge-border-width)); color: var(--swc-badge-label-icon-color, token("white")); background: var(--swc-badge-background-color, token("accent-background-color-default")); border: var(--_swc-badge-border-width) solid var(--swc-badge-border-color, transparent); @@ -38,14 +42,21 @@ cursor: default; } -.swc-Badge:has(.swc-Badge-icon) { - --swc-badge-padding-inline: var(--swc-badge-with-icon-padding-inline, token("component-edge-to-visual-100")); +.swc-Badge:where(:has(.swc-Badge-icon):not(.swc-Badge--no-label)) { + --swc-badge-padding-inline-start: var(--swc-badge-with-icon-padding-inline, token("component-edge-to-visual-100")); +} + +.swc-Badge--no-label:where(:has(.swc-Badge-icon)) { + --swc-badge-padding-block: var(--swc-badge-with-icon-only-padding-block, token("component-edge-to-visual-only-100")); + --swc-badge-padding-inline-start: var(--swc-badge-with-icon-only-padding-inline, token("component-edge-to-visual-only-100")); + --swc-badge-padding-inline: var(--swc-badge-with-icon-only-padding-inline, token("component-edge-to-visual-only-100")); + --swc-badge-gap: 0; } .swc-Badge-label { font-size: var(--swc-badge-font-size, token("font-size-100")); font-weight: token("medium-font-weight"); - line-height: var(--swc-badge-line-height, token("line-height-100")); + line-height: var(--_swc-badge-line-height); &:lang(ja), &:lang(zh), @@ -70,9 +81,12 @@ --swc-badge-gap: token("text-to-visual-75"); --swc-badge-padding-inline: token("component-edge-to-text-75"); --swc-badge-with-icon-padding-inline: token("component-edge-to-visual-75"); - --swc-badge-padding-block: token("component-top-to-text-75") token("component-bottom-to-text-75"); + --swc-badge-with-icon-only-padding-inline: token("component-edge-to-visual-only-75"); + --swc-badge-with-icon-only-padding-block: token("component-edge-to-visual-only-75"); + --swc-badge-padding-block: token("component-padding-vertical-75"); --swc-badge-font-size: token("font-size-75"); --swc-badge-icon-size: token("workflow-icon-size-75"); + --swc-badge-line-height: token("line-height-font-size-75"); } :host([size="l"]) { @@ -81,9 +95,12 @@ --swc-badge-gap: token("text-to-visual-200"); --swc-badge-padding-inline: token("component-edge-to-text-200"); --swc-badge-with-icon-padding-inline: token("component-edge-to-visual-200"); - --swc-badge-padding-block: token("component-top-to-text-200") token("component-bottom-to-text-200"); + --swc-badge-with-icon-only-padding-inline: token("component-edge-to-visual-only-200"); + --swc-badge-with-icon-only-padding-block: token("component-edge-to-visual-only-200"); + --swc-badge-padding-block: token("component-padding-vertical-200"); --swc-badge-font-size: token("font-size-200"); --swc-badge-icon-size: token("workflow-icon-size-200"); + --swc-badge-line-height: token("line-height-font-size-200"); } :host([size="xl"]) { @@ -92,9 +109,12 @@ --swc-badge-gap: token("text-to-visual-300"); --swc-badge-padding-inline: token("component-edge-to-text-300"); --swc-badge-with-icon-padding-inline: token("component-edge-to-visual-300"); - --swc-badge-padding-block: token("component-top-to-text-300") token("component-bottom-to-text-300"); + --swc-badge-with-icon-only-padding-inline: token("component-edge-to-visual-only-300"); + --swc-badge-with-icon-only-padding-block: token("component-edge-to-visual-only-300"); + --swc-badge-padding-block: token("component-padding-vertical-300"); --swc-badge-font-size: token("font-size-300"); --swc-badge-icon-size: token("workflow-icon-size-300"); + --swc-badge-line-height: token("line-height-font-size-300"); } .swc-Badge--fixed-inline-start { 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 66217a399d..1b238e18fd 100644 --- a/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts +++ b/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts @@ -10,13 +10,14 @@ * governing permissions and limitations under the License. */ -import { html } from 'lit'; +import { html, TemplateResult } from 'lit'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; import { Badge } from '@adobe/spectrum-wc/badge'; import '@adobe/spectrum-wc/badge'; +import '@adobe/spectrum-wc/icon'; import { BADGE_VALID_SIZES, @@ -29,6 +30,12 @@ import { FIXED_VALUES, type FixedValues, } from '../../../../core/components/badge/Badge.types.js'; +import { + Checkmark75Icon, + Checkmark100Icon, + Checkmark200Icon, + Checkmark300Icon, +} from '../../icon/elements/index.js'; // ──────────────── // METADATA @@ -44,7 +51,7 @@ argTypes.variant = { table: { category: 'attributes', defaultValue: { - summary: 'informative', + summary: 'neutral', }, }, }; @@ -73,6 +80,15 @@ argTypes.size = { }, }; +// @todo: create a select dropdown with all available/acceptable icons for a component. +// For now, this arg is turned off in the control table since the string doesn't get parsed as HTML: SWC-1853 +argTypes['icon-slot'] = { + ...argTypes['icon-slot'], + control: false, + description: + 'Accepts an icon element. The control is disabled. Use the Anatomy story to see icon usage. Enhancements to this control will be added in a future release.', +}; + /** * Similar to [status lights](/docs/components-status-light--readme), they use color and text to convey status or category information. * @@ -152,6 +168,18 @@ const nonSemanticLabels = { const allVariantsLabels = { ...semanticLabels, ...nonSemanticLabels }; +const checkmarkIconForSize = (size: string): TemplateResult => { + const validSize: BadgeSize = BADGE_VALID_SIZES.includes(size as BadgeSize) + ? (size as BadgeSize) + : 'm'; + return { + s: Checkmark75Icon, + m: Checkmark100Icon, + l: Checkmark200Icon, + xl: Checkmark300Icon, + }[validSize](); +}; + const fixedLabels = { 'block-start': 'Block start', 'block-end': 'Block end', @@ -166,8 +194,6 @@ const fixedLabels = { export const Playground: Story = { render: (args) => template(args), args: { - size: 'm', - variant: 'informative', 'default-slot': 'Active', }, tags: ['autodocs', 'dev'], @@ -183,8 +209,6 @@ export const Overview: Story = { `, tags: ['overview'], args: { - size: 'm', - variant: 'informative', 'default-slot': 'Active', }, }; @@ -206,19 +230,31 @@ export const Overview: Story = { * - **icon slot**: (optional) - Visual indicator positioned before the label */ export const Anatomy: Story = { - render: (args) => html` - ${template({ ...args, 'default-slot': 'Label only' })} - ${template({ ...args, 'icon-slot': '✓', 'aria-label': 'Icon only' })} - ${template({ - ...args, - 'icon-slot': '✓', - 'default-slot': 'Icon and label', - })} - `, + render: (args) => { + const size = args.size as BadgeSize; + return html` + ${template({ ...args, 'default-slot': 'Label only' })} + + + ${checkmarkIconForSize(size)} + + + + + ${checkmarkIconForSize(size)} + + Icon and label + + `; + }, tags: ['anatomy'], args: { - variant: 'informative', - size: 'm', + variant: 'neutral', }, }; @@ -236,20 +272,25 @@ export const Anatomy: Story = { * * The `m` size is the default and most frequently used option. Use larger sizes sparingly to create a hierarchy of importance on a page. */ + +// @todo - We should make sure to capture icon-only badges in all sizes for VRTs: SWC-1852 export const Sizes: Story = { render: (args) => html` - ${BADGE_VALID_SIZES.map((size) => - template({ - ...args, - size, - 'default-slot': sizeLabels[size], - }) + ${BADGE_VALID_SIZES.map( + (size) => html` + + + ${checkmarkIconForSize(size)} + + ${sizeLabels[size]} + + ` )} `, parameters: { 'section-order': 1 }, tags: ['options'], args: { - variant: 'informative', + variant: 'neutral', }, }; @@ -291,7 +332,6 @@ SemanticVariants.storyName = 'Semantic variants'; * - Creating department, team, or project color schemes * * > **Note**: 2nd-gen adds `pink`, `turquoise`, `brown`, `cinnamon`, and `silver` variants. - * 1st-gen variants `gray`, `red`, `orange`, `green`, and `blue` are not available in 2nd-gen. */ export const NonSemanticVariants: Story = { render: (args) => html` @@ -374,16 +414,13 @@ export const Fixed: Story = { template({ ...args, fixed, + variant: 'neutral', 'default-slot': fixedLabels[fixed], }) )} `, parameters: { 'section-order': 6 }, tags: ['options'], - args: { - variant: 'informative', - size: 'm', - }, }; // ────────────────────────────── @@ -400,16 +437,12 @@ export const TextWrapping: Story = { render: (args) => html` ${template({ ...args, - variant: 'informative', + variant: 'notice', 'default-slot': 'Document review pending approval from manager', style: 'max-inline-size: 120px', })} `, tags: ['behaviors'], - args: { - variant: 'informative', - size: 'm', - }, }; // ──────────────────────────────── // ACCESSIBILITY STORIES @@ -432,11 +465,28 @@ export const TextWrapping: Story = { * - Screen readers will announce the badge content as static text * - No keyboard interaction is required or expected * + * ### Text label + * + * Badges with visible text are announced directly by screen readers. The text in the default slot is the accessible name. + * + * ### Icon + text + * + * When an icon accompanies a text label, the icon is decorative and should be hidden from assistive technology. + * Apply `aria-hidden="true"` to the `` so screen readers only announce the label text. + * + * ### Icon only + * + * When space is limited and no visible label is shown, the badge **must** have an accessible name. + * Set `role="img"` and `aria-label` directly on the `` element to describe the badge's meaning. + * `role="img"` is required because custom elements have no implicit ARIA role — without it, `aria-label` is not + * permitted by the ARIA specification and will fail automated accessibility checks. + * Without both attributes, the badge has no accessible name and fails WCAG 1.1.1. + * * ### Best practices * * - Use semantic variants (`positive`, `negative`, `notice`, `informative`, `neutral`, `accent`) when the status has specific meaning * - Include clear, descriptive labels that explain the status without relying on color alone - * - For icon-only badges, provide descriptive text in the default slot or use the `aria-label` attribute directly on the element + * - For icon-only badges, always set `role="img"` and `aria-label` on `swc-badge` * - Ensure sufficient color contrast between the badge and its background * - Badges are not interactive elements - for interactive status indicators, consider using buttons, tags, or links instead * - When using multiple badges together, ensure they're clearly associated with their related content @@ -486,9 +536,26 @@ export const Accessibility: Story = { variant: 'silver', 'default-slot': 'Version 1.2.10', })} + + + + + Approved + + + + + + ${checkmarkIconForSize(args.size)} + + `, tags: ['a11y'], - args: { - size: 'm', - }, }; diff --git a/2nd-gen/packages/swc/components/badge/test/badge.test.ts b/2nd-gen/packages/swc/components/badge/test/badge.test.ts index 50c7cfefc7..d8e3ccd1da 100644 --- a/2nd-gen/packages/swc/components/badge/test/badge.test.ts +++ b/2nd-gen/packages/swc/components/badge/test/badge.test.ts @@ -61,7 +61,7 @@ export const OverviewTest: Story = { const badge = await getComponent(canvasElement, 'swc-badge'); await step('renders expected default values and slot content', async () => { - expect(badge.variant).toBe('informative'); + expect(badge.variant).toBe('neutral'); expect(badge.size).toBe('m'); expect(badge.textContent?.trim()).toBeTruthy(); }); @@ -139,7 +139,7 @@ export const AnatomyTest: Story = { expect(badgeWithIcon).toBeTruthy(); const slottedIcon = badgeWithIcon?.querySelector('[slot="icon"]'); expect(slottedIcon).toBeTruthy(); - expect(slottedIcon?.textContent?.trim()).toBeTruthy(); + expect(slottedIcon?.children.length).toBeGreaterThan(0); }); }, }; diff --git a/2nd-gen/packages/swc/components/divider/Divider.ts b/2nd-gen/packages/swc/components/divider/Divider.ts index 75cc6ca95e..bc985142fb 100644 --- a/2nd-gen/packages/swc/components/divider/Divider.ts +++ b/2nd-gen/packages/swc/components/divider/Divider.ts @@ -40,7 +40,7 @@ export class Divider extends DividerBase { [`swc-Divider--size${this.size?.toUpperCase()}`]: this.size != null, [`swc-Divider--static${capitalize(this.staticColor)}`]: this.staticColor != null, - [`swc-Divider--vertical`]: this.vertical, + ['swc-Divider--vertical']: this.vertical, })} >
`; diff --git a/2nd-gen/packages/swc/components/divider/divider.css b/2nd-gen/packages/swc/components/divider/divider.css index f404896678..c566a17cad 100644 --- a/2nd-gen/packages/swc/components/divider/divider.css +++ b/2nd-gen/packages/swc/components/divider/divider.css @@ -18,16 +18,25 @@ box-sizing: border-box; } +/* @todo capture visual regression tests for WHCM (forced-colors) and more static color sizes in Chromatic. SWC-1848 */ .swc-Divider { --_swc-divider-thickness: var(--swc-divider-thickness, token("divider-thickness-medium")); inline-size: 100%; block-size: var(--_swc-divider-thickness); background-color: var(--swc-divider-background-color, token("gray-200")); - border: none; border-radius: var(--_swc-divider-thickness); } +:host([size="s"]) { + --swc-divider-thickness: token("divider-thickness-small"); +} + +:host([size="l"]) { + --swc-divider-thickness: token("divider-thickness-large"); + --swc-divider-background-color: token("gray-800"); +} + .swc-Divider:not(.swc-Divider--vertical) { min-inline-size: token("divider-horizontal-minimum-width"); } @@ -37,16 +46,6 @@ inline-size: var(--_swc-divider-thickness); block-size: 100%; min-block-size: token("divider-vertical-minimum-height"); - margin-block: 0; -} - -:host([size="s"]) { - --swc-divider-thickness: token("divider-thickness-small"); -} - -:host([size="l"]) { - --swc-divider-thickness: token("divider-thickness-large"); - --swc-divider-background-color: token("gray-800"); } .swc-Divider--staticWhite { diff --git a/2nd-gen/packages/swc/components/divider/stories/divider.stories.ts b/2nd-gen/packages/swc/components/divider/stories/divider.stories.ts index ca518c4ad3..d65e53c223 100644 --- a/2nd-gen/packages/swc/components/divider/stories/divider.stories.ts +++ b/2nd-gen/packages/swc/components/divider/stories/divider.stories.ts @@ -28,6 +28,12 @@ argTypes.size = { ...argTypes.size, control: { type: 'select' }, options: Divider.VALID_SIZES, + table: { + category: 'attributes', + defaultValue: { + summary: 'm', + }, + }, }; argTypes['static-color'] = { @@ -84,9 +90,9 @@ export const Playground: Story = { export const Overview: Story = { render: (args) => html` -

Content above the divider

+

Account settings

${template({ ...args, size: 'm' })} -

Content below the divider

+

Update your personal details, password, and preferences.

`, parameters: { flexLayout: 'column-stretch', @@ -105,9 +111,9 @@ export const Overview: Story = { */ export const Anatomy: Story = { render: (args) => html` -

Content above the divider

+

Account settings

${template({ ...args, size: 'm' })} -

Content below the divider

+

Update your personal details, password, and preferences.

`, tags: ['anatomy'], parameters: { @@ -129,19 +135,19 @@ export const Anatomy: Story = { export const Sizes: Story = { render: (args) => html`
-

Small

+

Team members

${template({ ...args, size: 's' })} -

Content below the small divider.

+

Manage your team roles and access permissions.

-

Medium

+

Account settings

${template({ ...args, size: 'm' })} -

Content below the medium divider.

+

Update your personal details, password, and preferences.

-

Large

+

Projects

${template({ ...args, size: 'l' })} -

Content below the large divider.

+

Track progress across your projects.

`, parameters: { @@ -156,24 +162,24 @@ export const Sizes: Story = { */ export const Vertical: Story = { render: (args) => html` -

Small

+

Profile

${template({ ...args, size: 's', })} -

Content next to the small divider.

-

Medium

+

Your profile name appears when you log in.

+

Account settings

${template({ ...args, size: 'm', })} -

Content next to the medium divider.

-

Large

+

Update your password and preferences.

+

Projects

${template({ ...args, size: 'l', })} -

Content next to the large divider.

+

Track progress across your projects.

`, parameters: { 'section-order': 2, diff --git a/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts b/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts index fbf9154baa..8101b9e7c7 100644 --- a/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts +++ b/2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts @@ -31,7 +31,7 @@ import styles from './progress-circle.css'; * @status preview * @since 0.0.1 * - * @property {string} static-color - Static color variant for use on different backgrounds. + * @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 {string} size - Size of the component. @@ -42,6 +42,8 @@ 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 { // ──────────────────── @@ -76,24 +78,30 @@ export class ProgressCircle extends ProgressCircleBase { // SVG strokes are centered, so subtract half the stroke width from the radius to create an inner stroke. const radius = `calc(50% - ${strokeWidth / 2}px)`; + // At progress=0, a dashoffset of 100 makes the fill fully invisible, which fails WCAG 1.4.11 + // non-text contrast (the track alone may not meet 3:1 against the background). + // Clamp to 98 to show a minimum 2-unit fill so the graphical element remains perceivable. + // aria-valuenow stays at 0 — this is a visual-only adjustment. + const dashOffset = this.indeterminate + ? 100 - this.progress + : this.progress === 0 + ? 98 + : 100 - this.progress; + return html`
- - + 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 d7be291ada..f5f31896df 100644 --- a/2nd-gen/packages/swc/components/progress-circle/progress-circle.css +++ b/2nd-gen/packages/swc/components/progress-circle/progress-circle.css @@ -100,14 +100,15 @@ --swc-progress-circle-fill-border-color: token("static-black-track-indicator-color"); } -slot { - display: none; +@media (prefers-reduced-motion: reduce) { + .swc-ProgressCircle--indeterminate .swc-ProgressCircle-fill { + 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 af98c0d7d7..6557d000ea 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_S2, + PROGRESS_CIRCLE_VALID_SIZES, + type ProgressCircleSize, +} from '@spectrum-web-components/core/components/progress-circle'; import '@adobe/spectrum-wc/progress-circle'; @@ -34,6 +39,12 @@ argTypes.size = { ...argTypes.size, control: { type: 'select' }, options: ProgressCircle.VALID_SIZES, + table: { + category: 'attributes', + defaultValue: { + summary: 'm', + }, + }, }; argTypes['static-color'] = { @@ -69,6 +80,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 // ──────────────────── @@ -77,7 +98,6 @@ export const Playground: Story = { tags: ['autodocs', 'dev'], args: { progress: 50, - size: 'm', label: 'Uploading document', }, }; @@ -103,10 +123,10 @@ 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) + * 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 * * ### Content - * - **Default slot**: Alternative way to provide an accessible label (the `label` attribute is preferred) + * * - **Label**: Accessible text describing what is loading or progressing (not visually rendered) */ export const Anatomy: Story = { @@ -114,23 +134,23 @@ export const Anatomy: Story = { ${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', })} `, tags: ['anatomy'], + args: { + size: 'l', + }, }; // ────────────────────────── @@ -146,9 +166,15 @@ 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: { @@ -165,9 +191,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_S2.map( (color) => html` ${template({ ...args, 'static-color': color })} ` @@ -203,9 +230,6 @@ export const ProgressValues: Story = { ${template({ ...args, progress: 100, label: 'Download complete' })} `, tags: ['states'], - args: { - size: 'm', - }, parameters: { 'section-order': 1, }, @@ -226,7 +250,6 @@ export const Indeterminate: Story = { tags: ['states'], args: { indeterminate: true, - size: 'm', label: 'Processing request', }, parameters: { @@ -246,9 +269,7 @@ export const Indeterminate: Story = { * #### ARIA implementation * * 1. **ARIA role**: Automatically sets `role="progressbar"` for proper semantic meaning - * 2. **Labeling**: - * - Uses the `label` attribute value as `aria-label` - * - Alternative: Content in the default slot can provide the label + * 2. **Labeling**: Uses the `label` attribute value as `aria-label`, or rely on `aria-label` / `aria-labelledby` you set on the host * 3. **Progress state** (determinate): * - Sets `aria-valuenow` with the current `progress` value * 4. **Loading state** (indeterminate): @@ -262,6 +283,12 @@ 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 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 fe7e9040e4..8d26e93ef6 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 @@ -202,10 +202,10 @@ export const AriaLabelledbyAccessibleNameTest: Story = { }; // ────────────────────────────────────────────────────────────── -// TEST: Slots +// TEST: Light DOM (no default slot) // ────────────────────────────────────────────────────────────── -export const SlotLabelTest: Story = { +export const LightDomChildrenDoNotSetLabelTest: Story = { render: () => html` Loading data `, @@ -215,10 +215,62 @@ export const SlotLabelTest: Story = { 'swc-progress-circle' ); - await step('uses slot content as the label', async () => { - expect(progressCircle.label).toBe('Loading data'); - expect(progressCircle.getAttribute('aria-label')).toBe('Loading data'); - }); + await step( + 'does not use light DOM text as the label or accessible name', + async () => { + expect(progressCircle.label).toBe(''); + expect(progressCircle.getAttribute('aria-label')).toBeNull(); + } + ); + + await step( + 'warns in dev mode: deprecation for light DOM children and accessibility when there is no name', + () => + withWarningSpy(async (warnCalls) => { + 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); + }) + ); + }, +}; + +export const LightDomWithLabelDeprecationOnlyTest: Story = { + render: () => html` + + Ignored slot content + + `, + play: async ({ canvasElement, step }) => { + const progressCircle = await getComponent( + canvasElement, + 'swc-progress-circle' + ); + + await step( + 'still deprecates light DOM when label provides the accessible name', + () => + withWarningSpy(async (warnCalls) => { + progressCircle.progress = 6; + await progressCircle.updateComplete; + + expect(warnCalls.length).toBe(1); + expect(String(warnCalls[0]?.[1] ?? '')).toContain( + 'no longer has a default slot' + ); + }) + ); }, }; @@ -243,6 +295,35 @@ export const ProgressValuesTest: Story = { }, }; +export const ProgressClampTest: Story = { + render: () => html` + + + `, + play: async ({ canvasElement, step }) => { + const circles = await getComponents( + canvasElement, + 'swc-progress-circle' + ); + + await step('clamps progress above 100 to 100', async () => { + expect(circles[0].progress).toBe(100); + expect(circles[0].getAttribute('aria-valuenow')).toBe('100'); + }); + + await step('clamps progress below 0 to 0', async () => { + expect(circles[1].progress).toBe(0); + expect(circles[1].getAttribute('aria-valuenow')).toBe('0'); + }); + }, +}; + export const IndeterminateTest: Story = { ...Indeterminate, play: async ({ canvasElement, step }) => { diff --git a/2nd-gen/packages/swc/components/status-light/status-light.css b/2nd-gen/packages/swc/components/status-light/status-light.css index b90ae8dde5..ebab4c79c7 100644 --- a/2nd-gen/packages/swc/components/status-light/status-light.css +++ b/2nd-gen/packages/swc/components/status-light/status-light.css @@ -22,22 +22,21 @@ } .swc-StatusLight { - --_swc-statuslight-top-to-text: var(--swc-statuslight-top-to-text, token("component-top-to-text-100")); - --_swc-statuslight-bottom-to-text: var(--swc-statuslight-bottom-to-text, token("component-bottom-to-text-100")); + --_swc-statuslight-padding-block: var(--swc-statuslight-padding-block, token("component-padding-vertical-100")); --_swc-statuslight-top-to-dot: var(--swc-statuslight-top-to-dot, token("status-light-top-to-dot-medium")); --_swc-statuslight-text-to-visual: var(--swc-statuslight-text-to-visual, token("status-light-text-to-visual-100")); + --_swc-statuslight-line-height: var(--swc-statuslight-line-height, token("line-height-font-size-100")); display: flex; gap: var(--_swc-statuslight-text-to-visual); align-items: flex-start; min-block-size: var(--swc-statuslight-height, token("component-height-100")); - padding-block-start: var(--_swc-statuslight-top-to-text); - padding-block-end: var(--_swc-statuslight-bottom-to-text); + padding-block: var(--_swc-statuslight-padding-block); font-size: var(--swc-statuslight-font-size, token("font-size-100")); font-style: token("default-font-style"); font-weight: token("regular-font-weight"); - line-height: token("line-height-100"); - color: var(--swc-statuslight-content-color, token("neutral-content-color-default")); + line-height: var(--_swc-statuslight-line-height); + color: token("neutral-content-color-default"); &:lang(ja), &:lang(zh), @@ -49,13 +48,11 @@ &::before { --_swc-statuslight-dot-size: var(--swc-statuslight-dot-size, token("status-light-dot-size-medium")); - box-sizing: border-box; - display: inline-block; flex-grow: 0; flex-shrink: 0; inline-size: var(--_swc-statuslight-dot-size); block-size: var(--_swc-statuslight-dot-size); - margin-block-start: calc(var(--_swc-statuslight-top-to-dot) - var(--_swc-statuslight-top-to-text)); + margin-block-start: calc(var(--_swc-statuslight-top-to-dot) - var(--_swc-statuslight-padding-block)); background-color: var(--swc-statuslight-dot-color); border-radius: token("corner-radius-full"); content: ""; @@ -63,36 +60,36 @@ } :host([size="s"]) { - --swc-statuslight-top-to-text: token("component-top-to-text-75"); - --swc-statuslight-bottom-to-text: token("component-bottom-to-text-75"); + --swc-statuslight-padding-block: token("component-padding-vertical-75"); --swc-statuslight-top-to-dot: token("status-light-top-to-dot-small"); --swc-statuslight-text-to-visual: token("status-light-text-to-visual-75"); --swc-statuslight-height: token("component-height-75"); --swc-statuslight-dot-size: token("status-light-dot-size-small"); --swc-statuslight-font-size: token("font-size-75"); + --swc-statuslight-line-height: token("line-height-font-size-75"); } :host([size="l"]) { - --swc-statuslight-top-to-text: token("component-top-to-text-200"); - --swc-statuslight-bottom-to-text: token("component-bottom-to-text-200"); + --swc-statuslight-padding-block: token("component-padding-vertical-200"); --swc-statuslight-top-to-dot: token("status-light-top-to-dot-large"); --swc-statuslight-text-to-visual: token("status-light-text-to-visual-200"); --swc-statuslight-height: token("component-height-200"); --swc-statuslight-dot-size: token("status-light-dot-size-large"); --swc-statuslight-font-size: token("font-size-200"); + --swc-statuslight-line-height: token("line-height-font-size-200"); } :host([size="xl"]) { - --swc-statuslight-top-to-text: token("component-top-to-text-300"); - --swc-statuslight-bottom-to-text: token("component-bottom-to-text-300"); + --swc-statuslight-padding-block: token("component-padding-vertical-300"); --swc-statuslight-top-to-dot: token("status-light-top-to-dot-extra-large"); --swc-statuslight-text-to-visual: token("status-light-text-to-visual-300"); --swc-statuslight-height: token("component-height-300"); --swc-statuslight-dot-size: token("status-light-dot-size-extra-large"); --swc-statuslight-font-size: token("font-size-300"); + --swc-statuslight-line-height: token("line-height-font-size-300"); } -/* Semantic Colors */ +/* Semantic colors */ :host([variant="neutral"]) { --swc-statuslight-content-color: token("gray-600"); --swc-statuslight-dot-color: token("neutral-visual-color"); @@ -114,7 +111,7 @@ --swc-statuslight-dot-color: token("positive-visual-color"); } -/* Non-Semantic Colors */ +/* Non-semantic colors */ .swc-StatusLight--yellow { --swc-statuslight-dot-color: token("yellow-visual-color"); } 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 b5d8345153..1fc14ab4bf 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_VALID_SIZES, STATUSLIGHT_VARIANTS_COLOR_S2, STATUSLIGHT_VARIANTS_SEMANTIC_S2, StatusLightColorVariantS2, StatusLightSemanticVariantS2, + 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' })} + ${STATUSLIGHT_VALID_SIZES.map((size) => + template({ + ...args, + size, + 'default-slot': sizeLabels[size], + }) + )} `, parameters: { 'section-order': 1 }, tags: ['options'], @@ -196,6 +210,8 @@ export const Sizes: Story = { * - **`positive`**: Approved, complete, success, new, purchased, licensed * - **`notice`**: Needs approval, pending, scheduled, syncing, indexing, processing * - **`negative`**: Error, alert, rejected, failed + * + * Semantic status lights should never be used for color coding categories or labels, and vice versa. */ export const SemanticVariants: Story = { render: (args) => html` @@ -286,11 +302,19 @@ export const TextWrapping: Story = { * * - Semantic variants provide consistent color associations for common statuses * - Text labels provide clear context for all users + * - Disabled status lights are deprecated for Spectrum 2. Content like "Unavailable" may be used to communicate that concept instead. + * + * #### Non-interactive element + * + * - Status lights have no interactive behavior and are not focusable + * - Screen readers will announce the status light content as static text + * - No keyboard interaction is required or expected * * ### Best practices * * - Always provide a descriptive text label that explains the status * - Use semantic variants (`info`, `positive`, `negative`, `notice`, `neutral`) when the status has specific meaning + * - Status lights are not interactive elements - for interactive status indicators, consider using buttons, tags, or links instead * - Use meaningful, specific labels (e.g., "Approved" instead of "Green") * - Ensure sufficient color contrast between the status light and its background * - For non-semantic variants, ensure the text label provides complete context @@ -363,6 +387,7 @@ export const Accessibility: Story = { * textfield.value from translations.json based on context.globals.lang. * Used by the Fonts guide and for locale/font demos. This story is "docs-only." */ +// @todo: a withLocaleWrapper could be pulled up into a global decorator/helper to be implemented by more components. SWC-1872 function withLocaleWrapperRender( args: Record & { lang?: string; 'default-slot'?: string }, context: { globals: { lang?: string } } @@ -384,7 +409,9 @@ function withLocaleWrapperRender( /** * Status light with label driven by the Language toolbar and translations.json. * Use this story in the Fonts guide to demonstrate font loading and translated copy. + * Learn more about [loading the expected fonts](/docs/guides-customization-fonts--readme). */ +// @todo: this story is docs-only, but we should start capturing Chromatic baselines for internationalized content in components. SWC-1871 export const WithLocaleWrapper: Story = { render: withLocaleWrapperRender, parameters: { diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md index 6105ccf247..f67bd8909f 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md @@ -269,7 +269,7 @@ Size variants (s, m, l, xl) use `:host([size="..."])` and update custom properti ```css :host([size="s"]) { - --swc-statuslight-top-to-text: token("component-top-to-text-75"); + --swc-statuslight-padding-block: token("component-padding-vertical-75"); --swc-statuslight-height: token("component-height-75"); --swc-statuslight-dot-size: token("status-light-dot-size-small"); --swc-statuslight-font-size: token("font-size-75"); diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md index 86dd5389ac..cdbdd131b3 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md @@ -58,12 +58,11 @@ CSS custom properties *normally* can't actually be "private". However, due to sh ```css .swc-Badge { --_swc-badge-border-width: token("border-width-200"); - --_swc-badge-border-width-deduction: calc(var(--_swc-badge-border-width) * 2); - --_swc-badge-padding-block-start: token("component-top-to-text-100"); - --_swc-badge-padding-block-end: token("component-bottom-to-text-100"); + --_swc-badge-padding-inline-start: var(--swc-badge-padding-inline-start, var(--swc-badge-padding-inline, token("component-edge-to-text-100"))); + --_swc-badge-padding-block: var(--swc-badge-padding-block, token("component-padding-vertical-100")); - padding-block-start: calc(var(--swc-badge-padding-block-start, var(--_swc-badge-padding-block-start)) - var(--_swc-badge-border-width-deduction)); - padding-block-end: calc(var(--swc-badge-padding-block-end, var(--_swc-badge-padding-block-end)) - var(--_swc-badge-border-width-deduction)); + padding-inline-start: calc(var(--_swc-badge-padding-inline-start) - var(--_swc-badge-border-width)); + padding-block: calc(var(--_swc-badge-padding-block) - var(--_swc-badge-border-width)); background: var(--swc-badge-background-color, token("accent-background-color-default")); } ``` @@ -72,11 +71,9 @@ CSS custom properties *normally* can't actually be "private". However, due to sh ```css .swc-StatusLight { - --_swc-statuslight-top-to-text: var(--swc-statuslight-top-to-text, token("component-top-to-text-100")); - --_swc-statuslight-bottom-to-text: var(--swc-statuslight-bottom-to-text, token("component-bottom-to-text-100")); + --_swc-statuslight-padding-block: var(--swc-statuslight-padding-block, token("component-padding-vertical-100")); - padding-block-start: var(--_swc-statuslight-top-to-text); - padding-block-end: var(--_swc-statuslight-bottom-to-text); + padding-block: var(--_swc-statuslight-padding-block); } ``` diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md index 0750eecbec..329e6b5807 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md @@ -148,21 +148,15 @@ Badge clearly distinguishes between **exposed customization** and **internal imp display: inline-flex; align-items: center; gap: var(--swc-badge-gap, token('text-to-visual-100')); - padding-inline: var( - --swc-badge-padding-inline, - token('component-edge-to-text-100') - ); - padding-block: var( - --swc-badge-padding-block, - token('component-top-to-text-100') - token('component-bottom-to-text-100') - ); + padding-inline-start: calc(var(--_swc-badge-padding-inline-start) - var(--_swc-badge-border-width)); + padding-inline-end: calc(var(--_swc-badge-padding-inline) - var(--_swc-badge-border-width)); + padding-block: calc(var(--_swc-badge-padding-block) - var(--_swc-badge-border-width)); } ``` ```css -.swc-Badge:has(.swc-Badge-icon) { - --swc-badge-padding-inline: token('component-edge-to-visual-100'); +.swc-Badge:where(:has(.swc-Badge-icon):not(.swc-Badge--no-label)) { + --swc-badge-padding-inline-start: var(--swc-badge-with-icon-padding-inline, token('component-edge-to-visual-100')); } ``` diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/06_property-order-quick-reference.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/06_property-order-quick-reference.md index e2c3d56e8a..8885f94be9 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/06_property-order-quick-reference.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/06_property-order-quick-reference.md @@ -104,7 +104,7 @@ From [status-light.css](../../../2nd-gen/packages/swc/components/status-light/st inline-size: var(--_swc-statuslight-dot-size); block-size: var(--_swc-statuslight-dot-size); /* Spacing */ - margin-block-start: calc(var(--_swc-statuslight-top-to-dot) - var(--_swc-statuslight-top-to-text)); + margin-block-start: calc(var(--_swc-statuslight-top-to-dot) - var(--_swc-statuslight-padding-block)); /* Decoration */ background-color: var(--swc-statuslight-dot-color); border-radius: token("corner-radius-full"); diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/02_class-structure.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/02_class-structure.md index ba6e0b8a81..551a011646 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/02_class-structure.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/02_class-structure.md @@ -181,10 +181,10 @@ protected get hasIcon(): boolean { } protected override update(changedProperties: PropertyValues): void { - super.update(changedProperties); if (window.__swc?.DEBUG) { - // ...variant validation... + // ...variant validation (before super.update so it runs before render)... } + super.update(changedProperties); } ``` @@ -312,6 +312,8 @@ protected override render(): TemplateResult { } ``` +For `classMap` object keys (bracketed string keys vs plain quoted keys, mixing with template literal keys, and what to avoid), see [classMap patterns](09_rendering-patterns.md#classmap-patterns). + ## Section comment format Section comments use ASCII box-drawing characters. The format has three lines: diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/06_method-patterns.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/06_method-patterns.md index 8daee6e48c..1ecca3cbfb 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/06_method-patterns.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/06_method-patterns.md @@ -27,24 +27,24 @@ This guide explains how to order and name methods in 2nd-gen component classes. ## Method ordering -Within the [IMPLEMENTATION section](02_class-structure.md#section-implementation) of a base class, or the [RENDERING & STYLING section](02_class-structure.md#section-rendering-and-styling) of a concrete class, order methods by access level: +Within the [IMPLEMENTATION section](02_class-structure.md#section-implementation) of a base class, or the [RENDERING & STYLING section](02_class-structure.md#section-rendering-and-styling) of a concrete class, use this **default** order by access level: 1. **public** methods first -2. **protected** methods second (including lifecycle) +2. **protected** methods second (including lifecycle overrides) 3. **private** methods last -**Example from ProgressCircle.base.ts — IMPLEMENTATION section:** +**Exception:** Keep lifecycle overrides in **Lit order** (e.g. `firstUpdated` before `updated`). You may place a **private method** **between** those hooks **when only those hooks call it** (and nothing else in the class does), so the file reads in execution order; if the method is also used elsewhere, put it **after** all protected members instead. + +**Example from [ProgressCircle.base.ts](../../../2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts) — IMPLEMENTATION section:** ```ts -// Protected methods (lifecycle and helpers) -protected makeRotation(rotation: number): string | undefined { ... } -protected handleSlotchange(): void { ... } +// Protected — firstUpdated protected override firstUpdated(changes: PropertyValues): void { ... } -// Private methods +// Private — used by updated() private formatProgress(): string { ... } -// Protected lifecycle (later in call order) +// Protected — updated protected override updated(changes: PropertyValues): void { ... } ``` diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/07_jsdoc-standards.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/07_jsdoc-standards.md index 10ae22fc21..06b953e514 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/07_jsdoc-standards.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/07_jsdoc-standards.md @@ -124,7 +124,7 @@ export class Badge extends BadgeBase { /** * @element swc-progress-circle * - * @property {string} static-color - Static color variant for use on different backgrounds. + * @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. * * @example @@ -142,6 +142,23 @@ Declares the slots the component provides. Use this on the **base class**. If th - Default slot: `@slot - Description` - Named slot: `@slot name - Description` +**CEM:** Put the full slot description on **one line** with the `@slot` tag. The Custom Elements Manifest analyzer does not preserve extra JSDoc lines as part of the slot description; continuation lines are easy to lose or merge unpredictably in generated manifests and Storybook. If you need more detail, add it in the class prose (above the tags) or in component stories—not on following lines under `@slot`. + +```ts +// ✅ Good — single line per slot +/** + * @slot - Text label of the badge. + * @slot icon - Optional icon to the left of the label. + */ + +// ❌ Bad — multiline slot description (do not rely on this for CEM) +/** + * @slot - Short summary on the first line. + * + * Extra lines you meant as part of the same slot description. + */ +``` + **Example from Badge.base.ts:** ```ts @@ -195,13 +212,15 @@ Declares properties at the class level for CEM tools. This is different from the **Format:** `@property {Type} name - Description` +Use the **JavaScript property name** in the tag (e.g. `staticColor`), not the HTML attribute spelling when it differs (e.g. `static-color`). Hyphenated names are not valid JSDoc namepaths and trigger `jsdoc/valid-types`. Mention the attribute in the description when useful. + **Example from ProgressCircle.ts:** ```ts /** * @element swc-progress-circle * - * @property {string} static-color - Static color variant for use on different backgrounds. + * @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 {string} size - Size of the component. @@ -365,14 +384,14 @@ Standard JSDoc tags for function parameters and return values. Use them on metho - `@returns {Type} Description` ```ts -// ✅ Good — describes non-obvious parameter +// ✅ Good — documents parameter and return when types alone are not enough /** - * Creates a CSS rotation string for the progress indicator. + * Whether the string is a supported banner variant. * - * @param rotation - The rotation angle in degrees - * @returns The CSS rotation value, or undefined if not needed + * @param variant - Candidate variant from an attribute or property + * @returns True when `variant` is in `ALERT_BANNER_VALID_VARIANTS` */ -protected makeRotation(rotation: number): string | undefined { ... } +protected isValidVariant(variant: string): boolean { ... } ``` `@param` and `@returns` are optional when the types and names are self-explanatory. 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 c5ee6fed9d..bd4db8e6aa 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 @@ -32,7 +32,16 @@ The `window.__swc` object provides debug utilities: ```ts interface SWCDebug { DEBUG: boolean; - warn(element: HTMLElement, message: string, url?: string, options?: { issues?: string[] }): void; + warn( + element: HTMLElement, + message: string, + url?: string, + options?: { + type?: string; + level?: 'default' | 'low' | 'medium' | 'high' | 'deprecation'; + issues?: string[]; + } + ): void; } ``` @@ -60,17 +69,28 @@ Different validation concerns belong in different lifecycle methods. Choose base - Property combinations that are invalid together - Required properties that affect rendering -**Why update():** +**Why `update()` (and where to put your code):** + +Lit’s [`update()`](https://lit.dev/docs/components/lifecycle/#update) “reflects property values to attributes and calls `render()` to update the component’s internal DOM.” In `LitElement`, that happens **inside** `super.update()` when your class extends Lit. So: + +- If you call `super.update()` **first** and then run DEBUG code, that code runs **after** `render()` has run for this cycle (still before Lit calls [`updated()`](https://lit.dev/docs/components/lifecycle/#updated)). +- For DEBUG that should fire **before** `render()` and DOM commit, run it **before** `super.update()` in your override, or use [`willUpdate()`](https://lit.dev/docs/components/lifecycle/#willupdate) (Lit calls it before `update()`). + +The [reactive update cycle](https://lit.dev/docs/components/lifecycle/#reactive-update-cycle) runs at microtask timing, generally before the browser paints the next frame—but “before paint” is not the same as “before `render()`” inside your component. -- Runs before render, so warnings appear before DOM changes -- Can short-circuit or adjust state before template evaluation -- Consistent with Lit's unidirectional data flow +**References (Lit):** + +- [Lifecycle: `update()`](https://lit.dev/docs/components/lifecycle/#update) +- [Lifecycle: `willUpdate()`](https://lit.dev/docs/components/lifecycle/#willupdate) +- [Lifecycle: `updated()`](https://lit.dev/docs/components/lifecycle/#updated) +- [Reactive update cycle](https://lit.dev/docs/components/lifecycle/#reactive-update-cycle) + +**Implementation note:** This repo’s `lit-element` version is the source of truth for exact ordering inside `LitElement.prototype.update` (e.g. `render()` then `super.update()` on `ReactiveElement`, then committing the template). See the `update(changedProperties)` method in the installed `lit-element` package under `node_modules`. **Example from Badge.base.ts:** ```ts protected override update(changedProperties: PropertyValues): void { - super.update(changedProperties); if (window.__swc?.DEBUG) { const constructor = this.constructor as typeof BadgeBase; // Validate variant value @@ -92,6 +112,7 @@ protected override update(changedProperties: PropertyValues): void { ); } } + super.update(changedProperties); } ``` @@ -138,9 +159,9 @@ protected override firstUpdated(changed: PropertyValues): void { **Why updated():** -- Runs after render, so DOM reflects current state -- Can check rendered output and slot assignments -- Good for "the current state is problematic" warnings +- Lit’s [`updated()`](https://lit.dev/docs/components/lifecycle/#updated) runs after the element’s DOM has been updated and rendered for this cycle. +- DOM reflects current state; you can check rendered output and slot assignments. +- Good for “the current state is problematic” warnings. **Example:** diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/badge/rendering-and-styling-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/badge/rendering-and-styling-migration-analysis.md index 91fd4a0cfd..4ea4d1208f 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/badge/rendering-and-styling-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/badge/rendering-and-styling-migration-analysis.md @@ -19,6 +19,7 @@ - [CSS => SWC mapping](#css--swc-mapping) - [Summary of changes](#summary-of-changes) - [CSS => SWC implementation gaps](#css--swc-implementation-gaps) + - [TODOs](#todos) - [CSS Spectrum 2 changes](#css-spectrum-2-changes) - [Resources](#resources) @@ -343,6 +344,14 @@ No significant structural changes. **Note**: Fixed positioning exists in both SWC and Spectrum 2 CSS but is not in the design spec. Consider whether to keep this for 2nd gen. +### TODOs + +#### 31 March 2026 + +Fixed positioning is documented in the design system guidance. Badges can be placed floating in a container or fixed to any edge, losing their default corner rounding on the fixed edge. + +New variants for badge, including notification and indicator were created. Further support and implementation for these missing variants (that are new to S2) are captured in SWC-1831. + ### CSS Spectrum 2 changes **No significant structural changes.**