From 4e4bd4a87be1e73fedbda3e49af498142f24cf84 Mon Sep 17 00:00:00 2001 From: Stephanie Eckles Date: Mon, 27 Apr 2026 15:11:23 -0500 Subject: [PATCH 1/9] feat(Button): migration of file structure, scaffold, API, and a11y (#6209) --- 1st-gen/packages/button/src/Button.ts | 66 ++++++- .../core/components/button/Button.base.ts | 140 +++++++++++++++ .../core/components/button/Button.types.ts | 44 +++++ .../packages/core/components/button/index.ts | 13 ++ 2nd-gen/packages/core/package.json | 7 + .../packages/swc/components/button/Button.ts | 168 ++++++++++++++++++ .../packages/swc/components/button/button.css | 17 ++ .../packages/swc/components/button/index.ts | 22 +++ .../swc/components/button/stories/.gitkeep | 0 .../swc/components/button/test/.gitkeep | 0 .../03_components/button/migration-plan.md | 58 +++--- 11 files changed, 506 insertions(+), 29 deletions(-) create mode 100644 2nd-gen/packages/core/components/button/Button.base.ts create mode 100644 2nd-gen/packages/core/components/button/Button.types.ts create mode 100644 2nd-gen/packages/core/components/button/index.ts create mode 100644 2nd-gen/packages/swc/components/button/Button.ts create mode 100644 2nd-gen/packages/swc/components/button/button.css create mode 100644 2nd-gen/packages/swc/components/button/index.ts create mode 100644 2nd-gen/packages/swc/components/button/stories/.gitkeep create mode 100644 2nd-gen/packages/swc/components/button/test/.gitkeep diff --git a/1st-gen/packages/button/src/Button.ts b/1st-gen/packages/button/src/Button.ts index 8752a44a762..d592726c9ed 100644 --- a/1st-gen/packages/button/src/Button.ts +++ b/1st-gen/packages/button/src/Button.ts @@ -22,8 +22,22 @@ import { PendingStateController } from '@spectrum-web-components/reactive-contro import buttonStyles from './button.css.js'; import { ButtonBase } from './ButtonBase.js'; +/** + * @deprecated The `DeprecatedButtonVariants` type export is deprecated and will + * be removed in a future release. + */ export type DeprecatedButtonVariants = 'cta' | 'overBackground'; + +/** + * @deprecated The `ButtonStaticColors` type export is deprecated and will be + * removed in a future release. + */ export type ButtonStaticColors = 'white' | 'black'; + +/** + * @deprecated The `ButtonVariants` type export is deprecated and will be + * removed in a future release. + */ export type ButtonVariants = | 'accent' | 'primary' @@ -31,6 +45,11 @@ export type ButtonVariants = | 'negative' | ButtonStaticColors | DeprecatedButtonVariants; + +/** + * @deprecated The `VALID_VARIANTS` export is deprecated and will be removed + * in a future release. + */ export const VALID_VARIANTS = [ 'accent', 'primary', @@ -39,8 +58,17 @@ export const VALID_VARIANTS = [ 'white', 'black', ]; + +/** + * @deprecated The `VALID_STATIC_COLORS` export is deprecated and will be + * removed in a future release. + */ export const VALID_STATIC_COLORS = ['white', 'black']; +/** + * @deprecated The `ButtonTreatments` type export is deprecated and will be + * removed in a future release. + */ export type ButtonTreatments = 'fill' | 'outline'; /** @@ -161,16 +189,30 @@ export class Button extends SizedMixin(ButtonBase, { noDefaultSize: true }) { /** * The visual treatment to apply to this button. + * + * @deprecated The `treatment` property is deprecated and will be replaced by + * `fill-style` in a future release. */ @property({ reflect: true }) public treatment: ButtonTreatments = 'fill'; /** - * Style this button to be less obvious + * Style this button to be less obvious. + * + * @deprecated The `quiet` property is deprecated and will be removed in a + * future release. */ @property({ type: Boolean }) public set quiet(quiet: boolean) { this.treatment = quiet ? 'outline' : 'fill'; + if (window.__swc?.DEBUG) { + window.__swc.warn( + this, + `The "quiet" property on <${this.localName}> has been deprecated and will be removed in a future release.`, + 'https://opensource.adobe.com/spectrum-web-components/components/button', + { level: 'deprecation' } + ); + } } /** @@ -178,9 +220,29 @@ export class Button extends SizedMixin(ButtonBase, { noDefaultSize: true }) { * Please note that this option is not a part of the design specification * and should be used carefully, with consideration of this overflow behavior * and the readability of the button's content. + * + * @deprecated The `no-wrap` attribute is deprecated and will be replaced by + * `truncate` in a future release. */ @property({ type: Boolean, attribute: 'no-wrap', reflect: true }) - public noWrap = false; + public set noWrap(value: boolean) { + const oldValue = this._noWrap; + this._noWrap = value; + this.requestUpdate('noWrap', oldValue); + if (window.__swc?.DEBUG) { + window.__swc.warn( + this, + `The "no-wrap" attribute on <${this.localName}> has been deprecated and will be replaced by "truncate" in a future release.`, + 'https://opensource.adobe.com/spectrum-web-components/components/button', + { level: 'deprecation' } + ); + } + } + + public get noWrap(): boolean { + return this._noWrap; + } + private _noWrap = false; public get quiet(): boolean { return this.treatment === 'outline'; diff --git a/2nd-gen/packages/core/components/button/Button.base.ts b/2nd-gen/packages/core/components/button/Button.base.ts new file mode 100644 index 00000000000..e3f8e56d95a --- /dev/null +++ b/2nd-gen/packages/core/components/button/Button.base.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; +import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; + +import { BUTTON_VALID_SIZES } from './Button.types.js'; + +/** + * Abstract base class for all button-like components. Owns shared semantic + * concerns: interaction state, sizing, accessible-name resolution, and + * host-to-internal-button attribute forwarding. + * + * Visual API specific to `sp-button` (`variant`, `fill-style`, `static-color`) + * is intentionally absent so that ActionButton, ClearButton, CloseButton, + * PickerButton, and InfieldButton can extend this base without inheriting + * the `swc-button` visual surface. + * + * @slot - Visible button label. + * @slot icon - Optional leading icon. + */ +export abstract class ButtonBase extends SizedMixin(SpectrumElement, { + validSizes: BUTTON_VALID_SIZES, +}) { + protected override createRenderRoot(): ShadowRoot { + return this.attachShadow({ mode: 'open', delegatesFocus: true }); + } + + /** + * Whether the button is disabled. Removes focusability and prevents + * interaction. + */ + @property({ type: Boolean, reflect: true }) + public disabled: boolean = false; + + /** + * Whether the button is in a pending (busy) state. The button remains + * focusable but activation is suppressed. + */ + @property({ type: Boolean, reflect: true }) + public pending: boolean = false; + + /** + * Custom accessible label used during the pending state. When omitted, + * the pending label is derived from the resolved non-busy accessible name + * plus a busy suffix (e.g. "Save, busy"). + */ + @property({ type: String, attribute: 'pending-label' }) + public pendingLabel?: string; + + /** + * Resolves the accessible name for the button from `aria-label` or + * visible text content. Returns `null` when no accessible name is + * determinable. + * + * @internal + */ + protected getResolvedAccessibleName(): string | null { + return ( + this.getAttribute('aria-label') ?? (this.textContent?.trim() || null) + ); + } + + /** + * Derives the pending-state accessible label. Prefers an explicit + * `pendingLabel`, then falls back to the resolved non-busy accessible + * name plus a ", busy" suffix, then a fixed "Busy" fallback. + * + * @internal + */ + protected getPendingAccessibleName(): string { + if (this.pendingLabel) { + return this.pendingLabel; + } + const resolvedName = this.getResolvedAccessibleName(); + return resolvedName ? `${resolvedName}, busy` : 'Busy'; + } + + /** + * Returns the set of attributes that should be forwarded to the internal + * semantic ` + `; + } + + protected override update(changedProperties: PropertyValues): void { + super.update(changedProperties); + if (window.__swc?.DEBUG) { + if (this.iconOnly && !this.getAttribute('aria-label')) { + window.__swc.warn( + this, + `<${this.localName}> with "icon-only" must have an "aria-label" attribute to be accessible.`, + 'https://opensource.adobe.com/spectrum-web-components/components/button/#icon-only', + { type: 'accessibility', level: 'high' } + ); + } + if (!BUTTON_VARIANTS.includes(this.variant)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "variant" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/button/#variants', + { issues: [...BUTTON_VARIANTS] } + ); + } + if (!BUTTON_FILL_STYLES.includes(this.fillStyle)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "fill-style" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/button/#fill-style', + { issues: [...BUTTON_FILL_STYLES] } + ); + } + if ( + this.fillStyle === 'outline' && + (this.variant === 'accent' || this.variant === 'negative') + ) { + window.__swc.warn( + this, + `<${this.localName}> element only supports "fill-style=outline" with the "primary" and "secondary" variants.`, + 'https://opensource.adobe.com/spectrum-web-components/components/button/#fill-style', + { issues: ['primary', 'secondary'] } + ); + } + if ( + this.staticColor && + (this.variant === 'accent' || this.variant === 'negative') + ) { + window.__swc.warn( + this, + `<${this.localName}> element only supports "static-color" with the "primary" and "secondary" variants.`, + 'https://opensource.adobe.com/spectrum-web-components/components/button/#static-color', + { issues: ['primary', 'secondary'] } + ); + } + } + } +} diff --git a/2nd-gen/packages/swc/components/button/button.css b/2nd-gen/packages/swc/components/button/button.css new file mode 100644 index 00000000000..4d53227e671 --- /dev/null +++ b/2nd-gen/packages/swc/components/button/button.css @@ -0,0 +1,17 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* Styles migrated in Phase 4 (styling). */ + +:host { + display: inline-block; +} diff --git a/2nd-gen/packages/swc/components/button/index.ts b/2nd-gen/packages/swc/components/button/index.ts new file mode 100644 index 00000000000..95a3f81bcee --- /dev/null +++ b/2nd-gen/packages/swc/components/button/index.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { defineElement } from '@spectrum-web-components/core/element/index.js'; + +import { Button } from './Button.js'; + +export * from './Button.js'; +declare global { + interface HTMLElementTagNameMap { + 'swc-button': Button; + } +} +defineElement('swc-button', Button); diff --git a/2nd-gen/packages/swc/components/button/stories/.gitkeep b/2nd-gen/packages/swc/components/button/stories/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/2nd-gen/packages/swc/components/button/test/.gitkeep b/2nd-gen/packages/swc/components/button/test/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md index a78e93154b9..2b7d7d70314 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md @@ -421,9 +421,9 @@ Allowed differences: ### Setup -- [ ] Create `2nd-gen/packages/core/components/button/` -- [ ] Create `2nd-gen/packages/swc/components/button/` -- [ ] Wire exports in both `package.json` files +- [x] Create `2nd-gen/packages/core/components/button/` +- [x] Create `2nd-gen/packages/swc/components/button/` +- [x] Wire exports in both `package.json` files - [ ] Implement Button styles so the component stylesheet and [`global-button.css`](../../../../2nd-gen/packages/swc/stylesheets/global/global-button.css) share source/imports if practical - [ ] Treat the current [`global-button.css`](../../../../2nd-gen/packages/swc/stylesheets/global/global-button.css) implementation as replaceable POC code, not as the canonical source of Button styling - [ ] Check out `spectrum-css` at `spectrum-two` branch as sibling directory @@ -432,24 +432,28 @@ Allowed differences: #### Naming and public surface -- [ ] `Button.types.ts`: define canonical `ButtonVariant`, `ButtonFillStyle`, `ButtonStaticColor`, and `ButtonSize` -- [ ] `Button.base.ts`: retain `size`, `variant`, `fillStyle`, `staticColor`, `disabled`, `pending`, `pendingLabel`, and accessible-name handling -- [ ] Rename legacy `noWrap` to `truncate` in the 2nd-gen API -- [ ] Remove `label` in favor of `aria-label` -- [ ] Remove deprecated link API (`href`, `target`, `download`, `referrerpolicy`, `rel`) from the 2nd-gen public surface -- [ ] Remove deprecated `variant` aliases (`cta`, `overBackground`, `white`, `black`) -- [ ] Do not carry forward `quiet` as a 2nd-gen visual API; document migration to explicit `fill-style="outline"` where the Figma matrix allows it +- [x] `Button.types.ts`: define canonical `ButtonVariant`, `ButtonFillStyle`, `ButtonStaticColor`, and `ButtonSize` +- [x] `Button.base.ts` (core): retain `disabled`, `pending`, `pendingLabel`, and accessible-name/pending-label logic. Also includes `SizedMixin` with `BUTTON_VALID_SIZES` — **deviation from plan**: the plan placed `size` in SWC as a non-reusable concern, but `SizedMixin` captures `validSizes` at construction time via closure; subclass static overrides have no effect at runtime. Because all Spectrum button-like components share the same four sizes (`s`, `m`, `l`, `xl`), placing `SizedMixin` in `ButtonBase` is safe and avoids requiring each subclass to re-apply the mixin. `variant`, `fillStyle`, and `staticColor` remain SWC-only. +- [x] `Button.ts` (SWC): define `variant`, `fillStyle`, `staticColor`, `iconOnly`, `truncate`, and visual combination validation warnings. `size` moved to `ButtonBase` (see above). Static class members (`VARIANTS`, `FILL_STYLES`, `STATIC_COLORS`, `VALID_SIZES`) were omitted — **deviation from plan**: these would be re-pointing the same module-level constants; debug validation code references the module constants directly instead. +- [x] Rename legacy `noWrap` to `truncate` in the 2nd-gen API — 2nd-gen `Button.ts` exposes `truncate`; `no-wrap` is deprecated in 1st-gen with `@deprecated` JSDoc and `window.__swc.warn()` +- [x] Add `@deprecated` JSDoc to 1st-gen type and const exports (`ButtonVariants`, `ButtonTreatments`, `ButtonStaticColors`, `DeprecatedButtonVariants`, `VALID_VARIANTS`, `VALID_STATIC_COLORS`) +- [x] Add `@deprecated` JSDoc to 1st-gen `treatment` property; no runtime warn added because `treatment` is set internally by the `quiet` setter and the `overBackground` variant alias, which already emit their own deprecation warnings +- [x] Add `@deprecated` JSDoc and `window.__swc.warn()` to 1st-gen `quiet` property +- [x] Remove `label` in favor of `aria-label` — 2nd-gen `Button.ts` has no `label` prop; accessible naming uses `aria-label` on the internal element +- [x] Remove deprecated link API (`href`, `target`, `download`, `referrerpolicy`, `rel`) from the 2nd-gen public surface — absent from 2nd-gen `Button.ts` +- [x] Remove deprecated `variant` aliases (`cta`, `overBackground`, `white`, `black`) from the 2nd-gen public surface — already absent in 2nd-gen `Button.ts` +- [x] Do not carry forward `quiet` as a 2nd-gen visual API — `quiet` is absent from 2nd-gen; deprecated in 1st-gen with `@deprecated` JSDoc and `window.__swc.warn()` - [ ] Document migration from `no-wrap` to `truncate` #### Semantics and forms -- [ ] Define which host attributes/semantics are forwarded to the internal button and which remain host-only -- [ ] When `pending`, expose unavailable state to assistive tech via `aria-disabled="true"` even when the button is not otherwise `disabled` (`SWC-459`) -- [ ] Replace the default pending accessible label with a descriptive busy-state pattern derived from the resolved non-busy accessible name, while still allowing `pending-label` override (`SWC-459`) -- [ ] Keep semantic helpers reusable in `core` so later button-like components can share behavior without sharing `sp-button` DOM or styling -- [ ] Do not recreate proxy patterns where the host carries button semantics while a different hidden internal control handles real activation +- [ ] Define which host attributes/semantics are forwarded to the internal button and which remain host-only — `getForwardedButtonAttributes()` documents the mapping as an extension hook; `type="button"` is explicitly set on the internal element (form-associated `submit`/`reset` types tracked in `SWC-2034`); formal documentation of the full contract is deferred to Phase 7 +- [x] When `pending`, expose unavailable state to assistive tech via `aria-disabled="true"` even when the button is not otherwise `disabled` (`SWC-459`) — `Button.ts` render template uses `ifDefined(this.pending && !this.disabled ? 'true' : undefined)` +- [x] Replace the default pending accessible label with a descriptive busy-state pattern derived from the resolved non-busy accessible name, while still allowing `pending-label` override (`SWC-459`) — `ButtonBase.getPendingAccessibleName()` returns `"${resolvedName}, busy"` or `"Busy"` fallback +- [x] Keep semantic helpers reusable in `core` so later button-like components can share behavior without sharing `sp-button` DOM or styling — `ButtonBase` in core provides `getResolvedAccessibleName()`, `getPendingAccessibleName()`, `getForwardedButtonAttributes()`, and `protected _handleClick` (subclasses wire this onto their internal ` `; } @@ -117,14 +183,6 @@ export class Button extends ButtonBase { protected override update(changedProperties: PropertyValues): void { super.update(changedProperties); if (window.__swc?.DEBUG) { - if (this.iconOnly && !this.getAttribute('aria-label')) { - window.__swc.warn( - this, - `<${this.localName}> with "icon-only" must have an "aria-label" attribute to be accessible.`, - 'https://opensource.adobe.com/spectrum-web-components/components/button/#icon-only', - { type: 'accessibility', level: 'high' } - ); - } if (!BUTTON_VARIANTS.includes(this.variant)) { window.__swc.warn( this, diff --git a/2nd-gen/packages/swc/components/button/button-base.css b/2nd-gen/packages/swc/components/button/button-base.css new file mode 100644 index 00000000000..e67f3607ace --- /dev/null +++ b/2nd-gen/packages/swc/components/button/button-base.css @@ -0,0 +1,54 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* Shared base styles for all button-like components (swc-button, swc-action-button, etc.). + Provides native ``` +Links and buttons support variant styles using associated modifier classes. + +{/* prettier-ignore */} +As Link (Secondary) +As Link (Accent) +As Link (Outlined) + + +```html +As Link (Secondary) +As Link (Accent) + + As Link (Outlined) + + +``` + +Buttons support `disabled` state styles. + +{/* prettier-ignore */} + + +```html + +``` + +Using a wrapper around the label with class `swc-Button-label` is recommended to best support text wrapping. It is required for truncation. + +{/* prettier-ignore */} + + + +```html + + +``` + +Icons in buttons or icon-only variants are supported, with the assumption of an SVG as the icon with the class of `swc-Button-icon`. For best text wrapping support, add `swc-Button--hasIcon` to the base element. + +Icon-only should include the class `swc-Button--iconOnly` on the base element, and provide a label via `aria-label`. + {/* prettier-ignore */} -As Link (Primary) + + ```html -As Link (Primary) + + + ``` diff --git a/2nd-gen/packages/swc/components/button/button-base.css b/2nd-gen/packages/swc/components/button/button-base.css index e67f3607ace..a00f37bfac1 100644 --- a/2nd-gen/packages/swc/components/button/button-base.css +++ b/2nd-gen/packages/swc/components/button/button-base.css @@ -15,11 +15,14 @@ Import as the first entry in a component's static styles CSSResultArray so that component-specific rules can override these defaults. */ +/* @global-exclude: host display/alignment apply to the custom element wrapper, not the native element */ :host { display: inline-block; vertical-align: top; } +/* @global-exclude-end */ + * { box-sizing: border-box; } diff --git a/2nd-gen/packages/swc/components/button/button.css b/2nd-gen/packages/swc/components/button/button.css index beb8d3062b8..f8fffe09c90 100644 --- a/2nd-gen/packages/swc/components/button/button.css +++ b/2nd-gen/packages/swc/components/button/button.css @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +/* @global-exclude: pending spinner animations require JS runtime */ @keyframes swc-pending-spinner-rotate { 0% { transform: rotate(var(--swc-pending-spinner-rotate-start, -90deg)); @@ -31,12 +32,14 @@ } } +/* @global-exclude-end */ + .swc-Button { --_swc-button-border-width: token("border-width-200"); --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); gap: var(--swc-button-gap, token("text-to-visual-100")); - max-inline-size: inherit; /* Inherit from :host to support truncation */ + max-inline-size: var(--swc-button-max-inline-size, inherit); /* Inherit from :host to support truncation */ min-block-size: var(--_swc-button-min-block-size); padding-block: calc(var(--swc-button-padding-vertical, token("component-padding-vertical-100")) - var(--_swc-button-border-width)); padding-inline: calc(var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100")) - var(--_swc-button-border-width)); @@ -334,6 +337,8 @@ slot[name="icon"]::slotted(*) { /* ── Pending ──────────────────────────────────────── */ +/* @global-exclude: pending state requires JS runtime (ButtonBase._pendingActive) */ + /* Cursor changes immediately on [pending]. Disabled colors, label/icon fade, and spinner appearance are deferred to .swc-Button--pendingActive, which is added by ButtonBase after a 1-second delay so the button does not flash to @@ -417,6 +422,8 @@ slot[name="icon"]::slotted(*) { will-change: transform; } +/* @global-exclude-end */ + /* ── Truncate ─────────────────────────────────────── */ :host([truncate]) .swc-Button-label { @@ -442,6 +449,7 @@ slot[name="icon"]::slotted(*) { transition-duration: 0ms; } + /* @global-exclude: pending spinner reduced-motion override requires JS runtime */ .swc-Button--pendingActive .swc-Button-pendingSpinner-fill { --swc-pending-spinner-dashoffset-30: 0; --swc-pending-spinner-rotate-start: 0deg; @@ -450,6 +458,8 @@ slot[name="icon"]::slotted(*) { animation-duration: 15s; animation-timing-function: linear, linear; } + + /* @global-exclude-end */ } @media (forced-colors: active) { diff --git a/2nd-gen/packages/swc/components/button/stories/button.stories.ts b/2nd-gen/packages/swc/components/button/stories/button.stories.ts index 4f33878205b..ea4e6e6b723 100644 --- a/2nd-gen/packages/swc/components/button/stories/button.stories.ts +++ b/2nd-gen/packages/swc/components/button/stories/button.stories.ts @@ -268,6 +268,8 @@ export const States: Story = { // BEHAVIORS STORIES // ────────────────────────────── +// TODO in documentation phase: Document use of global element classes for button-styled links + export const TextWrapping: Story = { render: (args) => html` ${template({ diff --git a/2nd-gen/packages/swc/stylesheets/global/global-button.css b/2nd-gen/packages/swc/stylesheets/global/global-button.css index aa7e8dfd0a0..5c58c2276be 100644 --- a/2nd-gen/packages/swc/stylesheets/global/global-button.css +++ b/2nd-gen/packages/swc/stylesheets/global/global-button.css @@ -10,123 +10,343 @@ * governing permissions and limitations under the License. */ -/* TODO: investigage importing styles from Button once migrated - * - * If not able to import, will need to match CSS guidelines for custom property exposure and token usage - */ -@layer swc-global-elements { - .swc-Button { - --swc-button-animation-duration: token("animation-duration-100"); - --swc-button-focus-ring-gap: token("focus-indicator-gap"); - --swc-button-focus-ring-thickness: token("focus-indicator-thickness"); - --swc-button-focus-indicator-color: token("focus-indicator-color"); - --swc-button-min-width: calc(token("component-height-100") * token("button-minimum-width-multiplier")); - --swc-button-height: token("component-height-100"); - --swc-button-border-radius: calc(var(--swc-button-height) / 2); - --swc-button-border-width: token("border-width-200"); - --swc-button-line-height: 1.2; - --swc-button-font-weight: token("bold-font-weight"); - --swc-button-font-size: token("font-size-100"); - --swc-button-edge-to-visual: calc(token("component-pill-edge-to-visual-100") - var(--swc-button-border-width)); - --swc-button-edge-to-visual-only: calc(token("component-pill-edge-to-visual-only-100") - var(--swc-button-border-width)); - --swc-button-edge-to-text: calc(token("component-pill-edge-to-text-100") - var(--swc-button-border-width)); - --swc-button-padding-label-to-icon: token("text-to-visual-100"); - --swc-button-top-to-text: token("component-top-to-text-100"); - --swc-button-bottom-to-text: token("component-bottom-to-text-100"); - --swc-button-top-to-icon: token("component-top-to-workflow-icon-100"); - --swc-button-intended-icon-size: token("workflow-icon-size-100"); - --swc-button-border-color-default: transparent; - --swc-button-border-color-hover: transparent; - --swc-button-border-color-down: transparent; - --swc-button-border-color-focus: transparent; - --swc-button-content-color-default: token("white"); - --swc-button-content-color-hover: token("white"); - --swc-button-content-color-down: token("white"); - --swc-button-content-color-focus: token("white"); - - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - box-sizing: border-box; - display: inline-flex; - position: relative; - gap: var(--swc-button-padding-label-to-icon); - align-items: center; - justify-content: center; - min-inline-size: var(--swc-button-min-width); - min-block-size: var(--swc-button-height); - padding-block: 0; - padding-inline: var(--swc-button-edge-to-text); - margin: 0; - font-family: token("sans-serif-font"); - font-size: var(--swc-button-font-size); - font-weight: var(--swc-button-font-weight); - line-height: token("line-height-100"); - vertical-align: top; - color: var(--swc-button-content-color-default); - text-transform: none; - text-decoration: none; - background-color: var(--swc-button-background-color-default); - border-color: var(--swc-button-border-color-default); - border-style: solid; - border-width: var(--swc-button-border-width); - border-radius: var(--swc-button-border-radius); - overflow: visible; - cursor: pointer; - user-select: none; - transition: - outline var(--swc-button-animation-duration, token("animation-duration-100")) linear, - border-color var(--swc-button-animation-duration, token("animation-duration-100")) linear, - color var(--swc-button-animation-duration, token("animation-duration-100")) linear, - background var(--swc-button-animation-duration, token("animation-duration-100")) linear; - } +/* DO NOT EDIT — auto-generated by vite-global-elements-css. + * Source: components/button/button.css + * Edit that file and rerun the dev server or build. */ - /* Default when no variant class used */ - .swc-Button, - .swc-Button--accent { - --swc-button-background-color-default: token("accent-background-color-default"); - --swc-button-background-color-hover: token("accent-background-color-hover"); - --swc-button-background-color-down: token("accent-background-color-down"); - --swc-button-background-color-focus: token("accent-background-color-key-focus"); - } +@layer swc-global-elements {.swc-Button, .swc-Button * { + box-sizing: border-box; +} - .swc-Button--primary { - --swc-button-content-color-default: token("gray-25"); - --swc-button-content-color-hover: token("gray-25"); - --swc-button-content-color-down: token("gray-25"); - --swc-button-content-color-focus: token("gray-25"); - --swc-button-background-color-default: token("neutral-background-color-default"); - --swc-button-background-color-hover: token("neutral-background-color-hover"); - --swc-button-background-color-down: token("neutral-background-color-down"); - --swc-button-background-color-focus: token("neutral-background-color-key-focus"); - } +.swc-Button { + -webkit-tap-highlight-color: transparent; + display: inline-flex; + position: relative; + gap: var(--swc-button-gap, token("text-to-visual-100")); + align-items: center; + justify-content: center; + max-inline-size: var(--swc-button-max-inline-size, inherit); + min-block-size: var(--_swc-button-min-block-size); + padding-block: calc(var(--swc-button-padding-vertical, token("component-padding-vertical-100")) - var(--_swc-button-border-width)); + padding-inline: calc(var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100")) - var(--_swc-button-border-width)); + margin: 0; + font-family: token("sans-serif-font"); + font-size: var(--swc-button-font-size, token("font-size-100")); + font-weight: token("bold-font-weight"); + line-height: round(down, calc(token("line-height-100") * 1em), 1px); + color: var(--swc-button-content-color-default, token("gray-25")); + text-transform: none; + text-decoration: none; + background-color: var(--swc-button-background-color-default, token("neutral-background-color-default")); + border-color: var(--swc-button-border-color-default, transparent); + border-style: solid; + border-width: var(--_swc-button-border-width); + border-radius: var(--swc-button-border-radius, calc(var(--_swc-button-min-block-size) / 2)); + overflow: visible; + cursor: pointer; + user-select: none; + transition-timing-function: token("animation-ease-in-out"); + transition-duration: token("animation-duration-100"); + transition-property: outline, border-color, color, background-color, transform; - @media (hover: hover) { - .swc-Button:hover { - color: var(--swc-button-content-color-hover); - background-color: var(--swc-button-background-color-hover); - border-color: var(--swc-button-border-color-hover); - } - } + --_swc-button-border-width: token("border-width-200"); + --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); +} - .swc-Button:focus-visible { - color: var(--swc-button-content-color-focus); - background-color: var(--swc-button-background-color-focus); - border-color: var(--swc-button-border-color-focus); +.swc-Button:focus-visible { + color: var(--swc-button-content-color-focus, token("gray-25")); + background-color: var(--swc-button-background-color-focus, token("neutral-background-color-key-focus")); + border-color: var(--swc-button-border-color-focus, transparent); + outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); + outline-offset: token("focus-indicator-gap"); +} - /* Replaces outdated box-shadow method */ - outline: var(--swc-button-focus-ring-thickness) solid var(--swc-button-focus-indicator-color); - outline-offset: var(--swc-button-focus-ring-gap); - } +.swc-Button:disabled:is(*, :hover) { + color: var(--swc-button-content-color-disabled, token("disabled-content-color")); + background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); + border-color: var(--swc-button-border-color-disabled, transparent); + cursor: default; + transform: none; +} + +.swc-Button:hover { + color: var(--swc-button-content-color-hover, token("gray-25")); + background-color: var(--swc-button-background-color-hover, token("neutral-background-color-hover")); + border-color: var(--swc-button-border-color-hover, transparent); +} + +.swc-Button:active { + color: var(--swc-button-content-color-down, token("gray-25")); + background-color: var(--swc-button-background-color-down, token("neutral-background-color-down")); + border-color: var(--swc-button-border-color-down, transparent); + transform: perspective(var(--_swc-button-min-block-size)) translate3d(0, 0, token("component-size-difference-down")); + will-change: transform; +} + +.swc-Button-label { + text-align: center; +} + +.swc-Button--hasIcon .swc-Button-label { + text-align: start; +} + +.swc-Button-icon, +.swc-Button-pendingSpinner { + --_swc-button-icon-size: var(--swc-button-icon-size, token("workflow-icon-size-100")); + --_swc-button-icon-block-size: var(--swc-button-icon-block-size, var(--_swc-button-icon-size)); + + flex-shrink: 0; + align-self: flex-start; + inline-size: var(--swc-button-icon-inline-size, var(--_swc-button-icon-size)); + block-size: var(--_swc-button-icon-block-size); + margin-block: calc((var(--_swc-button-icon-block-size) - 1lh) / 2 * -1); + margin-inline-start: calc(var(--swc-button-edge-to-visual, token("component-pill-edge-to-visual-100")) - var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100"))); +} + +.swc-Button-icon { + color: inherit; + fill: currentcolor; +} + +.swc-Button--sizeS { + --swc-button-min-block-size: token("component-height-75"); + --swc-button-font-size: token("font-size-75"); + --swc-button-gap: token("text-to-visual-75"); + --swc-button-edge-to-text: token("component-pill-edge-to-text-75"); + --swc-button-edge-to-visual: token("component-pill-edge-to-visual-75"); + --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-75"); + --swc-button-padding-vertical: token("component-padding-vertical-75"); + --swc-button-icon-size: token("workflow-icon-size-75"); +} + +.swc-Button--sizeL { + --swc-button-min-block-size: token("component-height-200"); + --swc-button-font-size: token("font-size-200"); + --swc-button-gap: token("text-to-visual-200"); + --swc-button-edge-to-text: token("component-pill-edge-to-text-200"); + --swc-button-edge-to-visual: token("component-pill-edge-to-visual-200"); + --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-200"); + --swc-button-padding-vertical: token("component-padding-vertical-200"); + --swc-button-icon-size: token("workflow-icon-size-200"); +} + +.swc-Button--sizeXl { + --swc-button-min-block-size: token("component-height-300"); + --swc-button-font-size: token("font-size-300"); + --swc-button-gap: token("text-to-visual-300"); + --swc-button-edge-to-text: token("component-pill-edge-to-text-300"); + --swc-button-edge-to-visual: token("component-pill-edge-to-visual-300"); + --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-300"); + --swc-button-padding-vertical: token("component-padding-vertical-300"); + --swc-button-icon-size: token("workflow-icon-size-300"); +} + +.swc-Button--accent { + --swc-button-content-color-default: token("white"); + --swc-button-content-color-hover: token("white"); + --swc-button-content-color-down: token("white"); + --swc-button-content-color-focus: token("white"); + --swc-button-background-color-default: token("accent-background-color-default"); + --swc-button-background-color-hover: token("accent-background-color-hover"); + --swc-button-background-color-down: token("accent-background-color-down"); + --swc-button-background-color-focus: token("accent-background-color-key-focus"); +} + +.swc-Button--negative { + --swc-button-content-color-default: token("white"); + --swc-button-content-color-hover: token("white"); + --swc-button-content-color-down: token("white"); + --swc-button-content-color-focus: token("white"); + --swc-button-background-color-default: token("negative-background-color-default"); + --swc-button-background-color-hover: token("negative-background-color-hover"); + --swc-button-background-color-down: token("negative-background-color-down"); + --swc-button-background-color-focus: token("negative-background-color-key-focus"); +} + +.swc-Button--secondary { + --swc-button-content-color-default: token("neutral-content-color-default"); + --swc-button-content-color-hover: token("neutral-content-color-hover"); + --swc-button-content-color-down: token("neutral-content-color-down"); + --swc-button-content-color-focus: token("neutral-content-color-key-focus"); + --swc-button-background-color-default: token("gray-100"); + --swc-button-background-color-hover: token("gray-200"); + --swc-button-background-color-down: token("gray-200"); + --swc-button-background-color-focus: token("gray-200"); +} + +.swc-Button--primary.swc-Button--outline { + --swc-button-background-color-default: transparent; + --swc-button-background-color-hover: token("gray-100"); + --swc-button-background-color-down: token("gray-100"); + --swc-button-background-color-focus: token("gray-100"); + --swc-button-border-color-default: token("gray-800"); + --swc-button-border-color-hover: token("gray-900"); + --swc-button-border-color-down: token("gray-900"); + --swc-button-border-color-focus: token("gray-900"); + --swc-button-content-color-default: token("neutral-content-color-default"); + --swc-button-content-color-hover: token("neutral-content-color-hover"); + --swc-button-content-color-down: token("neutral-content-color-down"); + --swc-button-content-color-focus: token("neutral-content-color-key-focus"); + --swc-button-background-color-disabled: transparent; + --swc-button-border-color-disabled: token("disabled-border-color"); +} + +.swc-Button--secondary.swc-Button--outline { + --swc-button-background-color-default: transparent; + --swc-button-background-color-hover: token("gray-100"); + --swc-button-background-color-down: token("gray-100"); + --swc-button-background-color-focus: token("gray-100"); + --swc-button-border-color-default: token("gray-300"); + --swc-button-border-color-hover: token("gray-400"); + --swc-button-border-color-down: token("gray-400"); + --swc-button-border-color-focus: token("gray-400"); + --swc-button-background-color-disabled: transparent; + --swc-button-border-color-disabled: token("disabled-border-color"); +} + +.swc-Button--staticWhite { + --swc-button-focus-indicator-color: token("static-white-focus-indicator-color"); + --swc-button-background-color-default: token("transparent-white-800"); + --swc-button-background-color-hover: token("transparent-white-900"); + --swc-button-background-color-down: token("transparent-white-900"); + --swc-button-background-color-focus: token("transparent-white-900"); + --swc-button-content-color-default: token("black"); + --swc-button-content-color-hover: token("black"); + --swc-button-content-color-down: token("black"); + --swc-button-content-color-focus: token("black"); + --swc-button-background-color-disabled: token("disabled-static-white-background-color"); + --swc-button-border-color-disabled: transparent; + --swc-button-content-color-disabled: token("disabled-static-white-content-color"); +} - .swc-Button:active { - color: var(--swc-button-content-color-down); - background-color: var(--swc-button-background-color-down); - border-color: var(--swc-button-border-color-down); - transform: translateZ(token("component-size-difference-down")); +.swc-Button--staticWhite.swc-Button--secondary { + --swc-button-background-color-default: token("transparent-white-100"); + --swc-button-background-color-hover: token("transparent-white-200"); + --swc-button-background-color-down: token("transparent-white-200"); + --swc-button-background-color-focus: token("transparent-white-200"); + --swc-button-content-color-default: token("transparent-white-800"); + --swc-button-content-color-hover: token("transparent-white-900"); + --swc-button-content-color-down: token("transparent-white-900"); + --swc-button-content-color-focus: token("transparent-white-900"); +} + +.swc-Button--staticWhite.swc-Button--outline { + --swc-button-background-color-default: token("transparent-white-25"); + --swc-button-background-color-hover: token("transparent-white-100"); + --swc-button-background-color-down: token("transparent-white-100"); + --swc-button-background-color-focus: token("transparent-white-100"); + --swc-button-border-color-default: token("transparent-white-800"); + --swc-button-border-color-hover: token("transparent-white-900"); + --swc-button-border-color-down: token("transparent-white-900"); + --swc-button-border-color-focus: token("transparent-white-900"); + --swc-button-content-color-default: token("transparent-white-800"); + --swc-button-content-color-hover: token("transparent-white-900"); + --swc-button-content-color-down: token("transparent-white-900"); + --swc-button-content-color-focus: token("transparent-white-900"); + --swc-button-background-color-disabled: token("transparent-white-25"); + --swc-button-border-color-disabled: token("disabled-static-white-border-color"); + --swc-button-content-color-disabled: token("disabled-static-white-content-color"); +} + +.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { + --swc-button-background-color-default: token("transparent-white-25"); + --swc-button-background-color-hover: token("transparent-white-100"); + --swc-button-background-color-down: token("transparent-white-100"); + --swc-button-background-color-focus: token("transparent-white-100"); + --swc-button-border-color-default: token("transparent-white-300"); + --swc-button-border-color-hover: token("transparent-white-400"); + --swc-button-border-color-down: token("transparent-white-400"); + --swc-button-border-color-focus: token("transparent-white-400"); +} + +.swc-Button--staticBlack { + --swc-button-focus-indicator-color: token("static-black-focus-indicator-color"); + --swc-button-background-color-default: token("transparent-black-800"); + --swc-button-background-color-hover: token("transparent-black-900"); + --swc-button-background-color-down: token("transparent-black-900"); + --swc-button-background-color-focus: token("transparent-black-900"); + --swc-button-content-color-default: token("white"); + --swc-button-content-color-hover: token("white"); + --swc-button-content-color-down: token("white"); + --swc-button-content-color-focus: token("white"); + --swc-button-background-color-disabled: token("disabled-static-black-background-color"); + --swc-button-border-color-disabled: transparent; + --swc-button-content-color-disabled: token("disabled-static-black-content-color"); +} + +.swc-Button--staticBlack.swc-Button--secondary { + --swc-button-background-color-default: token("transparent-black-100"); + --swc-button-background-color-hover: token("transparent-black-200"); + --swc-button-background-color-down: token("transparent-black-200"); + --swc-button-background-color-focus: token("transparent-black-200"); + --swc-button-content-color-default: token("transparent-black-800"); + --swc-button-content-color-hover: token("transparent-black-900"); + --swc-button-content-color-down: token("transparent-black-900"); + --swc-button-content-color-focus: token("transparent-black-900"); +} + +.swc-Button--staticBlack.swc-Button--outline { + --swc-button-background-color-default: token("transparent-black-25"); + --swc-button-background-color-hover: token("transparent-black-100"); + --swc-button-background-color-down: token("transparent-black-100"); + --swc-button-background-color-focus: token("transparent-black-100"); + --swc-button-border-color-default: token("transparent-black-800"); + --swc-button-border-color-hover: token("transparent-black-900"); + --swc-button-border-color-down: token("transparent-black-900"); + --swc-button-border-color-focus: token("transparent-black-900"); + --swc-button-content-color-default: token("transparent-black-800"); + --swc-button-content-color-hover: token("transparent-black-900"); + --swc-button-content-color-down: token("transparent-black-900"); + --swc-button-content-color-focus: token("transparent-black-900"); + --swc-button-background-color-disabled: token("transparent-black-25"); + --swc-button-border-color-disabled: token("disabled-static-black-border-color"); + --swc-button-content-color-disabled: token("disabled-static-black-content-color"); +} + +.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { + --swc-button-background-color-default: token("transparent-black-25"); + --swc-button-background-color-hover: token("transparent-black-100"); + --swc-button-background-color-down: token("transparent-black-100"); + --swc-button-background-color-focus: token("transparent-black-100"); + --swc-button-border-color-default: token("transparent-black-300"); + --swc-button-border-color-hover: token("transparent-black-400"); + --swc-button-border-color-down: token("transparent-black-400"); + --swc-button-border-color-focus: token("transparent-black-400"); +} + +.swc-Button--iconOnly { + --swc-button-border-radius: token("corner-radius-full"); + --swc-button-gap: 0; + + padding-inline: calc(var(--swc-button-edge-to-visual-only, token("component-pill-edge-to-visual-only-100")) - var(--_swc-button-border-width)); +} + +.swc-Button--iconOnly .swc-Button-icon { + --swc-button-edge-to-visual: 0; + + align-self: center; +} + +.swc-Button--truncate .swc-Button-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.swc-Button--justified { + flex-grow: 1; + justify-self: stretch; + inline-size: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .swc-Button { + transition-duration: 0ms; } } +} -/* Results in encapsulating styles, preventing stronger application selectors from overriding basic properties like `color` */ .swc-Button { - all: revert-layer !important; +all: revert-layer !important; } diff --git a/2nd-gen/packages/swc/tsconfig.json b/2nd-gen/packages/swc/tsconfig.json index 81d0564ab88..5fe7ded31c3 100644 --- a/2nd-gen/packages/swc/tsconfig.json +++ b/2nd-gen/packages/swc/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist", "paths": { "@adobe/spectrum-wc/*": ["./components/*"], + "@adobe/vite-global-elements-css": ["../tools/vite-global-elements-css"], "@spectrum-web-components/core/*": ["../core/*"] }, "rootDir": "./", diff --git a/2nd-gen/packages/swc/vite.config.ts b/2nd-gen/packages/swc/vite.config.ts index fd0ad4ba2c2..1b89596924e 100644 --- a/2nd-gen/packages/swc/vite.config.ts +++ b/2nd-gen/packages/swc/vite.config.ts @@ -11,6 +11,7 @@ */ import postcssToken from '@adobe/postcss-token'; +import { globalElementCSS } from '@adobe/vite-global-elements-css'; import autoprefixer from 'autoprefixer'; import { readFile, writeFile } from 'fs/promises'; import { glob } from 'glob'; @@ -62,6 +63,9 @@ function processStylesheets(): Plugin { export default defineConfig({ plugins: [ + globalElementCSS({ + elements: [{ component: 'button' }], + }), litCss({ exclude: ['./stylesheets/**/*.css'] }), processStylesheets(), dts({ @@ -132,6 +136,10 @@ export default defineConfig({ '@adobe/spectrum-wc': resolve(__dirname, './components'), '@adobe/postcss-token': resolve(__dirname, '../tools/postcss-token'), '@adobe/swc-tokens': resolve(__dirname, '../tools/swc-tokens'), + '@adobe/vite-global-elements-css': resolve( + __dirname, + '../tools/vite-global-elements-css' + ), }, }, server: { diff --git a/2nd-gen/packages/tools/vite-global-elements-css/README.md b/2nd-gen/packages/tools/vite-global-elements-css/README.md new file mode 100644 index 00000000000..115651debac --- /dev/null +++ b/2nd-gen/packages/tools/vite-global-elements-css/README.md @@ -0,0 +1,223 @@ +# vite-global-elements-css + +Derives `global-{component}.css` stylesheets from component shadow-DOM CSS sources. Keeps global-element styles in sync with their component counterparts without manual duplication. + +## How it works + +Shadow-DOM stylesheets use selectors that are meaningless outside a custom element (`::slotted`, `:host([attr])`). Global stylesheets target native elements with BEM modifier classes. This plugin bridges the two by reading the component CSS, applying deterministic selector transformations, stripping component-only blocks, and writing the result to `stylesheets/global/global-{component}.css`. + +- **In dev**: runs at startup and re-derives on every source file save (triggers a full page reload). +- **At build**: runs in `configResolved`, before `processStylesheets` picks up the generated file in `closeBundle`. + +The generated file is annotated with a `DO NOT EDIT` header that includes the source file path. It contains `token()` calls unchanged — those are resolved by the existing PostCSS pipeline. + +## Selector transformation + +Transformations are derived from BEM conventions with no per-component mapping required. + +| Source (shadow DOM) | Global output | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `:host` | `.swc-[Component]` | +| `:host([variant="primary"])` | `.swc-[Component]--primary` | +| `:host([truncate])` | `.swc-[Component]--truncate` (boolean attr → attr name as modifier) | +| `:host([variant="secondary"][fill-style="outline"])` | `.swc-[Component]--secondary.swc-[Component]--outline` (compound → chained classes) | +| `:host([variant="primary"]) .swc-[Component]-label` | `.swc-[Component]--primary .swc-[Component]-label` | +| `slot[name="icon"]::slotted(*)` | `.swc-[Component]-icon` (slot name → BEM element) | + +The modifier value comes from: + +- **String attributes**: the attribute **value** (e.g. `variant="primary"` → `--primary`) +- **Boolean attributes**: the attribute **name** (e.g. `[truncate]` → `--truncate`) + +This relies on all modifier values being unique within a component's BEM namespace, which is enforced by the type definitions in `Button.types.ts` and equivalent files. + +### SWC API attribute prefixing + +Two attributes in the stable SWC API produce **prefixed** modifiers automatically — no configuration needed: + +| Attribute | Value | Modifier | +| -------------- | ------- | --------------- | +| `size` | `s` | `--sizeS` etc. | +| `static-color` | `white` | `--staticWhite` | +| `static-color` | `black` | `--staticBlack` | + +The prefix is the first hyphen-segment of the attribute name (`static-color` → `static`). For single-word attributes like `size`, the prefix is the full attribute name. This avoids ambiguous single-character modifier classes (`--l`, `--s`) in the global stylesheet. + +### Wildcard rule injection + +Shadow DOM stylesheets commonly include `* { box-sizing: border-box }` scoped to the shadow root. A bare `*` selector in a global stylesheet would leak to the entire page, so the plugin instead **injects** the wildcard declarations directly into the root block rule and descendents: + +```css +/* source */ +* { + box-sizing: border-box; +} + +/* generated — box-sizing injected, no wildcard in output */ +.swc-Button, +.swc-Button * { + box-sizing: border-box; +} +``` + +## `@global-exclude` fences + +Mark component-only blocks that have no global equivalent. The plugin strips everything between the open and close comments: + +```css +/* @global-exclude: pending state requires JS runtime */ +@keyframes swc-pending-spinner-rotate { ... } + +:host([pending]) .swc-Button { + cursor: default; +} + +.swc-Button--pendingActive { ... } +/* @global-exclude-end */ +``` + +The reason string after `@global-exclude:` is optional but recommended for clarity. + +### Fences inside at-rules + +Fences work at any nesting depth. A fence inside an `@media` block strips only the rules within that block's scope — the `@media` wrapper itself is preserved unless all of its content is fenced (in which case the empty at-rule is removed automatically): + +```css +@media (prefers-reduced-motion: reduce) { + .swc-Button { + transition-duration: 0ms; /* kept — has a global equivalent */ + } + + /* @global-exclude: pending spinner override requires JS runtime */ + .swc-Button--pendingActive .swc-Button-pendingSpinner-fill { + animation-duration: 15s; + } + /* @global-exclude-end */ +} +``` + +The generated global stylesheet will contain the `@media` block with only `transition-duration: 0ms`. + +## Comment stripping + +All CSS comments are removed from the source before generating the output. The generated file receives a single Apache 2.0 copyright header and a `DO NOT EDIT` notice pointing to the source file. This prevents stale or duplicated comment blocks when the plugin concatenates base and component stylesheets. + +## Rule merging + +When base and component stylesheets both define rules with the same selector (e.g. both declare `.swc-Button { … }`), the plugin merges them into a single rule. If the same property appears in both, the last declaration wins — matching CSS cascade order. + +## Cascade layer + +The generated stylesheet wraps all rules inside `@layer swc-global-elements`: + +```css +@layer swc-global-elements { + .swc-Button { … } + .swc-Button--accent { … } +} + +.swc-Button { all: revert-layer !important; } +``` + +This provides encapsulation similar to shadow DOM to prevent application styles affecting these global element style utilities. + +## Base file auto-detection + +If a `{name}-base.css` file exists alongside `{name}.css` in the same component directory, the plugin automatically concatenates it before the component CSS and includes it in the derivation. This covers shared resets (like `button-base.css`) without any extra configuration. + +## Setup + +### 1. Install + +The package is workspace-local. Add the alias to `vite.config.ts` and `tsconfig.json`: + +```ts +// vite.config.ts +resolve: { + alias: { + '@adobe/vite-global-elements-css': resolve(__dirname, '../tools/vite-global-elements-css'), + }, +} +``` + +```json +// tsconfig.json +{ + "compilerOptions": { + "paths": { + "@adobe/vite-global-elements-css": ["../tools/vite-global-elements-css"] + } + } +} +``` + +### 2. Register the plugin + +The `rootElementSelector` class is derived automatically from the component name (`'button'` → `'swc-Button'`, `'action-button'` → `'swc-ActionButton'`), so only `component` is required: + +```ts +import { globalElementCSS } from '@adobe/vite-global-elements-css'; + +export default defineConfig({ + plugins: [ + globalElementCSS({ + elements: [{ component: 'button' }, { component: 'action-button' }], + }), + // ... other plugins + ], +}); +``` + +### 3. Add `@global-exclude` fences to component CSS + +Wrap any block that should not appear in the global stylesheet: + +```css +/* @global-exclude: spinner animations require JS pending state */ +@keyframes swc-pending-spinner-rotate { ... } +@keyframes swc-pending-spinner-dashoffset { ... } +/* @global-exclude-end */ +``` + +## Options + +### `elements` + +Array of component entries. Each entry: + +| Option | Type | Required | Description | +| --------------------- | -------- | -------- | ----------------------------------------------------------------------- | +| `component` | `string` | yes | Component name, e.g. `'button'`. Derives all paths and the block class. | +| `source` | `string` | no | Override source CSS filename when it differs from `component`. | +| `rootElementSelector` | `string` | no | Override the derived BEM block class (e.g. `'swc-Button'`). | + +The `source` option is only needed for naming discrepancies: + +```ts +// CSS lives at components/close-btn/close-btn.css +// but the component name is 'close-button' +{ component: 'close-button', source: 'close-btn' } +``` + +The `rootElementSelector` option is only needed when the derived class doesn't match the actual block class: + +```ts +// Derived class would be swc-CloseBtn, but the actual class is swc-CloseButton +{ component: 'close-button', source: 'close-btn', rootElementSelector: 'swc-CloseButton' } +``` + +## Output + +Generated files are written to `stylesheets/global/global-{component}.css` relative to the Vite project root. Each file: + +- Begins with an Apache 2.0 copyright header and a `DO NOT EDIT` notice that includes the source file path +- Contains no CSS comments (all stripped from source) +- Wraps all rules in `@layer swc-global-elements` +- Contains `token()` calls unchanged — those are resolved by the PostCSS pipeline that processes `stylesheets/` at build time + +Commit the generated files. They are stable output — the same input always produces the same output. + +## Limitations + +- The transformation assumes modifier **values** are unique within a component's BEM namespace. If two attributes share a value name (unlikely given Spectrum naming conventions), the output modifier class will be ambiguous. Resolve by using distinct values in the component's type definitions. +- Global stylesheets do not support component-only states (pending, JS-driven class toggling). Exclude those blocks with fences. diff --git a/2nd-gen/packages/tools/vite-global-elements-css/index.d.ts b/2nd-gen/packages/tools/vite-global-elements-css/index.d.ts new file mode 100644 index 00000000000..55646ef49e5 --- /dev/null +++ b/2nd-gen/packages/tools/vite-global-elements-css/index.d.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Plugin } from 'vite'; + +export interface GlobalElementEntry { + /** + * Component identifier used to derive source and output paths. + * + * `'button'` resolves to: + * - source: `components/button/button.css` + * - base (auto): `components/button/button-base.css` (if it exists) + * - output: `stylesheets/global/global-button.css` + * - block (auto): `swc-Button` + */ + component: string; + + /** + * Override the source path segment when the CSS filename differs from the component name. + * Only needed for naming discrepancies. + * + * @example + * // CSS is at components/close-btn/close-btn.css but component is 'close-button' + * { component: 'close-button', source: 'close-btn' } + */ + source?: string; + + /** + * Override the BEM block class used for selector derivation. + * Derived automatically from `component` if omitted: 'button' → 'swc-Button', + * 'action-button' → 'swc-ActionButton'. + * + * @example 'swc-Button' + */ + rootElementSelector?: string; +} + +export interface GlobalElementCSSOptions { + /** + * One entry per component whose global stylesheet should be derived. + */ + elements: GlobalElementEntry[]; +} + +/** + * Vite plugin that derives `global-{component}.css` from a component's shadow-DOM + * stylesheet, transforming selectors to BEM class equivalents and stripping + * component-only blocks marked with `@global-exclude` fences. + */ +export declare function globalElementCSS( + options: GlobalElementCSSOptions +): Plugin; + +/** + * Derives the BEM block class from a component name. + * 'button' → 'swc-Button', 'action-button' → 'swc-ActionButton'. + */ +export declare function deriveBlock(component: string): string; + +/** + * Transforms a comma-separated shadow-DOM selector list to its global BEM equivalent. + * Wildcard selectors (`*`) are scoped to `.block, .block *`. + * Attributes in the stable SWC API (`size`, `static-color`) are automatically prefixed: + * `size="l"` → `--sizeL`, `static-color="white"` → `--staticWhite`. + */ +export declare function transformSelector(list: string, block: string): string; + +/** + * Derives a global-elements stylesheet from raw component shadow-DOM CSS. + * Strips fenced blocks, removes comments, transforms selectors, merges duplicate + * rules, and wraps in the cascade layer. Token calls are left intact for the + * PostCSS pipeline to resolve. + */ +export declare function deriveCSS(sourceCss: string, block: string): string; diff --git a/2nd-gen/packages/tools/vite-global-elements-css/index.js b/2nd-gen/packages/tools/vite-global-elements-css/index.js new file mode 100644 index 00000000000..147a6f69dfb --- /dev/null +++ b/2nd-gen/packages/tools/vite-global-elements-css/index.js @@ -0,0 +1,618 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import postcss from 'postcss'; + +const LAYER = 'swc-global-elements'; +const FENCE_OPEN_RE = /^@global-exclude(?:\s*:.*)?$/; +const FENCE_CLOSE = '@global-exclude-end'; + +// Attributes in the stable SWC API whose modifier values are prefixed with the +// first hyphen-segment of the attribute name to avoid ambiguous short tokens: +// size="l" → sizeL (bare "l" conveys nothing in a global stylesheet) +// static-color="white" → staticWhite ("static" is the semantic distinguisher) +const SWC_PREFIXED_ATTRS = ['size', 'static-color']; + +// ── File header ───────────────────────────────────────────────────────────── + +/** + * @param {string} sourcePath - Path relative to project root. + * @returns {string} + */ +function makeHeader(sourcePath) { + const year = new Date().getFullYear(); + return ( + '/**\n' + + ` * Copyright ${year} Adobe. All rights reserved.\n` + + ' * This file is licensed to you under the Apache License, Version 2.0 (the "License");\n' + + ' * you may not use this file except in compliance with the License. You may obtain a copy\n' + + ' * of the License at http://www.apache.org/licenses/LICENSE-2.0\n' + + ' *\n' + + ' * Unless required by applicable law or agreed to in writing, software distributed under\n' + + ' * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS\n' + + ' * OF ANY KIND, either express or implied. See the License for the specific language\n' + + ' * governing permissions and limitations under the License.\n' + + ' */\n\n' + + '/* DO NOT EDIT — auto-generated by vite-global-elements-css.\n' + + ` * Source: ${sourcePath}\n` + + ' * Edit that file and rerun the dev server or build. */\n\n' + ); +} + +// ── Block name derivation ─────────────────────────────────────────────────── + +/** + * Derives the BEM block class from a component name: 'button' → 'swc-Button', + * 'action-button' → 'swc-ActionButton'. + * + * @param {string} component - Component identifier, e.g. 'button' or 'action-button'. + * @returns {string} + */ +export function deriveBlock(component) { + return ( + 'swc-' + + component + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join('') + ); +} + +/** + * Returns the resolved BEM block class for an entry. + * + * @param {import('./index.d.ts').GlobalElementEntry} entry - Entry to resolve the block for. + * @returns {string} + */ +function getBlock(entry) { + return entry.rootElementSelector ?? deriveBlock(entry.component); +} + +// ── Selector transformation ───────────────────────────────────────────────── + +/** + * Splits a comma-separated selector list while respecting parentheses nesting. + * + * @param {string} list + * @returns {string[]} + */ +function splitSelectors(list) { + const parts = []; + let depth = 0; + let current = ''; + for (const ch of list) { + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } else if (ch === ',' && depth === 0) { + parts.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + if (current.trim()) { + parts.push(current.trim()); + } + return parts; +} + +/** + * Parses attribute selectors from a :host() argument string. + * + * @param {string} args - e.g. '[variant="primary"][fill-style="outline"]' + * @returns {{ name: string; value?: string }[]} + */ +function parseHostAttrs(args) { + /** @type {{ name: string; value?: string }[]} */ + const attrs = []; + const re = /\[([^\]=\s]+?)(?:="([^"]*)")?\]/g; + let m; + while ((m = re.exec(args)) !== null) { + attrs.push({ name: m[1], value: m[2] }); + } + return attrs; +} + +/** + * Builds the BEM modifier string for a single host attribute. + * + * Attributes in SWC_PREFIXED_ATTRS get prefixed with the first hyphen-segment + * of the attribute name followed by a PascalCase value: + * size="l" → 'sizeL' + * static-color="white" → 'staticWhite' + * + * All other attributes use the raw value (string attrs) or the attribute name + * itself (boolean attrs). + * + * @param {string} attrName + * @param {string | undefined} value + * @returns {string} + */ +function buildModifier(attrName, value) { + if (value === undefined) { + return attrName; + } + if (SWC_PREFIXED_ATTRS.includes(attrName)) { + const prefix = attrName.split('-')[0]; + return prefix + value.charAt(0).toUpperCase() + value.slice(1); + } + return value; +} + +/** + * Transforms `slot[name="X"]::slotted(*)` occurrences to `.block-X`. + * + * @param {string} fragment + * @param {string} block + * @returns {string} + */ +function transformSlotted(fragment, block) { + return fragment.replace( + /slot\[name="([^"]+)"\]::slotted\([^)]*\)/g, + (_, name) => `.${block}-${name}` + ); +} + +/** + * Transforms a single (no-comma) shadow-DOM selector to its global BEM equivalent. + * + * Rules: + * * → .block, .block * (scoped wildcard) + * :host → .block + * :host([attr="value"]) → .block--value (value becomes modifier) + * :host([boolAttr]) → .block--boolAttr (attr name becomes modifier) + * :host([a="x"][b="y"]) → .block--x.block--y (compound, same element) + * :host([a="x"]) .block__el → .block--x .block-el + * slot[name="X"]::slotted(*) → .block__X + * + * @param {string} selector + * @param {string} block + * @returns {string} + */ +function transformSingle(selector, block) { + // Wildcard → scoped to the block and all its descendants, preserving the + // intent of shadow-root-scoped rules (e.g. * { box-sizing: border-box }) + // without leaking into the global scope. + if (selector.trim() === '*') { + return `.${block}, .${block} *`; + } + + const m = selector.match(/^:host(\(([^)]+)\))?\s*([\s\S]*)/); + if (!m) { + return transformSlotted(selector, block); + } + + const [, , rawArgs, rawRest] = m; + const rest = (rawRest ?? '').trim(); + + if (!rawArgs) { + // Bare :host → .block + const r = transformSlotted(rest, block); + return r ? `.${block} ${r}` : `.${block}`; + } + + // :host([attr="value"][boolAttr]) → modifier classes joined (no space = same element) + const attrs = parseHostAttrs(rawArgs); + const hostClass = attrs + .map(({ name, value }) => `.${block}--${buildModifier(name, value)}`) + .join(''); + + const r = transformSlotted(rest, block); + + // When the rest is exactly the block class, the shadow-DOM inner element is + // the root element in global context — collapse into the modifier so styles + // apply directly rather than producing an unreachable descendant combinator. + if (r === `.${block}`) { + return hostClass; + } + + return r ? `${hostClass} ${r}` : hostClass; +} + +/** + * Transforms a full comma-separated selector list from shadow-DOM form to global BEM form. + * + * @param {string} list + * @param {string} block + * @returns {string} + */ +export function transformSelector(list, block) { + return splitSelectors(list) + .map((s) => transformSingle(s, block)) + .join(',\n'); +} + +// ── PostCSS transformation ────────────────────────────────────────────────── + +/** + * Removes nodes enclosed by @global-exclude / @global-exclude-end comment fences. + * Works at any nesting depth: fences inside a media query strip only rules within + * that block. After stripping, at-rules left empty are removed. + * + * @param {import('postcss').Container} container - PostCSS container to strip fences from. + * + * @returns {boolean} `true` if the operation succeeded (all fences were closed). + */ +function stripExcludedBlocks(container) { + /** @type {import('postcss').Node[]} */ + const toRemove = []; + let excluding = false; + + container.each((node) => { + if (node.type === 'comment') { + const text = node.text.trim(); + if (FENCE_OPEN_RE.test(text)) { + excluding = true; + toRemove.push(node); + return; + } + if (text === FENCE_CLOSE) { + excluding = false; + toRemove.push(node); + return; + } + } + if (excluding) { + toRemove.push(node); + } else if (node.type === 'atrule' && node.nodes) { + // Recurse so fences inside @media, @supports, etc. work within their scope + if (!stripExcludedBlocks(node)) { + excluding = true; + } + } + }); + + for (const node of toRemove) { + node.remove(); + } + + // Remove at-rules left empty after fence removal (e.g. @media with only fenced content) + container.each((node) => { + if (node.type === 'atrule' && node.nodes && node.nodes.length === 0) { + node.remove(); + } + }); + return !excluding; +} + +/** + * Removes all comment nodes from the AST. Called after fence stripping so that + * fence comments are already gone and only source comments remain. + * + * @param {import('postcss').Root} root + */ +function stripComments(root) { + /** @type {import('postcss').Comment[]} */ + const toRemove = []; + root.walkComments((c) => toRemove.push(c)); + for (const c of toRemove) { + c.remove(); + } +} + +/** + * Walks all rules in the AST (including those nested inside @media etc.) and + * applies BEM selector derivation. + * + * @param {import('postcss').Root} root + * @param {string} block + */ +function applySelectTransform(root, block) { + root.walkRules((rule) => { + rule.selector = transformSelector(rule.selector, block); + }); +} + +/** + * Merges rules that share the same selector within the same container, combining + * all declarations into the first occurrence and deduplicating (last wins per property). + * Recurses into at-rules so nested rules inside @media are also merged. + * + * @param {import('postcss').Container} container + */ +function mergeRules(container) { + // Recurse into at-rules first so inner rules are merged before outer grouping + container.each((node) => { + if (node.type === 'atrule' && node.nodes) { + mergeRules(node); + } + }); + + // Group direct-child rules by selector, recording first occurrence + /** @type {Map} */ + const selectorFirst = new Map(); + + container.each((node) => { + if (node.type !== 'rule') { + return; + } + const sel = node.selector; + if (!selectorFirst.has(sel)) { + selectorFirst.set(sel, node); + return; + } + // Append all declarations from duplicate to the first occurrence + const first = selectorFirst.get(sel); + node.each((decl) => { + if (decl.type === 'decl') { + first.append(decl.clone()); + } + }); + node.remove(); + }); + + // Deduplicate within merged rules — last declaration wins per property + for (const rule of selectorFirst.values()) { + /** @type {Map} */ + const lastSeen = new Map(); + /** @type {import('postcss').Declaration[]} */ + const toRemove = []; + rule.each((decl) => { + if (decl.type !== 'decl') { + return; + } + if (lastSeen.has(decl.prop)) { + toRemove.push(lastSeen.get(decl.prop)); + } + lastSeen.set(decl.prop, decl); + }); + for (const dup of toRemove) { + dup.remove(); + } + } +} + +/** + * Moves all remaining nodes into `@layer swc-global-elements { }` and appends + * the all:revert-layer rule after the layer. + * + * @param {import('postcss').Root} root + * @param {string} block + */ +function wrapInLayer(root, block) { + /** @type {import('postcss').ChildNode[]} */ + const children = []; + root.each((node) => children.push(node)); + for (const node of children) { + node.remove(); + } + + const layer = postcss.atRule({ + name: 'layer', + params: LAYER, + raws: { afterName: ' ', between: ' ', after: '\n' }, + }); + for (const node of children) { + layer.append(node); + } + root.append(layer); + + // Escape-hatch rule outside the layer. Allows page styles on .block to + // revert any property to the layer-defined value rather than being + // overridden by unlayered application CSS. + const revert = postcss.rule({ selector: `.${block}` }); + revert.append( + postcss.decl({ prop: 'all', value: 'revert-layer !important' }) + ); + root.append(revert); +} + +/** + * Derives a global-elements stylesheet from one or more component shadow-DOM CSS sources. + * + * Pipeline: + * 1. stripExcludedBlocks — remove fenced blocks (fence comments are consumed here) + * 2. stripComments — remove all remaining comments + * 3. applySelectTransform — :host/::slotted → BEM classes; * → .block, .block * + * 4. mergeRules — combine duplicate selectors, deduplicate declarations + * 5. wrapInLayer — wrap in @layer and add revert-layer escape hatch + * + * @param {string} sourceCss - Concatenated raw CSS (base + component, token() calls intact). + * @param {string} block - BEM block class name, e.g. 'swc-Button'. + * @returns {string} + */ +export function deriveCSS(sourceCss, block) { + const root = postcss.parse(sourceCss); + if (!stripExcludedBlocks(root)) { + throw new Error( + '[vite-global-elements-css] Unclosed @global-exclude fence — missing @global-exclude-end' + ); + } + stripComments(root); + applySelectTransform(root, block); + mergeRules(root); + wrapInLayer(root, block); + return root.toResult({ map: false }).css; +} + +// ── Path helpers ──────────────────────────────────────────────────────────── + +/** + * @param {import('./index.d.ts').GlobalElementEntry} entry + * @returns {string} + */ +function getSourceName(entry) { + return entry.source ?? entry.component; +} + +/** + * @param {import('./index.d.ts').GlobalElementEntry} entry + * @param {string} projectRoot + * @returns {string} + */ +function getSourcePath(entry, projectRoot) { + const name = getSourceName(entry); + return join(projectRoot, 'components', name, `${name}.css`); +} + +/** + * Returns the path to `{name}-base.css` if it exists alongside `{name}.css`, else null. + * + * @param {import('./index.d.ts').GlobalElementEntry} entry + * @param {string} projectRoot + * @returns {string | null} + */ +function getBasePath(entry, projectRoot) { + const name = getSourceName(entry); + const p = join(projectRoot, 'components', name, `${name}-base.css`); + return existsSync(p) ? p : null; +} + +/** + * @param {import('./index.d.ts').GlobalElementEntry} entry + * @param {string} projectRoot + * @returns {string} + */ +function getOutputPath(entry, projectRoot) { + return join( + projectRoot, + 'stylesheets', + 'global', + `global-${entry.component}.css` + ); +} + +// ── Post-processing ────────────────────────────────────────────────────────── + +/** + * Walks up from startDir through node_modules/.bin to find a binary. + * + * @param {string} name + * @param {string} startDir + * @returns {string | null} + */ +function findBin(name, startDir) { + let dir = startDir; + for (let i = 0; i < 6; i++) { + const p = join(dir, 'node_modules', '.bin', name); + if (existsSync(p)) { + return p; + } + const parent = join(dir, '..'); + if (parent === dir) { + break; + } + dir = parent; + } + return null; +} + +/** + * Runs stylelint --fix on the generated file if stylelint is available in the + * workspace. Silently skips if the binary cannot be found or the command fails. + * + * @param {string} filePath - Absolute path to the generated CSS file. + * @param {string} projectRoot - Vite project root (used to find the binary). + */ +function runStylelintFix(filePath, projectRoot) { + const bin = findBin('stylelint', projectRoot); + if (!bin) { + return; + } + try { + spawnSync(bin, ['--fix', filePath], { + stdio: 'pipe', + cwd: projectRoot, + }); + } catch { + // Best-effort — ignore failures + } +} + +// ── Generation ────────────────────────────────────────────────────────────── + +/** + * Reads source(s), derives global CSS, and writes to the output path. + * + * @param {import('./index.d.ts').GlobalElementEntry} entry + * @param {string} projectRoot + */ +function generateEntry(entry, projectRoot) { + const src = getSourcePath(entry, projectRoot); + + if (!existsSync(src)) { + console.warn(`[vite-global-elements-css] Source not found: ${src}`); + return; + } + + const base = getBasePath(entry, projectRoot); + const combined = [ + base ? readFileSync(base, 'utf-8') : '', + readFileSync(src, 'utf-8'), + ] + .filter(Boolean) + .join('\n\n'); + + const block = getBlock(entry); + const derived = deriveCSS(combined, block); + const out = getOutputPath(entry, projectRoot); + const sourcePath = relative(projectRoot, src); + + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, makeHeader(sourcePath) + derived, 'utf-8'); + runStylelintFix(out, projectRoot); +} + +// ── Vite plugin ───────────────────────────────────────────────────────────── + +/** + * Vite plugin that derives `global-{component}.css` from a component's shadow-DOM + * stylesheet, transforming selectors to BEM class equivalents and stripping + * component-only blocks marked with @global-exclude fences. + * + * Runs in `configResolved` (covers both dev and build) so derived files are + * written before `processStylesheets` runs in `closeBundle`. In dev mode, + * `configureServer` watches source files and re-derives on save. + * + * @param {import('./index.d.ts').GlobalElementCSSOptions} options + * @returns {import('vite').Plugin} + */ +export function globalElementCSS(options) { + let projectRoot = ''; + + return { + name: 'vite-global-elements-css', + + configResolved(config) { + projectRoot = config.root; + for (const entry of options.elements) { + generateEntry(entry, projectRoot); + } + }, + + configureServer(server) { + for (const entry of options.elements) { + const src = getSourcePath(entry, projectRoot); + const base = getBasePath(entry, projectRoot); + const toWatch = [src, base].filter(Boolean); + + for (const file of toWatch) { + server.watcher.add(file); + } + + server.watcher.on('change', (changed) => { + if (!toWatch.includes(changed)) { + return; + } + generateEntry(entry, projectRoot); + // Global CSS lives in tags, not the JS module graph — full reload required. + server.ws.send({ type: 'full-reload' }); + }); + } + }, + }; +} diff --git a/2nd-gen/packages/tools/vite-global-elements-css/index.test.js b/2nd-gen/packages/tools/vite-global-elements-css/index.test.js new file mode 100644 index 00000000000..c1c5b7358b7 --- /dev/null +++ b/2nd-gen/packages/tools/vite-global-elements-css/index.test.js @@ -0,0 +1,418 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; + +import { deriveBlock, deriveCSS, transformSelector } from './index.js'; + +// ── deriveBlock ────────────────────────────────────────────────────────────── + +describe('deriveBlock', () => { + it('converts a single-word component to swc-PascalCase', () => { + expect(deriveBlock('button')).toBe('swc-Button'); + }); + + it('converts a two-word component to swc-PascalCase', () => { + expect(deriveBlock('action-button')).toBe('swc-ActionButton'); + }); + + it('converts a three-word component to swc-PascalCase', () => { + expect(deriveBlock('close-circle-button')).toBe('swc-CloseCircleButton'); + }); +}); + +// ── transformSelector — basic ──────────────────────────────────────────────── + +describe('transformSelector — basic', () => { + it(':host → .block', () => { + expect(transformSelector(':host', 'swc-Button')).toBe('.swc-Button'); + }); + + it(':host([attr="value"]) → .block--value', () => { + expect(transformSelector(':host([variant="accent"])', 'swc-Button')).toBe( + '.swc-Button--accent' + ); + }); + + it(':host([boolAttr]) → .block--boolAttr', () => { + expect(transformSelector(':host([truncate])', 'swc-Button')).toBe( + '.swc-Button--truncate' + ); + }); + + it(':host([a="x"][b="y"]) → .block--x.block--y (chained, same element)', () => { + expect( + transformSelector( + ':host([variant="primary"][fill-style="outline"])', + 'swc-Button' + ) + ).toBe('.swc-Button--primary.swc-Button--outline'); + }); + + it(':host .block-el → .block .block-el', () => { + expect(transformSelector(':host .swc-Button-label', 'swc-Button')).toBe( + '.swc-Button .swc-Button-label' + ); + }); + + it(':host([attr="value"]) .block-el → .block--value .block-el', () => { + expect( + transformSelector( + ':host([variant="primary"]) .swc-Button-label', + 'swc-Button' + ) + ).toBe('.swc-Button--primary .swc-Button-label'); + }); + + it(':host([attr]) .block → .block--attr (block collapses into modifier — same element in global context)', () => { + expect( + transformSelector(':host([justified]) .swc-Button', 'swc-Button') + ).toBe('.swc-Button--justified'); + }); + + it(':host([attr="value"]) .block → .block--value (value modifier, same element collapse)', () => { + expect( + transformSelector(':host([variant="accent"]) .swc-Button', 'swc-Button') + ).toBe('.swc-Button--accent'); + }); + + it('slot[name="X"]::slotted(*) → .block-X', () => { + expect( + transformSelector('slot[name="icon"]::slotted(*)', 'swc-Button') + ).toBe('.swc-Button-icon'); + }); + + it('handles comma-separated selectors', () => { + expect( + transformSelector( + ':host([variant="accent"]), :host([variant="negative"])', + 'swc-Button' + ) + ).toBe('.swc-Button--accent,\n.swc-Button--negative'); + }); + + it('passes through non-host, non-slotted selectors unchanged', () => { + expect(transformSelector('.swc-Button-label', 'swc-Button')).toBe( + '.swc-Button-label' + ); + }); +}); + +// ── transformSelector — wildcard ───────────────────────────────────────────── + +describe('transformSelector — wildcard', () => { + it('* → .block, .block * (scoped to block descendants)', () => { + expect(transformSelector('*', 'swc-Button')).toBe( + '.swc-Button, .swc-Button *' + ); + }); +}); + +// ── transformSelector — SWC API attribute prefixing ────────────────────────── + +describe('transformSelector — SWC API attribute prefixing', () => { + it('prefixes size="l" → .block--sizeL', () => { + expect(transformSelector(':host([size="l"])', 'swc-Button')).toBe( + '.swc-Button--sizeL' + ); + }); + + it('prefixes size="s" → .block--sizeS', () => { + expect(transformSelector(':host([size="s"])', 'swc-Button')).toBe( + '.swc-Button--sizeS' + ); + }); + + it('prefixes size="xl" → .block--sizeXl', () => { + expect(transformSelector(':host([size="xl"])', 'swc-Button')).toBe( + '.swc-Button--sizeXl' + ); + }); + + it('prefixes static-color="white" → .block--staticWhite', () => { + expect( + transformSelector(':host([static-color="white"])', 'swc-Button') + ).toBe('.swc-Button--staticWhite'); + }); + + it('prefixes static-color="black" → .block--staticBlack', () => { + expect( + transformSelector(':host([static-color="black"])', 'swc-Button') + ).toBe('.swc-Button--staticBlack'); + }); + + it('does not prefix non-SWC-standard attributes', () => { + expect(transformSelector(':host([variant="accent"])', 'swc-Button')).toBe( + '.swc-Button--accent' + ); + }); + + it('applies prefixing in compound attribute selectors', () => { + expect( + transformSelector( + ':host([static-color="white"][fill-style="outline"])', + 'swc-Button' + ) + ).toBe('.swc-Button--staticWhite.swc-Button--outline'); + }); + + it('does not prefix boolean (valueless) attributes', () => { + expect(transformSelector(':host([truncate])', 'swc-Button')).toBe( + '.swc-Button--truncate' + ); + }); +}); + +// ── deriveCSS — fence removal ──────────────────────────────────────────────── + +describe('deriveCSS — fence removal', () => { + it('strips top-level fenced blocks', () => { + const css = ` + :host { color: red; } + /* @global-exclude: test */ + :host([pending]) { color: blue; } + /* @global-exclude-end */ + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).toContain('.swc-Button'); + expect(result).not.toContain('.swc-Button--pending'); + }); + + it('strips top-level fenced @keyframes', () => { + const css = ` + /* @global-exclude: spinner animations */ + @keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } } + /* @global-exclude-end */ + :host { color: red; } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).not.toContain('@keyframes'); + expect(result).toContain('.swc-Button'); + }); + + it('strips fenced rules inside a media query, keeping the rest', () => { + const css = ` + @media (prefers-reduced-motion: reduce) { + :host { transition-duration: 0ms; } + /* @global-exclude: pending spinner */ + :host([pending]) .swc-Button { animation: none; } + /* @global-exclude-end */ + } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).toContain('@media (prefers-reduced-motion: reduce)'); + expect(result).toContain('transition-duration: 0ms'); + expect(result).not.toContain('animation: none'); + }); + + it('removes a media query left empty after stripping all fenced content', () => { + const css = ` + @media (prefers-reduced-motion: reduce) { + /* @global-exclude: all content is pending-only */ + :host([pending]) .swc-Button { animation: none; } + /* @global-exclude-end */ + } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).not.toContain('@media'); + }); + + it('merges :host([attr]) and :host([attr]) .block into one rule when both collapse to the same modifier', () => { + const css = ` + :host([justified]) { + flex-grow: 1; + inline-size: 100%; + } + :host([justified]) .swc-Button { + inline-size: 100%; + } + `; + const result = deriveCSS(css, 'swc-Button'); + const occurrences = (result.match(/\.swc-Button--justified/g) ?? []).length; + expect(occurrences).toBe(1); + expect(result).toContain('flex-grow: 1'); + expect(result).toContain('inline-size: 100%'); + }); + + it('throws on an unclosed @global-exclude fence', () => { + const css = ` + /* @global-exclude */ + :host([pending]) { color: blue; } + :host { color: red; } + `; + expect(() => deriveCSS(css, 'swc-Button')).toThrow( + 'Unclosed @global-exclude fence' + ); + }); + + it('accepts @global-exclude without a reason string', () => { + const css = ` + /* @global-exclude */ + :host([foo]) { color: blue; } + /* @global-exclude-end */ + :host { color: red; } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).not.toContain('color: blue'); + expect(result).toContain('color: red'); + }); +}); + +// ── deriveCSS — comment stripping ─────────────────────────────────────────── + +describe('deriveCSS — comment stripping', () => { + it('strips section divider comments from source', () => { + const css = ` + /* ── Sizes ──── */ + :host([size="s"]) { font-size: 12px; } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).not.toContain('Sizes'); + expect(result).toContain('font-size: 12px'); + }); + + it('strips inline property comments', () => { + const css = `:host { max-inline-size: inherit; /* inherit from host */ }`; + const result = deriveCSS(css, 'swc-Button'); + expect(result).not.toContain('inherit from host'); + expect(result).toContain('max-inline-size: inherit'); + }); + + it('strips copyright block comments from source', () => { + const css = ` + /** + * Copyright 2026 Adobe. All rights reserved. + * Licensed under Apache 2.0. + */ + :host { color: red; } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).not.toContain('Copyright'); + expect(result).toContain('color: red'); + }); +}); + +// ── deriveCSS — rule merging ───────────────────────────────────────────────── + +describe('deriveCSS — rule merging', () => { + it('merges duplicate top-level rules with the same selector', () => { + const css = ` + .swc-Button { display: inline-flex; cursor: pointer; } + .swc-Button { gap: 8px; min-block-size: 32px; } + `; + const result = deriveCSS(css, 'swc-Button'); + // 1 merged rule inside the layer + 1 revert-layer escape hatch outside = 2 + const count = (result.match(/\.swc-Button\s*\{/g) ?? []).length; + expect(count).toBe(2); + expect(result).toContain('display: inline-flex'); + expect(result).toContain('gap: 8px'); + }); + + it('last declaration wins when properties are duplicated across merged rules', () => { + const css = ` + .swc-Button { display: inline-block; } + .swc-Button { display: inline-flex; } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).toContain('display: inline-flex'); + expect(result).not.toContain('display: inline-block'); + }); + + it('merges rules derived from :host selectors', () => { + const css = ` + :host { display: inline-block; } + :host { vertical-align: top; } + `; + const result = deriveCSS(css, 'swc-Button'); + // 1 merged rule inside the layer + 1 revert-layer escape hatch outside = 2 + const count = (result.match(/\.swc-Button\s*\{/g) ?? []).length; + expect(count).toBe(2); + expect(result).toContain('display: inline-block'); + expect(result).toContain('vertical-align: top'); + }); + + it('merges rules inside media queries independently from top-level rules', () => { + const css = ` + .swc-Button { transition-duration: 200ms; } + @media (prefers-reduced-motion: reduce) { + .swc-Button { transition-duration: 0ms; } + .swc-Button { animation: none; } + } + `; + const result = deriveCSS(css, 'swc-Button'); + expect(result).toContain('transition-duration: 200ms'); + expect(result).toContain('transition-duration: 0ms'); + expect(result).toContain('animation: none'); + }); +}); + +// ── deriveCSS — layer wrapping ─────────────────────────────────────────────── + +describe('deriveCSS — layer wrapping', () => { + it('wraps output in @layer swc-global-elements', () => { + const result = deriveCSS(':host { color: red; }', 'swc-Button'); + expect(result).toContain('@layer swc-global-elements'); + }); + + it('appends the revert-layer escape-hatch rule outside the layer', () => { + const result = deriveCSS(':host { color: red; }', 'swc-Button'); + expect(result).toContain('all: revert-layer !important'); + }); +}); + +// ── deriveCSS — selector transformation ───────────────────────────────────── + +describe('deriveCSS — selector transformation', () => { + it('transforms :host rules to BEM class selectors', () => { + const result = deriveCSS( + ':host([size="s"]) { font-size: 12px; }', + 'swc-Button' + ); + expect(result).toContain('.swc-Button--sizeS'); + expect(result).not.toContain(':host'); + }); + + it('applies SWC API prefixing automatically in full pipeline', () => { + const result = deriveCSS( + ':host([size="l"]) { font-size: 18px; }', + 'swc-Button' + ); + expect(result).toContain('.swc-Button--sizeL'); + expect(result).not.toContain('.swc-Button--l'); + }); + + it('scopes wildcard rules to .block and .block * in full pipeline', () => { + const result = deriveCSS('* { box-sizing: border-box; }', 'swc-Button'); + expect(result).toContain('.swc-Button, .swc-Button *'); + // A bare unscoped wildcard would appear as "* {" at the start of a line. + // The transformed form ".swc-Button, .swc-Button * {" is on a single line, so + // no line begins with "* {" in the output. + expect(result).not.toMatch(/(?:^|\n)\s*\*\s*\{/); + }); + + it('transforms ::slotted selectors to BEM element selectors', () => { + const result = deriveCSS( + 'slot[name="icon"]::slotted(*) { color: inherit; }', + 'swc-Button' + ); + expect(result).toContain('.swc-Button-icon'); + expect(result).not.toContain('::slotted'); + }); + + it('preserves token() calls for downstream PostCSS processing', () => { + const result = deriveCSS( + ':host { color: token("gray-800"); }', + 'swc-Button' + ); + expect(result).toContain('token("gray-800")'); + }); +}); diff --git a/2nd-gen/packages/tools/vite-global-elements-css/package.json b/2nd-gen/packages/tools/vite-global-elements-css/package.json new file mode 100644 index 00000000000..2e6df9c7d80 --- /dev/null +++ b/2nd-gen/packages/tools/vite-global-elements-css/package.json @@ -0,0 +1,45 @@ +{ + "name": "@adobe/vite-global-elements-css", + "version": "0.0.1", + "private": true, + "description": "Derives global-elements CSS from component shadow-DOM stylesheets", + "license": "Apache-2.0", + "author": "Adobe", + "contributors": [ + "Stephanie Eckles <5t3ph@users.noreply.github.com>" + ], + "homepage": "https://opensource.adobe.com/spectrum-web-components/", + "repository": { + "directory": "2nd-gen/packages/tools/vite-global-elements-css", + "type": "git", + "url": "https://github.com/adobe/spectrum-web-components.git" + }, + "bugs": { + "url": "https://github.com/adobe/spectrum-web-components/issues" + }, + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "test": "vitest --run", + "test:watch": "vitest --watch" + }, + "dependencies": { + "postcss": "8.5.9" + }, + "peerDependencies": { + "postcss": "8.5.9", + "vite": "8.0.8" + }, + "devDependencies": { + "vite": "8.0.8", + "vitest": "4.0.15" + }, + "keywords": [ + "design-system", + "spectrum", + "adobe", + "adobe-spectrum", + "2nd-gen" + ] +} diff --git a/2nd-gen/packages/tools/vite-global-elements-css/vitest.config.ts b/2nd-gen/packages/tools/vite-global-elements-css/vitest.config.ts new file mode 100644 index 00000000000..ea6c16db042 --- /dev/null +++ b/2nd-gen/packages/tools/vite-global-elements-css/vitest.config.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md index 56a78a46bd1..4025f0c3f0c 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md @@ -343,7 +343,7 @@ If you are renaming or removing a public prop or attribute, confirm with the tea ### What to do 1. **Verify or create the stories file** — Visual verification requires a stories file. If `stories/[component].stories.ts` does not exist, create it before writing CSS using the `migration-styling` skill’s Phase 4 stories template. The stories file at this phase should have Playground, Overview, Anatomy, Options, States, and CSS-visible Behaviors — no JSDoc prose, and the Accessibility story left as a `// TODO` comment. Confirm the component renders in Storybook with no console errors before touching CSS. -2. **Align render template class names with CSS selectors** — Read the component’s `render()` method and note every class name emitted. The CSS you write must use those exact names; mismatches cause styles to silently not apply. When migrating from 1st-gen single-hyphen naming (e.g. `.swc-Button-label`) to 2nd-gen BEM double-underscore notation (e.g. `.swc-Button__label`), update `render()` first, confirm the component still renders, then write the CSS. +2. **Align render template class names with CSS selectors** — Read the component’s `render()` method and note every class name emitted. The CSS you write must use those exact names; mismatches cause styles to silently not apply. When migrating from 1st-gen single-hyphen naming (e.g. `.swc-Button-label`) to 2nd-gen BEM double-underscore notation (e.g. `.swc-Button-label`), update `render()` first, confirm the component still renders, then write the CSS. 3. **Follow the migration steps** — [Step 6](06_migrate-rendering-and-styles.md) and the [full migration steps](../../../../02_style-guide/01_css/04_spectrum-swc-migration.md). Use [03_components/](../../../03_components/) for spectrum-two alignment. Copy S2 styles from your **spectrum-css** clone, **`spectrum-two`** branch, component `index.css` (not `dist`). 4. **Use tokens** — Replace hard-coded values with `token(...)`. Follow [component CSS](../../../../02_style-guide/01_css/01_component-css.md) and [custom properties](../../../../02_style-guide/01_css/02_custom-properties.md). 5. **Run stylelint** — After updating CSS, run `nx run swc:lint`. Fix all errors. The 2nd-gen config enforces: **property order** (see `linters/stylelint-property-order.js`); **no descending specificity** (e.g. `:host([disabled])` before `:host([checked][disabled])`); **declaration empty line** (empty line between groups); **token usage** (`token("...")` for color, font-size, etc.). @@ -367,7 +367,7 @@ For troubleshooting and detailed patterns (e.g. 1st-gen Constructable Stylesheet | Problem | Solution | |--------|----------| | Styles not applied; `adoptedStylesheets` empty | Never override `createRenderRoot()` to set shadow root options; use `static override shadowRootOptions = { ...ParentClass.shadowRootOptions, delegatesFocus: true }` instead. Overriding `createRenderRoot()` without calling `super` bypasses Lit’s `adoptStyles()`, silently dropping all component CSS. | -| CSS selector targets wrong element | Class name in CSS (e.g. `.swc-Button__label`) does not match what `render()` emits. Grep the render template for the old name and update it before blaming the CSS. | +| CSS selector targets wrong element | Class name in CSS (e.g. `.swc-Button-label`) does not match what `render()` emits. Grep the render template for the old name and update it before blaming the CSS. | | `order/properties-order` errors | Reorder declarations to match `linters/stylelint-property-order.js` (e.g. display → position → flex → box sizing → margin → font → overflow → pointer-events → content → opacity → transition). | | `no-descending-specificity` errors | Place lower-specificity selectors before higher-specificity ones (e.g. `:host([disabled])` before `:host([checked][disabled])`; single-attribute or single-pseudo before compound selectors). Split rule blocks if needed so order is consistent. | | Token / `declaration-strict-value` | Replace hard-coded colors, font-size, etc. with `token("...")`. | diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md index 7037892112b..bf198050a4d 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/migration-plan.md @@ -481,7 +481,7 @@ Allowed differences: #### Visual model and regressions - [x] Migrate icon-only, truncate (previously noWrap), size, static-color, and outline fill-style selectors — icon-only uses derived `swc-Button--iconOnly` class (not a consumer attribute); see iconOnly API deviation above. A companion `swc-Button--hasIcon` class is also applied whenever an icon is slotted (with or without a label), driving `text-align: start` on the label so wrapped text aligns to the icon rather than centering -- [x] Implement truncation behavior explicitly, not just `white-space: nowrap`; confirm overflow ellipsis treatment in S2 CSS — `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` on `.swc-Button__label` when `[truncate]` +- [x] Implement truncation behavior explicitly, not just `white-space: nowrap`; confirm overflow ellipsis treatment in S2 CSS — `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` on `.swc-Button-label` when `[truncate]` - [x] Ensure the button label inherits host visibility, or otherwise does not override it, so `visibility: hidden` on the host hides the label too (`SWC-701`) — current implementation is not setting `visibility` on the label - [x] Restrict accent and negative styling to fill-only combinations — CSS has no outline rules for accent/negative; `update()` emits `__swc.warn()` for invalid combinations - [x] Restrict static white/black styling to the primary and secondary families shown in Design/Figma — same pattern: CSS + debug warning diff --git a/eslint.config.js b/eslint.config.js index 70cf2f47a20..a366137d55f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -149,6 +149,7 @@ export default defineConfig([ '**/custom-elements.json', '**/tokens.css', '**/tokens.json', + '2nd-gen/packages/swc/stylesheets/global/**', // Config and tooling files (Node env; skip lint to avoid needing node globals for many files) '**/*.config.js', '**/*.config.cjs', diff --git a/yarn.lock b/yarn.lock index bdc3ebda804..7c72748d9ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -250,6 +250,19 @@ __metadata: languageName: unknown linkType: soft +"@adobe/vite-global-elements-css@workspace:2nd-gen/packages/tools/vite-global-elements-css": + version: 0.0.0-use.local + resolution: "@adobe/vite-global-elements-css@workspace:2nd-gen/packages/tools/vite-global-elements-css" + dependencies: + postcss: "npm:8.5.9" + vite: "npm:8.0.8" + vitest: "npm:4.0.15" + peerDependencies: + postcss: 8.5.9 + vite: 8.0.8 + languageName: unknown + linkType: soft + "@apideck/better-ajv-errors@npm:^0.3.1": version: 0.3.6 resolution: "@apideck/better-ajv-errors@npm:0.3.6" From 83cd43c052b03fb309cc4d51bb6b2174de5cec79 Mon Sep 17 00:00:00 2001 From: Stephanie Eckles Date: Fri, 8 May 2026 11:41:22 -0500 Subject: [PATCH 5/9] feat(button): Storybook documentation, consumer migration guide (#6246) --- .../core/components/button/Button.base.ts | 2 +- 2nd-gen/packages/swc/.storybook/preview.ts | 12 + .../packages/swc/components/button/button.css | 5 +- .../button/consumer-migration-guide.mdx | 213 ++++++++++++++++++ .../button/stories/button.stories.ts | 199 ++++++++++++++-- .../button/test/button.a11y.spec.ts | 2 +- .../swc/components/button/test/button.test.ts | 36 +++ .../01_status.md | 2 +- .../03_components/button/migration-plan.md | 62 ++--- 9 files changed, 478 insertions(+), 55 deletions(-) create mode 100644 2nd-gen/packages/swc/components/button/consumer-migration-guide.mdx diff --git a/2nd-gen/packages/core/components/button/Button.base.ts b/2nd-gen/packages/core/components/button/Button.base.ts index e7a340195cb..323eed18a6a 100644 --- a/2nd-gen/packages/core/components/button/Button.base.ts +++ b/2nd-gen/packages/core/components/button/Button.base.ts @@ -153,13 +153,13 @@ export abstract class ButtonBase extends SizedMixin( } public override disconnectedCallback(): void { + this.removeEventListener('click', this.handleClick); if (this._pendingTimer !== null) { clearTimeout(this._pendingTimer); this._pendingTimer = null; } this.pendingActive = false; super.disconnectedCallback(); - this.removeEventListener('click', this.handleClick); } /** diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 07b0aec5e80..2783da64c3f 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -377,6 +377,7 @@ const preview = { 'Illustrated message', [ 'Accessibility migration analysis', + 'Migration plan', 'Rendering and styling migration analysis', ], 'Infield button', @@ -388,6 +389,17 @@ const preview = { 'Accessibility migration analysis', 'Rendering and styling migration analysis', ], + 'Menu', + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], + 'Menu group', + ['Accessibility migration analysis'], + 'Menu item', + ['Accessibility migration analysis'], + 'Menu separator', + ['Accessibility migration analysis'], 'Meter', [ 'Accessibility migration analysis', diff --git a/2nd-gen/packages/swc/components/button/button.css b/2nd-gen/packages/swc/components/button/button.css index f8fffe09c90..2d12511d1ab 100644 --- a/2nd-gen/packages/swc/components/button/button.css +++ b/2nd-gen/packages/swc/components/button/button.css @@ -337,7 +337,7 @@ slot[name="icon"]::slotted(*) { /* ── Pending ──────────────────────────────────────── */ -/* @global-exclude: pending state requires JS runtime (ButtonBase._pendingActive) */ +/* @global-exclude: pending state requires JS runtime (ButtonBase.pendingActive) */ /* Cursor changes immediately on [pending]. Disabled colors, label/icon fade, and spinner appearance are deferred to .swc-Button--pendingActive, which is @@ -462,6 +462,7 @@ slot[name="icon"]::slotted(*) { /* @global-exclude-end */ } +/* @global-exclude: pending state requires JS runtime (ButtonBase.pendingActive) */ @media (forced-colors: active) { /* Pending-active button maps all interactive color states to system disabled colors so it visually matches native disabled controls in high-contrast @@ -498,3 +499,5 @@ slot[name="icon"]::slotted(*) { --_swc-pending-spinner-fill-border-color: Highlight; } } + +/* @global-exclude-end */ diff --git a/2nd-gen/packages/swc/components/button/consumer-migration-guide.mdx b/2nd-gen/packages/swc/components/button/consumer-migration-guide.mdx new file mode 100644 index 00000000000..baf10579931 --- /dev/null +++ b/2nd-gen/packages/swc/components/button/consumer-migration-guide.mdx @@ -0,0 +1,213 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Button consumer migration guide + +Replace `` with ``, update the import, and migrate renamed attributes. + +## What changed + +### Renamed + +| Area | Spectrum 1 (`sp-button`) | Spectrum 2 (`swc-button`) | +| --------------------------- | ---------------------------------------------- | -------------------------------------------- | +| Tag | `sp-button` | `swc-button` | +| Import path | `@spectrum-web-components/button/sp-button.js` | `@adobe/spectrum-wc/button` | +| Visual treatment | `treatment="fill"` / `treatment="outline"` | `fill-style="fill"` / `fill-style="outline"` | +| Accessible name (icon-only) | `label="..."` | `accessible-label="..."` | +| Text truncation | `no-wrap` | `truncate` | +| CSS custom props | `--mod-button-*` / `--spectrum-button-*` | `--swc-button-*` (see [Styling](#styling)) | + +### Added in Spectrum 2 + +| Addition | Notes | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `justified` boolean attribute | Stretches the button to fill its container's inline size. The container must allow the element to grow. | +| `pending-label` derivation | When `pending` is set and no `pending-label` is provided, the accessible name is automatically derived as `"[label], busy"` (e.g. `"Save, busy"`). | + +### Removed in Spectrum 2 + +| Removed | Replacement | +| ---------------------------------------------------------- | ------------------------------------------------------------------------ | +| `href`, `target`, `download`, `referrerpolicy`, `rel` | Use a native `` element with global button styles (see step 5 below). | +| `quiet` boolean attribute | Use `fill-style="outline"` instead. | +| Variant aliases: `cta`, `overBackground`, `white`, `black` | Use `variant` + `static-color` (see step 3 below). | +| `label` attribute | Use `accessible-label` instead. | +| `--mod-button-*` custom properties | Use `--swc-button-*` equivalents (see [Styling](#styling)). | + +## Update your code + +### 1. Update the import + +```js +// Before +import '@spectrum-web-components/button/sp-button.js'; +// After +import '@adobe/spectrum-wc/button'; +``` + +### 2. Rename the tag and `treatment` attribute + +```html + +Save +Cancel +Cancel + +Save +Cancel +Cancel +``` + +`fill-style="fill"` is the default and may be omitted. + +### 3. Replace deprecated variant aliases + +The deprecated aliases `cta`, `overBackground`, `white`, and `black` are removed. Migrate to canonical `variant` + `static-color` combinations: + +```html + +Save +Action +Action +Action + +Save +Action +Action +Action +``` + +`static-color` is only supported with `primary` and `secondary` variants. + +### 4. Rename `label` to `accessible-label` + +```html + + + ... + + + + ... + +``` + +### 5. Migrate link-mode usage to native anchors + +`` is removed. Use a native `` element with [global button styles](/docs/guides-customization-global-element-styling--readme) instead: + +```html + +Go to dashboard + +Go to dashboard +``` + +Import the global button stylesheet: + +```js +import '@adobe/spectrum-wc/global-button.css'; +``` + +### 6. Rename `no-wrap` to `truncate` + +```html + +Long label that should not wrap + +Long label that should not wrap +``` + +## Accessibility + +- **Icon-only buttons** must have `accessible-label` on the host. See [step 4](#4-rename-label-to-accessible-label). +- **Pending state**: when `pending` is set, the button announces as `"[label], busy"` by default. Supply `pending-label` to override with a more specific description of the in-progress operation. +- **Disabled vs. pending**: use `pending` to keep the button focusable while an operation is in progress. Use `disabled` to remove it from the tab order entirely. Do not set both at the same time. +- **Navigation**: do not use `` for navigation. Buttons activate on both Enter and Space; links activate on Enter only. Use a native `` with global button styles instead (see [step 5](#5-migrate-link-mode-usage-to-native-anchors)). +- **Form submission**: `` behaves as `type="button"` only. For `submit` and `reset`, use a native `