diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a0ef3..894678c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ All notable changes to this package are documented in this file. ## Unreleased +## 1.5.0 - 2026-07-20 + +### Added + +- Added public transition tuple tokens for interaction properties, duration, easing, and delay while retaining the established motion and easing fallback chains. +- Added rendered precedence coverage for combined hover, persistent, transient active, busy, loading, disabled, and focus-visible states. + +### Changed + +- Made disabled, busy/loading, transient active, persistent, hover, and base feedback follow one deterministic precedence order. +- Kept `:focus-visible` as an orthogonal outline so keyboard focus remains visible without replacing the active interaction state. +- Made the public hover lift token resolve directly, without hidden host-specific compensation. + +### Fixed + +- Removed the UI-host `+1px` hover adjustment that changed authored lift values when packages were combined. +- Removed the UI-specific transition override and cross-package `!important` repair from the standalone preset. +- Fixed persistent state feedback being replaced by hover and busy/loading feedback being replaced during a transient press. + +### Accessibility + +- Preserved a visible focus ring across base, pressed, selected, current, busy, and loading states. +- Kept disabled feedback authoritative while retaining reduced-motion state meaning and forced-colors system affordances. + +### Documentation + +- Documented the exact state precedence, transition tuple, legacy fallbacks, and optional UI Style Kit integration boundary across the README and wiki. +- Updated release-facing package, CDN, testing, publishing, and roadmap guidance for `1.5.0`. + +### Testing + +- Added static contracts for transition tokens, legacy entry points, and the absence of UI-specific offset and importance rules. +- Added exact computed-style assertions for transition tuples, transform composition, state precedence, focus-visible overlays, and native file selector behavior. +- Stabilized Playwright worker counts and exercised the package contract in Chromium, Firefox, and WebKit. + ## 1.4.0 - 2026-07-19 ### Added diff --git a/README.md b/README.md index 3798e98..0cb79a7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Framework-agnostic CSS for reliable hover, focus, press, selected, current, loading, disabled, and motion affordances on interactive controls. Use it alone or as the interaction layer beside your existing layout and theme system. -Version 1.4.0 is a release candidate in this repository until its npm release is published. Existing 1.x imports, selectors, data hooks, ARIA hooks, and tokens remain supported. +Version 1.5.0 is a release candidate in this repository until its npm release is published. Existing 1.x imports, selectors, data hooks, ARIA hooks, and tokens remain supported. The package targets Node.js 20+ for npm installs and local validation. CI proves the minimum Node 20 lane and the preferred Node 22 lane before release. @@ -56,11 +56,11 @@ For a no-build page, pin the release: ```html ``` -The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.4.0/standalone-preset.css`. To follow future releases deliberately, use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in. +The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.5.0/standalone-preset.css`. To follow future releases deliberately, use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in. ## Semantic recipes @@ -127,7 +127,7 @@ Native `disabled` is preferred because the browser suppresses focus and activati | `import "interactive-surface-css/interactive-surface.css";` | Complete standalone compatibility bundle | Existing direct-CSS 1.x consumers | | `import "interactive-surface-css";` | JavaScript entry that imports the complete compatibility bundle | Existing bundlers configured for CSS imports | -`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.4.0. The compatibility paths remain stable; no 1.x migration is required. +`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.5.0. The compatibility paths remain stable; no 1.x migration is required. The package `main` and `module` fields preserve the CommonJS and ESM entries; both load `interactive-surface.css`. The `style`, `unpkg`, and `jsdelivr` fields also resolve to that complete compatibility bundle. @@ -142,11 +142,11 @@ The package `main` and `module` fields preserve the CommonJS and ESM entries; bo | Loading | `aria-busy="true"`, `.is-loading` | | Disabled | native `:disabled`, `aria-disabled="true"`, `.is-disabled` | | Variant | `.variant-primary`, `.variant-secondary`, `.variant-accent`, `.variant-subtle`, `.variant-warning`, `.variant-danger`, or matching `data-surface-variant` values | -| Level | `data-surface-level="1"`, `"2"`, or `"3"` | -| Icon | `.icon-only`; child `data-icon-role="light"`, `"dark"`, `"accessibility"`, or the legacy role classes | +| Level | `data-surface-level="1"`, `"2"`, or `"3"` | +| Icon | `.icon-only`; child `data-icon-role="light"`, `"dark"`, `"accessibility"`, or the legacy role classes | | Native subcontrol | `input[type="file"].interactive-surface::file-selector-button` inherits surface paint and hover/active feedback | -Disabled state has highest visual precedence. The state layer preserves static meaning under reduced motion and uses system-color affordances in forced-colors mode. Interaction lift uses the individual `translate` property, so consumer-owned `transform`, `scale`, and `rotate` declarations can coexist. +Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to that precedence, so keyboard focus remains visible without replacing the active interaction state. The state layer preserves static meaning under reduced motion and uses system-color affordances in forced-colors mode. Interaction lift uses the individual `translate` property, so consumer-owned `transform`, `scale`, and `rotate` declarations can coexist. For selector details and responsibilities, see the [complete API reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference). @@ -159,15 +159,19 @@ For selector details and responsibilities, see the [complete API reference](http --interactive-surface-focus-ring-color: rgb(0 95 115); --interactive-surface-state-layer-color: rgb(0 45 55); --interactive-surface-state-layer-hover-opacity: 0.1; - --interactive-surface-motion-default: 120ms; + --interactive-surface-transition-property: + translate, box-shadow, outline-color; + --interactive-surface-transition-duration: 120ms; + --interactive-surface-transition-easing: cubic-bezier(0.2, 0, 0.2, 1); + --interactive-surface-transition-delay: 0ms; } ``` -All public custom properties use the `--interactive-surface-*` namespace. Established legacy and semantic fallback tokens remain supported. See the [token ownership tables and full reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). +The transition defaults are `translate, box-shadow, outline-color`, the established default motion duration, the standard easing curve, and zero delay. Existing `--interactive-surface-motion-*`, `--interactive-surface-ease-*`, `--motion-*`, and `--ease-*` fallbacks remain supported. All public custom properties use the `--interactive-surface-*` namespace. See the [token ownership tables and full reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). ## Accessibility responsibilities -The CSS package provides visible keyboard focus, persistent-state treatment, disabled precedence, reduced-motion behavior, higher-contrast behavior, forced-colors affordances, hover gating for capable pointers, and standalone icon target sizing. +The CSS package provides visible keyboard focus, persistent-state treatment, disabled precedence, reduced-motion behavior, higher-contrast behavior, forced-colors affordances, hover gating for capable pointers, and standalone icon target sizing. The `:focus-visible` ring remains orthogonal across every focusable non-disabled interaction state. Applications still own semantics and behavior: @@ -188,7 +192,7 @@ import "ui-style-kit-css/with-bridge.css"; import "interactive-surface-css/state-core.css"; ``` -The bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. +The bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. ## Use all three libraries diff --git a/index.html b/index.html index 0ff1898..ca556a4 100644 --- a/index.html +++ b/index.html @@ -22,11 +22,35 @@ /> - - - - - + + + + + @@ -538,6 +562,7 @@

