diff --git a/.changeset/native-motion-preference.md b/.changeset/native-motion-preference.md new file mode 100644 index 0000000..824c6f6 --- /dev/null +++ b/.changeset/native-motion-preference.md @@ -0,0 +1,5 @@ +--- +'@reactive/silk-native': minor +--- + +Stop native Spinner and Progress from flashing their reduced-motion appearance on mount. React Native exposes the OS preference only through async `AccessibilityInfo`, so the first render is now `'unresolved'`: motion stays suppressed, but the full-motion visuals render until the OS confirms `'reduced'`. Adds `useMotionPreference` for components that swap appearance (not just animation) and caches the OS answer so only the first mount is ever unresolved. diff --git a/.changeset/native-visual-forms.md b/.changeset/native-visual-forms.md new file mode 100644 index 0000000..4d5e644 --- /dev/null +++ b/.changeset/native-visual-forms.md @@ -0,0 +1,5 @@ +--- +'@reactive/silk-native': minor +--- + +Expand the native renderer with visual primitives (Surface, Card, Heading, Badge, Separator, Avatar, StatusDot, Skeleton, Spinner, Progress) and forms (Input, Textarea, Field, Checkbox, Switch, RadioGroup). Same silk-core recipes as web; RNW a11y compat helpers; Slider deferred to preserve the shared array-valued contract. diff --git a/.gitignore b/.gitignore index ad4af64..8053f69 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ dist storybook-static apps/docs/.storybook-serve +# Expo local state +.expo/ + # Test / coverage coverage .audit diff --git a/apps/docs/src/Native.mdx b/apps/docs/src/Native.mdx index 2064c05..41268b2 100644 --- a/apps/docs/src/Native.mdx +++ b/apps/docs/src/Native.mdx @@ -8,9 +8,10 @@ React Native renderer for Silk. Same semantic `Theme` object and recipe contracts as `@reactive/silk` — delivered through React context, **not** CSS variables. Native should feel native; web should feel like the web. -Stories under **Native Components** and the **Fixtures/NativeShell** page render -these components via `react-native-web` so the docs site can exercise them -without a simulator. The real-native exit demo is `apps/native-example` (Expo). +Stories under **Native Components** and the **Fixtures/NativeShell** / +**Fixtures/NativeSettingsForm** pages render these components via +`react-native-web` so the docs site can exercise them without a simulator. The +real-native exit demo is `apps/native-example` (Expo). ## Install @@ -24,10 +25,14 @@ import { createTheme } from '@reactive/silk-core'; import { Box, Button, + Card, + Field, + Heading, Inline, + Input, SilkProvider, Stack, - Text, + Switch, } from '@reactive/silk-native'; const theme = createTheme({ colorScheme: 'light' }); @@ -36,15 +41,19 @@ export function Screen() { return ( - - Hello - - - - - + + + Hello + + Name + + + + + + + + ); @@ -83,8 +92,29 @@ No public CSS variables or `className` on native. ## Control geometry -Button padding steps and `lineHeight: 1.2` are **renderer-local**, intentionally -parallel to web `controlGeometry.ts` — not core tokens. +Button / Input / Checkbox / Switch / Spinner geometry is **renderer-local**, +expressed as density-aware space steps (parallel to web `controlGeometry.ts`) — +not core tokens. Avatar `mediaScale` sizes stay density-independent (content). + +## Native adaptations (documented) + +| Web | Native | +| --- | --- | +| Skeleton shimmer gradient | Opacity pulse (plain RN views) | +| Indeterminate Progress 35% solid-over-sunken mix | Static `tone.subtle` fill under reduced motion | +| Spinner 25% `color-mix` ring | `tone.subtle` + `tone.solid` (no color-string parsing) | +| `accessibilityLiveRegion` | Android-only enhancement; use roles + labels cross-platform | +| Slider (Radix array multi-thumb) | **Deferred** — do not ship a scalar fork | +| Roving keyboard focus (RadioGroup) | Screen readers navigate elements directly | + +RNW tests assert ARIA aliases (`aria-checked`, `aria-valuenow`, …) via the +`a11yState` / `a11yValue` helpers — RN object props alone are dropped by RNW. + +## Shipped components + +**Layout:** Box, Stack, Inline · **Visual:** Text, Button, Surface, Card, +Heading, Badge, Separator, Avatar, StatusDot, Skeleton, Spinner, Progress · +**Forms:** Input, Textarea, Field, Checkbox, Switch, RadioGroup ## Stage 1 spike findings (folded forward) diff --git a/apps/docs/src/fixtures/NativeSettingsForm.stories.tsx b/apps/docs/src/fixtures/NativeSettingsForm.stories.tsx new file mode 100644 index 0000000..5799896 --- /dev/null +++ b/apps/docs/src/fixtures/NativeSettingsForm.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from 'storybook-react-rsbuild'; +import { withSource } from '../docsSource'; +import { + NativeSettingsForm, + nativeSettingsFormStates, +} from './NativeSettingsForm'; +import nativeSettingsFormSource from './NativeSettingsForm.tsx?raw'; + +const meta = { + title: 'Fixtures/NativeSettingsForm', + component: NativeSettingsForm, + parameters: withSource(nativeSettingsFormSource), + args: { state: 'normal' }, + argTypes: { + state: { + control: 'select', + options: [...nativeSettingsFormStates], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Normal: Story = {}; + +export const Error: Story = { args: { state: 'error' } }; + +// Field.Root applies opacity when disabled, which drops label/description +// contrast below WCAG AA. Tracked as a token/style burn-down item. +const disabledContrastTodo = { a11y: { test: 'todo' as const } }; + +export const Disabled: Story = { + args: { state: 'disabled' }, + parameters: disabledContrastTodo, +}; + +export const InvalidDisabled: Story = { + args: { state: 'invalidDisabled' }, + parameters: disabledContrastTodo, +}; +export const Compact: Story = { args: { state: 'compact' } }; +export const Dark: Story = { args: { state: 'dark' } }; +export const ReducedMotion: Story = { args: { state: 'reducedMotion' } }; +export const LongContent: Story = { args: { state: 'longContent' } }; +export const Rtl: Story = { args: { state: 'rtl' } }; +export const Indeterminate: Story = { args: { state: 'indeterminate' } }; diff --git a/apps/docs/src/fixtures/NativeSettingsForm.test.tsx b/apps/docs/src/fixtures/NativeSettingsForm.test.tsx new file mode 100644 index 0000000..7cb4f95 --- /dev/null +++ b/apps/docs/src/fixtures/NativeSettingsForm.test.tsx @@ -0,0 +1,117 @@ +import { expect, test } from '@rstest/core'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { + NativeSettingsForm, + nativeSettingsFormStates, + type NativeSettingsFormState, +} from './NativeSettingsForm'; + +function renderFixture(state: NativeSettingsFormState) { + return render(); +} + +test.each(nativeSettingsFormStates)( + 'NativeSettingsForm state "%s" mounts with fixture markers', + (state) => { + const { container } = renderFixture(state); + const root = container.querySelector( + '[data-fixture="native-settings-form"]', + ); + expect(root).not.toBeNull(); + expect(root?.getAttribute('data-fixture-state')).toBe(state); + expect(screen.getByText('Settings')).toBeTruthy(); + }, +); + +test('error state shows alert and summary', () => { + renderFixture('error'); + expect(screen.getByRole('alert').textContent).toContain('required'); + expect(screen.getByText(/state=error/)).toBeTruthy(); +}); + +test('disabled switches and radios expose disabled', () => { + renderFixture('disabled'); + expect( + screen.getByRole('switch').getAttribute('aria-disabled'), + ).toBe('true'); + for (const radio of screen.getAllByRole('radio')) { + expect(radio.getAttribute('aria-disabled')).toBe('true'); + } +}); + +test('invalidDisabled keeps invalid error with disabled controls', () => { + renderFixture('invalidDisabled'); + expect(screen.getByRole('alert')).toBeTruthy(); + expect( + screen.getByRole('switch').getAttribute('aria-disabled'), + ).toBe('true'); +}); + +test('compact and dark report in summary', () => { + expect(renderFixture('compact').getByText(/density=compact/)).toBeTruthy(); + expect(renderFixture('dark').getByText(/scheme=dark/)).toBeTruthy(); +}); + +test('rtl sets dir attribute', () => { + const { container } = renderFixture('rtl'); + expect( + container + .querySelector('[data-fixture="native-settings-form"]') + ?.getAttribute('dir'), + ).toBe('rtl'); +}); + +test('indeterminate checkbox starts mixed then becomes checked', () => { + renderFixture('indeterminate'); + const box = screen.getByRole('checkbox', { name: /Select all/i }); + expect(box.getAttribute('aria-checked')).toBe('mixed'); + fireEvent.click(box); + expect( + screen.getByRole('checkbox', { name: /Select all/i }).getAttribute( + 'aria-checked', + ), + ).toBe('true'); +}); + +test('changing state without remount reseeds checkbox and field defaults', () => { + const { rerender } = render(); + const checkbox = () => + screen.getByRole('checkbox', { name: /Select all/i }); + const name = () => screen.getByTestId('settings-name'); + const bio = () => screen.getByTestId('settings-bio'); + + expect(checkbox().getAttribute('aria-checked')).toBe('false'); + expect((name() as HTMLInputElement).value).toBe(''); + expect((bio() as HTMLTextAreaElement).value).toBe(''); + + rerender(); + expect(checkbox().getAttribute('aria-checked')).toBe('mixed'); + + rerender(); + expect(checkbox().getAttribute('aria-checked')).toBe('false'); + expect((name() as HTMLInputElement).value.length).toBeGreaterThan(0); + expect((bio() as HTMLTextAreaElement).value.length).toBeGreaterThan(0); + expect( + document.querySelector('[data-region="long-content"]'), + ).not.toBeNull(); +}); + +test('progress exposes aria-valuenow', () => { + renderFixture('normal'); + const bar = screen.getByLabelText('Profile completeness'); + expect(bar.getAttribute('aria-valuenow')).toBe('72'); +}); + +test('longContent region present', () => { + const { container } = renderFixture('longContent'); + expect( + container.querySelector('[data-region="long-content"]'), + ).not.toBeNull(); +}); + +test('reducedMotion region present', () => { + const { container } = renderFixture('reducedMotion'); + expect( + container.querySelector('[data-region="reduced-motion"]'), + ).not.toBeNull(); +}); diff --git a/apps/docs/src/fixtures/NativeSettingsForm.tsx b/apps/docs/src/fixtures/NativeSettingsForm.tsx new file mode 100644 index 0000000..1437080 --- /dev/null +++ b/apps/docs/src/fixtures/NativeSettingsForm.tsx @@ -0,0 +1,214 @@ +import { + createTheme, + generatePairedPalette, + type ColorScheme, + type DensityName, +} from '@reactive/silk-core'; +import { + Badge, + Card, + Checkbox, + Field, + Heading, + Inline, + Input, + Progress, + RadioGroup, + Separator, + SilkProvider, + Skeleton, + Spinner, + Stack, + StatusDot, + Switch, + Text, + Textarea, +} from '@reactive/silk-native'; +import { useMemo, useState, type JSX } from 'react'; + +export const nativeSettingsFormStates = [ + 'normal', + 'error', + 'disabled', + 'invalidDisabled', + 'compact', + 'dark', + 'reducedMotion', + 'longContent', + 'rtl', + 'indeterminate', +] as const; + +export type NativeSettingsFormState = + (typeof nativeSettingsFormStates)[number]; + +const paired = generatePairedPalette('#0ea5e9'); +const longBio = Array.from({ length: 80 }, () => 'content').join(' '); + +export interface NativeSettingsFormProps { + readonly state?: NativeSettingsFormState; +} + +/** + * Native forms fixture — state matrix for docs + tests (RNW). + * Slider is deferred; Progress stands in for determinate feedback. + * + * Remounts the body when `state` changes so Storybook's select control + * reseeds useState / defaultValue (indeterminate, longContent, etc.). + */ +export function NativeSettingsForm({ + state = 'normal', +}: NativeSettingsFormProps): JSX.Element { + return ; +} + +function NativeSettingsFormBody({ + state, +}: { + readonly state: NativeSettingsFormState; +}): JSX.Element { + const scheme: ColorScheme = state === 'dark' ? 'dark' : 'light'; + const density: DensityName = state === 'compact' ? 'compact' : 'comfortable'; + const disabled = state === 'disabled' || state === 'invalidDisabled'; + const invalid = state === 'error' || state === 'invalidDisabled'; + const dir = state === 'rtl' ? 'rtl' : 'ltr'; + + const theme = useMemo( + () => + createTheme({ + colorScheme: scheme, + ...(state === 'dark' ? { palette: paired.dark } : {}), + }), + [scheme, state], + ); + + const [notify, setNotify] = useState(true); + const [plan, setPlan] = useState('pro'); + const [selectAll, setSelectAll] = useState( + state === 'indeterminate' ? 'indeterminate' : false, + ); + + return ( +
+ + + + + Settings + + {invalid ? 'needs attention' : 'native'} + + + + + + state={state} · scheme={scheme} · density={density} · dir={dir} + + + {state === 'reducedMotion' ? ( +
+ + + + + + reduced-motion: skeleton static / spinner dotted when OS prefers + +
+ ) : null} + + + + + Display name + + Shown on your profile. + {invalid ? ( + Display name is required. + ) : null} + + + + Bio +