@linhey/rhine-design 0.5.0 → 0.8.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 (100) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +26 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AppShell.tsx +46 -17
  5. package/components/MOTION.md +9 -1
  6. package/components/Media.tsx +7 -7
  7. package/components/NeutronStarBackdrop.tsx +43 -0
  8. package/components/OrbitBackdrop.tsx +35 -0
  9. package/components/Overlays.tsx +37 -8
  10. package/components/README.md +30 -2
  11. package/components/StatusTimeline.tsx +72 -6
  12. package/components/StatusTimelineList.tsx +38 -0
  13. package/components/ThemePicker.tsx +49 -0
  14. package/components/assets/orbit-bird.svg +578 -0
  15. package/components/assets/orbit-crystal.svg +11 -0
  16. package/components/assets/orbit-donut.svg +16 -0
  17. package/components/assets/theme-previews/arknights.png +0 -0
  18. package/components/assets/theme-previews/manifest.json +24 -0
  19. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  20. package/components/assets/theme-previews/rhine.png +0 -0
  21. package/components/common.css +3 -3
  22. package/components/components.tsx +3 -3
  23. package/components/dialog-settings.css +9 -0
  24. package/components/extensions.css +8 -32
  25. package/components/index.ts +13 -5
  26. package/components/media-parity.css +55 -2
  27. package/components/neutron.css +53 -0
  28. package/components/neutronRenderer.ts +327 -0
  29. package/components/orbit.css +120 -0
  30. package/components/orbitBirdFlight.ts +34 -0
  31. package/components/orbitBirdGeometry.ts +158 -0
  32. package/components/orbitDonut.ts +134 -0
  33. package/components/orbitDonutThree.ts +208 -0
  34. package/components/orbitObjectMaterials.ts +76 -0
  35. package/components/orbitObjects.ts +71 -0
  36. package/components/orbitObjectsThree.ts +215 -0
  37. package/components/orbitProjection.ts +21 -0
  38. package/components/orbitRenderer.ts +347 -0
  39. package/components/styles.css +4 -1
  40. package/components/theme-neo-brutalism.css +3 -2
  41. package/components/theme-picker.css +17 -0
  42. package/components/theme.tsx +2 -8
  43. package/components/themeMetadata.ts +7 -0
  44. package/components/timeline.css +17 -6
  45. package/components/useFloatingScale.ts +96 -0
  46. package/docs/APPLICATION_COMPONENTS.md +52 -3
  47. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  48. package/docs/DATE_RANGE_FILTER.md +60 -0
  49. package/docs/DESIGN_SYSTEM.md +7 -0
  50. package/docs/FLOATING_OVERLAYS.md +15 -0
  51. package/docs/INTERACTION_PATTERNS.md +5 -1
  52. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  53. package/docs/ORBIT_BACKDROP.md +73 -0
  54. package/docs/SETTINGS_DIALOG.md +21 -0
  55. package/docs/THEME_PICKER.md +26 -0
  56. package/docs/THEMING.md +2 -0
  57. package/docs/assets/compact-account-neo.png +0 -0
  58. package/docs/assets/neutron-star-original.png +0 -0
  59. package/docs/assets/neutron-star-theme.png +0 -0
  60. package/docs/assets/orbit-backdrop.png +0 -0
  61. package/docs/assets/orbit-bird.png +0 -0
  62. package/docs/assets/orbit-crystal.png +0 -0
  63. package/docs/assets/orbit-strawberry.png +0 -0
  64. package/package.json +8 -3
  65. package/preview/CompactAccountGallery.tsx +178 -0
  66. package/preview/DateRangeFilterGallery.tsx +109 -0
  67. package/preview/ExtensionsGallery.tsx +27 -18
  68. package/preview/GalleryShell.tsx +3 -3
  69. package/preview/MonitoringGallery.tsx +2 -0
  70. package/preview/NeutronStarGallery.tsx +56 -0
  71. package/preview/OrbitGallery.tsx +50 -0
  72. package/preview/OverlayBoundsGallery.tsx +49 -0
  73. package/preview/SettingsDialogGallery.tsx +33 -0
  74. package/preview/ThemePickerGallery.tsx +24 -0
  75. package/preview/ThemeSample.tsx +14 -0
  76. package/preview/TimelineListGallery.tsx +36 -0
  77. package/preview/extensions.css +9 -0
  78. package/preview/main.tsx +19 -4
  79. package/preview/preview.css +8 -0
  80. package/preview/theme-sample.html +1 -0
  81. package/scripts/check-overlay-bounds.js +76 -0
  82. package/scripts/check-settings-theme.js +88 -0
  83. package/skills/rhine-design/SKILL.md +3 -2
  84. package/skills/rhine-design/references/API_MAP.md +14 -1
  85. package/skills/rhine-design/references/TASK_MAP.md +8 -1
  86. package/skills/rhine-design/references/integration.md +1 -1
  87. package/tests/AppShellControls.test.tsx +82 -0
  88. package/tests/ButtonLink.test.tsx +94 -0
  89. package/tests/CompactAccountGallery.test.tsx +116 -0
  90. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  91. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  92. package/tests/NeutronStarGallery.test.tsx +62 -0
  93. package/tests/NeutronStarImport.test.tsx +15 -0
  94. package/tests/OrbitBackdrop.test.tsx +420 -0
  95. package/tests/OrbitDonutThree.test.tsx +91 -0
  96. package/tests/OrbitObjectsThree.test.tsx +164 -0
  97. package/tests/SettingsDialog.test.tsx +66 -0
  98. package/tests/StatusTimelineList.test.tsx +63 -0
  99. package/tests/ThemePicker.test.tsx +30 -0
  100. package/tests/TimelineLegend.test.tsx +60 -0
