@linhey/rhine-design 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTRIBUTING.md +44 -0
  3. package/LICENSE +202 -0
  4. package/NOTICE +4 -0
  5. package/README.md +144 -2
  6. package/components/Actions.tsx +40 -0
  7. package/components/COMMON_COMPONENTS.md +70 -0
  8. package/components/Data.tsx +42 -0
  9. package/components/Forms.tsx +64 -0
  10. package/components/Layout.tsx +13 -0
  11. package/components/Logo.tsx +34 -0
  12. package/components/MOTION.md +72 -0
  13. package/components/Overlays.tsx +53 -0
  14. package/components/README.md +137 -0
  15. package/components/STARTUP.md +34 -0
  16. package/components/Select.tsx +22 -0
  17. package/components/TYPOGRAPHY.md +60 -0
  18. package/components/Toast.tsx +25 -0
  19. package/components/Toolbar.tsx +14 -0
  20. package/components/Typography.tsx +13 -0
  21. package/components/assets/logo.svg +5 -0
  22. package/components/common.css +69 -0
  23. package/components/components.tsx +151 -0
  24. package/components/index.ts +24 -0
  25. package/components/styles.css +109 -0
  26. package/components/tokens.css +58 -0
  27. package/components/typography.css +31 -0
  28. package/design/README.md +14 -0
  29. package/docs/DESIGN_SYSTEM.md +83 -0
  30. package/docs/DESIGN_SYSTEM_AUDIT.md +63 -0
  31. package/docs/RELEASING.md +27 -0
  32. package/index.html +3 -0
  33. package/package.json +76 -4
  34. package/preview/CommonGallery.tsx +35 -0
  35. package/preview/MotionGallery.tsx +89 -0
  36. package/preview/StartupGallery.tsx +73 -0
  37. package/preview/brand-assembly.css +18 -0
  38. package/preview/main.tsx +100 -0
  39. package/preview/motion.css +25 -0
  40. package/preview/preview.css +27 -0
  41. package/preview/startup.css +33 -0
  42. package/scripts/check-build.mjs +15 -0
  43. package/skills/rhine-design/SKILL.md +37 -0
  44. package/skills/rhine-design/agents/openai.yaml +4 -0
  45. package/skills/rhine-design/references/API_MAP.md +34 -0
  46. package/skills/rhine-design/references/TASK_MAP.md +20 -0
  47. package/skills/rhine-design/references/integration.md +40 -0
  48. package/tests/CommonComponents.test.tsx +71 -0
  49. package/tests/DesignSystem.test.tsx +100 -0
  50. package/tests/MotionGallery.test.tsx +32 -0
  51. package/tests/StartupGallery.test.tsx +55 -0
  52. package/tests/setup.ts +4 -0
  53. package/tsconfig.json +32 -0
  54. package/vite.config.ts +3 -0
  55. package/vitest.config.ts +3 -0
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: rhine-design
3
+ description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
4
+ metadata:
5
+ version: "0.2.0"
6
+ ---
7
+
8
+ # Rhine Design · 莱茵设计
9
+
10
+ Use the installed version's components and contracts to build consistent interfaces. This is an independently versioned, general design system; retain the host product's identity and data ownership.
11
+
12
+ ## Locate the matching source
13
+
14
+ Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.2.0.
15
+
16
+ Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
17
+
18
+ ## Route the task
19
+
20
+ - Screen design, interaction, typography, motion: start with [TASK_MAP.md](references/TASK_MAP.md), then read only the relevant contract.
21
+ - A named component or prop: use [API_MAP.md](references/API_MAP.md), then inspect its exported type and implementation.
22
+ - First integration, linking, theme or missing styles: read [integration.md](references/integration.md).
23
+
24
+ ## Design before implementation
25
+
26
+ 1. Inventory existing components and tokens for the requested behavior. Compose them before creating a new component or duplicating styles.
27
+ 2. Update the host's page mockup or the library's real `preview/` showcase first. A new shared capability includes its contract and a usable example.
28
+ 3. Verify the changed states and interaction in the preview; then integrate into the app when implementation is in scope. Continue both stages when already authorized; a design-only request does not authorize an app migration.
29
+ 4. For library changes run `npm run check`; for visual changes inspect relevant widths (400/520/880), 200% zoom, keyboard/focus and reduced motion. For consumer changes run that project's checks. Documentation-only edits need link, example and version consistency checks, not an unrelated UI test campaign.
30
+
31
+ ## Preserve these contracts
32
+
33
+ - Use public exports, the `.tod-theme` root and existing tokens. `tod-` is a retained CSS API, not a required product name.
34
+ - Read the actual component signature before using it. Keep semantic headings separate from visual text variants.
35
+ - Host state determines data, selection and operation outcomes. Search and pagination remain host/server responsibilities; busy indicators never imply success.
36
+ - Motion explains a change. Respect reduced motion, cancellation and replay; startup decoration never blocks real readiness. Preview-only experiments are not exported components.
37
+ - Keep independent release boundaries: do not change unrelated products or publish packages merely because this skill was invoked.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Rhine Design · 莱茵设计"
3
+ short_description: "用莱茵设计组件与规范完成页面设计、字体排版、动效及应用接入"
4
+ default_prompt: "使用 $rhine-design,优先复用现有组件,先完成设计稿和交互验证,再按本次任务授权接入应用。"
@@ -0,0 +1,34 @@
1
+ # API map
2
+
3
+ All paths are relative to the matching package root. `components/index.ts` is the public export authority. This index locates APIs; exact props come from the installed source, not from remembered examples.
4
+
5
+ | Exports | Source |
6
+ | --- | --- |
7
+ | Logo | `components/Logo.tsx` |
8
+ | Text | `components/Typography.tsx` |
9
+ | Select | `components/Select.tsx` |
10
+ | Toolbar | `components/Toolbar.tsx` |
11
+ | Icon, IconButton, Link, CopyButton | `components/Actions.tsx` |
12
+ | Divider, Stack, Inline, Grid | `components/Layout.tsx` |
13
+ | Metric, KeyValue, DataTable, Pagination | `components/Data.tsx` |
14
+ | Checkbox, RadioGroup, Switch, Textarea, SearchField, Combobox | `components/Forms.tsx` |
15
+ | Dialog, ConfirmDialog, Popover, Menu, Tooltip | `components/Overlays.tsx` |
16
+ | Toast | `components/Toast.tsx` |
17
+ | Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx` |
18
+
19
+ Primary contracts: `components/README.md` and `components/COMMON_COMPONENTS.md`. Find behavioral evidence in `tests/` and composition examples in `preview/`.
20
+
21
+ ```sh
22
+ # From the resolved package root:
23
+ rg -n 'ConfirmDialog|DataTable|Combobox' components/index.ts components/*.tsx
24
+ rg -n 'ConfirmDialog|DataTable|Combobox' tests preview
25
+ ```
26
+
27
+ API decisions that commonly matter:
28
+
29
+ - `Select` uses a native single-select; searchable candidates use `Combobox`.
30
+ - `SelectionCard` uses checkbox semantics; the host determines single-selection policy. Do not nest other interactive controls in it.
31
+ - `Text`'s `as` chooses semantics; `variant` chooses visual hierarchy.
32
+ - `Logo` accepts `wordmark` and `label`; its `theme` affects the logo, not the full component library.
33
+ - `Toast` presents one notification, not a global queue. `DataTable` is not a query or table-state engine.
34
+ - Components in `preview/` are demonstrations unless re-exported through `components/index.ts`.
@@ -0,0 +1,20 @@
1
+ # Task map
2
+
3
+ Resolve the matching package root as described in SKILL.md. Open the first relevant source; expand only when the task crosses domains.
4
+
5
+ | Task | First reference (relative to package root) | Implementation / example |
6
+ | --- | --- | --- |
7
+ | Visual direction, colors, spacing, layout | `docs/DESIGN_SYSTEM.md` | `components/tokens.css`, `components/Layout.tsx` |
8
+ | Text hierarchy, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx` |
9
+ | Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
10
+ | Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
11
+ | Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
12
+ | Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
13
+ | Transition, replay, cancellation, reduced motion | `components/MOTION.md` | `preview/`, `components/styles.css` |
14
+ | Startup sequence, logo assembly, ready handoff | `components/STARTUP.md` | Search `startup` in `preview/`; not a public Startup component |
15
+ | Missing capability or system coverage | `docs/DESIGN_SYSTEM_AUDIT.md` | Verify status against `components/index.ts` |
16
+ | Version upgrade | `CHANGELOG.md` | Installed `package.json`, current types |
17
+
18
+ Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion` and `/#startup`. Do not assume another project's server or local filesystem layout.
19
+
20
+ For host pages, keep product-specific drafts in the host project. The library's `preview/` demonstrates reusable behavior, not business APIs. Labels such as “candidate” or “preview” in motion docs identify experiments; check whether the behavior is actually exported before integrating it.
@@ -0,0 +1,40 @@
1
+ # Integration and diagnosis
2
+
3
+ ## Supported baseline
4
+
5
+ Rhine Design 0.2.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
6
+
7
+ ```tsx
8
+ import { Button, Surface, Text } from '@linhey/rhine-design';
9
+
10
+ export function Example() {
11
+ return (
12
+ <div className="tod-theme">
13
+ <Surface>
14
+ <Text as="h2" variant="section">工作空间</Text>
15
+ <Button onClick={() => { /* host action */ }}>继续</Button>
16
+ </Surface>
17
+ </div>
18
+ );
19
+ }
20
+ ```
21
+
22
+ ## Integration invariants
23
+
24
+ - The entry imports CSS automatically. Keep the package's entry and CSS `sideEffects` declarations so production tree shaking retains styles.
25
+ - Put `.tod-theme` at the page root. Do not transform, clip or apply containment to it: overlays portal to the nearest theme root and need its variables and reduced-motion settings.
26
+ - For a locally linked checkout, use Vite `resolve.dedupe: ['react', 'react-dom']`. Duplicate React can break hooks; do not patch component state to mask this.
27
+ - Preserve SVG `?no-inline` handling. Logo uses a shared geometric master; missing symbols in production warrant checking the emitted SVG asset before replacing the logo.
28
+ - Reuse tokens for layout and state styling. Full dark theme is not exported in 0.2.0; the logo's dark variant alone does not establish dark-mode support.
29
+
30
+ ## Host responsibilities
31
+
32
+ Pass controlled form values, data, sorting/pagination intentions and operation results through the documented APIs. The host owns network requests, debouncing, IME composition, cancellation and stale-response protection. Do not replace server filtering with an unrequested full-data download.
33
+
34
+ Await actual copy/confirm outcomes. Errors remain actionable; busy state preserves meaningful focus. Inspect overlay focus return, Escape and keyboard navigation in the browser when changing those interactions. Progress with unknown duration is indeterminate; startup animation ends when the host is ready rather than imposing a decorative delay.
35
+
36
+ ## Upgrade and feedback
37
+
38
+ Check the installed version against this skill's metadata, then consult `CHANGELOG.md`. Refresh the copied skill together with the package; keep local customizations when comparing versions. If types and docs disagree, use source for the immediate API and repair the stale reference when in scope.
39
+
40
+ For reproducible feedback record package version, component/API, relevant source or reference path, expected and actual behavior, and the smallest reproduction. Public reports need no private host payloads or credentials. Filing an external issue remains a user-directed action.
@@ -0,0 +1,71 @@
1
+ import { useState } from 'react';
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { Checkbox, Combobox, ConfirmDialog, CopyButton, DataTable, Metric, Pagination, RadioGroup, SearchField, Switch, Toast } from '../components';
5
+
6
+ afterEach(() => vi.useRealTimers());
7
+ describe('common design components', () => {
8
+ it('distinguishes zero from unknown metrics', () => {
9
+ render(<><Metric label="计数" value={0}/><Metric label="未知" value={null}/></>);
10
+ expect(screen.getByText('0')).toBeVisible(); expect(screen.getByText('—')).toBeVisible();
11
+ });
12
+ it('emits sort intent without sorting or dropping stale rows on failure', () => {
13
+ const sort=vi.fn();
14
+ render(<DataTable caption="请求" rows={[{id:'b'},{id:'a'}]} rowKey={row=>row.id} columns={[{key:'id',header:'标识',sortable:true,render:row=>row.id}]} onSortChange={sort} error="连接失败"/>);
15
+ fireEvent.click(screen.getByRole('button',{name:'标识'}));
16
+ expect(sort).toHaveBeenCalledWith({key:'id',direction:'asc'});
17
+ expect(screen.getAllByRole('cell').map(node=>node.textContent)).toEqual(['b','a']);
18
+ expect(screen.getByRole('alert')).toHaveTextContent('保留上次读取');
19
+ });
20
+ it('respects pagination boundaries, busy state and unknown totals', () => {
21
+ const change=vi.fn();const {rerender}=render(<Pagination page={1} pageSize={10} total={23} onPageChange={change}/>);
22
+ expect(screen.getByRole('button',{name:'上一页'})).toBeDisabled();fireEvent.click(screen.getByRole('button',{name:'下一页'}));expect(change).toHaveBeenCalledWith(2);
23
+ rerender(<Pagination page={3} pageSize={10} total={23} onPageChange={change}/>);expect(screen.getByRole('button',{name:'下一页'})).toBeDisabled();
24
+ rerender(<Pagination page={3} pageSize={10} hasNext loading onPageChange={change}/>);expect(screen.getAllByRole('button').every(b=>b.hasAttribute('disabled'))).toBe(true);
25
+ rerender(<Pagination page={3} pageSize={10} hasNext onPageChange={change}/>);expect(screen.getByText('第 3 页')).toBeVisible();expect(screen.getByRole('button',{name:'下一页'})).toBeEnabled();
26
+ });
27
+ it('reports copy success only after resolution and prevents duplicate writes', async () => {
28
+ let resolve!:()=>void;const copy=vi.fn(()=>new Promise<void>(done=>{resolve=done;}));
29
+ render(<CopyButton value="demo" copy={copy}/>);const button=screen.getByRole('button',{name:'复制'});button.focus();fireEvent.click(button);fireEvent.click(button);
30
+ expect(copy).toHaveBeenCalledTimes(1);expect(button).toHaveFocus();expect(screen.queryByText('已复制')).not.toBeInTheDocument();
31
+ await act(async()=>resolve());expect(screen.getByRole('status')).toHaveTextContent('已复制');
32
+ });
33
+ it('allows failed copies to retry and ignores stale completion after a value change', async () => {
34
+ const copy=vi.fn().mockRejectedValueOnce(new Error('test')).mockResolvedValue(undefined);
35
+ const {rerender}=render(<CopyButton value="a" copy={copy}/>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await screen.findByRole('alert');fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(screen.getByRole('status')).toHaveTextContent('已复制'));
36
+ let resolve!:()=>void;const delayed=()=>new Promise<void>(done=>{resolve=done;});
37
+ rerender(<CopyButton value="b" copy={delayed}/>);fireEvent.click(screen.getByRole('button',{name:'复制'}));rerender(<CopyButton value="c" copy={delayed}/>);await act(async()=>resolve());expect(screen.getByRole('status')).toBeEmptyDOMElement();
38
+ });
39
+ it('exposes mixed checkbox state and separates native radio groups', () => {
40
+ render(<><Checkbox label="部分选择" indeterminate readOnly/><RadioGroup label="组一" value="a" onValueChange={()=>{}} items={[{value:'a',label:'A'}]}/><RadioGroup label="组二" value="a" onValueChange={()=>{}} items={[{value:'a',label:'A'}]}/></>);
41
+ expect(screen.getByRole('checkbox')).toBePartiallyChecked();const radios=screen.getAllByRole('radio');expect(radios[0].getAttribute('name')).not.toBe(radios[1].getAttribute('name'));
42
+ });
43
+ it('does not change a controlled switch before the caller updates it', () => {
44
+ const change=vi.fn();render(<Switch label="偏好" checked={false} onCheckedChange={change}/>);fireEvent.click(screen.getByRole('switch'));expect(change).toHaveBeenCalledWith(true);expect(screen.getByRole('switch')).not.toBeChecked();
45
+ });
46
+ it('does not submit searches while IME composition is active', () => {
47
+ const search=vi.fn();render(<SearchField label="搜索" value="研究" onValueChange={()=>{}} onSearch={search}/>);const input=screen.getByRole('searchbox');fireEvent.compositionStart(input);fireEvent.keyDown(input,{key:'Enter'});expect(search).not.toHaveBeenCalled();fireEvent.compositionEnd(input);fireEvent.keyDown(input,{key:'Enter'});expect(search).toHaveBeenCalledWith('研究');
48
+ });
49
+ it('navigates supplied combobox results, skips disabled items and blocks IME commit', () => {
50
+ const choose=vi.fn(),query=vi.fn();render(<Combobox label="模型" query="" onQueryChange={query} value={null} onValueChange={choose} options={[{value:'a',label:'A'},{value:'b',label:'B',disabled:true},{value:'c',label:'C'}]}/>);
51
+ const input=screen.getByRole('combobox');fireEvent.focus(input);fireEvent.keyDown(input,{key:'ArrowDown'});fireEvent.keyDown(input,{key:'ArrowDown'});expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).toHaveTextContent('C');
52
+ fireEvent.compositionStart(input);fireEvent.change(input,{target:{value:'中'}});fireEvent.keyDown(input,{key:'Enter'});expect(choose).not.toHaveBeenCalled();expect(query).not.toHaveBeenCalled();fireEvent.compositionEnd(input);expect(query).toHaveBeenCalledWith('中');fireEvent.keyDown(input,{key:'End'});fireEvent.keyDown(input,{key:'Enter'});expect(choose).toHaveBeenCalledWith({value:'c',label:'C'});expect(input).toHaveAttribute('aria-expanded','false');
53
+ });
54
+ it('does not allow stale combobox results to be picked during loading', () => {
55
+ const choose=vi.fn();render(<Combobox label="模型" query="" value={null} onQueryChange={()=>{}} onValueChange={choose} options={[{value:'a',label:'A'}]} loading/>);const input=screen.getByRole('combobox');fireEvent.focus(input);fireEvent.keyDown(input,{key:'ArrowDown'});fireEvent.keyDown(input,{key:'Enter'});expect(choose).not.toHaveBeenCalled();expect(screen.queryByRole('option')).not.toBeInTheDocument();
56
+ });
57
+ it('pauses success notification countdown on hover and keeps errors persistent', () => {
58
+ vi.useFakeTimers();const close=vi.fn();const {rerender}=render(<Toast open onOpenChange={close} title="已完成" duration={5000}/>);
59
+ act(()=>vi.advanceTimersByTime(2000));fireEvent.mouseEnter(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();fireEvent.mouseLeave(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(2999));expect(close).not.toHaveBeenCalled();act(()=>vi.advanceTimersByTime(1));expect(close).toHaveBeenCalledWith(false);
60
+ close.mockClear();rerender(<Toast open onOpenChange={close} title="失败" tone="error" duration={1000}/>);act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();
61
+ });
62
+ it('resets toast countdown when reopening', () => {
63
+ vi.useFakeTimers();function Demo(){const [open,setOpen]=useState(true);return <><button onClick={()=>setOpen(true)}>显示</button><Toast open={open} onOpenChange={setOpen} title="通知" duration={1000}/></>;}
64
+ render(<Demo/>);act(()=>vi.advanceTimersByTime(1000));expect(screen.queryByText('通知')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'显示'}));act(()=>vi.advanceTimersByTime(999));expect(screen.getByText('通知')).toBeVisible();act(()=>vi.advanceTimersByTime(1));expect(screen.queryByText('通知')).not.toBeInTheDocument();
65
+ });
66
+ it('keeps a failed confirmation open and closes only after successful retry', async () => {
67
+ const confirm=vi.fn().mockRejectedValueOnce(new Error('demo')).mockResolvedValue(undefined);
68
+ render(<div className="tod-theme"><ConfirmDialog trigger={<button>打开</button>} title="确认任务" description="演示" onConfirm={confirm}/></div>);
69
+ fireEvent.click(screen.getByRole('button',{name:'打开'}));expect(await screen.findByRole('alertdialog')).toBeVisible();expect(screen.getByRole('button',{name:'取消'})).toHaveFocus();fireEvent.click(screen.getByRole('button',{name:'确认操作'}));expect(await screen.findByRole('alert')).toHaveTextContent('操作未完成');fireEvent.click(screen.getByRole('button',{name:'确认操作'}));await waitFor(()=>expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
70
+ });
71
+ });
@@ -0,0 +1,100 @@
1
+ import { useState } from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { Button, Disclosure, Logo, Progress, SegmentedControl, Select, SelectionCard, Tabs, TextField } from '../components';
5
+
6
+ describe('reusable design components', () => {
7
+ it('uses the independent library identity and accepts a host wordmark', () => {
8
+ const { rerender } = render(<Logo variant="lockup" />);
9
+ expect(screen.getByRole('img', { name: 'Rhine Design' })).toHaveTextContent('Rhine Design');
10
+ rerender(<Logo variant="lockup" wordmark="Studio" />);
11
+ expect(screen.getByRole('img', { name: 'Studio' })).toHaveTextContent('Studio');
12
+ rerender(<Logo variant="lockup" wordmark="Studio" label="Studio home" decorative />);
13
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
14
+ });
15
+ it('associates native selects with unique labels, hints, errors and caller descriptions', () => {
16
+ render(<><span id="context">当前工作区</span><Select label="页面" hint="请选择" error="页面不可用" aria-describedby="context"><option>用量</option></Select><Select label="场景" disabled><option>正常</option></Select></>);
17
+ const page = screen.getByRole('combobox', {name:'页面'});
18
+ const scene = screen.getByRole('combobox', {name:'场景'});
19
+ expect(page.id).not.toBe(scene.id);
20
+ expect(page).toHaveAttribute('aria-invalid','true');
21
+ expect(page).toHaveAccessibleDescription('当前工作区 请选择 页面不可用');
22
+ expect(scene).toBeDisabled();
23
+ expect(screen.getAllByRole('combobox')).toHaveLength(2);
24
+ });
25
+ it('keeps the selected value controlled by the caller', () => {
26
+ function Demo() {
27
+ const [value,setValue] = useState('a');
28
+ return <Select label="页面" value={value} onChange={event=>setValue(event.target.value)}><option value="a">同步</option><option value="b">用量</option></Select>;
29
+ }
30
+ render(<Demo />);
31
+ const select=screen.getByRole('combobox');
32
+ fireEvent.change(select,{target:{value:'b'}});
33
+ expect(select).toHaveValue('b');
34
+ });
35
+ it('retains focus while busy and blocks repeat submission', () => {
36
+ const submit = vi.fn();
37
+ const { rerender } = render(<Button onClick={submit}>保存</Button>);
38
+ const button = screen.getByRole('button'); button.focus(); fireEvent.click(button);
39
+ rerender(<Button busy busyLabel="保存中" onClick={submit}>保存</Button>);
40
+ fireEvent.click(button);
41
+ expect(submit).toHaveBeenCalledTimes(1);
42
+ expect(button).toHaveFocus(); expect(button).toHaveAttribute('aria-busy', 'true');
43
+ expect(button).not.toBeDisabled();
44
+ });
45
+ it('keeps disabled actions unavailable', () => {
46
+ const action = vi.fn(); render(<Button disabled onClick={action}>删除</Button>);
47
+ fireEvent.click(screen.getByRole('button')); expect(action).not.toHaveBeenCalled();
48
+ });
49
+ it('uses non-submit buttons by default inside forms', () => {
50
+ render(<form><Button>动作</Button></form>); expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
51
+ });
52
+ it('tabs skip disabled entries, wrap, and preserve mounted input state', () => {
53
+ function Demo() {
54
+ const [value,setValue] = useState('a');
55
+ return <Tabs label="切换" value={value} onValueChange={setValue} items={[
56
+ {value:'a',label:'甲',content:<input aria-label="草稿" defaultValue=""/>},
57
+ {value:'b',label:'乙',disabled:true,content:null},
58
+ {value:'c',label:'丙',content:'第三页'},
59
+ ]}/>;
60
+ }
61
+ render(<Demo/>); fireEvent.change(screen.getByLabelText('草稿'),{target:{value:'保留草稿'}});
62
+ const first=screen.getByRole('tab',{name:'甲'}); first.focus(); fireEvent.keyDown(first,{key:'ArrowRight'});
63
+ const last=screen.getByRole('tab',{name:'丙'}); expect(last).toHaveFocus(); expect(last).toHaveAttribute('aria-selected','true');
64
+ fireEvent.keyDown(last,{key:'ArrowRight'}); expect(first).toHaveFocus();
65
+ expect(screen.getByLabelText('草稿')).toHaveValue('保留草稿');
66
+ fireEvent.keyDown(first,{key:'End'}); expect(last).toHaveFocus();
67
+ fireEvent.keyDown(last,{key:'Home'}); expect(first).toHaveFocus();
68
+ });
69
+ it('assigns unique ARIA associations to multiple tab groups', () => {
70
+ render(<>{['one','two'].map(label=><Tabs key={label} label={label} value="a" onValueChange={()=>{}} items={[{value:'a',label,content:label}]}/>)}</>);
71
+ const tabs=screen.getAllByRole('tab'); expect(tabs[0].id).not.toBe(tabs[1].id);
72
+ for(const tab of tabs) expect(document.getElementById(tab.getAttribute('aria-controls')!)).toHaveAttribute('aria-labelledby',tab.id);
73
+ });
74
+ it('keeps separate segmented controls in independent native radio groups', () => {
75
+ render(<>{['a','b'].map(label=><SegmentedControl key={label} label={label} value="one" onValueChange={()=>{}} items={[{value:'one',label:'一'},{value:'two',label:'二'}]}/>)}</>);
76
+ const radios=screen.getAllByRole('radio'); expect(radios[0].getAttribute('name')).not.toBe(radios[2].getAttribute('name'));
77
+ });
78
+ it('allows controlled selection to be cancelled', () => {
79
+ function Demo(){const [value,setValue]=useState(false);return <SelectionCard label="选择项" selected={value} onSelectedChange={setValue}>详情</SelectionCard>}
80
+ render(<Demo/>); const input=screen.getByRole('checkbox'); fireEvent.click(input); expect(input).toBeChecked();fireEvent.click(input);expect(input).not.toBeChecked();
81
+ });
82
+ it('associates input hints and errors with the actual field', () => {
83
+ render(<TextField label="名称" hint="必填" error="请填写名称" aria-describedby="external" />);
84
+ const input=screen.getByLabelText('名称'); expect(input).toHaveAttribute('aria-invalid','true');
85
+ const ids=input.getAttribute('aria-describedby')!.split(' '); expect(ids).toContain('external');
86
+ expect(ids.slice(1).map(id=>document.getElementById(id)?.textContent)).toEqual(['必填','请填写名称']);
87
+ });
88
+ it('does not invent a percentage for unknown progress and clamps known values', () => {
89
+ const {rerender}=render(<Progress label="进度" value={null}/>);
90
+ expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
91
+ rerender(<Progress label="进度" value={150}/>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','100');
92
+ rerender(<Progress label="进度" value={NaN}/>);expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
93
+ });
94
+ it('disclosure works without animation support and closes keyboard content', () => {
95
+ render(<Disclosure title="详细信息"><button>内部按钮</button></Disclosure>);
96
+ const summary=screen.getByText('详细信息');const details=summary.closest('details')!;
97
+ expect(details.open).toBe(false);fireEvent.click(summary);expect(details.open).toBe(true);
98
+ fireEvent.click(summary);expect(details.open).toBe(false);expect(screen.getByText('内部按钮')).not.toBeVisible();
99
+ });
100
+ });
@@ -0,0 +1,32 @@
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
+ import { afterEach, expect, it, vi } from 'vitest';
3
+ import { MotionGallery } from '../preview/MotionGallery';
4
+
5
+ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
6
+ function clock() {
7
+ vi.useFakeTimers();
8
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(()=>callback(0),16));
9
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
10
+ }
11
+ it('replay cancels the old simulated completion and keeps the control focused', () => {
12
+ clock(); render(<MotionGallery reduced={false}/>);
13
+ const card=within(screen.getByRole('article',{name:'操作 → 结果'}));
14
+ fireEvent.click(card.getByRole('button',{name:'播放操作 → 结果'}));
15
+ act(()=>vi.advanceTimersByTime(400));
16
+ const replay=card.getByRole('button',{name:'重播操作 → 结果'}); replay.focus(); fireEvent.click(replay);
17
+ act(()=>vi.advanceTimersByTime(400));
18
+ expect(card.queryByText('已完成 · 演示')).not.toBeInTheDocument();
19
+ expect(replay).toHaveFocus();
20
+ act(()=>vi.advanceTimersByTime(400));
21
+ expect(card.getByRole('status')).toHaveTextContent('已完成 · 演示');
22
+ });
23
+ it('reset and unmount cancel pending demo timers without producing a result', () => {
24
+ clock(); const {unmount}=render(<MotionGallery reduced={false}/>);
25
+ const card=within(screen.getByRole('article',{name:'加载 → 内容'}));
26
+ fireEvent.click(card.getByRole('button',{name:'播放加载 → 内容'})); act(()=>vi.advanceTimersByTime(100));
27
+ fireEvent.click(card.getByRole('button',{name:'复位加载 → 内容'})); act(()=>vi.advanceTimersByTime(1000));
28
+ expect(card.queryByText('研究工作区')).not.toBeInTheDocument();
29
+ expect(card.getByRole('button',{name:'重播加载 → 内容'})).toBeDisabled();
30
+ fireEvent.click(card.getByRole('button',{name:'播放加载 → 内容'})); act(()=>vi.advanceTimersByTime(100)); unmount();
31
+ expect(vi.getTimerCount()).toBe(0);
32
+ });
@@ -0,0 +1,55 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, expect, it, vi } from 'vitest';
3
+ import { StartupGallery } from '../preview/StartupGallery';
4
+
5
+ afterEach(() => vi.useRealTimers());
6
+
7
+ it('replay cancels stale readiness and preserves control focus; reset clears the timer', () => {
8
+ vi.useFakeTimers();
9
+ const { unmount } = render(<StartupGallery reduced={false} />);
10
+ fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
11
+ act(() => vi.advanceTimersByTime(1000));
12
+ const replay = screen.getByRole('button', { name: '重播启动动画' });
13
+ replay.focus(); fireEvent.click(replay);
14
+ act(() => vi.advanceTimersByTime(700));
15
+ expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
16
+ expect(replay).toHaveFocus();
17
+ fireEvent.click(screen.getByRole('button', { name: '复位启动动画' }));
18
+ act(() => vi.advanceTimersByTime(2000));
19
+ expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
20
+ fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
21
+ unmount(); expect(vi.getTimerCount()).toBe(0);
22
+ });
23
+
24
+ it('reduced motion skips synthetic normal delay but does not turn waiting or failure into success', () => {
25
+ vi.useFakeTimers();
26
+ const { rerender } = render(<StartupGallery reduced={false} />);
27
+ fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
28
+ rerender(<StartupGallery reduced />);
29
+ expect(screen.getByText('连接你的工作空间')).toBeInTheDocument();
30
+ expect(vi.getTimerCount()).toBe(0);
31
+ fireEvent.change(screen.getByLabelText('启动场景'), { target: { value: 'waiting' } });
32
+ fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
33
+ act(() => vi.advanceTimersByTime(10000));
34
+ expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
35
+ fireEvent.click(screen.getByRole('button', { name: '模拟就绪 · 立即进入' }));
36
+ expect(screen.getByText('连接你的工作空间')).toBeInTheDocument();
37
+ fireEvent.change(screen.getByLabelText('启动场景'), { target: { value: 'error' } });
38
+ fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
39
+ expect(screen.getByRole('alert')).toHaveTextContent('暂时无法恢复会话');
40
+ expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
41
+ });
42
+
43
+ it('keeps the same brand node when readiness arrives and makes the login preview usable immediately', () => {
44
+ vi.useFakeTimers();
45
+ const { container } = render(<StartupGallery reduced={false} />);
46
+ fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
47
+ const logo = container.querySelector('.ds-startup-logo-anchor .tod-logo');
48
+ act(() => vi.advanceTimersByTime(300));
49
+ fireEvent.click(screen.getByRole('button', { name: '模拟就绪 · 立即进入' }));
50
+ expect(container.querySelector('.ds-startup-logo-anchor .tod-logo')).toBe(logo);
51
+ expect(container.querySelectorAll('.ds-startup-stage .tod-logo')).toHaveLength(1);
52
+ fireEvent.click(screen.getByRole('button', { name: '在浏览器中登录' }));
53
+ expect(screen.getByText('仅演示登录入口,不会打开授权页面。')).toBeInTheDocument();
54
+ expect(vi.getTimerCount()).toBe(0);
55
+ });
package/tests/setup.ts ADDED
@@ -0,0 +1,4 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { afterEach } from 'vitest';
3
+ import { cleanup } from '@testing-library/react';
4
+ afterEach(cleanup);
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "ES2022",
6
+ "DOM",
7
+ "DOM.Iterable"
8
+ ],
9
+ "module": "ESNext",
10
+ "moduleResolution": "Bundler",
11
+ "jsx": "react-jsx",
12
+ "strict": true,
13
+ "noEmit": true,
14
+ "skipLibCheck": true,
15
+ "esModuleInterop": true,
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "types": [
21
+ "vite/client",
22
+ "node"
23
+ ]
24
+ },
25
+ "include": [
26
+ "components",
27
+ "preview",
28
+ "tests",
29
+ "vite.config.ts",
30
+ "vitest.config.ts"
31
+ ]
32
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ export default defineConfig({ base: './', plugins: [react()], build: { target: ['es2022', 'safari15'], sourcemap: false } });
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+ export default defineConfig({ plugins: [react()], test: { include: ['tests/**/*.test.tsx'], environment: 'jsdom', setupFiles: ['./tests/setup.ts'], clearMocks: true } });