Edit token

+ + diff --git a/interactive-surface.css b/interactive-surface.css index add355d..745f52e 100644 --- a/interactive-surface.css +++ b/interactive-surface.css @@ -54,23 +54,6 @@ background-clip: padding-box; } -/* - * UI Style Kit 2.0.1 contributes one pixel of transform-based hover lift and owns paint - * transitions. The preset offsets the resolved lift and protects the complete transition tuple. - */ -:where([data-ui][data-theme][data-mode]) .interactive-surface { - --_is-preset-hover-lift-offset: 1px; - - /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Each longhand must survive companion shorthands in either import order. */ - transition-property: - background-color, border-color, box-shadow, color, transform, translate, - outline-color !important; - transition-duration: var(--_is-transition-duration) !important; - transition-timing-function: var(--_is-transition-timing-function) !important; - transition-delay: var(--_is-transition-delay) !important; - /* stylelint-enable declaration-block-no-redundant-longhand-properties */ -} - /* Surface levels consume bridge tokens while preserving standalone fallbacks. */ .interactive-surface[data-surface-level] { --_is-bg: var(--interactive-surface-level-bg, var(--_is-base-bg)); @@ -483,13 +466,13 @@ border-color: GrayText; } - :is(.interactive-surface[type="file"])::file-selector-button { + :is(input[type="file"].interactive-surface)::file-selector-button { border-color: ButtonText; background-color: ButtonFace; color: ButtonText; } - :is(.interactive-surface[type="file"]):is( + :is(input[type="file"].interactive-surface):is( .is-disabled, :disabled, [aria-disabled="true"] @@ -514,9 +497,6 @@ --interactive-surface-lift-hover, var(--_is-preset-lift-hover, 0px) ); - --_is-hover-lift: calc( - var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) - ); --_is-lift-active: var( --interactive-surface-lift-active, var(--_is-preset-lift-active, 0px) @@ -588,10 +568,24 @@ var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) ); - /* A private tuple lets complete presets preserve timing across competing shorthands. */ - --_is-transition-duration: var(--_is-motion-default); - --_is-transition-timing-function: var(--_is-ease-standard); - --_is-transition-delay: 0s; + --_is-transition-property: var( + --interactive-surface-transition-property, + translate, + box-shadow, + outline-color + ); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--interactive-surface-motion-default, var(--motion-default, 140ms)) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ) + ); + --_is-transition-delay: var(--interactive-surface-transition-delay, 0s); --_is-focus-ring-color: var( --interactive-surface-focus-ring-color, var(--focus-ring, rgb(11 99 246)) @@ -611,13 +605,12 @@ -webkit-tap-highlight-color: var(--_is-tap-highlight); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); - transition: - translate var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - outline-color var(--_is-transition-duration) linear - var(--_is-transition-delay); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public tuple tokens must remain independently replaceable lists. */ + transition-property: var(--_is-transition-property); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ will-change: auto; } @@ -631,7 +624,12 @@ opacity: var(--_is-state-layer-base-opacity); pointer-events: none; z-index: -1; - transition: opacity var(--_is-motion-default) var(--_is-ease-standard); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: opacity; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } .interactive-surface:focus { @@ -648,63 +646,29 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):focus-visible { - translate: 0 var(--_is-lift-hover); - box-shadow: var(--_is-shadow-hover); outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); outline-offset: var(--_is-focus-ring-offset); } -.interactive-surface:not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::before { - opacity: var(--_is-state-layer-focus-opacity); -} - /* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ :where(input[type="file"].interactive-surface)::file-selector-button { cursor: pointer; - transition: - background-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - border-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - color var(--_is-transition-duration) var(--_is-transition-timing-function) - var(--_is-transition-delay); -} - -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::file-selector-button { - box-shadow: inset 0 0 0 999px - rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); -} - -/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { - translate: 0 var(--_is-lift-active); - box-shadow: var(--_is-shadow-active); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: background-color, border-color, box-shadow, color; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { - opacity: var(--_is-state-layer-active-opacity); +/* Matching specificity lets source order apply the public precedence to the native subcontrol. */ +input[type="file"].interactive-surface.interactive-surface:where( + :focus-visible + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); } @media (hover: hover) and (pointer: fine) { @@ -715,7 +679,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):hover { - translate: 0 var(--_is-hover-lift); + translate: 0 var(--_is-lift-hover); box-shadow: var(--_is-shadow-hover); } @@ -725,19 +689,62 @@ opacity: var(--_is-state-layer-hover-opacity); } - :where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):hover::file-selector-button { + input[type="file"].interactive-surface.interactive-surface:where( + :hover + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); } } +/* Repeating the public class normalizes specificity so source order can encode state precedence. */ +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); +} + .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active { - --_is-transition-duration: var(--_is-motion-press); - --_is-transition-timing-function: var(--_is-ease-press); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-press) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-press) + ); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); @@ -747,17 +754,51 @@ :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active::before { opacity: 0; - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); } -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):active::file-selector-button { +input[type="file"].interactive-surface.interactive-surface:where(:active):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-default) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-standard) + ); + + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-active-opacity)); - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); } /* @@ -786,10 +827,8 @@ transition: none; } -:where(input[type="file"].interactive-surface):is( - .is-disabled, - :disabled, - [aria-disabled="true"] +input[type="file"].interactive-surface.interactive-surface:where( + :is(.is-disabled, :disabled, [aria-disabled="true"]) )::file-selector-button { cursor: not-allowed; box-shadow: none; @@ -813,7 +852,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active, - .interactive-surface:where( + .interactive-surface.interactive-surface:where( .is-active, [aria-pressed="true"], [aria-pressed="mixed"], @@ -833,6 +872,11 @@ animation: none; transition: none; } + + :where(input[type="file"].interactive-surface)::file-selector-button { + animation: none; + transition: none; + } } @media (prefers-contrast: more) { @@ -867,8 +911,6 @@ --_is-lift-base: 0px; --_is-lift-hover: 0px; - /* Integration offsets must not bypass the forced-colors motion reset. */ - --_is-hover-lift: 0px; --_is-lift-active: 0px; --_is-shadow-base: none; --_is-shadow-hover: none; diff --git a/package-lock.json b/package-lock.json index 9148cd4..c8641d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "interactive-surface-css", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "interactive-surface-css", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "devDependencies": { "@playwright/test": "^1.57.0", diff --git a/package.json b/package.json index f297bd1..eb3e46d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interactive-surface-css", - "version": "1.4.0", + "version": "1.5.0", "description": "A framework-agnostic, accessible CSS interaction primitive for buttons, cards, and icon controls with token-driven theming.", "keywords": [ "css", diff --git a/playwright.config.mjs b/playwright.config.mjs index ec726ca..5d4a727 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -4,6 +4,7 @@ export default defineConfig({ testDir: "./tests", timeout: 30_000, retries: 0, + workers: 2, reporter: [["list"]], use: { trace: "on-first-retry", diff --git a/standalone-preset.css b/standalone-preset.css index 11827b8..b5846a3 100644 --- a/standalone-preset.css +++ b/standalone-preset.css @@ -54,23 +54,6 @@ background-clip: padding-box; } -/* - * UI Style Kit 2.0.1 contributes one pixel of transform-based hover lift and owns paint - * transitions. The preset offsets the resolved lift and protects the complete transition tuple. - */ -:where([data-ui][data-theme][data-mode]) .interactive-surface { - --_is-preset-hover-lift-offset: 1px; - - /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Each longhand must survive companion shorthands in either import order. */ - transition-property: - background-color, border-color, box-shadow, color, transform, translate, - outline-color !important; - transition-duration: var(--_is-transition-duration) !important; - transition-timing-function: var(--_is-transition-timing-function) !important; - transition-delay: var(--_is-transition-delay) !important; - /* stylelint-enable declaration-block-no-redundant-longhand-properties */ -} - /* Surface levels consume bridge tokens while preserving standalone fallbacks. */ .interactive-surface[data-surface-level] { --_is-bg: var(--interactive-surface-level-bg, var(--_is-base-bg)); @@ -483,13 +466,13 @@ border-color: GrayText; } - :is(.interactive-surface[type="file"])::file-selector-button { + :is(input[type="file"].interactive-surface)::file-selector-button { border-color: ButtonText; background-color: ButtonFace; color: ButtonText; } - :is(.interactive-surface[type="file"]):is( + :is(input[type="file"].interactive-surface):is( .is-disabled, :disabled, [aria-disabled="true"] @@ -514,9 +497,6 @@ --interactive-surface-lift-hover, var(--_is-preset-lift-hover, 0px) ); - --_is-hover-lift: calc( - var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) - ); --_is-lift-active: var( --interactive-surface-lift-active, var(--_is-preset-lift-active, 0px) @@ -588,10 +568,24 @@ var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) ); - /* A private tuple lets complete presets preserve timing across competing shorthands. */ - --_is-transition-duration: var(--_is-motion-default); - --_is-transition-timing-function: var(--_is-ease-standard); - --_is-transition-delay: 0s; + --_is-transition-property: var( + --interactive-surface-transition-property, + translate, + box-shadow, + outline-color + ); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--interactive-surface-motion-default, var(--motion-default, 140ms)) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ) + ); + --_is-transition-delay: var(--interactive-surface-transition-delay, 0s); --_is-focus-ring-color: var( --interactive-surface-focus-ring-color, var(--focus-ring, rgb(11 99 246)) @@ -611,13 +605,12 @@ -webkit-tap-highlight-color: var(--_is-tap-highlight); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); - transition: - translate var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - outline-color var(--_is-transition-duration) linear - var(--_is-transition-delay); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public tuple tokens must remain independently replaceable lists. */ + transition-property: var(--_is-transition-property); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ will-change: auto; } @@ -631,7 +624,12 @@ opacity: var(--_is-state-layer-base-opacity); pointer-events: none; z-index: -1; - transition: opacity var(--_is-motion-default) var(--_is-ease-standard); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: opacity; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } .interactive-surface:focus { @@ -648,63 +646,29 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):focus-visible { - translate: 0 var(--_is-lift-hover); - box-shadow: var(--_is-shadow-hover); outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); outline-offset: var(--_is-focus-ring-offset); } -.interactive-surface:not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::before { - opacity: var(--_is-state-layer-focus-opacity); -} - /* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ :where(input[type="file"].interactive-surface)::file-selector-button { cursor: pointer; - transition: - background-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - border-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - color var(--_is-transition-duration) var(--_is-transition-timing-function) - var(--_is-transition-delay); -} - -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::file-selector-button { - box-shadow: inset 0 0 0 999px - rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); -} - -/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { - translate: 0 var(--_is-lift-active); - box-shadow: var(--_is-shadow-active); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: background-color, border-color, box-shadow, color; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { - opacity: var(--_is-state-layer-active-opacity); +/* Matching specificity lets source order apply the public precedence to the native subcontrol. */ +input[type="file"].interactive-surface.interactive-surface:where( + :focus-visible + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); } @media (hover: hover) and (pointer: fine) { @@ -715,7 +679,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):hover { - translate: 0 var(--_is-hover-lift); + translate: 0 var(--_is-lift-hover); box-shadow: var(--_is-shadow-hover); } @@ -725,19 +689,62 @@ opacity: var(--_is-state-layer-hover-opacity); } - :where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):hover::file-selector-button { + input[type="file"].interactive-surface.interactive-surface:where( + :hover + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); } } +/* Repeating the public class normalizes specificity so source order can encode state precedence. */ +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); +} + .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active { - --_is-transition-duration: var(--_is-motion-press); - --_is-transition-timing-function: var(--_is-ease-press); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-press) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-press) + ); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); @@ -747,17 +754,51 @@ :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active::before { opacity: 0; - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); } -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):active::file-selector-button { +input[type="file"].interactive-surface.interactive-surface:where(:active):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-default) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-standard) + ); + + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-active-opacity)); - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); } /* @@ -786,10 +827,8 @@ transition: none; } -:where(input[type="file"].interactive-surface):is( - .is-disabled, - :disabled, - [aria-disabled="true"] +input[type="file"].interactive-surface.interactive-surface:where( + :is(.is-disabled, :disabled, [aria-disabled="true"]) )::file-selector-button { cursor: not-allowed; box-shadow: none; @@ -813,7 +852,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active, - .interactive-surface:where( + .interactive-surface.interactive-surface:where( .is-active, [aria-pressed="true"], [aria-pressed="mixed"], @@ -833,6 +872,11 @@ animation: none; transition: none; } + + :where(input[type="file"].interactive-surface)::file-selector-button { + animation: none; + transition: none; + } } @media (prefers-contrast: more) { @@ -867,8 +911,6 @@ --_is-lift-base: 0px; --_is-lift-hover: 0px; - /* Integration offsets must not bypass the forced-colors motion reset. */ - --_is-hover-lift: 0px; --_is-lift-active: 0px; --_is-shadow-base: none; --_is-shadow-hover: none; diff --git a/state-core.css b/state-core.css index 91a0850..1660498 100644 --- a/state-core.css +++ b/state-core.css @@ -19,9 +19,6 @@ --interactive-surface-lift-hover, var(--_is-preset-lift-hover, 0px) ); - --_is-hover-lift: calc( - var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) - ); --_is-lift-active: var( --interactive-surface-lift-active, var(--_is-preset-lift-active, 0px) @@ -93,10 +90,24 @@ var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) ); - /* A private tuple lets complete presets preserve timing across competing shorthands. */ - --_is-transition-duration: var(--_is-motion-default); - --_is-transition-timing-function: var(--_is-ease-standard); - --_is-transition-delay: 0s; + --_is-transition-property: var( + --interactive-surface-transition-property, + translate, + box-shadow, + outline-color + ); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--interactive-surface-motion-default, var(--motion-default, 140ms)) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ) + ); + --_is-transition-delay: var(--interactive-surface-transition-delay, 0s); --_is-focus-ring-color: var( --interactive-surface-focus-ring-color, var(--focus-ring, rgb(11 99 246)) @@ -116,13 +127,12 @@ -webkit-tap-highlight-color: var(--_is-tap-highlight); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); - transition: - translate var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - outline-color var(--_is-transition-duration) linear - var(--_is-transition-delay); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public tuple tokens must remain independently replaceable lists. */ + transition-property: var(--_is-transition-property); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ will-change: auto; } @@ -136,7 +146,12 @@ opacity: var(--_is-state-layer-base-opacity); pointer-events: none; z-index: -1; - transition: opacity var(--_is-motion-default) var(--_is-ease-standard); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: opacity; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } .interactive-surface:focus { @@ -153,65 +168,31 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):focus-visible { - translate: 0 var(--_is-lift-hover); - box-shadow: var(--_is-shadow-hover); outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); outline-offset: var(--_is-focus-ring-offset); } -.interactive-surface:not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::before { - opacity: var(--_is-state-layer-focus-opacity); -} - /* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ :where(input[type="file"].interactive-surface)::file-selector-button { cursor: pointer; - transition: - background-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - border-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - color var(--_is-transition-duration) var(--_is-transition-timing-function) - var(--_is-transition-delay); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: background-color, border-color, box-shadow, color; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::file-selector-button { +/* Matching specificity lets source order apply the public precedence to the native subcontrol. */ +input[type="file"].interactive-surface.interactive-surface:where( + :focus-visible + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); } -/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { - translate: 0 var(--_is-lift-active); - box-shadow: var(--_is-shadow-active); -} - -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { - opacity: var(--_is-state-layer-active-opacity); -} - @media (hover: hover) and (pointer: fine) { .interactive-surface { will-change: translate, box-shadow; @@ -220,7 +201,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):hover { - translate: 0 var(--_is-hover-lift); + translate: 0 var(--_is-lift-hover); box-shadow: var(--_is-shadow-hover); } @@ -230,19 +211,62 @@ opacity: var(--_is-state-layer-hover-opacity); } - :where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):hover::file-selector-button { + input[type="file"].interactive-surface.interactive-surface:where( + :hover + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); } } +/* Repeating the public class normalizes specificity so source order can encode state precedence. */ +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); +} + .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active { - --_is-transition-duration: var(--_is-motion-press); - --_is-transition-timing-function: var(--_is-ease-press); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-press) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-press) + ); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); @@ -252,17 +276,51 @@ :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active::before { opacity: 0; - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); } -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):active::file-selector-button { +input[type="file"].interactive-surface.interactive-surface:where(:active):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-default) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-standard) + ); + + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-active-opacity)); - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); } /* @@ -291,10 +349,8 @@ transition: none; } -:where(input[type="file"].interactive-surface):is( - .is-disabled, - :disabled, - [aria-disabled="true"] +input[type="file"].interactive-surface.interactive-surface:where( + :is(.is-disabled, :disabled, [aria-disabled="true"]) )::file-selector-button { cursor: not-allowed; box-shadow: none; @@ -318,7 +374,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active, - .interactive-surface:where( + .interactive-surface.interactive-surface:where( .is-active, [aria-pressed="true"], [aria-pressed="mixed"], @@ -338,6 +394,11 @@ animation: none; transition: none; } + + :where(input[type="file"].interactive-surface)::file-selector-button { + animation: none; + transition: none; + } } @media (prefers-contrast: more) { @@ -372,8 +433,6 @@ --_is-lift-base: 0px; --_is-lift-hover: 0px; - /* Integration offsets must not bypass the forced-colors motion reset. */ - --_is-hover-lift: 0px; --_is-lift-active: 0px; --_is-shadow-base: none; --_is-shadow-hover: none; diff --git a/styles/standalone-preset.css b/styles/standalone-preset.css index 9ca5613..2bbb6a4 100644 --- a/styles/standalone-preset.css +++ b/styles/standalone-preset.css @@ -48,23 +48,6 @@ background-clip: padding-box; } -/* - * UI Style Kit 2.0.1 contributes one pixel of transform-based hover lift and owns paint - * transitions. The preset offsets the resolved lift and protects the complete transition tuple. - */ -:where([data-ui][data-theme][data-mode]) .interactive-surface { - --_is-preset-hover-lift-offset: 1px; - - /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Each longhand must survive companion shorthands in either import order. */ - transition-property: - background-color, border-color, box-shadow, color, transform, translate, - outline-color !important; - transition-duration: var(--_is-transition-duration) !important; - transition-timing-function: var(--_is-transition-timing-function) !important; - transition-delay: var(--_is-transition-delay) !important; - /* stylelint-enable declaration-block-no-redundant-longhand-properties */ -} - /* Surface levels consume bridge tokens while preserving standalone fallbacks. */ .interactive-surface[data-surface-level] { --_is-bg: var(--interactive-surface-level-bg, var(--_is-base-bg)); @@ -477,13 +460,13 @@ border-color: GrayText; } - :is(.interactive-surface[type="file"])::file-selector-button { + :is(input[type="file"].interactive-surface)::file-selector-button { border-color: ButtonText; background-color: ButtonFace; color: ButtonText; } - :is(.interactive-surface[type="file"]):is( + :is(input[type="file"].interactive-surface):is( .is-disabled, :disabled, [aria-disabled="true"] diff --git a/styles/state-core.css b/styles/state-core.css index 5d860e0..3298352 100644 --- a/styles/state-core.css +++ b/styles/state-core.css @@ -13,9 +13,6 @@ --interactive-surface-lift-hover, var(--_is-preset-lift-hover, 0px) ); - --_is-hover-lift: calc( - var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) - ); --_is-lift-active: var( --interactive-surface-lift-active, var(--_is-preset-lift-active, 0px) @@ -87,10 +84,24 @@ var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) ); - /* A private tuple lets complete presets preserve timing across competing shorthands. */ - --_is-transition-duration: var(--_is-motion-default); - --_is-transition-timing-function: var(--_is-ease-standard); - --_is-transition-delay: 0s; + --_is-transition-property: var( + --interactive-surface-transition-property, + translate, + box-shadow, + outline-color + ); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--interactive-surface-motion-default, var(--motion-default, 140ms)) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ) + ); + --_is-transition-delay: var(--interactive-surface-transition-delay, 0s); --_is-focus-ring-color: var( --interactive-surface-focus-ring-color, var(--focus-ring, rgb(11 99 246)) @@ -110,13 +121,12 @@ -webkit-tap-highlight-color: var(--_is-tap-highlight); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); - transition: - translate var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - outline-color var(--_is-transition-duration) linear - var(--_is-transition-delay); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public tuple tokens must remain independently replaceable lists. */ + transition-property: var(--_is-transition-property); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ will-change: auto; } @@ -130,7 +140,12 @@ opacity: var(--_is-state-layer-base-opacity); pointer-events: none; z-index: -1; - transition: opacity var(--_is-motion-default) var(--_is-ease-standard); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: opacity; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } .interactive-surface:focus { @@ -147,65 +162,31 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):focus-visible { - translate: 0 var(--_is-lift-hover); - box-shadow: var(--_is-shadow-hover); outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); outline-offset: var(--_is-focus-ring-offset); } -.interactive-surface:not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::before { - opacity: var(--_is-state-layer-focus-opacity); -} - /* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ :where(input[type="file"].interactive-surface)::file-selector-button { cursor: pointer; - transition: - background-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - border-color var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - box-shadow var(--_is-transition-duration) - var(--_is-transition-timing-function) var(--_is-transition-delay), - color var(--_is-transition-duration) var(--_is-transition-timing-function) - var(--_is-transition-delay); + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Public timing tokens may contain independently replaceable lists. */ + transition-property: background-color, border-color, box-shadow, color; + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); + transition-delay: var(--_is-transition-delay); + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ } -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):focus-visible::file-selector-button { +/* Matching specificity lets source order apply the public precedence to the native subcontrol. */ +input[type="file"].interactive-surface.interactive-surface:where( + :focus-visible + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); } -/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { - translate: 0 var(--_is-lift-active); - box-shadow: var(--_is-shadow-active); -} - -.interactive-surface:where( - .is-active, - [aria-pressed="true"], - [aria-pressed="mixed"], - [aria-current]:not([aria-current="false"]), - [aria-selected="true"], - [aria-busy="true"], - .is-loading - ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { - opacity: var(--_is-state-layer-active-opacity); -} - @media (hover: hover) and (pointer: fine) { .interactive-surface { will-change: translate, box-shadow; @@ -214,7 +195,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):hover { - translate: 0 var(--_is-hover-lift); + translate: 0 var(--_is-lift-hover); box-shadow: var(--_is-shadow-hover); } @@ -224,19 +205,62 @@ opacity: var(--_is-state-layer-hover-opacity); } - :where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):hover::file-selector-button { + input[type="file"].interactive-surface.interactive-surface:where( + :hover + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); } } +/* Repeating the public class normalizes specificity so source order can encode state precedence. */ +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"] + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); +} + .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active { - --_is-transition-duration: var(--_is-motion-press); - --_is-transition-timing-function: var(--_is-ease-press); + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-press) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-press) + ); translate: 0 var(--_is-lift-base); box-shadow: var(--_is-shadow-base); @@ -246,17 +270,51 @@ :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active::before { opacity: 0; - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); } -:where(input[type="file"].interactive-surface):not( - :is(.is-disabled, :disabled, [aria-disabled="true"]) - ):active::file-selector-button { +input[type="file"].interactive-surface.interactive-surface:where(:active):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-transition-duration); + transition-timing-function: var(--_is-transition-easing); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: var( + --interactive-surface-transition-duration, + var(--_is-motion-default) + ); + --_is-transition-easing: var( + --interactive-surface-transition-easing, + var(--_is-ease-standard) + ); + + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +input[type="file"].interactive-surface.interactive-surface:where( + [aria-busy="true"], + .is-loading + ):where( + :not(:is(.is-disabled, :disabled, [aria-disabled="true"])) + )::file-selector-button { box-shadow: inset 0 0 0 999px rgb(0 0 0 / var(--_is-state-layer-active-opacity)); - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); } /* @@ -285,10 +343,8 @@ transition: none; } -:where(input[type="file"].interactive-surface):is( - .is-disabled, - :disabled, - [aria-disabled="true"] +input[type="file"].interactive-surface.interactive-surface:where( + :is(.is-disabled, :disabled, [aria-disabled="true"]) )::file-selector-button { cursor: not-allowed; box-shadow: none; @@ -312,7 +368,7 @@ .interactive-surface:not( :is(.is-disabled, :disabled, [aria-disabled="true"]) ):active, - .interactive-surface:where( + .interactive-surface.interactive-surface:where( .is-active, [aria-pressed="true"], [aria-pressed="mixed"], @@ -332,6 +388,11 @@ animation: none; transition: none; } + + :where(input[type="file"].interactive-surface)::file-selector-button { + animation: none; + transition: none; + } } @media (prefers-contrast: more) { @@ -366,8 +427,6 @@ --_is-lift-base: 0px; --_is-lift-hover: 0px; - /* Integration offsets must not bypass the forced-colors motion reset. */ - --_is-hover-lift: 0px; --_is-lift-active: 0px; --_is-shadow-base: none; --_is-shadow-hover: none; diff --git a/tests/documentation.test.mjs b/tests/documentation.test.mjs index 0f05323..67e712e 100644 --- a/tests/documentation.test.mjs +++ b/tests/documentation.test.mjs @@ -79,9 +79,11 @@ function readPackageResolutionRows(source) { "API reference must contain the package resolution contract table", ); - return [...match[1].matchAll(/^\| `([^`]+)` \| `([^`]+)` \|$/gm)].map( - ([, key, value]) => [key, value], - ); + return [ + ...match[1].matchAll( + /^\|[ \t]*`([^`]+)`[ \t]*\|[ \t]*`([^`]+)`[ \t]*\|[ \t]*$/gm, + ), + ].map(([, key, value]) => [key, value]); } test("README is an npm-first guide with durable project links", () => { @@ -188,6 +190,10 @@ test("README teaches the complete semantic and ecosystem contract", () => { "data-surface-level", "icon-only", "data-icon-role", + "--interactive-surface-transition-property", + "--interactive-surface-transition-duration", + "--interactive-surface-transition-easing", + "--interactive-surface-transition-delay", ].forEach((value) => assert.ok( readme.includes(value), @@ -201,6 +207,13 @@ test("README teaches the complete semantic and ecosystem contract", () => { /suppress activation[^\n]*`aria-disabled="true"`[^\n]*`.is-disabled`/i, ); assert.match(readme, /use one[^\n]*use two[^\n]*use all three/i); + assert.ok( + readme.includes( + "Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base", + ), + "README must state the exact interaction precedence", + ); + assert.match(readme, /`:focus-visible`[^\n]*orthogonal/i); const allThreeOrder = [ 'import "ui-style-kit-css/with-bridge.css";', @@ -214,7 +227,7 @@ test("README teaches the complete semantic and ecosystem contract", () => { ); }); -test("wiki API, token, and accessibility references cover the 1.4.0 contract", () => { +test("wiki API, token, and accessibility references cover the 1.5.0 contract", () => { assert.ok( wiki.api.includes( "`.size-sm`: default lift with a compact shadow profile.", @@ -270,6 +283,29 @@ test("wiki API, token, and accessibility references cover the 1.4.0 contract", ( ); } + [ + "--interactive-surface-transition-property", + "--interactive-surface-transition-duration", + "--interactive-surface-transition-easing", + "--interactive-surface-transition-delay", + "translate, box-shadow, outline-color", + ].forEach((value) => + assert.ok( + wiki.tokens.includes(value), + `Token reference is missing transition contract: ${value}`, + ), + ); + + for (const document of [wiki.api, wiki.accessibility]) { + assert.ok( + document.includes( + "Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base", + ), + "Public state guidance must state the exact interaction precedence", + ); + assert.match(document, /`:focus-visible`[^\n]*orthogonal/i); + } + [ 'aria-pressed="mixed"', "aria-current", @@ -298,8 +334,8 @@ test("wiki installation and quality guidance matches the release-candidate packa wiki.roadmap, ].forEach((document) => { assert.ok( - document.includes("1.4.0"), - "Release-facing wiki page must identify 1.4.0", + document.includes(manifest.version), + `Release-facing wiki page must identify ${manifest.version}`, ); assert.match(document, /release candidate/i); }); @@ -313,7 +349,9 @@ test("wiki installation and quality guidance matches the release-candidate packa }; const expectedLabUrl = normalizeUrlForComparison(labUrl); - const installationUrls = [...wiki.installation.matchAll(/https?:\/\/[^\s)>"'`]+/gi)] + const installationUrls = [ + ...wiki.installation.matchAll(/https?:\/\/[^\s)>"'`]+/gi), + ] .map((match) => match[0]) .map((candidate) => { try { diff --git a/tests/entry-points.spec.ts b/tests/entry-points.spec.ts index 2e588e8..4b7c6a6 100644 --- a/tests/entry-points.spec.ts +++ b/tests/entry-points.spec.ts @@ -44,7 +44,18 @@ function entryPointHtml(css: string) { - + + @@ -112,6 +123,9 @@ async function fileSelectorButtonSnapshot(page: Page, selector: string) { buttonBoxShadow: selectorButton.boxShadow, buttonColor: selectorButton.color, buttonCursor: selectorButton.cursor, + buttonTransitionDelay: selectorButton.transitionDelay, + buttonTransitionDuration: selectorButton.transitionDuration, + buttonTransitionEasing: selectorButton.transitionTimingFunction, buttonTransitionProperty: selectorButton.transitionProperty, hostBackgroundColor: host.backgroundColor, hostColor: host.color, @@ -193,12 +207,68 @@ test.describe("public stylesheet entry points", () => { expect(rest.buttonBackgroundColor).toBe(rest.hostBackgroundColor); expect(rest.buttonColor).toBe(rest.hostColor); expect(rest.buttonCursor).toBe("pointer"); - expect(rest.buttonTransitionProperty).toContain("box-shadow"); + expect(rest.buttonTransitionProperty).toBe( + "background-color, border-color, box-shadow, color", + ); + expect(rest.buttonTransitionDuration).toBe("0.075s, 0.125s, 0.175s"); + expect(rest.buttonTransitionEasing).toBe("linear, ease-in, ease-out"); + expect(rest.buttonTransitionDelay).toBe("0.02s, 0.03s, 0.04s"); await page.locator("#file").hover({ position: { x: 8, y: 8 } }); const hover = await fileSelectorButtonSnapshot(page, "#file"); expect(hover.buttonBoxShadow).not.toBe(rest.buttonBoxShadow); + + await page.mouse.down(); + try { + const active = await fileSelectorButtonSnapshot(page, "#file"); + expect(active.buttonBoxShadow).not.toBe(rest.buttonBoxShadow); + expect(active.buttonTransitionDuration).toBe("0.075s, 0.125s, 0.175s"); + } finally { + await page.mouse.up(); + } + + await page.locator("#file-state").hover({ position: { x: 8, y: 8 } }); + expect( + (await fileSelectorButtonSnapshot(page, "#file-state")).buttonBoxShadow, + ).toContain("0.18"); + + await page.locator("#file-state").evaluate((element) => { + element.setAttribute("aria-selected", "true"); + }); + const persistent = await fileSelectorButtonSnapshot(page, "#file-state"); + expect(persistent.buttonBoxShadow).toContain("0.32"); + + await page.locator("#file-state").evaluate((element) => { + element.removeAttribute("aria-selected"); + }); + await page.mouse.move(0, 0); + await page.locator("#file").focus(); + await page.keyboard.press("Tab"); + expect( + (await fileSelectorButtonSnapshot(page, "#file-state")).buttonBoxShadow, + ).toContain("0.24"); + + await page.locator("#file-state").evaluate((element) => { + element.setAttribute("aria-busy", "true"); + }); + const busy = await fileSelectorButtonSnapshot(page, "#file-state"); + expect(busy.buttonBoxShadow).toContain("0.32"); + + await page.locator("#file-state").evaluate((element) => { + element.removeAttribute("aria-busy"); + element.classList.add("is-loading"); + }); + const loading = await fileSelectorButtonSnapshot(page, "#file-state"); + expect(loading.buttonBoxShadow).toContain("0.32"); + + await page.locator("#file-state").evaluate((element) => { + (element as HTMLInputElement).disabled = true; + }); + const disabled = await fileSelectorButtonSnapshot(page, "#file-state"); + expect(disabled.buttonBoxShadow).toBe("none"); + expect(disabled.buttonCursor).toBe("not-allowed"); + expect(disabled.buttonTransitionDuration).toBe("0s"); }); } }); diff --git a/tests/example.spec.ts b/tests/example.spec.ts index 934730e..3c3817b 100644 --- a/tests/example.spec.ts +++ b/tests/example.spec.ts @@ -56,6 +56,23 @@ async function expectActionableError( await expectFocused(status); } +function expectElementWithAttributes( + source: string, + tagName: string, + attributes: Record, +): void { + const attributeLookaheads = Object.entries(attributes) + .map(([name, value]) => { + const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return `(?=[^>]*\\b${name}="${escapedValue}")`; + }) + .join(""); + + expect(source).toMatch( + new RegExp(`<${tagName}\\b${attributeLookaheads}[^>]*>`, "i"), + ); +} + test.describe("state-first example page", () => { test.describe.configure({ mode: "parallel" }); @@ -79,15 +96,44 @@ test.describe("state-first example page", () => { ); expect(sourceHtml).not.toMatch(/]+href="data:image\/svg\+xml/i); [ - '', - '', - '', - '', - '', - '', - '', - '', - ].forEach((assetReference) => expect(sourceHtml).toContain(assetReference)); + { rel: "icon", href: "./assets/favicon.ico", sizes: "any" }, + { + rel: "icon", + type: "image/png", + sizes: "16x16", + href: "./assets/favicon-16x16.png", + }, + { + rel: "icon", + type: "image/png", + sizes: "32x32", + href: "./assets/favicon-32x32.png", + }, + { + rel: "icon", + type: "image/png", + sizes: "48x48", + href: "./assets/favicon-48x48.png", + }, + { + rel: "icon", + type: "image/png", + sizes: "64x64", + href: "./assets/favicon-64x64.png", + }, + { + rel: "apple-touch-icon", + sizes: "180x180", + href: "./assets/apple-touch-icon.png", + }, + { rel: "manifest", href: "./assets/site.webmanifest" }, + ].forEach((attributes) => + expectElementWithAttributes(sourceHtml, "link", attributes), + ); + expectElementWithAttributes(sourceHtml, "meta", { + name: "msapplication-config", + content: "./assets/browserconfig.xml", + }); expect(sourceHtml).toContain('