@@ -2,7 +2,7 @@
2
2
  name: rhine-design
3
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
4
  metadata:
5
- version: "0.5.0"
5
+ version: "0.8.0"
6
6
  ---
7
7
 
8
8
  # Rhine Design · 莱茵设计
@@ -13,7 +13,7 @@ Use the installed version's components and contracts to build consistent interfa
13
13
 
14
14
  ## Locate the matching source
15
15
 
16
- Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.5.0, including application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; the new application APIs require 0.5.0. Check installed exports before adoption.
16
+ Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.8.0, including OrbitBackdrop, application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; application APIs require 0.5.0 and OrbitBackdrop requires 0.6.0. Check installed exports before adoption.
17
17
 
18
18
  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.
19
19
 
@@ -56,5 +56,6 @@ Required issue content:
56
56
  - Host state determines data, selection and operation outcomes. Search and pagination remain host/server responsibilities; busy indicators never imply success.
57
57
  - For multi-field failures, preserve input and focus a summary only after failed submission; links return to fields without changing routing. Use SideNav/Breadcrumbs/Steps only when exported by the installed source.
58
58
  - Monitoring shows units, freshness and stale/unknown states; errors must not rely on color, flashing or transient toasts.
59
+ - Design from the user's current task: keep identity, available actions, results and decision-relevant consequences; remove duplicate state labels and implementation prose. Reveal history on demand. Inside an existing Surface prefer borderless read-only summaries and paired facts; never compress by shrinking business text, cropping values, hiding unknown/error states or merging distinct costs/expiry meanings. See `docs/COMPACT_ACCOUNT_PATTERNS.md` when present; its new APIs are source candidates, not npm 0.6.0 features.
59
60
  - Motion explains a change. Respect reduced motion, cancellation and replay; startup decoration never blocks real readiness. Preview-only experiments are not exported components.
60
61
  - Keep independent release boundaries: do not change unrelated products or publish packages merely because this skill was invoked.
@@ -5,20 +5,28 @@ All paths are relative to the matching package root. `package.json` exports, `co
5
5
  | Exports | Source |
6
6
  | --- | --- |
7
7
  | TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
8
- | Background, AppShell(0.5.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
8
+ | Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
9
+ | AppShell.contentHeader(0.8.0) | `components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;正文列常驻导航插槽,与正文滚动区分离,组合 AppShellToggle / Inline / 标题与上下文操作 |
9
10
  | MediaCard, MediaViewer(0.5.0) | `components/Media.tsx`;中性画布,不嵌套主题 |
11
+ | Media 主题重设计(0.8.0) | `components/media-parity.css`;`docs/APPLICATION_COMPONENTS.md`;卡片、标签与查看器操作/信息栏随宿主主题,仅媒体画布中性;选择与重试独立 |
10
12
  | DateField, DateRangePicker, NumberField, TreeSelect, RemoteMultiSelect(0.5.0) | `components/AdvancedForms.tsx`;请求、时区和查询由宿主负责;只读/校验/多选键盘及AntD/MUI保留差异见 `docs/ANTD_MUI_AUDIT.md` |
13
+ | Date presets + confirmed custom range(组合,无新增导出) | `docs/DATE_RANGE_FILTER.md`、`preview/DateRangeFilterGallery.tsx`;复用 SegmentedControl + Button + Popover + DateRangePicker,宿主负责时区、预设和查询 |
11
14
  | ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
15
+ | OrbitBackdrop, OrbitBackdropProps (0.6.0) | `components/OrbitBackdrop.tsx`;契约 `docs/ORBIT_BACKDROP.md`;`components/orbitRenderer.ts` 为内部绘制实现 |
16
+ | NeutronStarBackdrop, NeutronStarBackdropProps (0.8.0) | `components/NeutronStarBackdrop.tsx`;契约 `docs/NEUTRON_STAR_BACKDROP.md`;Three.js drawing modules are internal |
12
17
  | Logo | `components/Logo.tsx` |
13
18
  | Text | `components/Typography.tsx` |
14
19
  | Select | `components/Select.tsx` |
15
20
  | Toolbar | `components/Toolbar.tsx` |
16
21
  | Icon, IconButton, Link, CopyButton | `components/Actions.tsx` |
22
+ | ButtonLink(0.8.0) | `components/Actions.tsx`;`docs/COMPACT_ACCOUNT_PATTERNS.md`;原生 anchor/下载语义,同 Button 视觉,busy 保焦点并移除 href |
17
23
  | Divider, Stack, Inline, Grid | `components/Layout.tsx` |
