Repository navigation
refactor(core): the reduced-motion mark carries a marker of its own, and the chat scroll, streaming text and swipe settle read it #9763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: lane4/theme-always-mark
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: the reduced-motion mark carries a marker class of its own, on the theme's root and on `<html>` for the root theme, and the chat scroll, streaming text and a swipe row's settle read the mark. No theme can set `reducedMotion: 'always'` yet, so nothing changes for anyone. | ||
|
|
||
| @vjeux @cixzhang |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| /** | ||
| * @file Item.swipeReducedMotion.test.tsx | ||
| * @input Item with swipe actions under a theme, the swipe gesture hook, spied | ||
| * @output Verifies the swipe layer settles a row at once under a theme with | ||
| * `reducedMotion: 'always'` with the person's setting off, as it does under | ||
| * the setting | ||
| * @position Companion to Item.swipeLayer.test.tsx for the swipe layer's | ||
| * reduced-motion read | ||
| */ | ||
|
|
||
| import {describe, expect, it, vi} from 'vitest'; | ||
| import {render, screen, waitFor} from '@testing-library/react'; | ||
| import {defineTheme} from '../theme/defineTheme'; | ||
| import {ALWAYS_TEMPO} from '../theme/reducedMotionAlways.testing'; | ||
| import {Theme} from '../theme/Theme'; | ||
| import {Item} from './Item'; | ||
| import type * as SwipeAction from './useSwipeAction'; | ||
|
|
||
| // No public theme can set reducedMotion yet: a theme with ALWAYS_TEMPO | ||
| // resolves to 'always' through this mock. | ||
| vi.mock('../theme/themeMotion', async importOriginal => { | ||
| const {resolvingAlways} = | ||
| await import('../theme/reducedMotionAlways.testing'); | ||
| return resolvingAlways(await importOriginal()); | ||
| }); | ||
|
|
||
| const {reducedMotionReads} = vi.hoisted(() => ({ | ||
| reducedMotionReads: [] as (boolean | undefined)[], | ||
| })); | ||
|
|
||
| vi.mock('./useSwipeAction', async importOriginal => { | ||
| const actual = await importOriginal<typeof SwipeAction>(); | ||
| return { | ||
| ...actual, | ||
| useSwipeAction: (options: Parameters<typeof actual.useSwipeAction>[0]) => { | ||
| reducedMotionReads.push(options.isReducedMotion); | ||
| return actual.useSwipeAction(options); | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| const still = defineTheme({ | ||
| name: 'SwipeStill', | ||
| motion: {tempo: ALWAYS_TEMPO}, | ||
| }); | ||
| const moving = defineTheme({name: 'SwipeMoving', motion: {tempo: 1.2}}); | ||
|
|
||
| async function renderRowUnder(theme: typeof still) { | ||
| reducedMotionReads.length = 0; | ||
| const view = render( | ||
| <Theme theme={theme}> | ||
| <Item | ||
| label="Inbox" | ||
| data-testid="row" | ||
| swipeActions={{trailing: [{label: 'Archive', onActivate: () => {}}]}} | ||
| /> | ||
| </Theme>, | ||
| ); | ||
| await waitFor(() => { | ||
| expect( | ||
| screen.getByTestId('row').querySelector('[data-swipe-panel="trailing"]'), | ||
| ).not.toBeNull(); | ||
| }); | ||
| return view; | ||
| } | ||
|
|
||
| describe('the swipe layer and reduced motion', () => { | ||
| it("settles at once under a theme with reducedMotion: 'always', with the setting off", async () => { | ||
| await renderRowUnder(still); | ||
| expect(reducedMotionReads.at(-1)).toBe(true); | ||
| }); | ||
|
|
||
| it('animates its settle under a theme that leaves reduced motion to the setting', async () => { | ||
| await renderRowUnder(moving); | ||
| expect(reducedMotionReads.at(-1)).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| /** | ||
| * @file ItemSwipeLayer.tsx | ||
| * @input The row root (by ref), the swipe entries per side, the behavior; | ||
| * useSwipeAction, theme tokens | ||
| * useSwipeAction, useReducesMotion, theme tokens | ||
| * @output Exports ItemSwipeLayer (default) — the panels of a row's swipe | ||
| * actions and the gesture that uncovers them — and the ItemSwipeBridge type | ||
| * through which the root reaches the gesture's handlers | ||
|
|
@@ -33,7 +33,7 @@ import {mergeProps} from '../utils/mergeProps'; | |
| import {themeProps} from '../utils/themeProps'; | ||
| import {disabledInteractionProps} from '../utils/disabledInteraction'; | ||
| import {focusOutlineProps} from '../utils/focusOutline.stylex'; | ||
| import {useMediaQuery} from '../hooks/useMediaQuery'; | ||
| import {useReducesMotion} from '../hooks/useReducesMotion'; | ||
| import { | ||
| useSwipeAction, | ||
| type SwipeHandlers, | ||
|
|
@@ -205,7 +205,8 @@ export default function ItemSwipeLayer({ | |
| }: ItemSwipeLayerProps) { | ||
| const leadingEntriesRef = useRef<HTMLDivElement | null>(null); | ||
| const trailingEntriesRef = useRef<HTMLDivElement | null>(null); | ||
| const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); | ||
| // The setting, live, or a theme's reducedMotion: 'always'. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking (INV29): this restates |
||
| const isReducedMotion = useReducesMotion(); | ||
| const outermost = (side: SwipeSide): ItemSwipeAction | undefined => { | ||
| const entries = side === 'leading' ? leading : trailing; | ||
| return entries[entries.length - 1]; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| 'use client'; | ||
|
|
||
| /** | ||
| * @file useReducesMotion.ts | ||
| * @input The person's Reduce Motion setting (live), ThemeReducesMotionContext | ||
| * @output useReducesMotion, whether motion is reduced for the calling component | ||
| * @position Hooks; the hook form of motion/reducedMotion.ts's reducesMotion, | ||
| * for a hook that holds no element | ||
| */ | ||
|
|
||
| import {use} from 'react'; | ||
| import {ThemeReducesMotionContext} from '../theme/reducedMotionContext'; | ||
| import {useMediaQuery} from './useMediaQuery'; | ||
|
|
||
| /** | ||
| * Whether motion is reduced for the calling component: the person's Reduce | ||
| * Motion setting, followed live, or a theme above it with | ||
| * `reducedMotion: 'always'`. The same rule as | ||
| * `reducesMotion(element)`, read from the theme tree for a hook that holds no | ||
| * element. | ||
| */ | ||
| export function useReducesMotion(): boolean { | ||
| const prefersReducedMotion = useMediaQuery( | ||
| '(prefers-reduced-motion: reduce)', | ||
| ); | ||
| const themeReducesMotion = use(ThemeReducesMotionContext); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: this reads only the theme context, so a component in useToast's detached fallback root reads false even when the root theme marks |
||
| return prefersReducedMotion || themeReducesMotion; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| /** | ||
| * @file useStreamingText.reducedMotion.test.tsx | ||
| * @input useStreamingText under a theme, React Testing Library | ||
| * @output Verifies streaming text snaps to the full text under a theme with | ||
| * `reducedMotion: 'always'` with the person's setting off, as it does under | ||
| * the setting | ||
| * @position Companion to useStreamingText.test.ts for the theme's mark | ||
| */ | ||
|
|
||
| import type {ReactElement, ReactNode} from 'react'; | ||
| import {describe, expect, it, vi} from 'vitest'; | ||
| import {renderHook} from '@testing-library/react'; | ||
| import {defineTheme} from '../theme/defineTheme'; | ||
| import {ALWAYS_TEMPO} from '../theme/reducedMotionAlways.testing'; | ||
| import {Theme} from '../theme/Theme'; | ||
| import {useStreamingText} from './useStreamingText'; | ||
|
|
||
| // No public theme can set reducedMotion yet: a theme with ALWAYS_TEMPO | ||
| // resolves to 'always' through this mock. | ||
| vi.mock('../theme/themeMotion', async importOriginal => { | ||
| const {resolvingAlways} = | ||
| await import('../theme/reducedMotionAlways.testing'); | ||
| return resolvingAlways(await importOriginal()); | ||
| }); | ||
|
|
||
| const still = defineTheme({ | ||
| name: 'StreamStill', | ||
| motion: {tempo: ALWAYS_TEMPO}, | ||
| }); | ||
| const moving = defineTheme({name: 'StreamMoving', motion: {tempo: 1.2}}); | ||
|
|
||
| function under(theme: typeof still) { | ||
| return function ThemeWrapper({ | ||
| children, | ||
| }: { | ||
| children: ReactNode; | ||
| }): ReactElement { | ||
| return <Theme theme={theme}>{children}</Theme>; | ||
| }; | ||
| } | ||
|
|
||
| describe('useStreamingText and reduced motion', () => { | ||
| it("snaps to the full text under a theme with reducedMotion: 'always', with the setting off", () => { | ||
| const {result} = renderHook(() => useStreamingText('Hello world', true), { | ||
| wrapper: under(still), | ||
| }); | ||
| expect(result.current).toBe('Hello world'); | ||
| }); | ||
|
|
||
| it('reveals progressively under a theme that leaves reduced motion to the setting', () => { | ||
| const {result} = renderHook(() => useStreamingText('Hello world', true), { | ||
| wrapper: under(moving), | ||
| }); | ||
| expect(result.current).toBe(''); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,7 @@ | |
|
|
||
| /** | ||
| * @file useStreamingText.ts | ||
| * @input React state, effects and refs; useMediaQuery; useTheme | ||
| * @input React state, effects and refs; useReducesMotion; useTheme | ||
| * @output useStreamingText hook for smooth text streaming | ||
| * @position Core hook; smooths bursty streamed text into a steady reveal. | ||
| * Timing comes from the motion tokens, with defaults outside a theme. | ||
|
|
@@ -15,7 +15,7 @@ | |
| */ | ||
|
|
||
| import {useEffect, useMemo, useRef, useState} from 'react'; | ||
| import {useMediaQuery} from './useMediaQuery'; | ||
| import {useReducesMotion} from './useReducesMotion'; | ||
| import {useTheme} from '../theme/useTheme'; | ||
|
|
||
| /** | ||
|
|
@@ -172,14 +172,13 @@ export function useStreamingText( | |
| } | ||
| } | ||
|
|
||
| // Reduced motion skips the progressive reveal and shows the full text. | ||
| // useMediaQuery follows a preference change mid-stream. | ||
| const prefersReducedMotion = useMediaQuery( | ||
| '(prefers-reduced-motion: reduce)', | ||
| ); | ||
| // Reduced motion skips the progressive reveal and shows the full text: the | ||
| // person's setting, followed live so a change mid-stream takes effect, or a | ||
| // theme's reducedMotion: 'always'. | ||
|
Comment on lines
+175
to
+177
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking (INV29): one 27-word sentence with two topics. Suggested: "Reduced motion skips the progressive reveal and shows the full text. A change mid-stream takes effect at once." |
||
| const reducesMotion = useReducesMotion(); | ||
|
|
||
| useEffect(() => { | ||
| if (!isStreaming || speed === 'instant' || prefersReducedMotion) { | ||
| if (!isStreaming || speed === 'instant' || reducesMotion) { | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -208,9 +207,9 @@ export function useStreamingText( | |
| rafRef.current = null; | ||
| } | ||
| }; | ||
| }, [isStreaming, charsPerTick, tickMs, speed, prefersReducedMotion]); | ||
| }, [isStreaming, charsPerTick, tickMs, speed, reducesMotion]); | ||
|
|
||
| if (!isStreaming || speed === 'instant' || prefersReducedMotion) { | ||
| if (!isStreaming || speed === 'instant' || reducesMotion) { | ||
| return targetText; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking (INV29): this restates
useReducesMotion's JSDoc; delete it.