@linhey/rhine-design 0.4.0 → 0.6.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 (69) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +24 -7
  3. package/components/AdvancedForms.tsx +184 -0
  4. package/components/AppShell.tsx +71 -0
  5. package/components/Background.tsx +29 -0
  6. package/components/COMMON_COMPONENTS.md +2 -2
  7. package/components/Charts.tsx +94 -0
  8. package/components/Data.tsx +98 -18
  9. package/components/Forms.tsx +14 -8
  10. package/components/MOTION.md +9 -1
  11. package/components/Media.tsx +82 -0
  12. package/components/OrbitBackdrop.tsx +33 -0
  13. package/components/Overlays.tsx +4 -3
  14. package/components/README.md +12 -1
  15. package/components/StatusTimeline.tsx +50 -12
  16. package/components/background.css +43 -0
  17. package/components/charts.css +27 -0
  18. package/components/common.css +5 -5
  19. package/components/data.css +6 -0
  20. package/components/drawer-placement.css +4 -0
  21. package/components/extensions.css +70 -0
  22. package/components/index.ts +14 -2
  23. package/components/media-parity.css +3 -0
  24. package/components/orbit.css +42 -0
  25. package/components/orbitRenderer.ts +230 -0
  26. package/components/patterns.css +1 -1
  27. package/components/styles.css +5 -1
  28. package/components/theme-arknights.css +10 -5
  29. package/components/theme-neo-brutalism.css +10 -4
  30. package/components/timeline.css +2 -2
  31. package/components/tokens.css +43 -0
  32. package/docs/ANTD_MUI_AUDIT.md +38 -0
  33. package/docs/APPLICATION_COMPONENTS.md +175 -0
  34. package/docs/DESIGN_SYSTEM_AUDIT.md +2 -0
  35. package/docs/ISSUE_4_VALIDATION.md +21 -0
  36. package/docs/ISSUE_VALIDATION.md +52 -0
  37. package/docs/ORBIT_BACKDROP.md +81 -0
  38. package/docs/REFERENCE_RESEARCH.md +2 -0
  39. package/docs/THEMING.md +41 -0
  40. package/docs/assets/application-shell.png +0 -0
  41. package/docs/assets/issue4-shell.png +0 -0
  42. package/docs/assets/media-viewer-mobile.png +0 -0
  43. package/docs/assets/monitoring-desktop.png +0 -0
  44. package/docs/assets/orbit-backdrop.png +0 -0
  45. package/docs/audits/DATA_ANTD_MUI.md +75 -0
  46. package/docs/audits/FORMS_ANTD_MUI.md +88 -0
  47. package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
  48. package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
  49. package/package.json +1 -1
  50. package/preview/BackgroundGallery.tsx +46 -0
  51. package/preview/ExtensionsGallery.tsx +95 -0
  52. package/preview/GalleryShell.tsx +4 -3
  53. package/preview/MonitoringGallery.tsx +27 -0
  54. package/preview/OrbitGallery.tsx +47 -0
  55. package/preview/backgrounds.css +10 -0
  56. package/preview/extensions.css +6 -0
  57. package/preview/main.tsx +12 -1
  58. package/skills/rhine-design/SKILL.md +24 -3
  59. package/skills/rhine-design/references/API_MAP.md +6 -0
  60. package/skills/rhine-design/references/TASK_MAP.md +3 -1
  61. package/skills/rhine-design/references/integration.md +4 -2
  62. package/tests/Background.test.tsx +22 -0
  63. package/tests/DataParity.test.tsx +148 -0
  64. package/tests/Extensions.test.tsx +145 -0
  65. package/tests/FormParity.test.tsx +185 -0
  66. package/tests/Monitoring.test.tsx +38 -0
  67. package/tests/MonitoringParity.test.tsx +125 -0
  68. package/tests/OrbitBackdrop.test.tsx +183 -0
  69. package/tests/ShellMediaParity.test.tsx +180 -0