18
24
  | Metric, KeyValue, DataTable, Pagination | `components/Data.tsx` |
19
25
  | Checkbox, RadioGroup, Switch, Textarea, SearchField, Combobox | `components/Forms.tsx` |
20
26
  | Dialog, Drawer (0.4.0), ConfirmDialog, Popover, Menu, Tooltip | `components/Overlays.tsx` |
21
27
  | StatusTimeline, TimelineLegend (0.4.0) | `components/StatusTimeline.tsx`;历史证据,不计算健康评分 |
28
+ | TimelineLegend compact/items(0.8.0) | `components/StatusTimeline.tsx`;`docs/COMPACT_ACCOUNT_PATTERNS.md`;宿主短标签/完整规则,一行局部滚动,hover/focus/touch 详情 |
29
+ | StatusTimelineList(0.8.0) | `components/StatusTimelineList.tsx`、`docs/APPLICATION_COMPONENTS.md`;稳定行 ID、标题/摘要/紧凑时间条组合、单一详情,宿主保留请求与分页;展台 `/#monitoring` |
22
30
  | SideNav, Breadcrumbs, Steps (0.4.0) | `components/Navigation.tsx` |
23
31
  | Fieldset, ErrorSummary (0.4.0) | `components/FormWorkflow.tsx` |
24
32
  | Toast | `components/Toast.tsx` |
@@ -35,8 +43,13 @@ rg -n 'ConfirmDialog|DataTable|Combobox' tests preview
35
43
  API decisions that commonly matter:
36
44
 
37
45
  - `Select` uses a native single-select; searchable candidates use `Combobox`.
46
+ - `SelectionCard.showIndicator=false` visually hides the native input while preserving keyboard focus, accessible state and card selection styling (0.8.0).
38
47
  - `SelectionCard` uses checkbox semantics; the host determines single-selection policy. Do not nest other interactive controls in it.
39
48
  - `Text`'s `as` chooses semantics; `variant` chooses visual hierarchy.
40
49
  - `Logo` accepts `wordmark` and `label`; its `theme` affects the logo, not the full component library.
50
+ - `OrbitBackdrop` is decorative artwork with built-in pause / resume. Its `reducedMotion` prop can strengthen but never override system reduction; see `docs/ORBIT_BACKDROP.md` for visibility, narrow-window and fallback behavior. In 0.8.0, theme artwork uses an origami paper bird, originium crystals or a strawberry donut; `appearance="original"` preserves the initial orbit. It does not express loading or business progress.
51
+ - `NeutronStarBackdrop` preserves the separate neutron-star artwork. `appearance="theme"` follows existing theme tokens; `original` restores its deep-space palette without replacing the host control theme. It lazy-loads WebGL, freezes under reduction or visibility constraints, and retains static art on failure. It requires 0.8.0 or later; see `docs/NEUTRON_STAR_BACKDROP.md`.
41
52
  - `Toast` presents one notification, not a global queue. `DataTable` is not a query or table-state engine.
42
53
  - Components in `preview/` are demonstrations. Public API must resolve through a `package.json` export; theme APIs use the separate `/theme` entry.
54
+
55
+ Available since 0.8.0 (check installed exports): `ThemePicker` / `ThemePreview` in `components/ThemePicker.tsx`, contract `docs/THEME_PICKER.md`; `Dialog.navigation` in `components/Overlays.tsx`, contract `docs/SETTINGS_DIALOG.md`; shared floating CSS zoom behavior in `docs/FLOATING_OVERLAYS.md`.
@@ -5,20 +5,27 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
5
5
  | Task | First reference (relative to package root) | Implementation / example |
6
6
  | --- | --- | --- |
7
7
  | Application shell, backgrounds, media, advanced inputs, controlled tables | `docs/APPLICATION_COMPONENTS.md` (0.5.0) | `preview/ExtensionsGallery.tsx`; `/#app-shell`, `/#advanced-forms`, `/#advanced-data`, `/#media` |
8
+ | Workspace header, duplicate page titles, optional breadcrumbs | `docs/APPLICATION_COMPONENTS.md` → 平级工作区顶部操作区 (source guideline) | `preview/ExtensionsGallery.tsx`; `/#app-shell`; keep status/actions, navigation restore and accessible page identity |
8
9
  | Theme integration, switching, controlled preferences | `docs/THEMING.md` (0.4.0) | `components/theme.tsx`, `preview/GalleryShell.tsx` |
9
10
  | Scenario selection, navigation, form recovery, monitoring | `docs/INTERACTION_PATTERNS.md` (0.4.0; check installed version) | `preview/PatternGallery.tsx`; research in `docs/REFERENCE_RESEARCH.md` |
11
+ | Date presets, visible date ranges, custom-range drafts and confirmation | `docs/DATE_RANGE_FILTER.md` (source pattern using existing 0.6.0 APIs) | `preview/DateRangeFilterGallery.tsx`; `/#date-range-filter`; no public DateRangeFilter component |
10
12
  | Visual direction, colors, spacing, layout | `docs/DESIGN_SYSTEM.md` | `components/tokens.css`, `components/Layout.tsx` |
