Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-motion-preference.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/native-visual-forms.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ dist
storybook-static
apps/docs/.storybook-serve

# Expo local state
.expo/

# Test / coverage
coverage
.audit
Expand Down
60 changes: 45 additions & 15 deletions apps/docs/src/Native.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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' });
Expand All @@ -36,15 +41,19 @@ export function Screen() {
return (
<SilkProvider theme={theme} density="comfortable">
<Box padding="4">
<Stack gap="3">
<Text role="heading">Hello</Text>
<Inline gap="2">
<Button>Save</Button>
<Button variant="outline" tone="neutral">
Cancel
</Button>
</Inline>
</Stack>
<Card padding="4">
<Stack gap="3">
<Heading level="2">Hello</Heading>
<Field.Root>
<Field.Label>Name</Field.Label>
<Input />
</Field.Root>
<Inline gap="2" align="center">
<Switch accessibilityLabel="Notify" />
<Button>Save</Button>
</Inline>
</Stack>
</Card>
</Box>
</SilkProvider>
);
Expand Down Expand Up @@ -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)

Expand Down
48 changes: 48 additions & 0 deletions apps/docs/src/fixtures/NativeSettingsForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof NativeSettingsForm>;

export default meta;

type Story = StoryObj<typeof meta>;

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' } };
117 changes: 117 additions & 0 deletions apps/docs/src/fixtures/NativeSettingsForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NativeSettingsForm state={state} />);
}

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(<NativeSettingsForm state="normal" />);
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(<NativeSettingsForm state="indeterminate" />);
expect(checkbox().getAttribute('aria-checked')).toBe('mixed');

rerender(<NativeSettingsForm state="longContent" />);
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();
});
Loading
Loading