@@ -0,0 +1,180 @@
1
+ import { createRef, useState } from 'react';
2
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { AppShell, Background, Dialog, Drawer, MediaCard, MediaViewer } from '../components';
5
+ import { ThemeProvider } from '../components/theme';
6
+
7
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
8
+
9
+ function shellWidth(initialWidth: number) {
10
+ let width = initialWidth, onResize = () => {};
11
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width);
12
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: width * 2 } as DOMRect));
13
+ vi.stubGlobal('ResizeObserver', class {
14
+ constructor(callback: () => void) { onResize = callback; }
15
+ observe() {} disconnect() {}
16
+ });
17
+ return (next: number) => act(() => { width = next; onResize(); });
18
+ }
19
+
20
+ describe('Ant Design / MUI shell and background contracts', () => {
21
+ it('exposes the background DOM ref without creating another theme root or hiding native attributes', () => {
22
+ const ref = createRef<HTMLDivElement>();
23
+ const { container } = render(<ThemeProvider><Background ref={ref} role="region" aria-label="局部背景" className="custom" style={{ padding: 12 }} appearance={{ color: 'pink' }} decoration="none">正文</Background></ThemeProvider>);
24
+ expect(ref.current).toBe(screen.getByRole('region', { name: '局部背景' }));
25
+ expect(ref.current).toHaveClass('rhine-background', 'custom');
26
+ expect(ref.current).toHaveStyle({ padding: '12px', '--rhine-background-color': 'pink' });
27
+ expect(container.querySelectorAll('.rhine-theme')).toHaveLength(1);
28
+ });
29
+
30
+ it('lets the host control the mobile drawer independently from the desktop open state', async () => {
31
+ shellWidth(400);
32
+ const onDrawerOpenChange = vi.fn(), onOpenChange = vi.fn();
33
+ const navigation = { label: '导航', content: <button>导航项目</button>, open: true, onOpenChange, drawerOpen: false, onDrawerOpenChange };
34
+ const { rerender } = render(<AppShell layout="single" navigation={navigation}>正文</AppShell>);
35
+ fireEvent.click(screen.getByRole('button', { name: '导航' }));
36
+ expect(onDrawerOpenChange).toHaveBeenLastCalledWith(true);
37
+ expect(onOpenChange).not.toHaveBeenCalled();
38
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
39
+ rerender(<AppShell layout="single" navigation={{ ...navigation, drawerOpen: true }}>正文</AppShell>);
40
+ expect(screen.getByRole('dialog', { name: '导航' })).toHaveAttribute('data-side', 'left');
41
+ fireEvent.click(screen.getByRole('button', { name: '关闭抽屉' }));
42
+ expect(onDrawerOpenChange).toHaveBeenLastCalledWith(false);
43
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
44
+ rerender(<AppShell layout="single" navigation={navigation}>正文</AppShell>);
45
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
46
+ });
47
+
48
+ it('supports a default-open drawer and keeps its side aligned with the desktop panel', async () => {
49
+ shellWidth(400);
50
+ const panel = { label: '导航', content: <p>导航内容</p>, open: true, onOpenChange: vi.fn() };
51
+ const secondary = { ...panel, label: '详情', defaultDrawerOpen: true };
52
+ const { rerender } = render(<AppShell layout="left-right" navigation={panel} secondary={secondary}>正文</AppShell>);
53
+ expect(screen.getByRole('dialog', { name: '详情' })).toHaveAttribute('data-side', 'right');
54
+ fireEvent.click(screen.getByRole('button', { name: '关闭抽屉' }));
55
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
56
+ rerender(<AppShell layout="double-left" navigation={panel} secondary={secondary}>正文</AppShell>);
57
+ fireEvent.click(screen.getByRole('button', { name: '详情' }));
58
+ expect(screen.getByRole('dialog', { name: '详情' })).toHaveAttribute('data-side', 'left');
59
+ });
60
+
61
+ it('uses custom container breakpoints and widths while returning focus when a pane hides', () => {
62
+ const resize = shellWidth(1000);
63
+ render(<AppShell layout="single" className="custom-shell" style={{ height: 600 }} navigation={{ label: '导航', content: <button>导航项目</button>, open: true, onOpenChange: vi.fn(), width: 300, breakpoint: 900 }}>正文</AppShell>);
64
+ const pane = screen.getByRole('complementary', { name: '导航' });
65
+ expect(pane).toHaveStyle({ flexBasis: '300px' });
66
+ expect(screen.getByRole('region', { name: '应用工作区' })).toHaveClass('custom-shell');
67
+ expect(screen.getByRole('region', { name: '应用工作区' })).toHaveStyle({ height: '600px' });
68
+ screen.getByRole('button', { name: '导航项目' }).focus();
69
+ resize(899);
70
+ expect(pane).not.toBeVisible();
71
+ expect(screen.getByRole('button', { name: '导航' })).toHaveFocus();
72
+ resize(900);
73
+ expect(pane).toBeVisible();
74
+ });
75
+
76
+ it('restores focus when the secondary prop is removed without stealing focus from outside the shell', () => {
77
+ shellWidth(1200);
78
+ const navigation = { label: '导航', content: <button>导航项目</button>, open: true, onOpenChange: vi.fn() };
79
+ const secondary = { ...navigation, label: '详情', content: <button>详情操作</button> };
80
+ const demo = (show: boolean) => <><button>外部操作</button><AppShell layout="left-right" navigation={navigation} secondary={show ? secondary : undefined}>正文</AppShell></>;
81
+ const { rerender } = render(demo(true));
82
+ screen.getByRole('button', { name: '详情操作' }).focus();
83
+ rerender(demo(false)); expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus();
84
+ rerender(demo(true)); screen.getByRole('button', { name: '详情操作' }).focus();
85
+ screen.getByRole('button', { name: '外部操作' }).focus();
86
+ rerender(demo(false)); expect(screen.getByRole('button', { name: '外部操作' })).toHaveFocus();
87
+ });
88
+
89
+ it('keeps the existing Drawer default on the right and supports an explicit left side', () => {
90
+ const props = { trigger: <button>打开</button>, title: '抽屉', description: '描述', open: true, children: <p>内容</p> };
91
+ const { rerender } = render(<Drawer {...props} />);
92
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-side', 'right');
93
+ rerender(<Drawer {...props} side="left" />);
94
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-side', 'left');
95
+ });
96
+ });
97
+
98
+ describe('Ant Design / MUI media contracts', () => {
99
+ it.each(['load', 'error'] as const)('mounts a hidden loading preview so its %s event can complete the host state', event => {
100
+ const completed = vi.fn();
101
+ function Demo() {
102
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
103
+ return <MediaCard title="预览" kind="photo" state={state} onOpen={() => {}} preview={<img alt="缩略图" src="/preview.png" onLoad={() => { completed(); setState('ready'); }} onError={() => { completed(); setState('error'); }} />} />;
104
+ }
105
+ render(<Demo />); const preview = screen.getByAltText('缩略图');
106
+ expect(preview).toBeInTheDocument(); expect(preview).not.toBeVisible();
107
+ expect(screen.getByText('正在加载预览…')).toBeVisible();
108
+ fireEvent[event](preview); expect(completed).toHaveBeenCalledOnce();
109
+ expect(screen.queryByText('正在加载预览…')).not.toBeInTheDocument();
110
+ if (event === 'load') { expect(screen.getByAltText('缩略图')).toBe(preview); expect(preview).toBeVisible(); }
111
+ else expect(screen.getByText('预览无法解码')).toBeVisible();
112
+ });
113
+
114
+ it('keeps card actions separate from selection and opening, including an error retry', () => {
115
+ const onOpen = vi.fn(), onSelectedChange = vi.fn(), retry = vi.fn(), ref = createRef<HTMLElement>();
116
+ const { rerender } = render(<MediaCard ref={ref} title="照片" kind="photo" state="error" onOpen={onOpen} onSelectedChange={onSelectedChange} className="custom-card" style={{ maxWidth: 320 }} actions={<button onClick={retry}>重新加载</button>} />);
117
+ fireEvent.click(screen.getByRole('button', { name: '重新加载' }));
118
+ expect(retry).toHaveBeenCalledOnce(); expect(onOpen).not.toHaveBeenCalled(); expect(onSelectedChange).not.toHaveBeenCalled();
119
+ expect(ref.current).toHaveClass('custom-card'); expect(ref.current).toHaveStyle({ maxWidth: '320px' });
120
+ fireEvent.click(screen.getByRole('checkbox')); expect(onSelectedChange).toHaveBeenCalledWith(true);
121
+ rerender(<MediaCard title="照片" kind="photo" onOpen={onOpen} onSelectedChange={onSelectedChange} disabled />);
122
+ expect(screen.getByRole('button', { name: '打开照片' })).toBeDisabled(); expect(screen.getByRole('checkbox')).toBeDisabled();
123
+ });
124
+
125
+ it('has an uncontrolled default-open viewer and a working uncontrolled metadata toggle', async () => {
126
+ render(<MediaViewer trigger={<button>打开</button>} title="照片" description="描述" defaultOpen defaultMetadataOpen metadata={0} className="custom-viewer" style={{ paddingTop: 4 }}><p>预览</p></MediaViewer>);
127
+ const dialog = screen.getByRole('dialog');
128
+ expect(dialog).toHaveClass('custom-viewer'); expect(dialog).toHaveStyle({ paddingTop: '4px' });
129
+ const information = screen.getByRole('complementary', { name: '媒体信息' });
130
+ expect(information).toHaveTextContent('0');
131
+ expect(screen.getByRole('button', { name: '收起信息' })).toHaveAttribute('aria-controls', information.id);
132
+ fireEvent.click(screen.getByRole('button', { name: '收起信息' })); expect(information).not.toBeVisible();
133
+ fireEvent.click(screen.getByRole('button', { name: '显示信息' })); expect(information).toBeVisible();
134
+ fireEvent.click(screen.getByRole('button', { name: '关闭媒体查看器' }));
135
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
136
+ });
137
+
138
+ it('does not mutate controlled metadata until the host updates the prop', () => {
139
+ const change = vi.fn();
140
+ const props = { trigger: <button>打开</button>, title: '照片', description: '描述', open: true, metadata: <p>详细信息</p>, onMetadataOpenChange: change, children: <p>预览</p> };
141
+ const { rerender } = render(<MediaViewer {...props} metadataOpen={false} />);
142
+ fireEvent.click(screen.getByRole('button', { name: '显示信息' }));
143
+ expect(change).toHaveBeenCalledWith(true); expect(screen.getByText('详细信息')).not.toBeVisible();
144
+ rerender(<MediaViewer {...props} metadataOpen />);
145
+ expect(screen.getByRole('complementary', { name: '媒体信息' })).toBeVisible();
146
+ });
147
+
148
+ it('navigates by arrow keys, respects independent boundaries, and allows opting out', () => {
149
+ const previous = vi.fn(), next = vi.fn();
150
+ const props = { trigger: <button>打开</button>, title: '照片', description: '描述', open: true, onPrevious: previous, onNext: next, children: <p>预览</p> };
151
+ const { rerender } = render(<MediaViewer {...props} previousDisabled />);
152
+ const dialog = screen.getByRole('dialog');
153
+ expect(screen.getByRole('button', { name: '上一项' })).toBeDisabled();
154
+ fireEvent.keyDown(dialog, { key: 'ArrowLeft' }); expect(previous).not.toHaveBeenCalled();
155
+ fireEvent.keyDown(dialog, { key: 'ArrowRight' }); expect(next).toHaveBeenCalledOnce();
156
+ screen.getByRole('button', { name: '下一项' }).focus();
157
+ rerender(<MediaViewer {...props} nextDisabled />);
158
+ expect(dialog).toHaveFocus();
159
+ fireEvent.keyDown(dialog, { key: 'ArrowLeft' }); expect(previous).toHaveBeenCalledOnce();
160
+ fireEvent.keyDown(dialog, { key: 'ArrowRight' }); expect(next).toHaveBeenCalledOnce();
161
+ rerender(<MediaViewer {...props} keyboardNavigation={false} />);
162
+ fireEvent.keyDown(dialog, { key: 'ArrowLeft' }); expect(previous).toHaveBeenCalledOnce();
163
+ expect(screen.getByRole('button', { name: '上一项' })).not.toHaveAttribute('aria-keyshortcuts');
164
+ fireEvent.click(screen.getByRole('button', { name: '下一项' })); expect(next).toHaveBeenCalledTimes(2);
165
+ });
166
+
167
+ it('leaves editor, media, modified, composition and nested dialog arrow keys to their owners', () => {
168
+ const next = vi.fn();
169
+ render(<MediaViewer trigger={<button>打开</button>} title="照片" description="描述" open onNext={next} actions={<Dialog trigger={<button>编辑详情</button>} title="编辑" description="编辑媒体"><button>内部按钮</button></Dialog>}>
170
+ <input aria-label="文件名" /><video aria-label="播放视频" controls /><div contentEditable suppressContentEditableWarning aria-label="注释">文字</div><div role="slider" aria-label="缩放" tabIndex={0} />
171
+ </MediaViewer>);
172
+ for (const control of [screen.getByRole('textbox'), screen.getByLabelText('播放视频'), screen.getByLabelText('注释'), screen.getByRole('slider')]) fireEvent.keyDown(control, { key: 'ArrowRight' });
173
+ const dialog = screen.getByRole('dialog');
174
+ fireEvent.keyDown(dialog, { key: 'ArrowRight', ctrlKey: true });
175
+ fireEvent.keyDown(dialog, { key: 'ArrowRight', isComposing: true });
176
+ fireEvent.click(screen.getByRole('button', { name: '编辑详情' }));
177
+ fireEvent.keyDown(within(screen.getByRole('dialog', { name: '编辑' })).getByRole('button', { name: '内部按钮' }), { key: 'ArrowRight' });
178
+ expect(next).not.toHaveBeenCalled();
179
+ });
180
+ });