11
13
  | Spacing, grouping, card composition | `components/SPACING.md` (0.4.0 docs; existing gap API works in 0.3.0) | `components/Layout.tsx`, `preview/SpacingGallery.tsx`; `/#spacing` |
12
14
  | Text hierarchy, line height, paragraphs, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx`, `preview/LineHeightGallery.tsx`; `/#typography` |
13
15
  | Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
14
16
  | Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
17
+ | Task-centered content reduction, borderless account summaries, credential actions, compact legends | `docs/COMPACT_ACCOUNT_PATTERNS.md` (source candidates; not npm 0.6.0) | `preview/CompactAccountGallery.tsx`; `/#compact-account`; ButtonLink + StatusTimelineList + TimelineLegend |
15
18
  | Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
16
19
  | Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
17
20
  | Transition, replay, cancellation, reduced motion | `components/MOTION.md` | `preview/`, `components/styles.css` |
18
21
  | Startup sequence, logo assembly, ready handoff | `components/STARTUP.md` | Search `startup` in `preview/`; not a public Startup component |
22
+ | Orbit artwork, decorative Canvas, pause / resume, reduced motion | `docs/ORBIT_BACKDROP.md` (base 0.6.0, themed objects 0.8.0) | `components/OrbitBackdrop.tsx`, internal `components/orbitRenderer.ts`; `/#orbit` |
23
+ | Neutron-star artwork, themed WebGL, async loading, static fallback | `docs/NEUTRON_STAR_BACKDROP.md` (0.8.0) | `components/NeutronStarBackdrop.tsx`; `preview/NeutronStarGallery.tsx`; `/#neutron-star` |
19
24
  | Missing capability or system coverage | `docs/DESIGN_SYSTEM_AUDIT.md` | Verify status against `components/index.ts` |
20
25
  | Version upgrade | `CHANGELOG.md` | Installed `package.json`, current types |
21
26
 
22
- 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.
27
+ Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion`, `/#startup` and `/#orbit`, plus `/#neutron-star` and `/#compact-account` in 0.8.0. Do not assume another project's server or local filesystem layout.
23
28
 
24
29
  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.
30
+
31
+ For 0.8.0 theme-comparison cards, settings navigation, or floating CSS zoom boundaries, read `docs/THEME_PICKER.md`, `docs/SETTINGS_DIALOG.md`, and `docs/FLOATING_OVERLAYS.md` respectively. Preview routes: `#theme-picker`, `#settings-dialog`, `#overlay-bounds`. Do not infer package availability from the main gallery.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Supported baseline
4
4
 
5
- Rhine Design 0.5.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.
5
+ Rhine Design 0.8.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
6
 
