@linhey/rhine-design 0.6.0 → 0.9.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.
- package/CHANGELOG.md +39 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AdvancedForms.tsx +38 -8
- package/components/AppShell.tsx +46 -17
- package/components/ListFilterBar.tsx +104 -0
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +11 -3
- package/components/Overlays.tsx +37 -8
- package/components/README.md +28 -3
- package/components/StatusTimeline.tsx +72 -6
- package/components/StatusTimelineList.tsx +38 -0
- package/components/ThemePicker.tsx +49 -0
- package/components/assets/orbit-bird.svg +578 -0
- package/components/assets/orbit-crystal.svg +11 -0
- package/components/assets/orbit-donut.svg +16 -0
- package/components/assets/theme-previews/arknights.png +0 -0
- package/components/assets/theme-previews/manifest.json +24 -0
- package/components/assets/theme-previews/neo-brutalism.png +0 -0
- package/components/assets/theme-previews/rhine.png +0 -0
- package/components/common.css +3 -3
- package/components/components.tsx +3 -3
- package/components/dialog-settings.css +9 -0
- package/components/extensions.css +8 -32
- package/components/index.ts +12 -5
- package/components/list-filter-bar.css +33 -0
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +100 -1
- package/components/orbitBirdFlight.ts +34 -0
- package/components/orbitBirdGeometry.ts +158 -0
- package/components/orbitDonut.ts +134 -0
- package/components/orbitDonutThree.ts +208 -0
- package/components/orbitObjectMaterials.ts +76 -0
- package/components/orbitObjects.ts +71 -0
- package/components/orbitObjectsThree.ts +215 -0
- package/components/orbitProjection.ts +21 -0
- package/components/orbitRenderer.ts +163 -46
- package/components/styles.css +3 -1
- package/components/theme-neo-brutalism.css +3 -2
- package/components/theme-picker.css +17 -0
- package/components/theme.tsx +2 -8
- package/components/themeMetadata.ts +7 -0
- package/components/timeline.css +17 -6
- package/components/useFloatingScale.ts +96 -0
- package/docs/APPLICATION_COMPONENTS.md +52 -3
- package/docs/BACKGROUND_FILTER_VERIFICATION.md +12 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +11 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +10 -2
- package/docs/LIST_FILTER_BAR.md +73 -0
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +61 -44
- package/docs/SETTINGS_DIALOG.md +21 -0
- package/docs/THEME_PICKER.md +26 -0
- package/docs/THEMING.md +2 -0
- package/docs/assets/compact-account-neo.png +0 -0
- package/docs/assets/neutron-star-original.png +0 -0
- package/docs/assets/neutron-star-theme.png +0 -0
- package/docs/assets/orbit-bird.png +0 -0
- package/docs/assets/orbit-crystal.png +0 -0
- package/docs/assets/orbit-strawberry.png +0 -0
- package/package.json +8 -3
- package/preview/BackgroundFiltersFixture.tsx +28 -0
- package/preview/CompactAccountGallery.tsx +178 -0
- package/preview/DateRangeFilterGallery.tsx +109 -0
- package/preview/ExtensionsGallery.tsx +27 -18
- package/preview/GalleryShell.tsx +3 -3
- package/preview/ListFilterBarGallery.tsx +170 -0
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +49 -32
- package/preview/OverlayBoundsGallery.tsx +49 -0
- package/preview/SettingsDialogGallery.tsx +33 -0
- package/preview/ThemePickerGallery.tsx +24 -0
- package/preview/ThemeSample.tsx +14 -0
- package/preview/TimelineListGallery.tsx +36 -0
- package/preview/extensions.css +9 -0
- package/preview/main.tsx +19 -3
- package/preview/preview.css +23 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-background-filters.js +58 -0
- package/scripts/check-background-motion.js +169 -0
- package/scripts/check-filter-native-date.js +47 -0
- package/scripts/check-overlay-bounds.js +76 -0
- package/scripts/check-settings-theme.js +88 -0
- package/skills/rhine-design/SKILL.md +3 -2
- package/skills/rhine-design/references/API_MAP.md +13 -2
- package/skills/rhine-design/references/TASK_MAP.md +8 -2
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/AppShellControls.test.tsx +82 -0
- package/tests/ButtonLink.test.tsx +94 -0
- package/tests/CompactAccountGallery.test.tsx +116 -0
- package/tests/DateNativeInput.test.tsx +147 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/ListFilterBar.test.tsx +198 -0
- package/tests/NeutronStarBackdrop.test.tsx +288 -0
- package/tests/NeutronStarGallery.test.tsx +62 -0
- package/tests/NeutronStarImport.test.tsx +15 -0
- package/tests/OrbitBackdrop.test.tsx +273 -9
- package/tests/OrbitDonutThree.test.tsx +91 -0
- package/tests/OrbitObjectsThree.test.tsx +164 -0
- package/tests/SettingsDialog.test.tsx +66 -0
- package/tests/StatusTimelineList.test.tsx +63 -0
- package/tests/ThemePicker.test.tsx +30 -0
- package/tests/TimelineLegend.test.tsx +60 -0
package/components/Overlays.tsx
CHANGED
|
@@ -7,6 +7,10 @@ import * as TooltipBase from '@radix-ui/react-tooltip';
|
|
|
7
7
|
import { Button, Notice } from './components';
|
|
8
8
|
import { IconButton } from './Actions';
|
|
9
9
|
import './drawer-placement.css';
|
|
10
|
+
import './dialog-settings.css';
|
|
11
|
+
import { useFloatingScale, useFloatingSide } from './useFloatingScale';
|
|
12
|
+
|
|
13
|
+
const dialogDismiss = new WeakMap<Element, () => void>();
|
|
10
14
|
|
|
11
15
|
// Keep portal content inside the root theme so tokens and reduced-motion overrides inherit.
|
|
12
16
|
function useScope() {
|
|
@@ -15,17 +19,36 @@ function useScope() {
|
|
|
15
19
|
useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
16
20
|
return { ref, container };
|
|
17
21
|
}
|
|
18
|
-
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode) }
|
|
22
|
+
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
|
|
19
23
|
export function Dialog(props: DialogProps) {
|
|
20
24
|
return <DialogFrame {...props}/>;
|
|
21
25
|
}
|
|
22
|
-
export function Drawer(props: Omit<DialogProps, 'sidePanel'> & { side?: 'left' | 'right' }) { return <DialogFrame {...props} drawer />; }
|
|
23
|
-
function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, drawer = false, side = 'right' }: DialogProps & { drawer?: boolean; side?: 'left' | 'right' }) {
|
|
26
|
+
export function Drawer(props: Omit<DialogProps, 'sidePanel' | 'navigation'> & { side?: 'left' | 'right' }) { return <DialogFrame {...props} drawer />; }
|
|
27
|
+
function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, navigation, onCloseAutoFocus, drawer = false, side = 'right' }: DialogProps & { drawer?: boolean; side?: 'left' | 'right' }) {
|
|
24
28
|
const { ref, container } = useScope();
|
|
29
|
+
const [localOpen, setLocalOpen] = useState(false);
|
|
30
|
+
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
|
31
|
+
const changeOpen = (next: boolean) => { setLocalOpen(next); onOpenChange?.(next); };
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
if (!content) return;
|
|
34
|
+
dialogDismiss.set(content, () => { setLocalOpen(false); onOpenChange?.(false); });
|
|
35
|
+
return () => { dialogDismiss.delete(content); };
|
|
36
|
+
}, [content, onOpenChange]);
|
|
37
|
+
const escape = (event: KeyboardEvent) => {
|
|
38
|
+
if (event.defaultPrevented || !(event.target instanceof Element)) return;
|
|
39
|
+
const owner = event.target.closest('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]');
|
|
40
|
+
if (!owner || owner === content) return;
|
|
41
|
+
// Radix's top layer may lag a newly focused nested dialog until its passive
|
|
42
|
+
// effect runs. Redirect only that stale callback; keep Tooltip/Menu priority.
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
dialogDismiss.get(owner)?.();
|
|
45
|
+
};
|
|
25
46
|
const sideNavigation = typeof sidePanel === 'function';
|
|
47
|
+
const settings = navigation != null && navigation !== false;
|
|
48
|
+
if (settings && sidePanel) throw new Error('Dialog navigation and sidePanel are mutually exclusive.');
|
|
26
49
|
const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
|
|
27
|
-
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open} onOpenChange={
|
|
28
|
-
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div><DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description><div className="rhine-dialog__body">{children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</DialogBase.Content>
|
|
50
|
+
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open ?? localOpen} onOpenChange={changeOpen}><DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
|
|
51
|
+
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${settings ? ' rhine-dialog--settings' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div><DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description><div className="rhine-dialog__body">{settings ? <div className="rhine-dialog__settings-grid"><div className="rhine-dialog__navigation">{navigation}</div><div className="rhine-dialog__settings-content">{children}</div></div> : children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</DialogBase.Content>
|
|
29
52
|
</DialogBase.Portal></DialogBase.Root></span>;
|
|
30
53
|
}
|
|
31
54
|
export function ConfirmDialog({ trigger, title, description, confirmLabel = '确认操作', danger = false, onConfirm }: { trigger: ReactElement; title: string; description: string; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void }) {
|
|
@@ -47,14 +70,20 @@ export function ConfirmDialog({ trigger, title, description, confirmLabel = '确
|
|
|
47
70
|
interface OverlayControl { open?: boolean; onOpenChange?: (open: boolean) => void; side?: 'top' | 'right' | 'bottom' | 'left' }
|
|
48
71
|
export function Popover({ trigger, title, children, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; title: string; children: ReactNode }) {
|
|
49
72
|
const { ref, container } = useScope();
|
|
50
|
-
|
|
73
|
+
const floatingRef = useFloatingScale(undefined, 'popover');
|
|
74
|
+
const floatingSide = useFloatingSide(ref, side, 300);
|
|
75
|
+
return <span className="rhine-overlay-anchor" ref={ref}><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Trigger asChild>{trigger}</PopoverBase.Trigger><PopoverBase.Portal container={container}><PopoverBase.Content ref={floatingRef} className="rhine-popover" aria-label={title} side={floatingSide} sideOffset={8} collisionPadding={12}><div className="rhine-dialog__head"><strong>{title}</strong><PopoverBase.Close asChild><IconButton label="关闭详情" icon="close" /></PopoverBase.Close></div>{children}</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></span>;
|
|
51
76
|
}
|
|
52
77
|
export interface MenuItem { id: string; label: string; disabled?: boolean; danger?: boolean; onSelect: () => void }
|
|
53
78
|
export function Menu({ trigger, label, items, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; label: string; items: MenuItem[] }) {
|
|
54
79
|
const { ref, container } = useScope();
|
|
55
|
-
|
|
80
|
+
const floatingRef = useFloatingScale(undefined, 'dropdown-menu');
|
|
81
|
+
const floatingSide = useFloatingSide(ref, side, 180);
|
|
82
|
+
return <span className="rhine-overlay-anchor" ref={ref}><MenuBase.Root open={open} onOpenChange={onOpenChange}><MenuBase.Trigger asChild>{trigger}</MenuBase.Trigger><MenuBase.Portal container={container}><MenuBase.Content ref={floatingRef} className="rhine-menu" aria-label={label} side={floatingSide} sideOffset={8} collisionPadding={12}>{items.map(item => <MenuBase.Item key={item.id} disabled={item.disabled} onSelect={item.onSelect} className="rhine-menu__item" data-danger={item.danger}>{item.label}</MenuBase.Item>)}</MenuBase.Content></MenuBase.Portal></MenuBase.Root></span>;
|
|
56
83
|
}
|
|
57
84
|
export function Tooltip({ children, content, open, onOpenChange, side }: OverlayControl & { children: ReactElement; content: string }) {
|
|
58
85
|
const { ref, container } = useScope();
|
|
59
|
-
|
|
86
|
+
const floatingRef = useFloatingScale(undefined, 'tooltip');
|
|
87
|
+
const floatingSide = useFloatingSide(ref, side, 280);
|
|
88
|
+
return <span className="rhine-overlay-anchor" ref={ref}><TooltipBase.Provider delayDuration={300}><TooltipBase.Root open={open} onOpenChange={onOpenChange}><TooltipBase.Trigger asChild>{children}</TooltipBase.Trigger><TooltipBase.Portal container={container}><TooltipBase.Content ref={floatingRef} className="rhine-tooltip" side={floatingSide} sideOffset={8} collisionPadding={12}>{content}</TooltipBase.Content></TooltipBase.Portal></TooltipBase.Root></TooltipBase.Provider></span>;
|
|
60
89
|
}
|
package/components/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
本目录提供[莱茵设计 · Rhine Design](../README.md)的 React 组件、设计变量和 API 契约。组件接收应用传入的数据与动作,不绑定特定后端或原生平台。包名为`@linhey/rhine-design`;样式变量和类名使用`rhine-`命名空间。
|
|
4
4
|
|
|
5
|
-
0.6.0
|
|
5
|
+
0.8.0 共66个组件,新增 NeutronStarBackdrop、StatusTimelineList、AppShellToggle 与 ButtonLink;0.6.0 为60个;0.5.0为59个,0.4.0为48个,0.3.0为40个。新增导航、表单流程及健康时间轴组件见[场景与交互规范](../docs/INTERACTION_PATTERNS.md),原24个扩展组件见[常用组件契约](./COMMON_COMPONENTS.md)。[字体与文字规范](./TYPOGRAPHY.md)与[主流系统对照与缺口](../docs/DESIGN_SYSTEM_AUDIT.md)共同约束复用。静态页面样稿也使用同源Toolbar/Select CSS,不运行React。
|
|
6
|
+
|
|
7
|
+
中子星艺术、主题物品和生命周期见[中子星契约](../docs/NEUTRON_STAR_BACKDROP.md)与[轨道背景契约](../docs/ORBIT_BACKDROP.md)。安装版本与源码导出分别核对。
|
|
6
8
|
|
|
7
9
|
页面改动遵循:优先复用组件 → 先在设计稿/展台修改并验证 → 再按任务授权实现到App。缺组件先补组件与展台,不在业务页另写同用途样式;同一任务已授权设计和实现时连续推进,无需重复确认。
|
|
8
10
|
|
|
@@ -51,14 +53,16 @@ export function Example() {
|
|
|
51
53
|
| 组件 | 关键输入 | 状态与边界 |
|
|
52
54
|
| --- | --- | --- |
|
|
53
55
|
| Button | variant=primary/secondary/quiet/danger;busy;busyLabel;原生按钮属性 | 默认 type=button;busy 保留焦点并拦截再次 click,不自动把业务标记成功;disabled 使用原生禁用 |
|
|
56
|
+
| ButtonLink(0.8.0) | 必填 href;原生 anchor 属性与 ref;Button 同款 variant/busy/busyLabel;disabled | 原生导航/下载;disabled 退出 Tab;busy 保焦点;两者移除 href、阻止主/辅助点击;[契约](../docs/COMPACT_ACCOUNT_PATTERNS.md) |
|
|
54
57
|
| Logo | variant=mark/icon/lockup;theme=light/dark/auto;size;animate;decorative;label;wordmark | 第三版三角构架;默认静态、48px、浅色单标志;最小16px;无点击/业务状态 |
|
|
55
58
|
| Text | variant;as;tone;numeric;HTML属性 | 默认p/body;视觉层级不决定HTML语义;长文本可换行;不转换数值或截断文本 |
|
|
56
59
|
| Select | label;hint;error;原生单选select属性;option子节点 | 标签与描述自动关联;原生菜单和键盘;受控或defaultValue;不支持multiple/size列表模式;箭头装饰性且不接收点击 |
|
|
57
60
|
| Toolbar | title;description;children;HTML div属性 | 自适应控件组合,role=group与普通Tab顺序;不伪装为需要方向键导航的ARIA toolbar;不含业务状态 |
|
|
61
|
+
| ListFilterBar(0.9.0) | label;search;primary/filters 字段;onReset;refresh;一个 action;result/pending/error | 最多两个常显条件,超出项进入同一 Drawer;宿主维护即时状态、查询和权限;[契约与版本边界](../docs/LIST_FILTER_BAR.md) |
|
|
58
62
|
| Badge | tone=neutral/success/warning/error;children | 只读短状态,颜色必须配文字,不做可点击按钮 |
|
|
59
63
|
| Surface | HTML div 属性 | 静态分区,无点击语义;不是弹窗或导航 |
|
|
60
64
|
| Background(0.5.0) | roleTone=canvas/content/sidebar/surface/overlay/media;decoration=auto/theme/none;appearance={color,textColor,texture,textureSize};HTML div 属性 | 默认正文纯色;仅画布默认使用主题纹理;局部覆盖不创建主题根或定位上下文;完整配置见[场景背景](../docs/THEMING.md#场景背景050) |
|
|
61
|
-
| SelectionCard | label;selected;onSelectedChange;disabled;children | 原生 checkbox
|
|
65
|
+
| SelectionCard | label;selected;onSelectedChange;disabled;showIndicator=true;children | 原生 checkbox 可取消;是否单选由上层控制;showIndicator=false 仅视觉隐藏勾选框,保留键盘与读屏,卡片边界表达选中;内部不嵌套按钮/链接/其他输入 |
|
|
62
66
|
| TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
|
|
63
67
|
| Tabs | label;items[{value,label,content,disabled?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树 |
|
|
64
68
|
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange | 原生 radio,独立 name 防多组干扰;方向键和禁用行为由浏览器提供 |
|
|
@@ -70,6 +74,8 @@ export function Example() {
|
|
|
70
74
|
|
|
71
75
|
新增24项API与组合示例见[常用组件契约](./COMMON_COMPONENTS.md)。TextField封装单行输入,Select使用原生单选;需要查询候选时用Combobox。复杂浮层采用Radix Dialog、AlertDialog、Popover、DropdownMenu、Tooltip的无样式基础,莱茵设计提供视觉与动效。Toast只处理单条通知;不加入全局队列、表格引擎、图表包或业务数据层。
|
|
72
76
|
|
|
77
|
+
0.8.0 的 `StatusTimelineList` 与 `TimelineLegend compact/items` 提供紧凑历史组合及一行短图例;宿主传入完整规则,悬停/聚焦/点按查看。无参 TimelineLegend 仍兼容既有标签。真实样机 `/#compact-account` 同时验证无框额度摘要、配对事实与渐进展示的凭证操作,见[紧凑组合规范](../docs/COMPACT_ACCOUNT_PATTERNS.md)。npm 0.6.0 不提供这些扩展。
|
|
78
|
+
|
|
73
79
|
浏览状态不等于执行结果;页面使用宿主的真实状态决定selected/busy/notice,视觉反馈不能提前宣称业务完成。
|
|
74
80
|
|
|
75
81
|
## 动效规范
|
|
@@ -151,6 +157,25 @@ export function Example() {
|
|
|
151
157
|
|
|
152
158
|
新增 `Background` 与 `AppShell`,0.5.0 共 59 个组件;npm 0.4.0 仍为 48 个。Background 支持六类 roleTone、auto/theme/none 装饰策略及 appearance 局部覆盖;AppShell 提供组合展台使用的固定顶部操作区与响应式侧栏。完整 API、最小接入、背景滚动及 Portal 边界见[背景与应用外壳契约](../docs/APPLICATION_COMPONENTS.md)。
|
|
153
159
|
|
|
160
|
+
0.8.0 新增可选 `AppShell.contentHeader`:工作区导航常驻正文列顶部,正文独立滚动;组合现有 `AppShellToggle`、`Inline`、标题与操作。0.8.0 媒体组件同步更新主题边界:卡片、标签、查看器工具栏与信息栏跟随宿主主题,仅媒体画布保持中性。以上不包含于 npm 0.6.0,交互与验证边界见[应用组件契约](../docs/APPLICATION_COMPONENTS.md)。
|
|
161
|
+
|
|
154
162
|
## 轨道背景(0.6.0)
|
|
155
163
|
|
|
156
|
-
`OrbitBackdrop`
|
|
164
|
+
`OrbitBackdrop` 自 0.6.0 提供,接收 `HTMLAttributes<HTMLDivElement>` 和 `reducedMotion?: boolean`,包含可选 `children`。0.6.0 提供固定深色轨道艺术与内置暂停 / 继续按钮,Canvas 为 `aria-hidden` 装饰。系统、显式减少动态、祖先偏好、窄窗、页面隐藏、离屏及零尺寸时停帧;恢复沿冻结时间继续,不追赶。Canvas 不可用时保留静态艺术与内容。宿主只组合布局,不复制动画或覆盖组件状态。完整生命周期、主题及接入约束见[轨道背景契约](../docs/ORBIT_BACKDROP.md),真实组件展台为 `/#orbit`。自 0.6.0 起共 60 个组件。
|
|
165
|
+
|
|
166
|
+
0.8.0 新增 `appearance?: 'theme' | 'original'`:莱茵展示暖白纸纹、浅绿折面、轻微振翅与悬浮纸屑的折纸鸟;战术展示带倒角晶面、矿物纹理、琥珀裂纹与漂浮碎片的源石晶簇;橙趣展示有烘焙纹理、糖霜、糖针与糖尘的草莓甜甜圈。三套物品按需加载 Three.js,窄屏可播放,遵循同一暂停、减少动态、隐藏与离屏规则;WebGL 失败回退 Canvas2D,Canvas 不可用时使用本地 SVG。`original` 保留初版完整深色轨道。切换主题复用时钟和绘制器,保留暂停与内容草稿。详见[轨道背景契约](../docs/ORBIT_BACKDROP.md)。
|
|
167
|
+
|
|
168
|
+
0.8.0 新增 `AppShellToggle` 与 `AppShell.controls="inline"`。品牌区/正文区成对放置与可达性要求见[内联控制契约](../docs/APPLICATION_COMPONENTS.md#内联控制按钮080)。
|
|
169
|
+
## 中子星背景(0.8.0)
|
|
170
|
+
|
|
171
|
+
`NeutronStarBackdrop` 接收 `HTMLAttributes<HTMLDivElement>`、`reducedMotion?: boolean`、`appearance?: 'theme' | 'original'` 和可选 `children`。默认从当前主题取得艺术配色,`original` 保留原始深空;切换颜色不重置时间,实体内容控件仍用宿主主题。按需加载 Three.js,保留 28 条磁场线、560 个流粒、700 颗星尘、极向喷流与星核;星系以 28 秒有效播放时间完成一轮深度公转,镜头保持固定。
|
|
172
|
+
|
|
173
|
+
内置暂停 / 继续;系统、显式和祖先减少动态、隐藏、离屏或零尺寸停帧,恢复不追赶。SSR 不访问浏览器绘制环境,异步初始化晚于卸载时不创建渲染器;无 WebGL、导入失败或 context lost 保留静态艺术和业务内容,context restored 按当前偏好恢复,卸载释放监听、帧循环及 GPU 资源。宿主负责尺寸和普通内容布局,正文不模糊;不以装饰状态控制业务。完整用法与验收见[中子星背景契约](../docs/NEUTRON_STAR_BACKDROP.md),真实源码展台为 `/#neutron-star`。
|
|
174
|
+
|
|
175
|
+
内容层只预留底部 `44px + 2 × --rhine-space-4` 控件安全区,不强制正文四边 padding;宿主在自己的 Stack 或页面容器设置边距,避免重复叠加。静态 fallback 保留当前配色的星核、磁场意图,不保证与三维画面逐像素一致。
|
|
176
|
+
|
|
177
|
+
## 0.8.0 补充
|
|
178
|
+
|
|
179
|
+
- [浮层缩放与边界](../docs/FLOATING_OVERLAYS.md):Popover、Menu、Tooltip 与时间轴共享 CSS zoom 修正。
|
|
180
|
+
- [设置 Dialog](../docs/SETTINGS_DIALOG.md):`navigation` 插槽及窄窗/缩放重排。
|
|
181
|
+
- [主题选择](../docs/THEME_PICKER.md):`ThemePicker` / `ThemePreview`,候选源码新增两项组件至 66;旧版安装包仍按自身导出判断。
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useId, useLayoutEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react';
|
|
2
2
|
import * as PopoverBase from '@radix-ui/react-popover';
|
|
3
|
+
import { useFloatingScale } from './useFloatingScale';
|
|
3
4
|
import { Text } from './Typography';
|
|
4
5
|
import { KeyValue } from './Data';
|
|
5
6
|
import { Button } from './components';
|
|
@@ -18,6 +19,17 @@ export interface StatusTimelineProps {
|
|
|
18
19
|
}
|
|
19
20
|
export const timelineLabels: Record<TimelineStatus, string> = { healthy: '有输出·未见异常', recovered: '重试已恢复', degraded: '异常·结果待确认', failed: '存在最终失败', unknown: '未知 / 无数据' };
|
|
20
21
|
|
|
22
|
+
function useTimelineHoverExit(trigger: RefObject<HTMLElement | null>, popup: RefObject<HTMLDivElement | null>, close: () => void) {
|
|
23
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
24
|
+
const cancel = () => { clearTimeout(timer.current); timer.current = undefined; };
|
|
25
|
+
const leave = () => { cancel(); timer.current = setTimeout(() => {
|
|
26
|
+
const keyboardFocus = document.activeElement === trigger.current && trigger.current?.matches(':focus-visible');
|
|
27
|
+
if (!keyboardFocus && !popup.current?.contains(document.activeElement)) close();
|
|
28
|
+
}, 150); };
|
|
29
|
+
useEffect(() => cancel, []);
|
|
30
|
+
return { cancel, leave };
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
/** Host supplies ordered, validated evidence; this component never computes a health score. */
|
|
22
34
|
export function StatusTimeline({ label, segments, startLabel, endLabel, compact = false, disabled = false, activeId, defaultActiveId, onActiveChange, open: controlledOpen, defaultOpen = false, onOpenChange }: StatusTimelineProps) {
|
|
23
35
|
const [localId, setLocalId] = useState(defaultActiveId);
|
|
@@ -25,6 +37,7 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
25
37
|
const open = controlledOpen ?? localOpen;
|
|
26
38
|
const setOpen = (next: boolean) => { if (disabled && next) return; if (controlledOpen === undefined) setLocalOpen(next); if (next !== open) onOpenChange?.(next); };
|
|
27
39
|
const root = useRef<HTMLDivElement>(null), slider = useRef<HTMLInputElement>(null), popup = useRef<HTMLDivElement>(null), returning = useRef(false), focusDetail = useRef(false);
|
|
40
|
+
const detailHadFocus = useRef(false);
|
|
28
41
|
useLayoutEffect(() => { setContainer(root.current?.closest<HTMLElement>('.rhine-theme') ?? document.body); }, []);
|
|
29
42
|
const detailId = useId();
|
|
30
43
|
const matched = segments.findIndex(item => item.id === (activeId ?? localId));
|
|
@@ -33,7 +46,11 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
33
46
|
const segment = segments[selected];
|
|
34
47
|
const summary = segment ? `${segment.label} · ${timelineLabels[segment.status]}` : '暂无记录';
|
|
35
48
|
const hasPopup = !disabled && !!segment && (compact || !!segment.detail);
|
|
36
|
-
|
|
49
|
+
const attachPopup = useFloatingScale(popup);
|
|
50
|
+
const hover = useTimelineHoverExit(slider, popup, () => setOpen(false));
|
|
51
|
+
return <div ref={root} className={`rhine-timeline${compact ? ' rhine-timeline--compact' : ''}`}
|
|
52
|
+
onPointerEnter={hover.cancel}
|
|
53
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && popup.current?.contains(event.relatedTarget))) hover.leave(); }}>
|
|
37
54
|
<PopoverBase.Root open={open && hasPopup} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-timeline__track">
|
|
38
55
|
<div className="rhine-timeline__bars" aria-hidden="true">{segments.map((item, i) => <span key={item.id} data-status={item.status} data-selected={i === selected} />)}</div>
|
|
39
56
|
<input ref={slider} type="range" min={0} max={Math.max(0, segments.length - 1)} value={Math.max(0, selected)} disabled={disabled || !segments.length}
|
|
@@ -47,8 +64,12 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
47
64
|
onChange={event => { select(Number(event.target.value)); setOpen(true); }}
|
|
48
65
|
onPointerDown={() => setOpen(true)}
|
|
49
66
|
onPointerMove={event => { if (disabled || event.pointerType !== 'mouse' || !segments.length) return; const rect = event.currentTarget.getBoundingClientRect(); if (rect.width) select(Math.min(segments.length - 1, Math.max(0, Math.floor((event.clientX - rect.left) / rect.width * segments.length)))); setOpen(true); }} />
|
|
50
|
-
</div></PopoverBase.Anchor><PopoverBase.Portal container={container}><PopoverBase.Content ref={
|
|
51
|
-
|
|
67
|
+
</div></PopoverBase.Anchor><PopoverBase.Portal container={container}><PopoverBase.Content ref={attachPopup} id={`${detailId}-popup`} className="rhine-timeline-popover" side="top" sideOffset={8} collisionPadding={12} aria-label={`${label}时段详情`}
|
|
68
|
+
onPointerEnter={hover.cancel}
|
|
69
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && root.current?.contains(event.relatedTarget))) hover.leave(); }}
|
|
70
|
+
onFocusCapture={() => { detailHadFocus.current = true; }}
|
|
71
|
+
onBlurCapture={event => { if (event.relatedTarget && event.relatedTarget !== document.body && !event.currentTarget.contains(event.relatedTarget)) detailHadFocus.current = false; }}
|
|
72
|
+
onOpenAutoFocus={event => { event.preventDefault(); if (focusDetail.current) { focusDetail.current = false; popup.current?.querySelector<HTMLButtonElement>('button')?.focus(); } }} onCloseAutoFocus={event => { event.preventDefault(); focusDetail.current = false; if (detailHadFocus.current && (document.activeElement === document.body || popup.current?.contains(document.activeElement))) { returning.current = true; slider.current?.focus(); } detailHadFocus.current = false; }}
|
|
52
73
|
onInteractOutside={event => { if (root.current?.contains(event.target as Node)) event.preventDefault(); }}>
|
|
53
74
|
<div className="rhine-dialog__head"><strong>{summary}</strong><PopoverBase.Close asChild><Button variant="quiet" aria-label="关闭时段详情">关闭</Button></PopoverBase.Close></div>
|
|
54
75
|
{segment?.detail && <><KeyValue items={[
|
|
@@ -62,6 +83,51 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
62
83
|
</div>;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
export
|
|
66
|
-
|
|
86
|
+
export interface TimelineLegendItem { status: TimelineStatus; label: string; description?: string }
|
|
87
|
+
export interface TimelineLegendProps { label?: string; compact?: boolean; items?: TimelineLegendItem[] }
|
|
88
|
+
const defaultLegendItems: TimelineLegendItem[] = Object.entries(timelineLabels).map(([status, label]) => ({ status: status as TimelineStatus, label }));
|
|
89
|
+
|
|
90
|
+
/** Short labels describe evidence; hosts provide the precise rules rather than relying on color. */
|
|
91
|
+
export function TimelineLegend({ label = '健康记录图例', compact = false, items = defaultLegendItems }: TimelineLegendProps) {
|
|
92
|
+
const root = useRef<HTMLUListElement>(null);
|
|
93
|
+
const [container, setContainer] = useState<HTMLElement>();
|
|
94
|
+
const [active, setActive] = useState<TimelineStatus | null>(null);
|
|
95
|
+
useLayoutEffect(() => { setContainer(root.current?.closest<HTMLElement>('.rhine-theme') ?? document.body); }, []);
|
|
96
|
+
useEffect(() => { if (active && !items.some(item => item.status === active && item.description)) setActive(null); }, [active, items]);
|
|
97
|
+
return <ul ref={root} className={`rhine-timeline__legend${compact ? ' rhine-timeline__legend--compact' : ''}`} aria-label={label}>
|
|
98
|
+
{items.map(item => <LegendItem key={item.status} item={item} container={container} open={active === item.status}
|
|
99
|
+
onOpenChange={next => setActive(current => next ? item.status : current === item.status ? null : current)} />)}
|
|
100
|
+
</ul>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function LegendItem({ item, container, open, onOpenChange }: { item: TimelineLegendItem; container?: HTMLElement; open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
104
|
+
const id = useId(), trigger = useRef<HTMLButtonElement>(null), popup = useRef<HTMLDivElement>(null), returning = useRef(false);
|
|
105
|
+
const detailHadFocus = useRef(false);
|
|
106
|
+
const hover = useTimelineHoverExit(trigger, popup, () => onOpenChange(false));
|
|
107
|
+
const attachPopup = useFloatingScale(popup);
|
|
108
|
+
const marker = <span data-status={item.status} aria-hidden="true" />;
|
|
109
|
+
if (!item.description) return <li>{marker}{item.label}</li>;
|
|
110
|
+
return <li><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Anchor asChild>
|
|
111
|
+
<button ref={trigger} type="button" className="rhine-timeline__legend-trigger" aria-haspopup="dialog" aria-expanded={open} aria-controls={open ? id : undefined}
|
|
112
|
+
onFocus={() => { if (!returning.current) onOpenChange(true); returning.current = false; }}
|
|
113
|
+
onClick={() => onOpenChange(true)}
|
|
114
|
+
onPointerEnter={event => { if (event.pointerType === 'mouse') { hover.cancel(); onOpenChange(true); } }}
|
|
115
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && popup.current?.contains(event.relatedTarget))) hover.leave(); }}
|
|
116
|
+
onKeyDown={event => {
|
|
117
|
+
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); onOpenChange(false); }
|
|
118
|
+
if ((event.key === 'Enter' || event.key === ' ') && open) { event.preventDefault(); popup.current?.querySelector<HTMLButtonElement>('button')?.focus(); }
|
|
119
|
+
}}>
|
|
120
|
+
{marker}{item.label}
|
|
121
|
+
</button>
|
|
122
|
+
</PopoverBase.Anchor><PopoverBase.Portal container={container}><PopoverBase.Content ref={attachPopup} id={id} className="rhine-timeline-popover" aria-label={`${item.label}规则`} side="top" sideOffset={8} collisionPadding={12}
|
|
123
|
+
onFocusCapture={() => { detailHadFocus.current = true; }}
|
|
124
|
+
onBlurCapture={event => { if (event.relatedTarget && event.relatedTarget !== document.body && !event.currentTarget.contains(event.relatedTarget)) detailHadFocus.current = false; }}
|
|
125
|
+
onOpenAutoFocus={event => event.preventDefault()}
|
|
126
|
+
onCloseAutoFocus={event => { event.preventDefault(); if (detailHadFocus.current && (popup.current?.contains(document.activeElement) || document.activeElement === document.body)) { returning.current = true; trigger.current?.focus(); } detailHadFocus.current = false; }}
|
|
127
|
+
onInteractOutside={event => { if (event.target === trigger.current || trigger.current?.contains(event.target as Node)) event.preventDefault(); }}
|
|
128
|
+
onPointerEnter={hover.cancel}
|
|
129
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && trigger.current?.contains(event.relatedTarget))) hover.leave(); }}>
|
|
130
|
+
<div className="rhine-dialog__head"><strong>{item.label}</strong><PopoverBase.Close asChild><Button variant="quiet" aria-label={`关闭${item.label}规则`}>关闭</Button></PopoverBase.Close></div>
|
|
131
|
+
<Text>{item.description}</Text>
|
|
132
|
+
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></li>;
|
|
67
133
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Text } from './Typography';
|
|
3
|
+
import { StatusTimeline, type StatusTimelineProps } from './StatusTimeline';
|
|
4
|
+
|
|
5
|
+
export interface StatusTimelineItem {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
summary?: ReactNode;
|
|
9
|
+
note?: string;
|
|
10
|
+
timeline: Pick<StatusTimelineProps, 'segments' | 'startLabel' | 'endLabel' | 'disabled'> | null;
|
|
11
|
+
unavailableLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface StatusTimelineListProps {
|
|
14
|
+
label: string;
|
|
15
|
+
items: StatusTimelineItem[];
|
|
16
|
+
emptyLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Hosts supply ordering, summaries and evidence; the list only coordinates presentation. */
|
|
20
|
+
export function StatusTimelineList({ label, items, emptyLabel = '暂无历史记录' }: StatusTimelineListProps) {
|
|
21
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const item = items.find(item => item.id === openId);
|
|
24
|
+
if (openId !== null && (!item?.timeline || item.timeline.disabled || !item.timeline.segments.length)) setOpenId(null);
|
|
25
|
+
}, [items, openId]);
|
|
26
|
+
if (!items.length) return <Text variant="caption">{emptyLabel}</Text>;
|
|
27
|
+
return <ul className="rhine-timeline-list" aria-label={label}>
|
|
28
|
+
{items.map(item => <li className="rhine-timeline-list__item" key={item.id}>
|
|
29
|
+
<div className="rhine-timeline-list__heading"><Text as="h4" variant="body">{item.title}</Text>
|
|
30
|
+
{item.summary != null && <Text variant="caption" tone="muted">{item.summary}</Text>}
|
|
31
|
+
</div>
|
|
32
|
+
{item.note && <Text variant="caption" tone="muted">{item.note}</Text>}
|
|
33
|
+
{item.timeline ? <StatusTimeline {...item.timeline} compact label={`${item.title} 历史时段`}
|
|
34
|
+
open={openId === item.id} onOpenChange={next => setOpenId(current => next ? item.id : current === item.id ? null : current)} />
|
|
35
|
+
: <Text variant="caption" tone="muted">{item.unavailableLabel ?? '历史记录暂不可用 · 未知'}</Text>}
|
|
36
|
+
</li>)}
|
|
37
|
+
</ul>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useId, useState } from 'react';
|
|
2
|
+
import { themes, type ThemeName } from './themeMetadata';
|
|
3
|
+
import rhine from './assets/theme-previews/rhine.png?no-inline';
|
|
4
|
+
import arknights from './assets/theme-previews/arknights.png?no-inline';
|
|
5
|
+
import neoBrutalism from './assets/theme-previews/neo-brutalism.png?no-inline';
|
|
6
|
+
import './theme-picker.css';
|
|
7
|
+
|
|
8
|
+
const sources: Record<ThemeName, string> = { rhine, arknights, 'neo-brutalism': neoBrutalism };
|
|
9
|
+
const descriptions: Record<ThemeName, string> = {
|
|
10
|
+
rhine: '暖白纸面、苔绿操作与细线边界。',
|
|
11
|
+
arknights: '深色工作区、明亮信号与切角边界。',
|
|
12
|
+
'neo-brutalism': '米白表面、亮橙操作与墨色硬阴影。',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ThemePreviewProps { theme: ThemeName; src?: string }
|
|
16
|
+
function PreviewImage({ src }: { src: string }) {
|
|
17
|
+
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
18
|
+
return <span className="rhine-theme-preview" aria-hidden="true" data-state={state}>
|
|
19
|
+
{state === 'error' ? <span>预览暂不可用</span> : <><img src={src} alt="" width={960} height={600} decoding="async" onLoad={() => setState('ready')} onError={() => setState('error')} />{state === 'loading' && <span className="rhine-theme-preview__loading">正在加载预览</span>}</>}
|
|
20
|
+
</span>;
|
|
21
|
+
}
|
|
22
|
+
/** Decorative, versioned snapshot; it never mounts a nested ThemeProvider. */
|
|
23
|
+
export function ThemePreview({ theme, src = sources[theme] }: ThemePreviewProps) {
|
|
24
|
+
return <PreviewImage key={src} src={src} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ThemePickerOption {
|
|
28
|
+
value: ThemeName; label?: string; description?: string; disabled?: boolean; previewSrc?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ThemePickerProps {
|
|
31
|
+
label?: string; value: ThemeName; onValueChange: (value: ThemeName) => void;
|
|
32
|
+
options?: readonly ThemePickerOption[]; disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Controlled native radios: the host owns applying and persisting the theme. */
|
|
35
|
+
export function ThemePicker({ label = '界面主题', value, onValueChange, options = themes.map(theme => ({ value: theme.name })), disabled = false }: ThemePickerProps) {
|
|
36
|
+
const id = useId();
|
|
37
|
+
return <fieldset className="rhine-theme-picker" disabled={disabled}><legend>{label}</legend>
|
|
38
|
+
<div className="rhine-theme-picker__options">{options.map(option => {
|
|
39
|
+
const title = option.label ?? themes.find(theme => theme.name === option.value)!.label;
|
|
40
|
+
const selected = value === option.value;
|
|
41
|
+
const descriptionId = `${id}-${option.value}`;
|
|
42
|
+
return <label className="rhine-theme-option" key={option.value}>
|
|
43
|
+
<input className="rhine-sr-only" type="radio" name={id} value={option.value} checked={selected} disabled={option.disabled} aria-label={title} aria-describedby={descriptionId} onChange={() => onValueChange(option.value)} />
|
|
44
|
+
<ThemePreview theme={option.value} src={option.previewSrc} />
|
|
45
|
+
<span className="rhine-theme-option__copy"><strong>{title}</strong><span className="rhine-theme-option__description" id={descriptionId}>{option.description ?? descriptions[option.value]}</span><span className="rhine-theme-option__status" aria-hidden="true">{selected ? '✓ 已使用' : option.disabled || disabled ? '暂不可用' : '选择应用'}</span></span>
|
|
46
|
+
</label>;
|
|
47
|
+
})}</div>
|
|
48
|
+
</fieldset>;
|
|
49
|
+
}
|