diff --git a/.changeset/progress-bar-reduced-motion-pulse.md b/.changeset/progress-bar-reduced-motion-pulse.md new file mode 100644 index 0000000000000..a8b04e3f8f842 --- /dev/null +++ b/.changeset/progress-bar-reduced-motion-pulse.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under reduced motion, an indeterminate ProgressBar no longer slides: on every pointer its fill spans the whole track and gently pulses its opacity in place, from full down to 84% and back, two seconds each way. A theme's tempo does not change the pulse, and in the default light and dark themes every fill tone that stands out from the track at 3:1 still does at the pulse's lowest point. + +@vjeux diff --git a/.changeset/progress-bar-touch-timing.md b/.changeset/progress-bar-touch-timing.md new file mode 100644 index 0000000000000..f7cde2fe2ddf3 --- /dev/null +++ b/.changeset/progress-bar-touch-timing.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under a finger (a coarse primary pointer, on every touch device), a determinate ProgressBar moves to a new value the way iOS's progress view does: linearly, taking one second per full track of change at the theme's default tempo (0.8 s from 10% to 90%), and from wherever the fill is drawn when the value changes again mid-way. A theme's tempo scales that time. Above zero the fill never draws shorter than two track heights, and at zero it is not drawn. The motion runs on the compositor, so it keeps going while a script runs; under reduced motion, or with forced colors on, a new value lands at once. A mouse or trackpad keeps the theme's duration and easing. + +@vjeux diff --git a/docs/specs/AST-094-touch-presentations/motion-roles.md b/docs/specs/AST-094-touch-presentations/motion-roles.md index 3aa6b3bbad9fb..e3494615ae0c7 100644 --- a/docs/specs/AST-094-touch-presentations/motion-roles.md +++ b/docs/specs/AST-094-touch-presentations/motion-roles.md @@ -4,25 +4,28 @@ Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. -| Role | Kind | Default | Named by | -| ---------------------------- | -------- | ----------------------------------- | ------------------ | -| `springs.contextMenuDismiss` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.contextMenuLift` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.contextMenuMorph` | spring | response 0.3 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.directChange` | spring | response 0.5 s, damping ratio 0.825 | `spec:AST-094` FR4 | -| `springs.emphasis` | spring | duration 0.5 s, bounce 0.3 | `spec:AST-094` FR4 | -| `springs.fingerTracking` | spring | response 0.15 s, damping ratio 0.86 | `spec:AST-094` FR4 | -| `springs.menuClose` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.menuOpen` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.quickChange` | spring | duration 0.5 s, bounce 0.15 | `spec:AST-094` FR4 | -| `springs.settle` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | -| `springs.stateChange` | spring | response 0.55 s, damping ratio 1 | `spec:AST-094` FR4 | -| `springs.subMenuBack` | spring | response 0.4 s, damping ratio 1 | `spec:AST-094` FR4 | -| `springs.subMenuOpen` | spring | response 0.4 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.timedChange` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | -| `springs.viewChange` | spring | mass 3, stiffness 1000, damping 500 | `spec:AST-094` FR4 | -| `timing.curves.entrance` | curve | cubic-bezier(0, 0, 0.58, 1) | `spec:AST-094` FR4 | -| `timing.curves.exit` | curve | cubic-bezier(0.42, 0, 1, 1) | `spec:AST-094` FR4 | -| `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | -| `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | -| `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | +| Role | Kind | Default | Named by | +| ------------------------------- | -------- | ----------------------------------- | ----------------------- | +| `progress.curves.fill` | curve | cubic-bezier(0, 0, 1, 1) | `component:ProgressBar` | +| `progress.fillTrackMs` | duration | 1000 ms | `component:ProgressBar` | +| `progress.reducedMotionPulseMs` | duration | 2000 ms | `component:ProgressBar` | +| `springs.contextMenuDismiss` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.contextMenuLift` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.contextMenuMorph` | spring | response 0.3 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.directChange` | spring | response 0.5 s, damping ratio 0.825 | `spec:AST-094` FR4 | +| `springs.emphasis` | spring | duration 0.5 s, bounce 0.3 | `spec:AST-094` FR4 | +| `springs.fingerTracking` | spring | response 0.15 s, damping ratio 0.86 | `spec:AST-094` FR4 | +| `springs.menuClose` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.menuOpen` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.quickChange` | spring | duration 0.5 s, bounce 0.15 | `spec:AST-094` FR4 | +| `springs.settle` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | +| `springs.stateChange` | spring | response 0.55 s, damping ratio 1 | `spec:AST-094` FR4 | +| `springs.subMenuBack` | spring | response 0.4 s, damping ratio 1 | `spec:AST-094` FR4 | +| `springs.subMenuOpen` | spring | response 0.4 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.timedChange` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | +| `springs.viewChange` | spring | mass 3, stiffness 1000, damping 500 | `spec:AST-094` FR4 | +| `timing.curves.entrance` | curve | cubic-bezier(0, 0, 0.58, 1) | `spec:AST-094` FR4 | +| `timing.curves.exit` | curve | cubic-bezier(0.42, 0, 1, 1) | `spec:AST-094` FR4 | +| `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | +| `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | +| `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | diff --git a/docs/specs/components/ProgressBar/ProgressBar.spec.md b/docs/specs/components/ProgressBar/ProgressBar.spec.md index 7c0dfe944d4c2..4b07dd0714d01 100644 --- a/docs/specs/components/ProgressBar/ProgressBar.spec.md +++ b/docs/specs/components/ProgressBar/ProgressBar.spec.md @@ -16,6 +16,7 @@ verified_by: packages/core/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx, packages/core/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts, packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts, + packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts, packages/core/src/theme/themingTargets.test.ts, .github/visual-baseline/manifest.json, scripts/check-knowledge.mjs, @@ -52,7 +53,7 @@ system_specs: | Behavior | A labelled `progressbar` whose fill shows the clamped value against a muted track, moving at UIKit's speed under a finger, or slides while indeterminate and pulses in place under reduced motion, with optional value text and labelled target marks. | | End-user impact | People see how far an operation has gone, and assistive technology reads the name and value; marks reveal their labels on hover or focus. | | Builder impact | A builder's `data-variant` replaces the reflected variant today, so theme rules for the bar's real variant stop matching (FR8 gap); otherwise none. | -| Compatibility/readiness | No API or default changes; the touch fill timing (FR4) and the reduced-motion pulse (FR6) are unbuilt. Implementation gaps are listed under Current-state impact. | +| Compatibility/readiness | No API or default changes. Implementation gaps are listed under Current-state impact. | | Review checks | Reject a determinate bar whose correctness depends on nearby text, a value outside `[0, max]`, a leaked `NaN`, value semantics while indeterminate, or a target off its part. | | Governing rules | `component:ProgressBar/DEC-1`, `DEC-2`; `spec:AST-002`; `spec:AST-094` FR4, FR13; `design:reduced-motion` DR1; `architecture:component-theming-surface`; `architecture:theme-tokens` INV11; `architecture:internationalization` INV1a; `architecture:public-component-api` INV5–INV8. | @@ -108,15 +109,6 @@ Consumer migration instructions belong in consumer docs and release notes. where `architecture:internationalization` INV11 resolves mirroring from the rendered region. -- **Touch fill timing is unbuilt (FR4).** Under a coarse primary pointer the - determinate fill still eases over `--duration-medium` like a mouse's, a value - just above zero can draw shorter than 16px, and the `progress.fillTrackMs` - and `progress.curves.fill` roles are not in the generated motion role table. -- **The reduced-motion indeterminate fill still travels (FR6).** Under reduced - motion the 40% segment keeps sliding, slowed to 3s a pass, on every pointer, - where FR6 holds a full-width fill that only pulses its opacity, and the - `progress.reducedMotionPulseMs` role is not in the generated motion role - table. - **Warning and neutral fills under 3:1 in the light default theme (FR6, OQ1).** Against the track, the warning fill reaches 1.65–1.82:1 and the neutral fill 1.84–2.03:1 even at full opacity (on the body and surface @@ -158,17 +150,17 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| FR1 | Every determinate presentation ProgressBar provides is a sufficient standalone visual for distinguishing completed from remaining progress; external visible text may supplement it but is never required, and supplementary text or graphics never substitute for that distinction. ProgressBar does not make visual correctness depend on caller-declared external content it cannot verify or associate. | `component:ProgressBar/DEC-1`; `spec:AST-002` | -| FR2 | New public API that adjusts the visual distinction is considered only after the component has a correct base behavior and a stable caller-owned distinction still remains. | `component:ProgressBar/DEC-1`; `spec:AST-002` | -| FR3 | The root holds a label row and a track. The label names the track (`role="progressbar"`) through `aria-labelledby`; `isLabelHidden` hides it visually only. When determinate, the track exposes `aria-valuenow` (the clamped value), `aria-valuemin` 0, `aria-valuemax` (`max`), and `aria-valuetext` from `formatValueLabel`, which defaults to the rounded percentage; `hasValueLabel` shows the same text beside the label. A zero or non-finite `max` gives 0% and never renders `NaN`. Astryx-owned value text comes from one catalog message (`architecture:internationalization` INV1a). Implementation gap: the default text is assembled in code (Current-state impact). | Current source and tests | -| FR4 | The track is 8px high, fully rounded, in `--color-background-muted`, and clips the fill. The determinate fill spans the track and slides along it so its leading edge sits at the clamped percentage, mirrored by the bar's own writing direction, moving over `--duration-medium` when the value changes. Under a coarse primary pointer (`spec:AST-094` FR13) it instead moves linearly from where it is drawn when the value changes, for the `progress.fillTrackMs` role (1000ms, UIKit's progress view for a whole-track change) times the share of the track the value moved, so 10% to 90% takes 800ms; above zero it draws at least 16px long, a mark that length covers counts as on the fill (FR7), and at zero it paints nothing. That duration and its linear curve, `progress.curves.fill`, are roles in the generated motion role table, and a theme's tempo scales the duration (`spec:AST-094` FR4, DEC-1, DEC-5; `spec:AST-108` FR3); any other pointer keeps the theme's eased duration. Under reduced motion a new value lands at once on every pointer (`design:reduced-motion` DR1). Implementation gap: the touch timing, the 16px minimum, and both roles are unbuilt (Current-state impact). The fill paints `--color-accent`, `--color-success`, `--color-warning`, or `--color-error`, or `--color-text-disabled` for `neutral` and disabled bars; a theme-added variant has a safe theme-independent fill (`architecture:component-theming-surface` INV14). Implementation gap: a theme-added variant paints no fill and no on-fill mark color until a theme styles it (Current-state impact). In forced colors the fill is sized to the value instead of sliding and keeps a 1px `CanvasText` outline so its shape stays visible (`architecture:theme-tokens` INV11). | Current source and tests; UIKit measurement (iOS 26.2 simulator); `component:ProgressBar/DEC-2` | -| FR5 | `isDisabled` mutes the fill, label, value text, and marks and adds no ARIA state. | Current source and tests | -| FR6 | `isIndeterminate` removes every value attribute and the value text. Without reduced motion, on every pointer, a 40% fill slides across the clipped track, eased, 1.5s a pass, mirrored by the bar's own rendered direction (`architecture:internationalization` INV11). Under reduced motion, on every pointer, the fill spans the whole track as one solid bar, with no segment and no travel, and only its opacity pulses: from 1 down to 0.84 and back, each way taking the `progress.reducedMotionPulseMs` role (2000ms) on the `timing.curves.move` curve; the track is unchanged. The pulse is a busy loop, so tempo does not scale it (`spec:AST-094` FR4, `spec:AST-108` FR4). It applies `design:reduced-motion` DR1's no-travel rule, replacing the travel with an opacity change, to a loop that record leaves to its component. At the 0.84 floor the fill keeps at least 3:1 against the track in both themes for every tone that reaches 3:1 at full opacity (AR4). Implementation gap: under reduced motion the segment still slides, slowed to 3s a pass, and the pulse role is unbuilt (Current-state impact). Implementation gap: the slide, and the centering of marks, mirror under any right-to-left ancestor rather than the bar's own direction, so in a left-to-right region of a right-to-left page they run against the label (Current-state impact). | Current source and tests; `component:ProgressBar/DEC-2` | -| FR7 | Each finite mark renders, outside the `progressbar` element, a tick at its clamped position whose tooltip shows its label on hover and focus; marks inside the filled part take the fill's contrasting color, and marks do not render while indeterminate. A focusable mark exposes its label as its accessible name (WCAG 2.2 SC 4.1.2). Implementation gap: marks are focusable `span`s with no role or name, labelled only as a description once the tooltip loads (Current-state impact). | Current source and tests | -| FR8 | The root carries `progress-bar` reflecting `variant`, the track `progress-bar-track`, the fill `progress-bar-fill` reflecting the resolved fill variant, and each mark `progress-bar-mark` reflecting the resolved fill variant and placement; the label and value text carry no target. Reflected state stays component-owned (`architecture:public-component-api` INV5). Implementation gap: a caller `data-variant` replaces the root's reflected variant (Current-state impact). | Current source and tests | -| FR9 | `ref` and `data-testid` reach the root; supported DOM, data, ARIA, and event props reach the root, while the reflected theme attributes stay component-owned (FR8); `xstyle`, `className`, and `style` compose after component styles. | Current source and tests; `architecture:public-component-api` INV5–INV8 | +| ID | Invariant | Basis | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| FR1 | Every determinate presentation ProgressBar provides is a sufficient standalone visual for distinguishing completed from remaining progress; external visible text may supplement it but is never required, and supplementary text or graphics never substitute for that distinction. ProgressBar does not make visual correctness depend on caller-declared external content it cannot verify or associate. | `component:ProgressBar/DEC-1`; `spec:AST-002` | +| FR2 | New public API that adjusts the visual distinction is considered only after the component has a correct base behavior and a stable caller-owned distinction still remains. | `component:ProgressBar/DEC-1`; `spec:AST-002` | +| FR3 | The root holds a label row and a track. The label names the track (`role="progressbar"`) through `aria-labelledby`; `isLabelHidden` hides it visually only. When determinate, the track exposes `aria-valuenow` (the clamped value), `aria-valuemin` 0, `aria-valuemax` (`max`), and `aria-valuetext` from `formatValueLabel`, which defaults to the rounded percentage; `hasValueLabel` shows the same text beside the label. A zero or non-finite `max` gives 0% and never renders `NaN`. Astryx-owned value text comes from one catalog message (`architecture:internationalization` INV1a). Implementation gap: the default text is assembled in code (Current-state impact). | Current source and tests | +| FR4 | The track is 8px high, fully rounded, in `--color-background-muted`, and clips the fill. The determinate fill spans the track and slides along it so its leading edge sits at the clamped percentage, mirrored by the bar's own writing direction, moving over `--duration-medium` when the value changes. Under a coarse primary pointer (`spec:AST-094` FR13) it instead moves linearly from where it is drawn when the value changes, for the `progress.fillTrackMs` role (1000ms, UIKit's progress view for a whole-track change) times the share of the track the value moved, so 10% to 90% takes 800ms; above zero it draws at least 16px long, a mark that length covers counts as on the fill (FR7), and at zero it paints nothing. That duration and its linear curve, `progress.curves.fill`, are roles in the generated motion role table, and a theme's tempo scales the duration (`spec:AST-094` FR4, DEC-1, DEC-5; `spec:AST-108` FR3); any other pointer keeps the theme's eased duration. Under reduced motion a new value lands at once on every pointer (`design:reduced-motion` DR1). The fill paints `--color-accent`, `--color-success`, `--color-warning`, or `--color-error`, or `--color-text-disabled` for `neutral` and disabled bars; a theme-added variant has a safe theme-independent fill (`architecture:component-theming-surface` INV14). Implementation gap: a theme-added variant paints no fill and no on-fill mark color until a theme styles it (Current-state impact). In forced colors the fill is sized to the value instead of sliding and keeps a 1px `CanvasText` outline so its shape stays visible (`architecture:theme-tokens` INV11). | Current source and tests; UIKit measurement (iOS 26.2 simulator); `component:ProgressBar/DEC-2` | +| FR5 | `isDisabled` mutes the fill, label, value text, and marks and adds no ARIA state. | Current source and tests | +| FR6 | `isIndeterminate` removes every value attribute and the value text. Without reduced motion, on every pointer, a 40% fill slides across the clipped track, eased, 1.5s a pass, mirrored by the bar's own rendered direction (`architecture:internationalization` INV11). Under reduced motion, on every pointer, the fill spans the whole track as one solid bar, with no segment and no travel, and only its opacity pulses: from 1 down to 0.84 and back, each way taking the `progress.reducedMotionPulseMs` role (2000ms) on the `timing.curves.move` curve; the track is unchanged. The pulse is a busy loop, so tempo does not scale it (`spec:AST-094` FR4, `spec:AST-108` FR4). It applies `design:reduced-motion` DR1's no-travel rule, replacing the travel with an opacity change, to a loop that record leaves to its component. At the 0.84 floor the fill keeps at least 3:1 against the track in both themes for every tone that reaches 3:1 at full opacity (AR4). Implementation gap: the slide, and the centering of marks, mirror under any right-to-left ancestor rather than the bar's own direction, so in a left-to-right region of a right-to-left page they run against the label (Current-state impact). | Current source and tests; `component:ProgressBar/DEC-2` | +| FR7 | Each finite mark renders, outside the `progressbar` element, a tick at its clamped position whose tooltip shows its label on hover and focus; marks inside the filled part take the fill's contrasting color, and marks do not render while indeterminate. A focusable mark exposes its label as its accessible name (WCAG 2.2 SC 4.1.2). Implementation gap: marks are focusable `span`s with no role or name, labelled only as a description once the tooltip loads (Current-state impact). | Current source and tests | +| FR8 | The root carries `progress-bar` reflecting `variant`, the track `progress-bar-track`, the fill `progress-bar-fill` reflecting the resolved fill variant, and each mark `progress-bar-mark` reflecting the resolved fill variant and placement; the label and value text carry no target. Reflected state stays component-owned (`architecture:public-component-api` INV5). Implementation gap: a caller `data-variant` replaces the root's reflected variant (Current-state impact). | Current source and tests | +| FR9 | `ref` and `data-testid` reach the root; supported DOM, data, ARIA, and event props reach the root, while the reflected theme attributes stay component-owned (FR8); `xstyle`, `className`, and `style` compose after component styles. | Current source and tests; `architecture:public-component-api` INV5–INV8 | ### Allowed variation @@ -275,9 +267,9 @@ those parts stay untargeted. | FR4 | `ProgressBar.test.tsx` track-clip, transform, variant, and forced-colors tests; `ProgressBarMotion.a11y.chromium.spec.ts` | Every variant; value changes; RTL region; reduced motion; forced colors | A track that does not clip, a fill sized by width outside forced colors, a lost forced-colors outline, a leading edge off the value or unmirrored in an RTL region, a value change that does not land at once under reduced motion, or per-frame layout while moving fails. Gap: the variant colors have no direct check. Gap: the "fixed 8px track height" test only checks that the bar renders, so the track height has no check; no test covers a theme-added variant. | `audit:ProgressBar/visual` | | FR5 | `ProgressBar.test.tsx` disabled and disabled-mark tests | Disabled | A missing label or undimmed marks fail. Gap: the muted fill and label colors have no direct check. | `audit:ProgressBar/visual` | | FR6 | `ProgressBar.test.tsx` indeterminate suite | Every variant; RTL | Value attributes or value text while indeterminate, a lost label, an unclipped track, or an unmirrored slide fails. No test covers the slide in a left-to-right region inside a right-to-left page. Gap: the RTL test asserts the right-to-left ancestor selector, so it pins the FR6 gap. | `audit:ProgressBar/behavior` | -| FR4 touch timing | Pending: real-browser computed-animation tests under a coarse and a fine pointer (not yet in the suite) | Coarse vs fine pointer; 10% to 90%; a new value mid-move; 1% on a narrow track; zero; reduced motion | A coarse-pointer move that eases, starts from the old value instead of the drawn position, or lasts other than `progress.fillTrackMs` times the share moved; a fine-pointer move that leaves the theme's eased duration; a non-zero fill shorter than 16px; a painted fill at zero; or a duration played as a literal instead of the role fails. | `audit:ProgressBar/behavior` | -| FR6 reduced motion | Pending: real-browser computed-animation tests under reduced motion, coarse and fine pointer (not yet in the suite) | Every variant; coarse and fine pointer | A segment, any transform or travel, a fill narrower than the track, an animated property other than the fill's opacity, an opacity below 0.84, a period other than `progress.reducedMotionPulseMs` each way, or a changed track fails. | `audit:ProgressBar/behavior` | -| AR4 | Pending: real-browser contrast check of the fill at 0.84 opacity over the track (not yet in the suite) | Every variant; light and dark default themes; body and surface backgrounds | A tone that reaches 3:1 at full opacity and falls below 3:1 at the floor fails. Gap: warning and neutral in light are below 3:1 at full opacity (OQ1). | `audit:ProgressBar/a11y` | +| FR4 touch timing | `ProgressBarTouch.a11y.chromium.spec.ts`; the `ProgressBar.test.tsx` touch suite | Coarse vs fine pointer; 10% to 90%; a new value mid-move; 1% on a narrow track; zero; reduced motion | A coarse-pointer move that eases, starts from the old value instead of the drawn position, or lasts other than `progress.fillTrackMs` times the share moved; a fine-pointer move that leaves the theme's eased duration; a non-zero fill shorter than 16px; a painted fill at zero; or a duration played as a literal instead of the role fails. | `audit:ProgressBar/behavior` | +| FR6 reduced motion | The reduced-motion pulse cases of `ProgressBarMotion.a11y.chromium.spec.ts`; the `ProgressBar.test.tsx` pulse suite | Every variant; coarse and fine pointer | A segment, any transform or travel, a fill narrower than the track, an animated property other than the fill's opacity, an opacity below 0.84, a period other than `progress.reducedMotionPulseMs` each way, or a changed track fails. | `audit:ProgressBar/behavior` | +| AR4 | The pulse-floor contrast case of `ProgressBarMotion.a11y.chromium.spec.ts` | Every variant; light and dark default themes; body and surface backgrounds | A tone that reaches 3:1 at full opacity and falls below 3:1 at the floor fails. Gap: warning and neutral in light are below 3:1 at full opacity (OQ1). | `audit:ProgressBar/a11y` | | FR7 | `ProgressBar.test.tsx` target-mark suite | None; one; many; clamped; non-finite; on fill; indeterminate; disabled; tooltip | A missing, misplaced, or clipped mark, a mark inside the `progressbar`, a lost tooltip, or a wrong on-fill color fails. Gap: the test asserts that marks have no role and no `aria-label`, which pins the FR7 gap. | `audit:ProgressBar/a11y` | | FR8 | `ProgressBar.test.tsx` target-name and mark-target tests; `themingTargets.test.ts` | Four canonical targets | A missing or renamed target fails. Gap: no test covers a caller `data-variant`. `themingTargets.test.ts` catches only a renamed or undocumented target; no check fails if a part stops carrying its target. | `audit:ProgressBar/theming` | | FR9 | `ProgressBar.test.tsx` ref and `data-testid` tests | Ref; test id | A ref that is not a `div` or a lost test id fails. Gap: the ref test checks only that the ref is a `div`, not that it is the root, and no test covers DOM props, `className`, `style`, or the `xstyle` merge order. | `audit:ProgressBar/api` | diff --git a/packages/core/src/ProgressBar/ProgressBar.doc.mjs b/packages/core/src/ProgressBar/ProgressBar.doc.mjs index d4105cd7566e6..561aa41b38df1 100644 --- a/packages/core/src/ProgressBar/ProgressBar.doc.mjs +++ b/packages/core/src/ProgressBar/ProgressBar.doc.mjs @@ -101,7 +101,8 @@ export const docs = { { name: 'isIndeterminate', type: 'boolean', - description: 'Animated loading indicator for unknown progress.', + description: + 'Animated loading indicator for unknown progress. Under reduced motion it pulses in place instead of sliding.', default: 'false', }, { @@ -160,7 +161,7 @@ export const docs = { usage: { anatomy, description: - "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting.", + "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting. Under a finger a new value animates as on iOS: linearly, in proportion to how far it moves (a full track takes one second at the theme's default tempo), and the fill keeps a minimum visible length above zero.", bestPractices: [ { guidance: true, @@ -304,7 +305,7 @@ export const docsZh = { usage: { anatomy, description: - "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting.", + "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting. Under a finger a new value animates as on iOS: linearly, in proportion to how far it moves (a full track takes one second at the theme's default tempo), and the fill keeps a minimum visible length above zero.", bestPractices: [ { guidance: true, @@ -347,7 +348,7 @@ export const docsDense = { usage: { anatomy, description: - "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting.", + "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting. Under a finger a new value animates as on iOS: linearly, in proportion to how far it moves (a full track takes one second at the theme's default tempo), and the fill keeps a minimum visible length above zero.", bestPractices: [ { guidance: true, @@ -390,7 +391,8 @@ export const docsDense = { formatValueLabel: 'Custom value label formatter; defaults to percentage string.', variant: 'Semantic color variant.', - isIndeterminate: 'Animated loading indicator for unknown progress.', + isIndeterminate: + 'Animated loading indicator for unknown progress. Under reduced motion it pulses in place instead of sliding.', marks: 'Fixed target marks ({value, label?}) drawn on the track in the 0..max scale; stay visible past the fill. Marks inside the fill take the variant on-color; marks on the bare track take the primary text color (secondary when disabled). A label reveals a tooltip on hover/focus. Ignored when indeterminate.', isDisabled: 'Visually disabled: grays out fill and text.', diff --git a/packages/core/src/ProgressBar/ProgressBar.test.tsx b/packages/core/src/ProgressBar/ProgressBar.test.tsx index 257f5bd472752..c72b75587b47b 100644 --- a/packages/core/src/ProgressBar/ProgressBar.test.tsx +++ b/packages/core/src/ProgressBar/ProgressBar.test.tsx @@ -7,10 +7,24 @@ * @position Colocated unit tests; mark geometry is checked in Storybook */ -import {describe, it, expect, beforeAll} from 'vitest'; +import {describe, it, expect, beforeAll, vi} from 'vitest'; import {render, screen, waitFor} from '@testing-library/react'; import {ProgressBar} from './ProgressBar'; import {getForcedColorsRules} from '../__tests__/forcedColors'; +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import { + progressBar, + registerProgressBarTable, +} from '../motion/uikit/progressBar'; +import {formatMotionRoleValue, motionRole, motionRoleOf} from '../motion/roles'; +import { + REDUCED_MOTION_FADE_QUERY, + REDUCED_MOTION_INSTANT_QUERY, +} from '../motion/reducedMotion'; +import {TOUCH_QUERY} from '../utils/touchQueries'; +import {TOUCH_MIN_FILL_PX} from './ProgressBarTouch'; +import {timing} from '../motion/uikit/timing'; // A labeled mark wraps the tick in a lazily-loaded Tooltip (React.lazy + // Suspense). Preload that chunk once so the Tooltip (and its @@ -914,6 +928,423 @@ describe('ProgressBar theme target names', () => { }); }); +/** + * The value the browser would give `property` on `el` with this pointer and + * reduced-motion preference: the last rule (in stylesheet order) for one of + * its classes whose media conditions all hold. StyleX rewrites a key into + * conditions that negate its later siblings, so this evaluates them rather + * than matching their text. `@supports` blocks never hold (no engine gate). + */ +function valueUnder( + el: Element, + property: string, + env: {coarse: boolean; reduce: boolean}, +): string | undefined { + const holds = (condition: string): boolean => { + const tokens = condition + .replace(/\(pointer:\s*coarse\)/g, env.coarse ? ' T ' : ' F ') + .replace( + /\(prefers-reduced-motion:\s*reduce\)/g, + env.reduce ? ' T ' : ' F ', + ) + // A mouse hovers; any other feature is taken as unmet. + .replace(/\(hover:\s*hover\)/g, env.coarse ? ' F ' : ' T ') + .replace(/\([a-z-]+:[^()]*\)/g, ' F ') + .replace(/\(/g, ' ( ') + .replace(/\)/g, ' ) ') + .trim() + .split(/\s+/); + let i = 0; + const or = (): boolean => { + let value = and(); + while (tokens[i] === 'or') { + i += 1; + value = and() || value; + } + return value; + }; + const and = (): boolean => { + let value = unary(); + while (tokens[i] === 'and') { + i += 1; + value = unary() && value; + } + return value; + }; + const unary = (): boolean => { + const token = tokens[i++]; + if (token === 'not') { + return !unary(); + } + if (token === '(') { + const value = or(); + i += 1; // ')' + return value; + } + if (token === 'T' || token === 'F') { + return token === 'T'; + } + throw new Error(`unexpected ${token} in ${condition}`); + }; + return or(); + }; + // StyleX raises a conditional rule's specificity by repeating its class, + // so the winner is the most specific matching rule, then the last one. + let best: {value: string; specificity: number} | undefined; + const specificityOf = (selector: string) => + (selector.split(':not(')[0].match(/\./g) ?? []).length; + const visit = (rules: CSSRuleList, ok: boolean) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSMediaRule) { + visit( + rule.cssRules, + ok && holds(rule.conditionText ?? rule.media.mediaText), + ); + } else if (rule instanceof CSSSupportsRule) { + visit(rule.cssRules, false); + } else if (rule instanceof CSSStyleRule) { + if ( + ok && + Array.from(el.classList).some(c => + rule.selectorText.includes(`.${c}`), + ) && + rule.style.getPropertyValue(property) !== '' + ) { + const specificity = specificityOf(rule.selectorText); + if (best == null || specificity >= best.specificity) { + best = {value: rule.style.getPropertyValue(property), specificity}; + } + } + } else if ('cssRules' in rule) { + // @layer and other grouping rules hold whatever they wrap. + visit((rule as CSSGroupingRule).cssRules, ok); + } + } + }; + for (const sheet of Array.from(document.styleSheets)) { + visit(sheet.cssRules, true); + } + return best?.value; +} + +/** + * The declarations the element's classes carry in rules at the top level + * (`media` null), inside `@media `, or inside exactly the nested + * conditions listed (`@media` texts as they are, `@supports` ones prefixed, + * in either nesting order), as `prop: value`. + */ +function rulesOf( + el: Element, + media: string | null | ReadonlyArray, +): string[] { + const out: string[] = []; + const key = (list: ReadonlyArray) => list.slice().sort().join(' & '); + const want = key( + media == null ? [] : typeof media === 'string' ? [media] : media, + ); + const visit = (rules: CSSRuleList, conditions: ReadonlyArray) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSMediaRule) { + visit(rule.cssRules, [ + ...conditions, + rule.conditionText ?? rule.media.mediaText, + ]); + } else if (rule instanceof CSSSupportsRule) { + visit(rule.cssRules, [ + ...conditions, + `@supports ${rule.conditionText}`, + ]); + } else if (rule instanceof CSSStyleRule && key(conditions) === want) { + if ( + Array.from(el.classList).some(c => + rule.selectorText.includes(`.${c}`), + ) + ) { + for (let i = 0; i < rule.style.length; i += 1) { + const prop = rule.style[i]; + out.push(`${prop}: ${rule.style.getPropertyValue(prop)}`); + } + } + } + } + }; + for (const sheet of Array.from(document.styleSheets)) { + visit(sheet.cssRules, []); + } + return out; +} + +const valueOf = (declarations: string[], prop: string) => + declarations + .filter(d => d.startsWith(`${prop}:`)) + .map(d => d.slice(prop.length + 1).trim()); + +describe('ProgressBar under a finger: UIProgressView', () => { + const view = progressBar.progressView; + // The resolver's touch query, as TOUCH_QUERY spells it: every touch device. + const TOUCH = TOUCH_QUERY; + const fillOf = () => + screen.getByRole('progressbar').firstElementChild as HTMLElement; + const moveOf = () => fillOf().style.getPropertyValue('--_progress-bar-move'); + const source = () => + readFileSync(path.resolve(__dirname, 'ProgressBar.tsx'), 'utf8'); + + it('styles a mark the minimum fill covers as on the fill', () => { + // A finger, as the touch presentation resolver reads it, and + // a track measured 200 px wide: the 16 px minimum covers its first 8%. + const originalMatchMedia = window.matchMedia; + const originalObserver = globalThis.ResizeObserver; + window.matchMedia = (query: string) => ({ + matches: /pointer:\s*coarse/.test(query), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }); + class MeasuredTrack { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + const entry = {target, contentRect: {width: 200}}; + this.callback([entry as unknown as ResizeObserverEntry], this); + } + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = MeasuredTrack; + // The shared observer's first call reads the box itself. + const box = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({width: 200} as unknown as DOMRect); + try { + render( + , + ); + const placements = Array.from( + document.querySelectorAll('[data-placement]'), + ).map(mark => mark.getAttribute('data-placement')); + // 5% sits inside the drawn 8%; 20% is out on the track. + expect(placements).toEqual(['fill', 'track']); + } finally { + window.matchMedia = originalMatchMedia; + globalThis.ResizeObserver = originalObserver; + box.mockRestore(); + } + }); + + it('moves linearly for the progress.fillTrackMs role times the share of the track it moved, from the last value', () => { + const {rerender} = render(); + // Nothing to animate on the first render. + expect(moveOf()).toBe('0ms'); + rerender(); + expect(moveOf()).toBe(`${0.5 * progressBar.fillTrackMs}ms`); + // A re-render with the same value keeps the running move's length. + rerender(); + expect(moveOf()).toBe(`${0.5 * progressBar.fillTrackMs}ms`); + // The duration is the model change: 60 → 40 is 0.2 of the track. + rerender(); + expect(moveOf()).toBe(`${0.2 * progressBar.fillTrackMs}ms`); + // On the progress.curves.fill role: linear. + expect(fillOf().style.getPropertyValue('--_progress-bar-curve')).toBe( + formatMotionRoleValue('curve', progressBar.curves.fill), + ); + const touch = {coarse: true, reduce: false}; + // CSS scales the move by the theme's tempo (spec:AST-108 FR3). + expect(valueUnder(fillOf(), 'transition-duration', touch)).toMatch( + /^calc\(var\(--_progress-bar-move,\s*0m?s\)\s*\*\s*var\(--_astryx-motion-tempo,\s*1\)\)$/, + ); + expect(valueUnder(fillOf(), 'transition-timing-function', touch)).toMatch( + /^var\(--_progress-bar-curve,\s*linear\)$/, + ); + // No engine gate: the touch query alone decides, on every touch device. + const css = Array.from(document.querySelectorAll('style')) + .map(style => style.textContent ?? '') + .join('\n'); + expect(css).not.toContain('-webkit-touch-callout'); + // With a mouse or trackpad the theme's tokens still time it. + const fine = rulesOf(fillOf(), null); + expect(valueOf(fine, 'transition-duration')[0]).toMatch(/^var\(--/); + expect(valueOf(fine, 'transition-timing-function')[0]).toMatch(/^var\(--/); + }); + + it('never draws shorter than two track heights above zero, and paints nothing at zero', () => { + const {rerender} = render(); + const track = screen.getByRole('progressbar'); + const trackHeight = Number.parseFloat( + valueOf(rulesOf(track, null), 'height')[0], + ); + const minimum = view.minimumFillHeights * trackHeight; + expect(TOUCH_MIN_FILL_PX).toBe(minimum); + expect(fillOf().style.getPropertyValue('--_progress-bar-min')).toBe( + `${minimum}px`, + ); + // Read from the source: the touch transform sits under a negated StyleX + // condition (the forced-colors key after it), which the jsdom runtime + // injects only in part; the Chromium spec checks what a browser draws. + const compact = source().replace(/\s+/g, ''); + const fill = compact.slice(compact.indexOf('fill:{')); + const transform = fill.slice( + fill.indexOf('transform:{'), + fill.indexOf('willChange:'), + ); + const coarse = transform.slice( + transform.indexOf("'@media(pointer:coarse)':{"), + ); + const atLeast = + 'max(var(--_progress-bar-fill,0%),var(--_progress-bar-min,0px))'; + expect(coarse).toContain(atLeast); + // Mirrored in RTL: the fill stops at the same minimum from the right. + expect(coarse).toContain('calc(100%-var(--_progress-bar-min,0px))'); + // Under forced colors with a finger the sized fill keeps that minimum, + // and lands at once: sizing is layout, which touch motion never animates. + const width = fill.slice(fill.indexOf('width:{'), fill.indexOf('height:')); + expect(width).toContain(atLeast); + const duration = fill.slice( + fill.indexOf('transitionDuration:{'), + fill.indexOf('transitionTimingFunction:'), + ); + expect(duration).toMatch( + /'@media\(pointer:coarse\)':\{default:[^}]*'@media\(forced-colors:active\)':'0s'/, + ); + // At zero the fill paints nothing under a finger (UIKit's alpha 0)… + expect(view.emptyFillAlpha).toBe(0); + expect(valueOf(rulesOf(fillOf(), TOUCH), 'opacity')).toEqual(['0']); + // …and is drawn as soon as there is any progress. + rerender(); + expect(valueOf(rulesOf(fillOf(), TOUCH), 'opacity')).toEqual([]); + }); + + it('plays the progress table roles, never a literal', () => { + // component:ProgressBar FR4; spec:AST-094 FR4: touch motion plays its + // roles, never a literal. + registerProgressBarTable(); + expect(motionRole('progress.fillTrackMs')?.value).toBe( + progressBar.fillTrackMs, + ); + expect(motionRoleOf(progressBar.curves.fill)?.id).toBe( + 'progress.curves.fill', + ); + const text = source(); + const fill = text.slice( + text.indexOf(' fill: {'), + text.indexOf(' fillEmpty: {'), + ); + expect(fill).not.toMatch(/\d+m?s\)'|'linear'|\b16px\b/); + }); + + it('declares 0 s under both shared reduced-motion queries, after the touch key', () => { + // The fill's transition-duration declares 0 s under both shared + // reduced-motion queries, after the touch key so they win there. Read + // from the source: the jsdom runtime injects only some of StyleX's + // negated conditions, and the Chromium spec checks what a browser does. + const text = source(); + const fill = text.slice(text.indexOf(' fill: {')); + const duration = fill.slice( + fill.indexOf('transitionDuration: {'), + fill.indexOf('transitionTimingFunction:'), + ); + const keys = [REDUCED_MOTION_INSTANT_QUERY, REDUCED_MOTION_FADE_QUERY].map( + query => duration.indexOf(`'@media ${query}':`), + ); + expect(keys.every(index => index >= 0)).toBe(true); + expect(duration.indexOf("'@media (pointer: coarse)':")).toBeLessThan( + Math.min(...keys), + ); + for (const index of keys) { + expect(duration.slice(index).split(',')[0]).toMatch(/'0s'$|\n\s*'0s'$/); + } + }); +}); + +describe('ProgressBar under reduced motion: the indeterminate pulse', () => { + const fillOf = () => + screen.getByRole('progressbar').firstElementChild as HTMLElement; + const source = () => + readFileSync(path.resolve(__dirname, 'ProgressBar.tsx'), 'utf8'); + + for (const coarse of [false, true]) { + it(`spans the track and only pulses its opacity, with ${coarse ? 'a finger' : 'a mouse'}`, () => { + render(); + const fill = fillOf(); + const reduce = {coarse, reduce: true}; + expect(valueUnder(fill, 'width', reduce)).toBe('100%'); + expect(valueUnder(fill, 'animation-duration', reduce)).toMatch( + /^var\(--_progress-bar-pulse\)$/, + ); + expect(valueUnder(fill, 'animation-timing-function', reduce)).toMatch( + /^var\(--_progress-bar-pulse-curve\)$/, + ); + expect(valueUnder(fill, 'animation-direction', reduce)).toBe('alternate'); + // Without reduced motion the 40% segment still slides. + const motion = {coarse, reduce: false}; + expect(valueUnder(fill, 'width', motion)).toBe('40%'); + expect(valueUnder(fill, 'animation-duration', motion)).toBe('1.5s'); + expect(valueUnder(fill, 'animation-direction', motion)).toBeUndefined(); + }); + } + + it('pulses from full opacity down to the progress table floor and back', () => { + render(); + expect(fillOf().style.getPropertyValue('--_progress-bar-pulse-floor')).toBe( + String(progressBar.reducedMotionPulseFloor), + ); + expect(progressBar.reducedMotionPulseFloor).toBe(0.84); + // jsdom has no CSSKeyframesRule global: match the rule's text. + const frames = Array.from(document.styleSheets) + .flatMap(sheet => Array.from(sheet.cssRules)) + .map(rule => rule.cssText.replace(/\s+/g, '')) + .filter(text => text.startsWith('@keyframes')) + .filter(text => text.includes('--_progress-bar-pulse-floor')); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatch(/opacity:1/); + expect(frames[0]).toMatch(/opacity:var\(--_progress-bar-pulse-floor\)/); + // The pulse never moves the fill. + expect(frames[0]).not.toMatch(/transform/); + }); + + it('plays the progress.reducedMotionPulseMs role on the timing.curves.move curve, which tempo does not scale', () => { + render(); + const fill = fillOf(); + expect(fill.style.getPropertyValue('--_progress-bar-pulse')).toBe( + `${progressBar.reducedMotionPulseMs}ms`, + ); + expect(progressBar.reducedMotionPulseMs).toBe(2000); + expect(fill.style.getPropertyValue('--_progress-bar-pulse-curve')).toBe( + formatMotionRoleValue('curve', timing.curves.move), + ); + registerProgressBarTable(); + expect(motionRole('progress.reducedMotionPulseMs')?.value).toBe( + progressBar.reducedMotionPulseMs, + ); + // A busy loop (spec:AST-108 FR4): nothing in the indeterminate fill reads + // the theme's tempo, and its reduced-motion keys play no literal. + const text = source(); + const block = text.slice( + text.indexOf(' indeterminateFill: {'), + text.indexOf(' // A mark is a vertical tick'), + ); + expect(block).not.toContain('--_astryx-motion-tempo'); + const reduceValues = [ + ...block.matchAll( + /'@media \(prefers-reduced-motion: reduce\)':\s*([^,\n]+)/g, + ), + ].map(match => match[1].trim()); + expect(reduceValues.length).toBeGreaterThan(0); + for (const value of reduceValues) { + expect(value).not.toMatch(/^'\d+(\.\d+)?m?s'$|cubic-bezier|ease/); + } + }); +}); + // jsdom cannot emulate forced colors, so this asserts the compiled rule the // status fill relies on (architecture:theme-tokens INV11). describe('ProgressBar forced colors', () => { diff --git a/packages/core/src/ProgressBar/ProgressBar.tsx b/packages/core/src/ProgressBar/ProgressBar.tsx index 50ca143e31b63..2dd99fde1f15c 100644 --- a/packages/core/src/ProgressBar/ProgressBar.tsx +++ b/packages/core/src/ProgressBar/ProgressBar.tsx @@ -4,7 +4,10 @@ /** * @file ProgressBar.tsx - * @input Uses React, useId, stylex, color/spacing/radius/transition tokens + * @input Uses React, useId, stylex, color/spacing/radius/transition tokens, + * the touch layer (ProgressBarTouch: the progress table's motion roles and + * the 16px touch minimum), and the progress table's reduced-motion pulse + * (motion/uikit progressBar, read through resolveMotion) * @output Exports ProgressBar component, ProgressBarProps, ProgressBarVariant types * @position Core implementation; consumed by index.ts * @@ -12,6 +15,7 @@ * - /packages/core/src/ProgressBar/ProgressBar.doc.mjs (props table, features, implementation notes) * - /packages/core/src/ProgressBar/ProgressBar.test.tsx (tests for new/changed behavior) * - /packages/core/src/ProgressBar/ProgressBarMarkTooltip.tsx (the lazy Tooltip wrapper for labeled marks) + * - /packages/core/src/ProgressBar/ProgressBarTouch.ts (the touch layer's variables) * - /packages/core/src/ProgressBar/index.ts (exports if types change) * - /apps/storybook/stories/ProgressBar.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/ProgressBar/ (showcase blocks) @@ -35,6 +39,11 @@ import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; import {VisuallyHidden} from '../VisuallyHidden'; import type {ProgressBarVariantMap} from './index'; +import {useProgressBarTouch} from './ProgressBarTouch'; +import {formatMotionRoleValue} from '../motion/roles'; +import {resolveMotion} from '../motion/resolveMotion'; +import {progressBar} from '../motion/uikit/progressBar'; +import {timing} from '../motion/uikit/timing'; // Every mark is wrapped in a Tooltip (marks always carry a label), so load it // lazily: a ProgressBar with no marks never bundles the Tooltip chunk. While it @@ -169,6 +178,19 @@ const indeterminateSlideRtl = stylex.keyframes({ }, }); +// Under reduced motion the indeterminate fill stops travelling and pulses in +// place (FR6): from full opacity down to the pulse's floor +// (`--_progress-bar-pulse-floor`, the progress table's) and back, the +// animation alternating direction each pass. +const indeterminatePulse = stylex.keyframes({ + from: { + opacity: 1, + }, + to: { + opacity: 'var(--_progress-bar-pulse-floor)', + }, +}); + // ============================================================================= // Styles // ============================================================================= @@ -249,10 +271,26 @@ const styles = stylex.create({ // no transform), as before: the track's clip would cut the CanvasText // outline's start cap off a slid fill, and that outline is what keeps the // fill's shape visible there (architecture:theme-tokens INV11). + // + // Under a finger (the touch presentation's TOUCH_QUERY, one touch model on + // every touch device) the fill moves the way UIProgressView's + // setProgress(_:animated: true) does: linearly, for the `progress.fillTrackMs` + // role times the share of the track the value moved (`--_progress-bar-move`) + // on the `progress.curves.fill` role (`--_progress-bar-curve`), both played + // by the touch layer (ProgressBarTouch) and scaled here by the theme's tempo, + // starting from wherever it is drawn. Above zero it never draws shorter than + // the touch minimum (`--_progress-bar-min`, 16px on the 8px track), and at + // zero it paints nothing (`fillEmpty`). Under reduced motion it lands at + // once, as everywhere (below). A mouse or trackpad keeps the theme's timing. fill: { width: { default: '100%', - '@media (forced-colors: active)': 'var(--_progress-bar-fill, 0%)', + '@media (forced-colors: active)': { + default: 'var(--_progress-bar-fill, 0%)', + // Under a finger: the touch minimum and the flip offset (below). + '@media (pointer: coarse)': + 'calc(max(var(--_progress-bar-fill, 0%), var(--_progress-bar-min, 0px)) + var(--_progress-bar-flip, 0) * 0.01px)', + }, }, height: '100%', // Forced colors replace status fills with Canvas; this boundary keeps the @@ -271,6 +309,19 @@ const styles = stylex.create({ default: 'translateX(calc(100% - var(--_progress-bar-fill, 0%)))', '@media (forced-colors: active)': 'none', }, + '@media (pointer: coarse)': { + // `--_progress-bar-flip` steps 0, 1, 2, 0… on every change and moves + // the fill by that many hundredths of a pixel, so a change back to + // the value it just left is never the running transition's own start + // value: CSS shortens a transition that reverses to its start, which + // would halve a return mid-flight (10 → 90 → 10). It takes three + // values because two would repeat on that return. + default: + 'translateX(calc(max(var(--_progress-bar-fill, 0%), var(--_progress-bar-min, 0px)) - 100% + var(--_progress-bar-flip, 0) * 0.01px))', + ':dir(rtl)': + 'translateX(calc(min(calc(100% - var(--_progress-bar-fill, 0%)), calc(100% - var(--_progress-bar-min, 0px))) - var(--_progress-bar-flip, 0) * 0.01px))', + }, + // After the touch key, so StyleX negates that key under forced colors. '@media (forced-colors: active)': 'none', }, willChange: { @@ -281,17 +332,37 @@ const styles = stylex.create({ default: 'transform', '@media (forced-colors: active)': 'width', }, - // Under reduced motion a new value lands at once, on every pointer: the - // fill never travels (design:reduced-motion DR1). The keys are the shared - // reduced-motion queries, a fine pointer's and a coarse pointer's, spelled - // as the record's other transitions spell them. + // Under reduced motion a new value lands at once, with a mouse as with a + // finger: the fill never travels (design:reduced-motion DR1). The keys are + // the shared reduced-motion queries, spelled as the record's other + // transitions spell them; the coarse one comes after the touch key so it + // wins there. transitionDuration: { default: durationVars['--duration-medium'], + // The touch layer's move (the `progress.fillTrackMs` role times the + // share moved), scaled by the theme's tempo (`spec:AST-108` FR3). + '@media (pointer: coarse)': { + default: + 'calc(var(--_progress-bar-move, 0ms) * var(--_astryx-motion-tempo, 1))', + // Under forced colors the fill is sized instead, by `width`, a layout + // property touch motion never animates (`spec:AST-094` FR5): with a + // finger it lands at once there. + '@media (forced-colors: active)': '0s', + }, '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': '0s', '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', }, - transitionTimingFunction: easeVars['--ease-standard'], + transitionTimingFunction: { + default: easeVars['--ease-standard'], + '@media (pointer: coarse)': 'var(--_progress-bar-curve, linear)', + }, + }, + fillEmpty: { + opacity: { + default: null, + '@media (pointer: coarse)': 0, + }, }, indeterminateFill: { height: '100%', @@ -304,17 +375,37 @@ const styles = stylex.create({ '@media (forced-colors: active)': 'CanvasText', }, outlineOffset: {default: null, '@media (forced-colors: active)': '-1px'}, - width: '40%', + // Under reduced motion, on every pointer, the fill spans the whole track + // as one solid bar and only its opacity pulses (FR6), each way taking the + // `progress.reducedMotionPulseMs` role on the `timing.curves.move` curve + // (`--_progress-bar-pulse`, `--_progress-bar-pulse-curve`, set inline from + // the progress table). A busy loop: tempo does not scale it. + width: { + default: '40%', + '@media (prefers-reduced-motion: reduce)': '100%', + }, borderRadius: radiusVars['--radius-full'], animationName: { default: indeterminateSlide, - ':is([dir="rtl"] *)': indeterminateSlideRtl, + ':is([dir="rtl"] *)': { + default: indeterminateSlideRtl, + '@media (prefers-reduced-motion: reduce)': indeterminatePulse, + }, + '@media (prefers-reduced-motion: reduce)': indeterminatePulse, }, animationDuration: { default: '1.5s', - '@media (prefers-reduced-motion: reduce)': '3s', + '@media (prefers-reduced-motion: reduce)': 'var(--_progress-bar-pulse)', + }, + animationTimingFunction: { + default: 'ease-in-out', + '@media (prefers-reduced-motion: reduce)': + 'var(--_progress-bar-pulse-curve)', + }, + animationDirection: { + default: null, + '@media (prefers-reduced-motion: reduce)': 'alternate', }, - animationTimingFunction: 'ease-in-out', animationIterationCount: 'infinite', }, // A mark is a vertical tick centered on the track via their shared positioning container. @@ -428,7 +519,39 @@ const markOnTrackStyles = stylex.create({ // `CSSProperties` has no index signature for custom properties, so the fill's // inline style — which publishes the filled share as the private // `--_progress-bar-fill` — needs this augmentation to typecheck. -type FillStyle = CSSProperties & Record<'--_progress-bar-fill', string>; +// The reduced-motion pulse's variables, from the progress table (FR6). +type PulseStyle = CSSProperties & + Record< + | '--_progress-bar-pulse' + | '--_progress-bar-pulse-curve' + | '--_progress-bar-pulse-floor', + string + >; + +type FillStyle = CSSProperties & + Record< + | '--_progress-bar-fill' + | '--_progress-bar-move' + | '--_progress-bar-curve' + | '--_progress-bar-flip' + | '--_progress-bar-min', + string + >; + +/** + * The reduced-motion pulse plays the `progress.reducedMotionPulseMs` role on + * the `timing.curves.move` curve, down to the progress table's floor (FR6, + * AR4). + */ +function pulseStyle(element: Element | null): PulseStyle { + const pulseMs = resolveMotion(element, progressBar, 'reducedMotionPulseMs'); + const curve = resolveMotion(element, timing.curves.move); + return { + '--_progress-bar-pulse': `${pulseMs}ms`, + '--_progress-bar-pulse-curve': formatMotionRoleValue('curve', curve), + '--_progress-bar-pulse-floor': String(progressBar.reducedMotionPulseFloor), + }; +} function defaultFormatValueLabel(value: number, max: number): string { const pct = max > 0 ? Math.round((value / max) * 100) : 0; @@ -509,7 +632,16 @@ export function ProgressBar({ const showValueLabel = hasValueLabel && !isIndeterminate; const fillVariant = isDisabled ? 'disabled' : variant; - const fillStyle: FillStyle = {'--_progress-bar-fill': `${percentage}%`}; + // Under a finger the fill moves as UIProgressView's does and draws at least + // 16px; what that needs from script is the touch layer's (ProgressBarTouch). + const touch = useProgressBarTouch( + percentage, + !isIndeterminate && (marks?.length ?? 0) > 0, + ); + const fillStyle: FillStyle = { + '--_progress-bar-fill': `${percentage}%`, + ...touch.fillVars, + }; // Marks make no sense without a determinate value, so they are only drawn // in determinate mode. Non-finite mark values are dropped; the rest are @@ -532,7 +664,7 @@ export function ProgressBar({ value: mark.value, label: mark.label, pct, - isOnFill: percentage > 0 && pct <= percentage, + isOnFill: percentage > 0 && pct <= touch.drawnPercentage, }; }) : []; @@ -582,6 +714,7 @@ export function ProgressBar({ aria-valuemax={isIndeterminate ? undefined : safeMax} aria-labelledby={labelId} aria-valuetext={isIndeterminate ? undefined : valueText} + ref={touch.trackRef} {...mergeProps( themeProps('progress-bar-track'), stylex.props(styles.track), @@ -595,12 +728,17 @@ export function ProgressBar({ variantStyles[fillVariant], ), )} + style={pulseStyle(null)} /> ) : (
diff --git a/packages/core/src/ProgressBar/ProgressBarTouch.ts b/packages/core/src/ProgressBar/ProgressBarTouch.ts new file mode 100644 index 0000000000000..6de69cd150a38 --- /dev/null +++ b/packages/core/src/ProgressBar/ProgressBarTouch.ts @@ -0,0 +1,119 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ProgressBarTouch.ts + * @input Uses React (useEffect, useRef, useState), the touch presentation + * resolver (useTouchPresentation), the shared resize observer, and the + * progress table's roles (motion/uikit progressBar, read through + * resolveMotion) + * @output useProgressBarTouch: what the touch presentation needs from script, + * the length of the move a change makes (`--_progress-bar-move`: the + * `progress.fillTrackMs` role times the share of the track it moved), its + * curve (`--_progress-bar-curve`: the `progress.curves.fill` role), the + * three-step offset that keeps a return off CSS's reversing path + * (`--_progress-bar-flip`), and, under a finger with marks, the share of + * the track the fill draws at least (TOUCH_MIN_FILL_PX), measured on the + * track + * @position ProgressBar's touch layer, with one import site (ProgressBar.tsx): + * everything ProgressBar runs in script for a finger lives here + * + * SYNC: When modified, update /packages/core/src/ProgressBar/ProgressBar.tsx + * (its touch styles read these variables and the 16px minimum) and + * /packages/core/src/motion/uikit/progressBar.ts. + */ + +import {useEffect, useRef, useState, type RefObject} from 'react'; +import {formatMotionRoleValue} from '../motion/roles'; +import {resolveMotion} from '../motion/resolveMotion'; +import {progressBar} from '../motion/uikit/progressBar'; +import {observeResize} from '../utils/sharedResizeObserver'; +import {useTouchPresentation} from '../utils/touchPresentation'; + +/** The track's height (`component:ProgressBar` FR4). */ +const TRACK_HEIGHT_PX = 8; + +/** + * Under a finger the fill never draws shorter than this above zero: + * UIProgressView's minimum, two track heights, on ProgressBar's 8px track. + */ +export const TOUCH_MIN_FILL_PX = + progressBar.progressView.minimumFillHeights * TRACK_HEIGHT_PX; + +export interface ProgressBarTouch { + /** The fill's touch timing variables, set inline on the fill. */ + fillVars: Record< + | '--_progress-bar-move' + | '--_progress-bar-curve' + | '--_progress-bar-flip' + | '--_progress-bar-min', + string + >; + /** The track, measured under a finger when the bar has marks. */ + trackRef: RefObject; + /** The share of the track the fill draws (0–100), for placing marks. */ + drawnPercentage: number; +} + +export function useProgressBarTouch( + percentage: number, + hasMarks: boolean, +): ProgressBarTouch { + // The touch fill's duration comes from how far the value moved since the + // last one (the model change, as UIKit measures it), held until the next + // change so a re-render with the same value does not touch it. + const [motion, setMotion] = useState({percentage, change: 0, flip: 0}); + const isNewValue = motion.percentage !== percentage; + // The share of the track the value moved (0–1). `flip` steps through three + // values on every change (see the fill's touch transform). + const change = isNewValue + ? Math.abs(percentage - motion.percentage) / 100 + : motion.change; + const flip = isNewValue ? (motion.flip + 1) % 3 : motion.flip; + if (isNewValue) { + setMotion({percentage, change, flip}); + } + + // Under a finger the fill draws at least TOUCH_MIN_FILL_PX long, so a mark + // it covers there sits on the fill. That length is a share of the track's + // width, which only layout knows: the track is measured only under a + // finger, and only when the bar has marks. + const {isTouch} = useTouchPresentation(); + const watchesTrack = isTouch && hasMarks; + const trackRef = useRef(null); + const [trackWidth, setTrackWidth] = useState(0); + useEffect(() => { + const element = trackRef.current; + if (!watchesTrack || element == null) { + return; + } + // The shared observer; its first call (on registration) carries no + // contentRect, so that one reads the box. + return observeResize(element, entry => { + setTrackWidth( + entry.contentRect?.width ?? element.getBoundingClientRect().width, + ); + }); + }, [watchesTrack]); + const drawnPercentage = + watchesTrack && percentage > 0 && trackWidth > 0 + ? Math.max(percentage, (TOUCH_MIN_FILL_PX / trackWidth) * 100) + : percentage; + + // The move plays the progress table's roles: the whole-track duration + // times the share of the track moved, on its linear curve. CSS scales the + // duration by the theme's tempo. + const fillTrackMs = resolveMotion(null, progressBar, 'fillTrackMs'); + const curve = resolveMotion(null, progressBar.curves.fill); + return { + fillVars: { + '--_progress-bar-move': `${Math.round(change * fillTrackMs * 1000) / 1000}ms`, + '--_progress-bar-curve': formatMotionRoleValue('curve', curve), + '--_progress-bar-flip': String(flip), + '--_progress-bar-min': `${TOUCH_MIN_FILL_PX}px`, + }, + trackRef, + drawnPercentage, + }; +} diff --git a/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts b/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts index 67e026a160561..66afb6cdf8889 100644 --- a/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts +++ b/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts @@ -8,13 +8,16 @@ * @output Browser evidence that a determinate value change moves the fill on * the compositor: it keeps moving while a script holds the main thread, * lays out and paints nothing per frame, and its leading edge lands on the - * value (mirrored in RTL). Writes a storyboard (contact sheet + JSON trace) - * to test-results/progress-bar-motion/, the change held and seeked frame by - * frame. + * value (mirrored in RTL); and that under reduced motion the indeterminate + * fill spans the track and only pulses its opacity, on every pointer, at a + * floor that keeps every tone with 3:1 against the track at 3:1. Writes a + * storyboard (contact sheet + JSON trace) to test-results/progress-bar-motion/, + * the change held and seeked frame by frame. * @position Chromium proof for ProgressBar's sliding fill; the iOS Safari side * of the same proof is a simulator recording attached to the pull request. * - * SYNC: Keep in step with ProgressBar.tsx (`styles.fill`, `styles.track`). + * SYNC: Keep in step with ProgressBar.tsx (`styles.fill`, `styles.track`, + * `styles.indeterminateFill`) and motion/uikit/progressBar.ts. */ import fs from 'node:fs'; @@ -36,6 +39,9 @@ import { trackMarker, type TrackedFrame, } from '@astryxdesign/interaction-harness'; +import {formatMotionRoleValue} from '../../motion/roles'; +import {progressBar} from '../../motion/uikit/progressBar'; +import {timing} from '../../motion/uikit/timing'; let storybook: StaticServer; test.beforeAll(async () => { @@ -54,6 +60,7 @@ const EVIDENCE = path.resolve( path.join('test-results', 'progress-bar-motion'), ); const STORY = 'core-progressbar--default'; +const INDETERMINATE = 'core-progressbar--indeterminate-variants'; const BLOCK_MS = 700; const DPR = 2; @@ -571,6 +578,79 @@ for (const pointer of ['a mouse', 'a finger'] as const) { : {hasTouch: false, isMobile: false}, ); + test('under reduced motion the indeterminate fill spans the track and only pulses its opacity', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await page.goto( + `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story`, + ); + await expect(page.getByRole('progressbar').first()).toBeVisible(); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(pointer === 'a finger'); + const pulseMs = progressBar.reducedMotionPulseMs; + const bars = await page.evaluate( + passMs => + Array.from(document.querySelectorAll('[role="progressbar"]')).map( + track => { + const fill = track.firstElementChild as HTMLElement; + const animations = fill.getAnimations(); + const [pulse] = animations; + const timing = pulse.effect?.getTiming(); + const t = track.getBoundingClientRect(); + // Seek one pass: the box never moves, only the opacity changes. + const samples = [0, 0.25, 0.5, 1].map(share => { + pulse.currentTime = Math.min(share * passMs, passMs - 0.01); + const f = fill.getBoundingClientRect(); + return { + left: f.left - t.left, + width: f.width, + opacity: Number(getComputedStyle(fill).opacity), + }; + }); + return { + label: track.getAttribute('aria-labelledby'), + trackWidth: t.width, + count: animations.length, + duration: Number(timing?.duration), + direction: timing?.direction, + iterations: timing?.iterations, + curve: getComputedStyle(fill).animationTimingFunction, + samples, + }; + }, + ), + pulseMs, + ); + expect(bars).toHaveLength(5); + const floor = progressBar.reducedMotionPulseFloor; + for (const bar of bars) { + expect(bar.count, bar.label ?? '').toBe(1); + // The progress.reducedMotionPulseMs role each way, alternating, on + // the timing.curves.move curve. + expect(bar.duration).toBe(pulseMs); + expect(bar.direction).toBe('alternate'); + expect(bar.iterations).toBe(Infinity); + expect(bar.curve).toBe( + formatMotionRoleValue('curve', timing.curves.move), + ); + for (const sample of bar.samples) { + expect(sample.left).toBeCloseTo(0, 1); + expect(sample.width).toBeCloseTo(bar.trackWidth, 1); + } + const [start, quarter, half, end] = bar.samples.map( + sample => sample.opacity, + ); + expect(start).toBeCloseTo(1, 2); + expect(half).toBeCloseTo((1 + floor) / 2, 2); + expect(end).toBeCloseTo(floor, 2); + // Eased, not linear: a quarter of the way it has moved less than a + // quarter of the drop. + expect(quarter).toBeGreaterThan(1 - (1 - floor) / 4 + 0.005); + } + }); + test('under reduced motion a new value lands at once', async ({page}) => { // Both shared reduced-motion queries, a fine pointer's and a coarse // pointer's, set the fill's duration to 0 s. @@ -620,6 +700,142 @@ for (const pointer of ['a mouse', 'a finger'] as const) { }); } +test('a theme\u2019s tempo leaves the reduced-motion pulse unscaled', async ({ + page, +}) => { + // A busy loop (spec:AST-108 FR4): the pulse keeps its role's 2000 ms at a + // slower tempo. + await page.emulateMedia({reducedMotion: 'reduce'}); + await page.goto( + `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story`, + ); + await expect(page.getByRole('progressbar').first()).toBeVisible(); + await page.addStyleTag({content: ':root { --_astryx-motion-tempo: 1.5; }'}); + const durations = await page.evaluate(() => + Array.from(document.querySelectorAll('[role="progressbar"]')).map(track => + Number( + (track.firstElementChild as HTMLElement) + .getAnimations()[0] + ?.effect?.getTiming().duration, + ), + ), + ); + expect(durations).toEqual( + durations.map(() => progressBar.reducedMotionPulseMs), + ); +}); + +test('at the pulse floor every tone with 3:1 against the track keeps it, light and dark, on the body and the surface', async ({ + page, +}) => { + // component:ProgressBar AR4, in the light and dark default themes (the base + // tokens, Storybook's "None" theme, whose muted track is translucent, so + // the body and the surface differ). Composited from the browser's computed + // colors (hex, so the channels are exact): the page background, the muted + // track over it, and the fill over the track at full opacity, at the floor, + // and just below the floor. + const floor = progressBar.reducedMotionPulseFloor; + const rows: { + mode: string; + surface: string; + tone: string; + full: number; + atFloor: number; + below: number; + }[] = []; + for (const mode of ['light', 'dark'] as const) { + await page.emulateMedia({reducedMotion: 'reduce'}); + await page.goto( + `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story&globals=astryxTheme:none;colorMode:${mode}`, + ); + await expect(page.getByRole('progressbar').first()).toBeVisible(); + rows.push( + ...(await page.evaluate( + ({mode: m, opacities}) => { + const parse = (css: string): number[] => { + const rgb = /^rgba?\(([^)]+)\)$/.exec(css); + if (rgb != null) { + const [r, g, b, a = 1] = rgb[1] + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + return [r / 255, g / 255, b / 255, a]; + } + const srgb = /^color\(srgb ([^)]+)\)$/.exec(css); + if (srgb != null) { + const [r, g, b, a = 1] = srgb[1] + .split(/[\s/]+/) + .filter(Boolean) + .map(Number); + return [r, g, b, a]; + } + throw new Error(`unparsed color ${css}`); + }; + const over = (top: number[], alpha: number, under: number[]) => + under.map((channel, i) => + i < 3 ? top[i] * alpha + channel * (1 - alpha) : 1, + ); + const luminance = (c: number[]) => { + const lin = c + .slice(0, 3) + .map(v => + v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; + }; + const contrast = (a: number[], b: number[]) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + }; + const tracks = Array.from( + document.querySelectorAll('[role="progressbar"]'), + ); + const probe = document.createElement('div'); + tracks[0].parentElement?.appendChild(probe); + const out = []; + for (const surface of ['body', 'surface']) { + probe.style.backgroundColor = `var(--color-background-${surface})`; + const page = parse(getComputedStyle(probe).backgroundColor); + for (const track of tracks) { + const fill = track.firstElementChild as HTMLElement; + const trackColor = parse(getComputedStyle(track).backgroundColor); + const fillColor = parse(getComputedStyle(fill).backgroundColor); + const base = over(trackColor, trackColor[3], page); + const at = (opacity: number) => + contrast(over(fillColor, fillColor[3] * opacity, base), base); + const label = document.getElementById( + track.getAttribute('aria-labelledby') ?? '', + ); + out.push({ + mode: m, + surface, + tone: label?.textContent ?? '', + full: at(1), + atFloor: at(opacities.floor), + below: at(opacities.floor - 0.01), + }); + } + } + probe.remove(); + return out; + }, + {mode, opacities: {floor}}, + )), + ); + } + expect(rows).toHaveLength(20); + const qualifying = rows.filter(row => row.full >= 3); + expect(qualifying.length).toBeGreaterThan(0); + for (const row of qualifying) { + expect( + row.atFloor, + `${row.tone}, ${row.mode}, ${row.surface}: ${row.atFloor.toFixed(3)}:1`, + ).toBeGreaterThanOrEqual(3); + } + // The floor is the lowest that holds: one step below, some tone drops. + expect(qualifying.some(row => row.below < 3)).toBe(true); +}); + test('below one track height of fill, the leading end follows the track\u2019s curve', async ({ page, }) => { diff --git a/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts b/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..89dd007abf01b --- /dev/null +++ b/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts @@ -0,0 +1,754 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ProgressBarTouch.a11y.chromium.spec.ts + * @input Built ProgressBar stories in real Chromium with a coarse (touch) + * pointer (Web Animations seeking, CDP screencast) + * @output Browser evidence that under a finger (a coarse primary pointer, on + * every touch device; Chromium as a phone here) a determinate value + * change runs the way UIProgressView's setProgress(_:animated: true) does: + * the `progress.fillTrackMs` role per whole track of the change on the + * `progress.curves.fill` role (linear), scaled by the theme's tempo, + * retargeted from where the fill is drawn with the model change's + * duration, never narrower than two track + * heights above zero, transparent at zero, and still moving while a script + * holds the main thread; and that a mouse or trackpad keeps the theme's + * timing and no minimum. Writes a storyboard (contact sheet + JSON trace) + * to test-results/progress-bar-touch/. + * @position Chromium proof for ProgressBar's touch presentation. Mobile + * Safari and Chrome on Android are simulator and emulator recordings + * attached to the pull request. + * + * SYNC: Keep in step with ProgressBar.tsx (`styles.fill`, `fillEmpty`), + * ProgressBarTouch.ts and motion/uikit/progressBar.ts. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {expect, test, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; +import {formatMotionRoleValue} from '../../motion/roles'; +import {progressBar} from '../../motion/uikit/progressBar'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +const EVIDENCE = path.resolve( + process.env.ASTRYX_MOTION_EVIDENCE ?? + path.join('test-results', 'progress-bar-touch'), +); +const STORY = 'core-progressbar--default'; +const VIEW = progressBar.progressView; +/** The progress.curves.fill role as CSS: linear. */ +const FILL_CURVE = formatMotionRoleValue('curve', progressBar.curves.fill); +const DPR = 3; + +test.use({ + viewport: {width: 402, height: 200}, + deviceScaleFactor: DPR, + hasTouch: true, + isMobile: true, +}); + +/** The default story at `value`, under a finger unless `coarse` is false. */ +async function open( + page: Page, + value: number, + {coarse = true, globals = ''} = {}, +) { + await page.goto( + `${storybook.origin}/iframe.html?id=${STORY}&viewMode=story${globals ? `&globals=${globals}` : ''}`, + ); + await expect(page.getByRole('progressbar')).toBeVisible(); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(coarse); + await setValue(page, value); + await page.waitForTimeout(1200); +} + +/** Re-renders the story with a new value and resolves once it rendered. */ +async function setValue(page: Page, value: number) { + await page.evaluate( + async ({storyId, updatedArgs}) => + new Promise(resolve => { + const channel = ( + window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__: { + emit: (event: string, payload: unknown) => void; + once: (event: string, handler: () => void) => void; + }; + } + ).__STORYBOOK_ADDONS_CHANNEL__; + channel.once('storyRendered', () => resolve()); + channel.emit('updateStoryArgs', {storyId, updatedArgs}); + setTimeout(resolve, 1500); + }), + {storyId: STORY, updatedArgs: {value}}, + ); +} + +/** + * Sets a new value and pauses the fill's transition the moment it is created, + * so its timing can be read and seeked. Resolves with the number of + * transitions on the fill. + */ +async function changeAndPause(page: Page, value: number) { + return page.evaluate( + async ({storyId, updatedArgs}) => + new Promise((resolve, reject) => { + const onRun = (event: Event) => { + const bar = document.querySelector('[role="progressbar"]'); + const fill = bar?.firstElementChild; + if (fill == null || event.target !== fill) { + return; + } + document.removeEventListener('transitionrun', onRun, true); + const animations = fill.getAnimations(); + for (const animation of animations) { + animation.pause(); + } + resolve(animations.length); + }; + document.addEventListener('transitionrun', onRun, true); + ( + window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__: { + emit: (event: string, payload: unknown) => void; + }; + } + ).__STORYBOOK_ADDONS_CHANNEL__.emit('updateStoryArgs', { + storyId, + updatedArgs, + }); + setTimeout(() => reject(new Error('no transition started')), 3000); + }), + {storyId: STORY, updatedArgs: {value}}, + ); +} + +/** Leading edge of the fill, in px from the track's start, and its opacity. */ +async function edge(page: Page) { + return page.evaluate(() => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + return { + edge: + fill.getBoundingClientRect().right - track.getBoundingClientRect().left, + width: track.getBoundingClientRect().width, + opacity: Number(getComputedStyle(fill).opacity), + }; + }); +} + +test('a value change runs linearly for the progress.fillTrackMs role per whole track of the change', async ({ + page, +}) => { + await open(page, 10); + await changeAndPause(page, 90); + const samples = await page.evaluate(() => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + const [transition] = fill.getAnimations(); + const duration = Number(transition.effect?.getTiming().duration); + const out: {t: number; edge: number}[] = []; + for (let i = 0; i <= 8; i += 1) { + const t = (duration * i) / 8; + transition.currentTime = Math.min(t, duration - 0.01); + out.push({ + t, + edge: + fill.getBoundingClientRect().right - + track.getBoundingClientRect().left, + }); + } + return { + duration, + easing: transition.effect?.getTiming().easing, + width: track.getBoundingClientRect().width, + out, + }; + }); + expect(samples.duration).toBeCloseTo(0.8 * progressBar.fillTrackMs, 3); + // The transition's own curve is linear (UIView.animate, curveLinear). + expect([FILL_CURVE, 'linear']).toContain(samples.easing); + for (const {t, edge: at} of samples.out) { + const expected = + samples.width * + (0.1 + (0.8 * Math.min(t, samples.duration)) / samples.duration); + expect(at, `edge at ${t} ms`).toBeCloseTo(expected, 0); + } +}); + +test('a theme\u2019s tempo scales the move', async ({page}) => { + // A theme with a tempo writes it beside its tokens (spec:AST-108 FR3); + // the move's duration is the role times the share moved, times tempo. + await open(page, 10); + await page.addStyleTag({content: ':root { --_astryx-motion-tempo: 1.5; }'}); + await changeAndPause(page, 90); + const duration = await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const [transition] = fill.getAnimations(); + return Number(transition.effect?.getTiming().duration); + }); + expect(duration).toBeCloseTo(1.5 * 0.8 * progressBar.fillTrackMs, 3); +}); + +test('with a mouse or trackpad the theme\u2019s timing and no minimum stay: touch work costs nothing under a fine pointer', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 402, height: 200}, + deviceScaleFactor: 3, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + await open(page, 10, {coarse: false}); + // A fine primary pointer in this context, before any check. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const theme = await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const style = getComputedStyle(fill); + return { + duration: style.transitionDuration, + easing: style.transitionTimingFunction, + }; + }); + await changeAndPause(page, 90); + const change = await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const [transition] = fill.getAnimations(); + return { + duration: Number(transition.effect?.getTiming().duration), + easing: String(transition.effect?.getTiming().easing), + }; + }); + // The theme's `--duration-medium` and `--ease-standard`, not UIKit's + // linear 0.8 s. + const themeMs = + Number.parseFloat(theme.duration) * + (theme.duration.endsWith('ms') ? 1 : 1000); + expect(change.duration).toBeCloseTo(themeMs, 0); + expect(change.duration).not.toBeCloseTo(0.8 * progressBar.fillTrackMs, 0); + expect(change.easing).toBe(theme.easing); + expect(change.easing).not.toBe('linear'); + expect(change.easing).not.toBe(FILL_CURVE); + // At 1% the fill is 1% of the track, with no two-track-height minimum. + await page.evaluate(() => { + for (const animation of document.getAnimations()) { + animation.finish(); + } + }); + await setValue(page, 1); + await page.waitForTimeout(800); + const box = await page.evaluate(() => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + return { + edge: + fill.getBoundingClientRect().right - track.getBoundingClientRect().left, + width: track.getBoundingClientRect().width, + }; + }); + expect(box.edge).toBeCloseTo(box.width * 0.01, 0); + await context.close(); +}); + +test('a change mid-flight starts from where the fill is drawn, timed by the model change', async ({ + page, +}) => { + await open(page, 10); + await changeAndPause(page, 90); + // Hold the first change halfway (50% drawn), then retarget to 30%. + await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const [transition] = fill.getAnimations(); + transition.currentTime = + Number(transition.effect?.getTiming().duration) / 2; + }); + const before = await edge(page); + expect(before.edge / before.width).toBeCloseTo(0.5, 2); + await changeAndPause(page, 30); + const retarget = await page.evaluate(() => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + // A retarget replaces the running transition with a new one. + const animations = fill.getAnimations(); + const transition = animations[animations.length - 1]; + transition.currentTime = 0; + return { + duration: Number(transition.effect?.getTiming().duration), + start: + fill.getBoundingClientRect().right - track.getBoundingClientRect().left, + width: track.getBoundingClientRect().width, + }; + }); + // Starts from the drawn 50%, not from the old value… + expect(retarget.start / retarget.width).toBeCloseTo(0.5, 2); + // …for 90 → 30 on the model: 0.6 of the track, 600 ms. + expect(retarget.duration).toBeCloseTo(0.6 * progressBar.fillTrackMs, 3); +}); + +test('a change back to the value it just left keeps the model change\u2019s duration', async ({ + page, +}) => { + await open(page, 10); + await changeAndPause(page, 90); + // Hold the first change halfway, then send it back to 10%: CSS shortens + // a transition that reverses to its own start value, which would halve + // this return. + await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const [transition] = fill.getAnimations(); + transition.currentTime = + Number(transition.effect?.getTiming().duration) / 2; + }); + await changeAndPause(page, 10); + const duration = await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const animations = fill.getAnimations(); + return Number( + animations[animations.length - 1].effect?.getTiming().duration, + ); + }); + // 90 → 10 on the model: 0.8 of the track, 800 ms. + expect(duration).toBeCloseTo(0.8 * progressBar.fillTrackMs, 3); +}); + +test('under reduced motion a new value lands at once under a finger too', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await open(page, 20); + // Read the fill in the same task React commits the new value: a running + // transition shows up there, before any frame could finish it. + const state = await page.evaluate( + async ({storyId}) => + new Promise<{running: number; edge: number}>(resolve => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as HTMLElement; + const observer = new MutationObserver(() => { + observer.disconnect(); + const t = track.getBoundingClientRect(); + resolve({ + running: fill.getAnimations().length, + edge: (fill.getBoundingClientRect().right - t.left) / t.width, + }); + }); + observer.observe(fill, {attributes: true, attributeFilter: ['style']}); + ( + window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__: { + emit: (event: string, payload: unknown) => void; + }; + } + ).__STORYBOOK_ADDONS_CHANNEL__.emit('updateStoryArgs', { + storyId, + updatedArgs: {value: 80}, + }); + }), + {storyId: STORY}, + ); + expect(state.running).toBe(0); + expect(state.edge).toBeCloseTo(0.8, 2); +}); + +test('never narrower than two track heights above zero, transparent at zero', async ({ + page, +}) => { + await open(page, 0); + const trackHeight = await page.evaluate( + () => + ( + document.querySelector('[role="progressbar"]') as Element + ).getBoundingClientRect().height, + ); + const minimum = VIEW.minimumFillHeights * trackHeight; + const empty = await edge(page); + expect(empty.opacity).toBe(VIEW.emptyFillAlpha); + expect(empty.edge).toBeCloseTo(minimum, 1); + await setValue(page, 1); + await page.waitForTimeout(400); + const tiny = await edge(page); + expect(tiny.opacity).toBe(1); + expect(tiny.edge).toBeCloseTo(minimum, 1); + await setValue(page, 50); + await page.waitForTimeout(800); + const half = await edge(page); + expect(half.edge).toBeCloseTo(half.width / 2, 0); +}); + +test('in RTL the minimum and the motion mirror', async ({page}) => { + // The story's own direction: a real RTL region (Storybook keeps every + // story inside its own \`dir\`). + await open(page, 0, {globals: 'direction:rtl'}); + const measure = async () => + page.evaluate(() => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + const t = track.getBoundingClientRect(); + const f = fill.getBoundingClientRect(); + // Visible length from the track's right (start) edge. + return {length: t.right - Math.max(f.left, t.left), height: t.height}; + }); + await setValue(page, 1); + await page.waitForTimeout(400); + const tiny = await measure(); + expect(tiny.length).toBeCloseTo(VIEW.minimumFillHeights * tiny.height, 1); + await changeAndPause(page, 81); + const half = await page.evaluate(() => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + const [transition] = fill.getAnimations(); + transition.currentTime = + Number(transition.effect?.getTiming().duration) / 2; + const t = track.getBoundingClientRect(); + return { + length: t.right - Math.max(fill.getBoundingClientRect().left, t.left), + width: t.width, + duration: Number(transition.effect?.getTiming().duration), + }; + }); + // 1 → 81 is 0.8 of the track: 800 ms. It leaves from the drawn minimum, + // so halfway sits midway between that and 81%, measured from the right. + expect(half.duration).toBeCloseTo(0.8 * progressBar.fillTrackMs, 3); + expect(half.length).toBeCloseTo((tiny.length + 0.81 * half.width) / 2, 0); +}); + +test('in forced colors under a finger the fill lands at once, sized to the value, its outline whole, in either direction', async ({ + page, +}) => { + // architecture:theme-tokens INV11: forced colors keep the fill's shape with + // a CanvasText outline, and the track clips what it holds, so the fill is + // sized to the value there (still at least two track heights above zero) + // rather than slid along the track. Sizing is layout, which touch motion + // never animates (spec:AST-094 FR5), so the new size lands at once. + await page.emulateMedia({forcedColors: 'active'}); + for (const {globals, fromLeft} of [ + {globals: '', fromLeft: true}, + {globals: 'direction:rtl', fromLeft: false}, + ]) { + await open(page, 0, {globals}); + for (const value of [1, 50]) { + // Read the fill in the same task React commits the new value: a + // transition would show up there, before any frame could finish it. + const {track, fill, height, running} = await page.evaluate( + async ({storyId, next}) => + new Promise<{ + track: {left: number; right: number}; + fill: {left: number; right: number}; + height: number; + running: number; + }>(resolve => { + const bar = document.querySelector( + '[role="progressbar"]', + ) as Element; + const child = bar.firstElementChild as HTMLElement; + const observer = new MutationObserver(() => { + observer.disconnect(); + const running = child.getAnimations().length; + const t = bar.getBoundingClientRect(); + const f = child.getBoundingClientRect(); + resolve({ + track: {left: t.left, right: t.right}, + fill: {left: f.left, right: f.right}, + height: t.height, + running, + }); + }); + observer.observe(child, { + attributes: true, + attributeFilter: ['style'], + }); + ( + window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__: { + emit: (event: string, payload: unknown) => void; + }; + } + ).__STORYBOOK_ADDONS_CHANNEL__.emit('updateStoryArgs', { + storyId, + updatedArgs: {value: next}, + }); + }), + {storyId: STORY, next: value}, + ); + const label = `${globals || 'ltr'} ${value}%`; + expect(running, `${label}: no transition runs`).toBe(0); + const expected = Math.max( + ((track.right - track.left) * value) / 100, + VIEW.minimumFillHeights * height, + ); + if (fromLeft) { + expect(fill.left - track.left, label).toBeCloseTo(0, 0); + expect(fill.right - fill.left, label).toBeCloseTo(expected, 0); + } else { + expect(track.right - fill.right, label).toBeCloseTo(0, 0); + expect(fill.right - fill.left, label).toBeCloseTo(expected, 0); + } + } + } +}); + +test('the fill keeps moving while a script holds the main thread', async ({ + page, +}) => { + await open(page, 10); + const frames: {t: number; edge: number}[] = []; + const cdp = await page.context().newCDPSession(page); + const raw: {t: number; data: string}[] = []; + const acks: Promise[] = []; + const ackFailures: string[] = []; + cdp.on('Page.screencastFrame', frame => { + raw.push({ + t: (frame.metadata.timestamp ?? Date.now() / 1000) * 1000, + data: frame.data, + }); + acks.push( + cdp + .send('Page.screencastFrameAck', {sessionId: frame.sessionId}) + .catch((error: unknown) => { + ackFailures.push(String(error)); + }), + ); + }); + const box = await page.getByRole('progressbar').boundingBox(); + if (box == null) { + throw new Error('no track'); + } + await cdp.send('Page.startScreencast', { + format: 'png', + maxWidth: 402 * DPR, + maxHeight: 200 * DPR, + everyNthFrame: 1, + }); + await page.waitForTimeout(300); + // A value change, one presented frame, then 700 ms of script. + const busy = await page.evaluate( + async ({storyId}) => + new Promise<{from: number; to: number}>(resolve => { + const onStart = (event: Event) => { + const bar = document.querySelector('[role="progressbar"]'); + if (bar == null || !bar.contains(event.target as Node)) { + return; + } + document.removeEventListener('transitionstart', onStart, true); + requestAnimationFrame(() => + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 700; + while (performance.now() < end) { + // hold + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }), + ); + }; + document.addEventListener('transitionstart', onStart, true); + ( + window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__: { + emit: (event: string, payload: unknown) => void; + }; + } + ).__STORYBOOK_ADDONS_CHANNEL__.emit('updateStoryArgs', { + storyId, + updatedArgs: {value: 90}, + }); + }), + {storyId: STORY}, + ); + await page.waitForTimeout(300); + await cdp.send('Page.stopScreencast'); + await Promise.all(acks); + await cdp.detach(); + // A lost acknowledgement stalls the screencast and thins the frames. + expect(ackFailures).toEqual([]); + // Leading edge per frame, along the track's middle row. + const scratch = await page.context().newPage(); + try { + for (let i = 0; i < raw.length; i += 10) { + const edges = await scratch.evaluate( + async ({items, track}) => { + const out: number[] = []; + for (const item of items) { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${item}`)).blob(), + ); + const scale = bitmap.width / 402; + const w = Math.round(track.width * scale); + const canvas = new OffscreenCanvas(w, 1); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage( + bitmap, + Math.round(track.x * scale), + Math.round((track.y + track.height / 2) * scale), + w, + 1, + 0, + 0, + w, + 1, + ); + const {data} = context.getImageData(0, 0, w, 1); + const at = (x: number) => [ + data[x * 4], + data[x * 4 + 1], + data[x * 4 + 2], + ]; + const fill = at(Math.round(4 * scale)); + const rest = at(w - Math.round(4 * scale)); + const d = (a: number[], b: number[]) => + Math.abs(a[0] - b[0]) + + Math.abs(a[1] - b[1]) + + Math.abs(a[2] - b[2]); + let edgeAt = 0; + for (let x = 0; x < w; x += 1) { + if (d(at(x), fill) < d(at(x), rest)) { + edgeAt = x + 1; + } + } + out.push(edgeAt / scale); + } + return out; + }, + {items: raw.slice(i, i + 10).map(f => f.data), track: box}, + ); + edges.forEach((e, k) => frames.push({t: raw[i + k].t, edge: e})); + } + } finally { + await scratch.close(); + } + const inside = frames.filter( + f => f.t >= busy.from + 20 && f.t <= busy.to - 20, + ); + const positions = new Set(inside.map(f => Math.round(f.edge))); + // Linear for 800 ms: it is still moving through the whole 700 ms block. + expect(positions.size).toBeGreaterThanOrEqual(20); + for (let i = 1; i < inside.length; i += 1) { + expect(inside[i].edge).toBeGreaterThanOrEqual(inside[i - 1].edge - 1); + } +}); + +test('storyboard: 10% to 90% under a finger @storyboard', async ({ + page, +}, testInfo) => { + await open(page, 10); + await changeAndPause(page, 90); + const step = 1000 / 60; + const shots: {t: number; edge: number; png: string}[] = []; + const duration = await page.evaluate(() => { + const fill = document.querySelector('[role="progressbar"]') + ?.firstElementChild as Element; + const [transition] = fill.getAnimations(); + return Number(transition.effect?.getTiming().duration); + }); + for (let t = 0; t <= duration; t += step * 3) { + const at = await page.evaluate( + time => { + const track = document.querySelector('[role="progressbar"]') as Element; + const fill = track.firstElementChild as Element; + const [transition] = fill.getAnimations(); + transition.currentTime = time; + return ( + fill.getBoundingClientRect().right - + track.getBoundingClientRect().left + ); + }, + Math.min(t, duration - 0.01), + ); + const png = (await page.getByRole('progressbar').screenshot()).toString( + 'base64', + ); + shots.push({t: Math.round(t * 10) / 10, edge: at, png}); + } + fs.mkdirSync(EVIDENCE, {recursive: true}); + fs.writeFileSync( + path.join(EVIDENCE, 'progress-bar-touch-move.json'), + JSON.stringify( + { + story: STORY, + from: 10, + to: 90, + duration, + frames: shots.map(({t, edge: e}) => ({ + t, + edge: Math.round(e * 10) / 10, + })), + }, + null, + 1, + ), + ); + const sheet = await page.context().newPage(); + try { + const png = await sheet.evaluate( + async items => { + const decoded = await Promise.all( + items.map(async item => + createImageBitmap( + await (await fetch(`data:image/png;base64,${item}`)).blob(), + ), + ), + ); + const w = decoded[0].width; + const h = decoded[0].height + 6; + const canvas = new OffscreenCanvas(w, decoded.length * h); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.fillStyle = '#fff'; + context.fillRect(0, 0, canvas.width, canvas.height); + decoded.forEach((bitmap, i) => context.drawImage(bitmap, 0, i * h + 3)); + const blob = await canvas.convertToBlob({type: 'image/png'}); + const bytes = new Uint8Array(await blob.arrayBuffer()); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); + }, + shots.map(s => s.png), + ); + fs.writeFileSync( + path.join(EVIDENCE, 'progress-bar-touch-move.png'), + Buffer.from(png, 'base64'), + ); + await testInfo.attach('progress-bar-touch-move.png', { + path: path.join(EVIDENCE, 'progress-bar-touch-move.png'), + contentType: 'image/png', + }); + } finally { + await sheet.close(); + } + // The trace is the assertion (checked after it is written, so a failing + // run still leaves its storyboard): equal steps of travel per equal time. + const deltas = shots.slice(1).map((s, i) => s.edge - shots[i].edge); + for (const delta of deltas.slice(0, -1)) { + expect(delta).toBeCloseTo(deltas[0], 0); + } +}); diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index fe236bf310866..d7c4e4abadf49 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -37,27 +37,28 @@ installed); rows marked † also on iOS 18 for the pre-glass delta. iPhone ## Shared (foundation) -| ID | What | App and gesture | Extract | Method | Feeds | -| ----- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| M-S1 | Deceleration rates | none | `UIScrollView.DecelerationRate.normal.rawValue`, `.fast.rawValue` | D | `scroll.decelerationRate` | -| M-S2 | Momentum coast | A long list (Contacts, or a probe table of 500 rows): flicks released at 1000, 2500, 5000 pt/s | contentOffset(t) from release to rest; decay constant; per-millisecond vs per-frame model; the speed at which it stops | C | `scroll.decelerationRate` | -| M-S3 | Bounce-back spring | Same list: drag 120 pt past the top, hold still, release; a flick into the top edge at 3000 pt/s | contentOffset(t) after release and after edge contact; spring response and damping ratio; velocity hand-off | C | `scroll.bounce` | -| M-S5 | Rubber band | Same list on an 852 pt view: drag 100, 300, 600 pt past the top; a horizontal pager | displayed overflow against finger overflow; confirm c = 0.55 and d = the view's size on the axis | C | `scroll.rubberBand` | -| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `scroll.paging` | -| M-ST1 | Scroll to top | Settings scrolled down 500, 2000, 8000 pt; tap the status bar | contentOffset(t); duration against distance; curve or spring fit | B, C | `scroll.scrollToTopRate` | -| M-N1 | Navigation push | Settings: tap General | incoming leading edge x(t), outgoing x(t) (parallax fraction), dimming alpha(t); animation parameters | A, B | `springs.navigationPush` | -| M-N2 | Navigation pop | General: back button | same as M-N1 | A, B | `springs.navigationPop` | -| M-N3 | Interactive pop | Settings ▸ General: edge swipe released at 40 % of the width at 600 pt/s (finishes) and at 30 % at rest (cancels) | x(t) after release; spring parameters; the finish / cancel rule (position, velocity) | A, B | `springs.navigationInteractivePopFinish` | -| M-A1 | Alert | Probe app: `UIAlertController(.alert)` presented and dismissed | scale(t), alpha(t) of the alert and the dimming view; animation parameters | A | `springs.alertPresent`, `.alertDismiss` | -| M-AS1 | Action sheet and menu | Probe app: `.actionSheet`; a button with a `UIMenu` as its primary action; Files: the … menu | scale, alpha, position (t) on open and close; animation parameters | A, B | `springs.actionSheet*`, `.menu*` | -| M-CM1 | Context menu | Files: long-press an item; dismiss by tapping outside | preview scale(t) on lift, backdrop blur and dim (t), menu scale(t); the preview's return on dismiss | A, B | `springs.contextMenu*` | -| M-K1 | UIKit legacy spring | Probe app: `UIView.animate(withDuration:usingSpringWithDamping:initialSpringVelocity:)`, durations 0.3, 0.5, 1 × ratios 0.5, 0.8, 1 × velocities 0, 2 | the `CASpringAnimation` UIKit attaches: mass, stiffness, damping, initialVelocity, duration, settlingDuration | A | `springs.ts` (UIKit's legacy springs) | -| M-K2 | Default spring, keyboard | Probe app: an animator with `UISpringTimingParameters()`; focus and blur a text field | both springs' parameters and allowsOverdamping; the keyboard notification's duration and curve | A, D | `springs.viewChange`, `.keyboard`, `timing.keyboard*` | -| M-CA1 | Settling duration | none: `CASpringAnimation` mass 1, stiffness 100, damping 5, 10, 15, 20, 40 × initialVelocity 0, 5, −5 × allowsOverdamping on, off | `settlingDuration` of each (checks the critically damped and overdamped branches) | D | `springs.ts` (settling) | -| M-T1 | Banner | A notification pushed to the simulator while an app is in the foreground | banner y(t) in and out; time on screen | B | `springs.banner` | -| M-RM1 | Reduced Motion | Reduce Motion on, Prefer Cross-Fade Transitions on and off: Settings push and pop, an alert (sheets: M-SH13; controls: M-SW11, M-SL12, M-SG15) | which transitions become cross-fades; alpha(t), duration and curve; what still moves | B | `timing.reducedMotionCrossFadeMs` (played at 200 ms: `design:reduced-motion` DR2 caps it, DEC-1 of 2026-10-24, departing from the measured 0.5 s) | -| M-PR1 | Press release curve | Settings and Contacts: a row tapped and released (the deselect cross-fade) | the timing function Core Animation attaches to the deselect fade (control points) | A | `timing.press.highlightOutCurve` | -| M-NB1 | Navigation bar hide / show | none | `UINavigationController.hideShowBarDuration` | D | `timing.navigationBarHideShowMs` | +| ID | What | App and gesture | Extract | Method | Feeds | +| ----- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| M-S1 | Deceleration rates | none | `UIScrollView.DecelerationRate.normal.rawValue`, `.fast.rawValue` | D | `scroll.decelerationRate` | +| M-S2 | Momentum coast | A long list (Contacts, or a probe table of 500 rows): flicks released at 1000, 2500, 5000 pt/s | contentOffset(t) from release to rest; decay constant; per-millisecond vs per-frame model; the speed at which it stops | C | `scroll.decelerationRate` | +| M-S3 | Bounce-back spring | Same list: drag 120 pt past the top, hold still, release; a flick into the top edge at 3000 pt/s | contentOffset(t) after release and after edge contact; spring response and damping ratio; velocity hand-off | C | `scroll.bounce` | +| M-S5 | Rubber band | Same list on an 852 pt view: drag 100, 300, 600 pt past the top; a horizontal pager | displayed overflow against finger overflow; confirm c = 0.55 and d = the view's size on the axis | C | `scroll.rubberBand` | +| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `scroll.paging` | +| M-ST1 | Scroll to top | Settings scrolled down 500, 2000, 8000 pt; tap the status bar | contentOffset(t); duration against distance; curve or spring fit | B, C | `scroll.scrollToTopRate` | +| M-N1 | Navigation push | Settings: tap General | incoming leading edge x(t), outgoing x(t) (parallax fraction), dimming alpha(t); animation parameters | A, B | `springs.navigationPush` | +| M-N2 | Navigation pop | General: back button | same as M-N1 | A, B | `springs.navigationPop` | +| M-N3 | Interactive pop | Settings ▸ General: edge swipe released at 40 % of the width at 600 pt/s (finishes) and at 30 % at rest (cancels) | x(t) after release; spring parameters; the finish / cancel rule (position, velocity) | A, B | `springs.navigationInteractivePopFinish` | +| M-A1 | Alert | Probe app: `UIAlertController(.alert)` presented and dismissed | scale(t), alpha(t) of the alert and the dimming view; animation parameters | A | `springs.alertPresent`, `.alertDismiss` | +| M-AS1 | Action sheet and menu | Probe app: `.actionSheet`; a button with a `UIMenu` as its primary action; Files: the … menu | scale, alpha, position (t) on open and close; animation parameters | A, B | `springs.actionSheet*`, `.menu*` | +| M-CM1 | Context menu | Files: long-press an item; dismiss by tapping outside | preview scale(t) on lift, backdrop blur and dim (t), menu scale(t); the preview's return on dismiss | A, B | `springs.contextMenu*` | +| M-K1 | UIKit legacy spring | Probe app: `UIView.animate(withDuration:usingSpringWithDamping:initialSpringVelocity:)`, durations 0.3, 0.5, 1 × ratios 0.5, 0.8, 1 × velocities 0, 2 | the `CASpringAnimation` UIKit attaches: mass, stiffness, damping, initialVelocity, duration, settlingDuration | A | `springs.ts` (UIKit's legacy springs) | +| M-K2 | Default spring, keyboard | Probe app: an animator with `UISpringTimingParameters()`; focus and blur a text field | both springs' parameters and allowsOverdamping; the keyboard notification's duration and curve | A, D | `springs.viewChange`, `.keyboard`, `timing.keyboard*` | +| M-CA1 | Settling duration | none: `CASpringAnimation` mass 1, stiffness 100, damping 5, 10, 15, 20, 40 × initialVelocity 0, 5, −5 × allowsOverdamping on, off | `settlingDuration` of each (checks the critically damped and overdamped branches) | D | `springs.ts` (settling) | +| M-T1 | Banner | A notification pushed to the simulator while an app is in the foreground | banner y(t) in and out; time on screen | B | `springs.banner` | +| M-RM1 | Reduced Motion | Reduce Motion on, Prefer Cross-Fade Transitions on and off: Settings push and pop, an alert (sheets: M-SH13; controls: M-SW11, M-SL12, M-SG15) | which transitions become cross-fades; alpha(t), duration and curve; what still moves | B | `timing.reducedMotionCrossFadeMs` (played at 200 ms: `design:reduced-motion` DR2 caps it, DEC-1 of 2026-10-24, departing from the measured 0.5 s) | +| M-PR1 | Press release curve | Settings and Contacts: a row tapped and released (the deselect cross-fade) | the timing function Core Animation attaches to the deselect fade (control points) | A | `timing.press.highlightOutCurve` | +| M-NB1 | Navigation bar hide / show | none | `UINavigationController.hideShowBarDuration` | D | `timing.navigationBarHideShowMs` | +| M-PB1 | Indeterminate pulse | Reduce Motion on: Safari loading a page (its indeterminate bar) beside ProgressBar's reduced-motion pulse | Not a parity value: an owner value (`component:ProgressBar` DEC-2); confirm the 2000 ms pulse reads as calm and the 0.84 floor keeps 3:1 (AR4) | B | `progressBar.reducedMotionPulseMs`, `progressBar.reducedMotionPulseFloor` | Done (UIKit lab, iOS 26.2 simulator, Oct 23 2026; values in `uikit/` tagged `measured:`): M-S1, M-NB1, M-K2, M-CA1 (underdamped grid matched to the last diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 6a0f6b68ac379..04ed1effc6e9b 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -7,7 +7,8 @@ source), the spring, decay, rubber-band and projection math UIKit uses, and a compositor animator that plays it. Nothing here is exported from the package entry point. Core's motion and gesture modules read the table by path; components read touch motion through its internal motion roles (`roles.ts`), -so each keeps its measured value; no shipped component reads the roles yet. +so each keeps its measured value; ProgressBar plays the progress table's two +roles through `resolveMotion`. ## Files @@ -18,6 +19,7 @@ so each keeps its measured value; no shipped component reads the roles yet. | `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, paging, `delaysContentTouches`, scroll to top. | | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | +| `uikit/progressBar.ts` | UIProgressView's fill: the whole-track duration and curve (two roles), its minimum length and empty alpha. | | `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). | | `roles.ts` | The internal motion roles: the springs, durations and curves `spec:AST-094` FR4 names, registered by the shared tables, and a role's CSS form read back. | | `roleValue.ts` | A role's CSS form, written: a spring's duration and bounce, a duration in ms, a curve's `cubic-bezier()`. | diff --git a/packages/core/src/motion/uikit/index.ts b/packages/core/src/motion/uikit/index.ts index a63ae7d6eaf18..66dac28bc22e5 100644 --- a/packages/core/src/motion/uikit/index.ts +++ b/packages/core/src/motion/uikit/index.ts @@ -4,7 +4,7 @@ * @file index.ts * @input The domain files of the UIKit constants table * @output Re-exports `springs` and one constant per spring (`SHEET_PRESENT_SPRING`, - * …), `scroll`, `timing`, `navigation` and the + * …), `scroll`, `timing`, `navigation`, `progressBar` and the * gesture thresholds (one `UIKIT_*` group per recognizer, their union * `UIKIT_GESTURES`, and the haptic tables) * @position Entry of the UIKit constants table (motion/uikit). Internal to @@ -39,6 +39,7 @@ export { type HapticFeedbackKind, } from './gestures'; export {navigation, registerNavigationTable} from './navigation'; +export {progressBar, registerProgressBarTable} from './progressBar'; export {registerScrollTable, scroll} from './scroll'; export { ACTION_SHEET_DISMISS_SPRING, diff --git a/packages/core/src/motion/uikit/progressBar.ts b/packages/core/src/motion/uikit/progressBar.ts new file mode 100644 index 0000000000000..1aaf60e496a1e --- /dev/null +++ b/packages/core/src/motion/uikit/progressBar.ts @@ -0,0 +1,111 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file progressBar.ts + * @input Nothing; data only + * @output Exports `progressBar` (UIProgressView's fill: the whole-track + * duration and curve, its minimum length and its empty alpha; and the + * reduced-motion pulse) and `registerProgressBarTable` + * @position ProgressBar's rows of the UIKit constants table (motion/uikit); + * read by ProgressBar's touch layer (ProgressBar/ProgressBarTouch.ts) + * + * Every leaf carries one or more `@source`; webkit and GUESS values also carry + * the `@measure` task that will confirm or replace them. uikit.test.ts + * enforces both. + * + * Lengths are in points (CSS px), durations in ms, alphas in 0..1. + * + * Three values are motion roles, named by `component:ProgressBar` FR4 and + * FR6 and listed in the generated motion role table (`spec:AST-094` DEC-5): + * the whole-track duration, `progress.fillTrackMs`, its curve, + * `progress.curves.fill`, and the reduced-motion pulse, + * `progress.reducedMotionPulseMs`. Every other value is an internal + * constant. + * + * SYNC: When modified, update /packages/core/src/ProgressBar/ProgressBarTouch.ts + * (the touch layer that plays these values) and + * /packages/core/src/ProgressBar/ProgressBar.test.tsx. + */ + +import {registerMotionTable} from '../roles'; + +/** UIProgressView's values, for ProgressBar. */ +export const progressBar = { + /** + * setProgress(_:animated: true) runs this long for a change of the whole + * track (0 → 1), and in proportion for less, measured on the model values + * (0.2 → 0.8 takes 0.6 s), starting from wherever the fill is drawn when + * it is retargeted. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIView animation on the fill's frame (duration, + * beginFromCurrentState) for jumps of 0.6, 0.4 and 0.6, Oct 23 2026 + */ + fillTrackMs: 1000, + curves: { + /** + * That animation's curve: linear, and Reduce Motion leaves it unchanged. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the fill's position animation (timing function + * linear), with Reduce Motion off and on, Oct 23 2026 + */ + fill: [0, 0, 1, 1], + }, + /** + * Under reduced motion the indeterminate fill stops travelling and pulses + * its opacity in place, each way taking this long, on the + * `timing.curves.move` curve (`component:ProgressBar` FR6). A busy loop, so + * tempo does not scale it. + * @source GUESS — An owner value, not a UIKit number + * (`component:ProgressBar` DEC-2): iOS Safari pulses its indeterminate bar + * for 1 s between 0.30 and 0.60 opacity; ProgressBar pulses slower, at a + * floor that keeps the bar visible. + * @measure M-PB1 + */ + reducedMotionPulseMs: 2000, + /** + * That pulse's lowest opacity: the lowest at which every tone that reaches + * 3:1 against the track at full opacity still does, in the light and dark + * default themes (`component:ProgressBar` AR4). + * @source GUESS — An owner value, not a UIKit number + * (`component:ProgressBar` DEC-2 and AR4). + * @measure M-PB1 + */ + reducedMotionPulseFloor: 0.84, + progressView: { + /** + * The track's height in the `.default` style; the fill is as tall. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, intrinsic size of UIProgressView(progressViewStyle: + * .default), Oct 23 2026 + */ + heightPt: 4, + /** + * Once progress is above zero the fill is never narrower than this many + * track heights (8 pt on the 4 pt track): at 0.005 of a 300 pt track it + * is 8 pt wide, at 0.05 it is 15. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the fill image view's frame at progress 0, 0.005, + * 0.01, 0.05, 0.5 and 1, Oct 23 2026 + */ + minimumFillHeights: 2, + /** + * At progress 0 the fill is transparent (alpha 0, still at its minimum + * width). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the fill image view's alpha at progress 0, Oct 23 2026 + */ + emptyFillAlpha: 0, + }, +} as const; + +/** + * Registers the table's three roles, for the generated motion role table and + * its test, which call it. Nothing at runtime reads the registry: + * `resolveMotion` returns the table value. Idempotent, and nothing runs on + * import. + */ +export function registerProgressBarTable(): void { + registerMotionTable('progress', progressBar, { + roles: ['fillTrackMs', 'curves.fill', 'reducedMotionPulseMs'], + }); +} diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts index 2c43dbfc109d1..3fb936541487e 100644 --- a/packages/core/src/theme/derivedVarRegistry.test.ts +++ b/packages/core/src/theme/derivedVarRegistry.test.ts @@ -110,6 +110,21 @@ const STRUCTURAL_VARS = new Set([ // every render and read by the fill's transform. Runtime state, never // authored by a theme. '--_progress-bar-fill', + // ProgressBar's touch layer (ProgressBarTouch) writes these inline on every + // render: the touch move's duration and curve (the progress table's motion + // roles, which no theme sets), the three-step offset that keeps a return + // off CSS's reversing path, and the touch minimum. Runtime state, never + // authored by a theme. + '--_progress-bar-move', + '--_progress-bar-curve', + '--_progress-bar-flip', + '--_progress-bar-min', + // The reduced-motion pulse's duration, curve and floor, written inline from + // the progress table on the indeterminate fill. Runtime state, never + // authored by a theme. + '--_progress-bar-pulse', + '--_progress-bar-pulse-curve', + '--_progress-bar-pulse-floor', ]); /**