7
7
  ```tsx
8
8
  import { Button, Surface, Text } from '@linhey/rhine-design';
@@ -0,0 +1,82 @@
1
+ import { useState } from 'react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
4
+ import { AppShell, AppShellToggle } from '../components';
5
+ import { ThemeProvider } from '../components/theme';
6
+
7
+ function Demo({ navigationBar = false }: { navigationBar?: boolean }) {
8
+ const [open, setOpen] = useState(true);
9
+ return <ThemeProvider><AppShell controls="inline" layout="single" navigation={{ label: '导航', open, onOpenChange: setOpen,
10
+ content: <div aria-label="品牌区"><strong>Workspace</strong><AppShellToggle placement="panel" iconOnly /><button>导航项</button></div> }}
11
+ contentHeader={navigationBar ? <nav aria-label="工作区导航"><AppShellToggle placement="content" iconOnly /><h1>收件箱</h1></nav> : undefined}>
12
+ {!navigationBar && <AppShellToggle placement="content" iconOnly />}<input aria-label="草稿" defaultValue="保留" />
13
+ </AppShell></ThemeProvider>;
14
+ }
15
+
16
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
17
+ describe('inline shell controls', () => {
18
+ it('keeps navigation outside the body and preserves drafts when navigation changes', () => {
19
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
20
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1040);
21
+ const { rerender } = render(<Demo navigationBar />);
22
+ const input = screen.getByLabelText('草稿');
23
+ fireEvent.change(input, { target: { value: '未保存的编辑' } });
24
+ expect(screen.getByRole('region', { name: '正文' })).not.toContainElement(screen.getByRole('navigation', { name: '工作区导航' }));
25
+ const collapse = screen.getByRole('button', { name: '收起导航' });
26
+ collapse.focus(); fireEvent.click(collapse);
27
+ const expand = within(screen.getByRole('navigation', { name: '工作区导航' })).getByRole('button', { name: '展开导航' });
28
+ expect(expand).toHaveFocus();
29
+ fireEvent.click(expand);
30
+ rerender(<Demo />);
31
+ expect(screen.getByLabelText('草稿')).toBe(input);
32
+ expect(input).toHaveValue('未保存的编辑');
33
+ });
34
+ it('collapses from the brand and restores focus without a standalone header', () => {
35
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
36
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1040);
37
+ const { container } = render(<Demo />);
38
+ expect(container.querySelector('.rhine-shell__header')).toBeNull();
39
+ const input = screen.getByLabelText('草稿');
40
+ const collapse = within(screen.getByLabelText('品牌区')).getByRole('button', { name: '收起导航' });
41
+ collapse.focus(); fireEvent.click(collapse);
42
+ const expand = screen.getByRole('button', { name: '展开导航' });
43
+ expect(expand).toHaveFocus();
44
+ fireEvent.click(expand);
45
+ expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus();
46
+ expect(screen.getByLabelText('草稿')).toBe(input);
47
+ expect(screen.queryByRole('dialog')).toBeNull();
48
+ });
49
+ it('opens the narrow drawer and returns focus after Escape', async () => {
50
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
51
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400);
52
+ render(<Demo />);
53
+ const trigger = screen.getByRole('button', { name: '导航' });
54
+ trigger.focus(); fireEvent.click(trigger);
55
+ const dialog = await screen.findByRole('dialog', { name: '导航' });
56
+ expect(within(dialog).getByText('Workspace')).toBeVisible();
57
+ expect(screen.queryByRole('button', { name: '收起导航' })).toBeNull();
58
+ fireEvent.keyDown(dialog, { key: 'Escape' });
59
+ await waitFor(() => expect(trigger).toHaveFocus());
60
+ });
61
+ it('moves focused navigation to the content trigger when the container becomes narrow', () => {
62
+ let resize = () => {};
63
+ vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} });
64
+ const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1040);
65
+ render(<Demo />);
66
+ screen.getByRole('button', { name: '导航项' }).focus();
67
+ width.mockReturnValue(400); act(resize);
68
+ expect(screen.getByRole('button', { name: '导航' })).toHaveFocus();
69
+ });
70
+ it('returns to the brand after closing a drawer that was resized to desktop', async () => {
71
+ let resize = () => {};
72
+ vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} });
73
+ const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400);
74
+ render(<Demo />);
75
+ fireEvent.click(screen.getByRole('button', { name: '导航' }));
76
+ const dialog = await screen.findByRole('dialog', { name: '导航' });
77
+ width.mockReturnValue(1040); act(resize);
78
+ expect(dialog).toBeVisible();
79
+ fireEvent.keyDown(dialog, { key: 'Escape' });
80
+ await waitFor(() => expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus());
81
+ });
82
+ });
@@ -0,0 +1,94 @@
1
+ import { createRef } from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ButtonLink } from '../components';
5
+
6
+ describe('ButtonLink', () => {
7
+ it('keeps native link and download attributes and forwards the anchor ref', () => {
8
+ const ref = createRef<HTMLAnchorElement>();
9
+ render(<ButtonLink ref={ref} href="#download" download="example.json" target="_blank" rel="noopener" variant="secondary" className="host-action">下载文件</ButtonLink>);
10
+ const link = screen.getByRole('link', { name: '下载文件' });
11
+ expect(link.tagName).toBe('A');
12
+ expect(ref.current).toBe(link);
13
+ expect(link).toHaveAttribute('href', '#download');
14
+ expect(link).toHaveAttribute('download', 'example.json');
15
+ expect(link).toHaveAttribute('target', '_blank');
16
+ expect(link).toHaveAttribute('rel', 'noopener');
17
+ expect(link).toHaveClass('rhine-button', 'rhine-button--secondary', 'host-action');
18
+ expect(link).not.toHaveAttribute('aria-disabled');
19
+ expect(link).not.toHaveAttribute('aria-busy');
20
+ expect(link.tabIndex).toBe(0);
21
+ });
22
+
23
+ it('leaves primary, modified and auxiliary clicks to the browser and caller', () => {
24
+ const click = vi.fn();
25
+ const auxiliary = vi.fn();
26
+ render(<ButtonLink href="#download" onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink>);
27
+ const link = screen.getByRole('link');
28
+ for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { shiftKey: true }]) {
29
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...modifiers });
30
+ expect(fireEvent(link, event)).toBe(true);
31
+ expect(event.defaultPrevented).toBe(false);
32
+ expect(click.mock.lastCall?.[0].ctrlKey).toBe(Boolean('ctrlKey' in modifiers && modifiers.ctrlKey));
33
+ expect(click.mock.lastCall?.[0].metaKey).toBe(Boolean('metaKey' in modifiers && modifiers.metaKey));
34
+ }
35
+ const event = new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true });
36
+ expect(fireEvent(link, event)).toBe(true);
37
+ expect(event.defaultPrevented).toBe(false);
38
+ expect(click).toHaveBeenCalledTimes(4);
39
+ expect(auxiliary).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it('lets the caller cancel native navigation', () => {
43
+ render(<ButtonLink href="#download" onClick={event => event.preventDefault()}>下载文件</ButtonLink>);
44
+ expect(fireEvent.click(screen.getByRole('link'))).toBe(false);
45
+ });
46
+
47
+ it('removes disabled destinations and blocks primary and auxiliary activation', () => {
48
+ const click = vi.fn();
49
+ const auxiliary = vi.fn();
50
+ const delegated = vi.fn();
51
+ render(<div onClick={delegated} onAuxClick={delegated}><ButtonLink href="#download" disabled tabIndex={0} onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink></div>);
52
+ const link = screen.getByRole('link', { name: '下载文件' });
53
+ expect(link).not.toHaveAttribute('href');
54
+ expect(link).toHaveAttribute('aria-disabled', 'true');
55
+ expect(link.tabIndex).toBe(-1);
56
+ expect(fireEvent.click(link, { ctrlKey: true })).toBe(false);
57
+ expect(fireEvent(link, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))).toBe(false);
58
+ expect(click).not.toHaveBeenCalled();
59
+ expect(auxiliary).not.toHaveBeenCalled();
60
+ expect(delegated).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('retains focus while busy and restores the destination when ready', () => {
64
+ const click = vi.fn();
65
+ const auxiliary = vi.fn();
66
+ const { rerender } = render(<ButtonLink href="#download" onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink>);
67
+ const link = screen.getByRole('link');
68
+ link.focus();
69
+ rerender(<ButtonLink href="#download" busy busyLabel="准备文件" onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink>);
70
+ expect(link).toHaveFocus();
71
+ expect(link).toHaveAttribute('aria-busy', 'true');
72
+ expect(link).toHaveAttribute('aria-disabled', 'true');
73
+ expect(link).toHaveAccessibleName('准备文件');
74
+ expect(link).not.toHaveAttribute('href');
75
+ expect(link.tabIndex).toBe(0);
76
+ expect(fireEvent.click(link)).toBe(false);
77
+ expect(fireEvent(link, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))).toBe(false);
78
+ expect(click).not.toHaveBeenCalled();
79
+ expect(auxiliary).not.toHaveBeenCalled();
80
+ rerender(<ButtonLink href="#ready" onClick={click}>下载文件</ButtonLink>);
81
+ expect(link).toHaveFocus();
82
+ expect(link).toHaveAttribute('href', '#ready');
83
+ expect(link).not.toHaveAttribute('aria-disabled');
84
+ expect(link).not.toHaveAttribute('aria-busy');
85
+ });
86
+
87
+ it('keeps caller focus policy while busy and gives disabled precedence', () => {
88
+ const { rerender } = render(<ButtonLink href="#download" busy tabIndex={-1}>下载文件</ButtonLink>);
89
+ expect(screen.getByRole('link').tabIndex).toBe(-1);
90
+ rerender(<ButtonLink href="#download" busy disabled tabIndex={0}>下载文件</ButtonLink>);
91
+ expect(screen.getByRole('link').tabIndex).toBe(-1);
92
+ expect(screen.getByRole('link')).toHaveAttribute('data-disabled', 'true');
93
+ });
94
+ });
@@ -0,0 +1,116 @@
1
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { CompactAccountExample, CompactAccountGallery, CompactSubscriptionExample } from '../preview/CompactAccountGallery';
4
+
5
+ const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
6
+ afterEach(() => {
7
+ vi.restoreAllMocks();
8
+ if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard);
9
+ else Reflect.deleteProperty(navigator, 'clipboard');
10
+ });
11
+ function demo(content: React.ReactNode) { return render(<div className="rhine-theme">{content}</div>); }
12
+
13
+ describe('compact account composition', () => {
14
+ it('preserves valid zero, unknown values and distinct expiry meanings', () => {
15
+ const view = demo(<CompactAccountExample state="zero" />);
16
+ expect(screen.getByRole('progressbar', { name: '7D 剩余' })).toHaveAttribute('aria-valuenow', '0');
17
+ expect(within(screen.getByTestId('compact-account-facts')).getAllByText('0')).toHaveLength(4);
18
+ expect(within(screen.getByTestId('compact-account-facts')).getAllByText('$0.00')).toHaveLength(2);
19
+ view.rerender(<div className="rhine-theme"><CompactAccountExample state="unknown" /></div>);
20
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
21
+ expect(screen.getByText('7D 剩余 — · 额度暂不可用')).toBeVisible();
22
+ expect(screen.getByText('历史记录不完整,仅展示已确认的时段。')).toBeVisible();
23
+ expect(within(screen.getByTestId('compact-account-facts')).getAllByText('—')).toHaveLength(5);
24
+ view.rerender(<div className="rhine-theme"><CompactAccountExample state="long" /></div>);
25
+ expect(screen.getByText('到期')).toBeVisible();
26
+ expect(screen.getByText('订阅到期')).toBeVisible();
27
+ expect(screen.getByText('$12,345.67 / $24,691.34')).toBeVisible();
28
+ expect(screen.getByText('$123,456.78 / $246,913.56')).toBeVisible();
29
+ });
30
+
31
+ it('uses the shared timeline with inspectable evidence and compact custom legend', async () => {
32
+ demo(<CompactAccountExample />);
33
+ expect(screen.getByRole('list', { name: '资源用量与异常记录' })).toBeInTheDocument();
34
+ const slider = screen.getByRole('slider', { name: '用量与异常记录 历史时段' });
35
+ act(() => slider.focus());
36
+ const details = await screen.findByRole('dialog', { name: '用量与异常记录 历史时段时段详情' });
37
+ expect(within(details).getByText(/按 15 分钟聚合的合成记录/)).toBeVisible();
38
+ const legend = screen.getByRole('list', { name: '历史状态图例' });
39
+ for (const label of ['有输出', '已恢复', '待确认', '失败', '未知']) expect(within(legend).getByText(label)).toBeVisible();
40
+ });
41
+
42
+ it('exposes long, narrow and scaled review settings without changing business values', () => {
43
+ demo(<CompactAccountGallery />);
44
+ fireEvent.change(screen.getByLabelText('账号读数'), { target: { value: 'long' } });
45
+ fireEvent.change(screen.getByLabelText('样机宽度'), { target: { value: '400' } });
46
+ fireEvent.change(screen.getByLabelText('内容缩放'), { target: { value: '2' } });
47
+ expect(screen.getByTestId('compact-stage')).toHaveStyle({ width: '400px' });
48
+ expect(screen.getByText('$123,456.78 / $246,913.56')).toBeVisible();
49
+ fireEvent.click(screen.getByLabelText('包含历史链接'));
50
+ expect(screen.getByText('历史链接(1)')).toBeVisible();
51
+ });
52
+ });
53
+
54
+ describe('compact credential actions composition', () => {
55
+ it('shows a native download action and no redundant empty history or repeated state', () => {
56
+ demo(<CompactSubscriptionExample />);
57
+ const download = screen.getByRole('link', { name: '下载文件' });
58
+ expect(download.tagName).toBe('A');
59
+ expect(download).toHaveAttribute('download', 'rhine-demo.json');
60
+ expect(download).toHaveClass('rhine-button');
61
+ expect(download.getAttribute('href')).toMatch(/^data:application\/json/);
62
+ expect(screen.queryByText(/历史链接/)).not.toBeInTheDocument();
63
+ expect(screen.queryByText('当前链接')).not.toBeInTheDocument();
64
+ expect(screen.queryByText('已生成')).not.toBeInTheDocument();
65
+ expect(screen.getByText('链接可下载凭证,请勿分享。')).toBeVisible();
66
+ expect(screen.getByText('重置或撤销后,旧链接失效。')).toBeVisible();
67
+ });
68
+
69
+ it('copies only the synthetic address and leaves it available after clipboard failure', async () => {
70
+ const writeText = vi.fn().mockResolvedValue(undefined);
71
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
72
+ demo(<CompactSubscriptionExample />);
73
+ fireEvent.click(screen.getByRole('button', { name: '复制链接' }));
74
+ await screen.findByText('已复制演示链接。');
75
+ expect(writeText).toHaveBeenCalledWith('https://example.invalid/subscriptions/demo/1');
76
+ writeText.mockRejectedValueOnce(new Error('clipboard unavailable'));
77
+ fireEvent.click(screen.getByRole('button', { name: '复制链接' }));
78
+ await screen.findByRole('alert');
79
+ expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/1');
80
+ expect(screen.getByRole('button', { name: '复制链接' })).toBeEnabled();
81
+ });
82
+
83
+ it('keeps history collapsed, then resets, revokes and explicitly regenerates a link', async () => {
84
+ demo(<CompactSubscriptionExample showHistory />);
85
+ const previous = screen.getByText('https://example.invalid/subscriptions/demo/retired-1');
86
+ expect(previous).not.toBeVisible();
87
+ fireEvent.click(screen.getByText('历史链接(1)'));
88
+ await waitFor(() => expect(previous).toBeVisible());
89
+ fireEvent.click(screen.getByRole('button', { name: '重置链接' }));
90
+ expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/2');
91
+ expect(screen.getByText('历史链接(2)')).toBeVisible();
92
+ fireEvent.click(screen.getByRole('button', { name: '撤销链接' }));
93
+ expect(screen.getByText('尚未生成订阅链接')).toBeVisible();
94
+ expect(screen.queryByRole('link', { name: '下载文件' })).not.toBeInTheDocument();
95
+ expect(screen.queryByRole('button', { name: '撤销链接' })).not.toBeInTheDocument();
96
+ fireEvent.click(screen.getByRole('button', { name: '获取订阅链接' }));
97
+ expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/3');
98
+ });
99
+
100
+ it('blocks loading actions and does not generate from an error or no-link state', () => {
101
+ const view = demo(<CompactSubscriptionExample initialState="loading" />);
102
+ expect(screen.getByRole('status')).toHaveTextContent('正在读取订阅链接');
103
+ expect(screen.getByRole('button', { name: '复制链接' })).toBeDisabled();
104
+ expect(screen.getByText('下载文件').closest('a')).toHaveAttribute('aria-disabled', 'true');
105
+ expect(screen.queryByLabelText('订阅链接')).not.toBeInTheDocument();
106
+ view.rerender(<div className="rhine-theme"><CompactSubscriptionExample key="error" initialState="error" /></div>);
107
+ expect(screen.getByRole('alert')).toHaveTextContent('订阅链接读取失败');
108
+ expect(screen.queryByLabelText('订阅链接')).not.toBeInTheDocument();
109
+ fireEvent.click(screen.getByRole('button', { name: '重试' }));
110
+ expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/1');
111
+ view.rerender(<div className="rhine-theme"><CompactSubscriptionExample key="empty" initialState="no-link" /></div>);
112
+ expect(screen.getByRole('button', { name: '获取订阅链接' })).toBeEnabled();
113
+ expect(screen.queryByLabelText('订阅链接')).not.toBeInTheDocument();
114
+ expect(screen.queryByText(/历史链接/)).not.toBeInTheDocument();
115
+ });
116
+ });
@@ -0,0 +1,85 @@
1
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { DateRangeFilterExample } from '../preview/DateRangeFilterGallery';
4
+
5
+ function demo(props = {}) {
6
+ render(<div className="rhine-theme"><DateRangeFilterExample {...props} /></div>);
7
+ }
8
+ const trigger = () => screen.getByRole('button', { name: /时间范围:.+选择日期/ });
9
+ async function open() {
10
+ trigger().focus(); fireEvent.click(trigger());
11
+ return screen.findByRole('dialog', { name: '时间范围设置' });
12
+ }
13
+
14
+ describe('date range filtering composition', () => {
15
+ it('applies a quick preset immediately and always displays its dates', () => {
16
+ demo();
17
+ fireEvent.click(screen.getByRole('radio', { name: '昨天' }));
18
+ expect(screen.getByRole('radio', { name: '昨天' })).toBeChecked();
19
+ expect(trigger()).toHaveTextContent('2026-10-23 — 2026-10-23');
20
+ expect(screen.getByRole('status')).toHaveTextContent('本次操作 1 次');
21
+ });
22
+
23
+ it('keeps edits as drafts, discards cancellation and restores trigger focus', async () => {
24
+ demo(); const dialog = await open();
25
+ fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '2026-10-01' } });
26
+ expect(screen.getByRole('status')).toHaveTextContent('2026-10-24 — 2026-10-24 · 本次操作 0 次');
27
+ fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
28
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
29
+ await waitFor(() => expect(trigger()).toHaveFocus());
30
+ const reopened = await open();
31
+ expect(within(reopened).getByLabelText('开始日期')).toHaveValue('2026-10-24');
32
+ });
33
+
34
+ it('does not commit on Escape or the overlay close button', async () => {
35
+ demo();
36
+ for (const close of ['escape', 'button']) {
37
+ const dialog = await open();
38
+ fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '2026-10-02' } });
39
+ if (close === 'escape') fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
40
+ else fireEvent.click(within(dialog).getByRole('button', { name: '关闭详情' }));
41
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
42
+ expect(screen.getByRole('status')).toHaveTextContent('本次操作 0 次');
43
+ await waitFor(() => expect(trigger()).toHaveFocus());
44
+ }
45
+ });
46
+
47
+ it('blocks incomplete, reversed and out-of-range dates before applying one valid range', async () => {
48
+ demo(); const dialog = await open();
49
+ const start = within(dialog).getByLabelText('开始日期'), end = within(dialog).getByLabelText('结束日期');
50
+ const apply = within(dialog).getByRole('button', { name: '应用日期' });
51
+ for (const [from, to] of [['', '2026-10-24'], ['2026-10-24', ''], ['2026-10-25', '2026-10-24'], ['2024-12-31', '2026-10-24'], ['2026-10-24', '2027-01-01']]) {
52
+ fireEvent.change(start, { target: { value: from } });
53
+ fireEvent.change(end, { target: { value: to } });
54
+ expect(apply).toBeDisabled();
55
+ fireEvent.submit(apply.closest('form')!);
56
+ expect(screen.getByRole('status')).toHaveTextContent('本次操作 0 次');
57
+ }
58
+ fireEvent.change(start, { target: { value: '2025-12-28' } });
59
+ fireEvent.change(end, { target: { value: '2026-01-06' } });
60
+ await waitFor(() => expect(apply).toBeEnabled());
61
+ fireEvent.click(apply);
62
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
63
+ expect(screen.getByRole('status')).toHaveTextContent('2025-12-28 — 2026-01-06 · 本次操作 1 次');
64
+ expect(screen.getAllByRole('radio').every(radio => !(radio as HTMLInputElement).checked)).toBe(true);
65
+ await waitFor(() => expect(trigger()).toHaveFocus());
66
+ });
67
+
68
+ it('applies additional presets immediately instead of submitting an unfinished draft', async () => {
69
+ demo(); const dialog = await open();
70
+ fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '' } });
71
+ fireEvent.click(within(dialog).getByRole('button', { name: '最近 30 天' }));
72
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
73
+ expect(trigger()).toHaveTextContent('2026-09-25 — 2026-10-24');
74
+ expect(screen.getByRole('status')).toHaveTextContent('本次操作 1 次');
75
+ });
76
+
77
+ it('keeps a disabled range readable and prevents all entry points', () => {
78
+ demo({ disabled: true });
79
+ expect(trigger()).toBeDisabled();
80
+ expect(screen.getAllByRole('radio').every(radio => (radio as HTMLInputElement).disabled)).toBe(true);
81
+ fireEvent.click(trigger());
82
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
83
+ expect(screen.getByRole('status')).toHaveTextContent('2026-10-24 — 2026-10-24 · 本次操作 0 次');
84
+ });
85
+ });