@linhey/rhine-design 0.3.0 → 0.5.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 +46 -0
- package/README.md +63 -14
- package/components/Actions.tsx +2 -0
- package/components/AdvancedForms.tsx +184 -0
- package/components/AppShell.tsx +71 -0
- package/components/Background.tsx +29 -0
- package/components/COMMON_COMPONENTS.md +5 -1
- package/components/Charts.tsx +94 -0
- package/components/Data.tsx +98 -18
- package/components/FormWorkflow.tsx +25 -0
- package/components/Forms.tsx +14 -8
- package/components/MOTION.md +6 -2
- package/components/Media.tsx +82 -0
- package/components/Navigation.tsx +42 -0
- package/components/Overlays.tsx +10 -3
- package/components/README.md +20 -5
- package/components/SPACING.md +82 -0
- package/components/StatusTimeline.tsx +67 -0
- package/components/TYPOGRAPHY.md +34 -2
- package/components/background.css +43 -0
- package/components/charts.css +27 -0
- package/components/common.css +17 -8
- package/components/data.css +6 -0
- package/components/drawer-placement.css +4 -0
- package/components/extensions.css +70 -0
- package/components/index.ts +18 -2
- package/components/media-parity.css +3 -0
- package/components/patterns.css +39 -0
- package/components/styles.css +15 -8
- package/components/theme-arknights.css +306 -0
- package/components/theme-neo-brutalism.css +90 -0
- package/components/theme.tsx +58 -0
- package/components/timeline.css +23 -0
- package/components/tokens.css +49 -1
- package/docs/ANTD_MUI_AUDIT.md +38 -0
- package/docs/APPLICATION_COMPONENTS.md +175 -0
- package/docs/DESIGN_SYSTEM.md +12 -2
- package/docs/DESIGN_SYSTEM_AUDIT.md +6 -4
- package/docs/INTERACTION_PATTERNS.md +129 -0
- package/docs/ISSUE_4_VALIDATION.md +21 -0
- package/docs/ISSUE_VALIDATION.md +52 -0
- package/docs/REFERENCE_RESEARCH.md +38 -0
- package/docs/THEME_NEO_BRUTALISM.md +37 -0
- package/docs/THEMING.md +145 -0
- package/docs/assets/application-shell.png +0 -0
- package/docs/assets/issue4-shell.png +0 -0
- package/docs/assets/media-viewer-mobile.png +0 -0
- package/docs/assets/monitoring-desktop.png +0 -0
- package/docs/audits/DATA_ANTD_MUI.md +75 -0
- package/docs/audits/FORMS_ANTD_MUI.md +88 -0
- package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
- package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
- package/package.json +5 -1
- package/preview/BackgroundGallery.tsx +46 -0
- package/preview/CommonGallery.tsx +8 -8
- package/preview/ExtensionsGallery.tsx +95 -0
- package/preview/GalleryShell.tsx +107 -0
- package/preview/LineHeightGallery.tsx +27 -0
- package/preview/MonitoringGallery.tsx +27 -0
- package/preview/MotionGallery.tsx +6 -2
- package/preview/PatternGallery.tsx +52 -0
- package/preview/SpacingGallery.tsx +32 -0
- package/preview/SubscriptionGallery.tsx +84 -0
- package/preview/backgrounds.css +10 -0
- package/preview/extensions.css +6 -0
- package/preview/line-height.css +2 -0
- package/preview/main.tsx +48 -25
- package/preview/patterns.css +7 -0
- package/preview/preview.css +2 -8
- package/preview/shell.css +35 -0
- package/preview/spacing.css +2 -0
- package/preview/subscriptions.css +27 -0
- package/preview/theme-arknights.css +234 -0
- package/preview/theme-neo-brutalism.css +11 -0
- package/skills/rhine-design/SKILL.md +26 -3
- package/skills/rhine-design/references/API_MAP.md +11 -3
- package/skills/rhine-design/references/TASK_MAP.md +5 -1
- package/skills/rhine-design/references/integration.md +7 -3
- package/tests/Background.test.tsx +22 -0
- package/tests/DataParity.test.tsx +148 -0
- package/tests/Extensions.test.tsx +145 -0
- package/tests/FormParity.test.tsx +185 -0
- package/tests/GalleryShell.test.tsx +98 -0
- package/tests/Monitoring.test.tsx +38 -0
- package/tests/MonitoringParity.test.tsx +125 -0
- package/tests/Patterns.test.tsx +75 -0
- package/tests/ShellMediaParity.test.tsx +180 -0
- package/tests/StatusTimeline.test.tsx +15 -0
- package/tests/Theme.test.tsx +112 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
import { DataTable } from './Data';
|
|
3
|
+
import { EmptyState, Notice } from './components';
|
|
4
|
+
|
|
5
|
+
export interface ChartPoint { id: string; label: string; value: number | null }
|
|
6
|
+
interface ChartContext { label: string; unit: string; rangeLabel: string; description: string; stale?: string; error?: string; loading?: boolean; valueFormatter?: (value: number | null) => string }
|
|
7
|
+
export interface TrendChartProps extends ChartContext {
|
|
8
|
+
points: ChartPoint[]; kind?: 'bar' | 'line';
|
|
9
|
+
activePointId?: string | null; defaultActivePointId?: string; onActivePointChange?: (id: string | null) => void;
|
|
10
|
+
}
|
|
11
|
+
const known = (value: number | null): value is number => value !== null && Number.isFinite(value);
|
|
12
|
+
|
|
13
|
+
function ChartFrame({ label, unit, rangeLabel, description, stale, error, loading, children }: ChartContext & { children: ReactNode }) {
|
|
14
|
+
return <figure className="rhine-chart" aria-label={label} aria-busy={loading}>
|
|
15
|
+
<figcaption><strong>{label}</strong><span>{rangeLabel} · 单位:{unit}</span><p>{description}</p></figcaption>
|
|
16
|
+
{error && <Notice title="图表读取失败" tone="error">{error} · 已有数据仅供参考。</Notice>}
|
|
17
|
+
{stale && <Notice title="数据已陈旧" tone="warning">{stale}</Notice>}
|
|
18
|
+
{loading && <p role="status">正在读取,保留已有数据。</p>}{children}
|
|
19
|
+
</figure>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The host provides bucket order, timestamps, units, and aggregation semantics. */
|
|
23
|
+
export function TrendChart({ points, kind = 'bar', activePointId, defaultActivePointId, onActivePointChange, ...context }: TrendChartProps) {
|
|
24
|
+
const id = useId(), finite = points.map(point => point.value).filter(known);
|
|
25
|
+
const [localActive, setLocalActive] = useState<string | null>(defaultActivePointId ?? null);
|
|
26
|
+
const [focused, setFocused] = useState(false);
|
|
27
|
+
const active = activePointId === undefined ? localActive : activePointId;
|
|
28
|
+
const activeIndex = points.findIndex(point => point.id === active), activePoint = points[activeIndex];
|
|
29
|
+
const select = (next: string | null) => { if (activePointId === undefined) setLocalActive(next); if (next !== active) onActivePointChange?.(next); };
|
|
30
|
+
const format = (value: number | null) => context.valueFormatter ? context.valueFormatter(known(value) ? value : null) : known(value) ? String(value) : '未知';
|
|
31
|
+
const minimum = finite.reduce((min, value) => Math.min(min, value), 0), maximum = finite.reduce((max, value) => Math.max(max, value), 0);
|
|
32
|
+
// Normalize first so opposite finite extremes cannot overflow the coordinate span.
|
|
33
|
+
const magnitude = Math.max(Math.abs(minimum), Math.abs(maximum), 1), span = (maximum / magnitude - minimum / magnitude) || 1;
|
|
34
|
+
const x = (index: number) => (index + 0.5) * 600 / Math.max(1, points.length);
|
|
35
|
+
const y = (value: number) => 170 - (value / magnitude - minimum / magnitude) / span * 160;
|
|
36
|
+
let connected = false;
|
|
37
|
+
const path = points.map((point, index) => {
|
|
38
|
+
if (!known(point.value)) { connected = false; return ''; }
|
|
39
|
+
const command = connected ? 'L' : 'M'; connected = true;
|
|
40
|
+
return `${command}${x(index)},${y(point.value)}`;
|
|
41
|
+
}).join(' ');
|
|
42
|
+
return <ChartFrame {...context}>
|
|
43
|
+
{!finite.length ? <EmptyState title={points.length ? '所有采样值均未知' : '暂无采样'} description="缺失值不会补零。" /> : <>
|
|
44
|
+
<div className="rhine-chart__scale"><span>{format(maximum)} {context.unit}</span><span>{format(minimum)} {context.unit}</span></div>
|
|
45
|
+
<svg className="rhine-chart__plot" viewBox="0 0 600 180" preserveAspectRatio="none" role="img" aria-labelledby={`${id}-title ${id}-description`} aria-describedby={`${id}-inspection`} tabIndex={0}
|
|
46
|
+
onFocus={() => { setFocused(true); if (!activePoint) select(points[0]?.id ?? null); }} onBlur={() => setFocused(false)}
|
|
47
|
+
onKeyDown={event => {
|
|
48
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
49
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : event.key === 'ArrowLeft' ? Math.max(0, activeIndex - 1) : event.key === 'ArrowRight' ? Math.min(points.length - 1, activeIndex + 1) : undefined;
|
|
50
|
+
if (next !== undefined) { event.preventDefault(); select(points[next]?.id ?? null); }
|
|
51
|
+
}}
|
|
52
|
+
onPointerMove={event => {
|
|
53
|
+
if (event.pointerType !== 'mouse' && !event.buttons) return;
|
|
54
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
55
|
+
if (rect.width) select(points[Math.max(0, Math.min(points.length - 1, Math.floor((event.clientX - rect.left) / rect.width * points.length)))]?.id ?? null);
|
|
56
|
+
}}
|
|
57
|
+
onPointerLeave={() => { if (!focused) select(null); }}
|
|
58
|
+
onClick={event => {
|
|
59
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
60
|
+
event.currentTarget.focus();
|
|
61
|
+
if (rect.width) select(points[Math.max(0, Math.min(points.length - 1, Math.floor((event.clientX - rect.left) / rect.width * points.length)))]?.id ?? null);
|
|
62
|
+
}}>
|
|
63
|
+
<title id={`${id}-title`}>{context.label} · {kind === 'bar' ? '柱形趋势' : '历史折线'}</title>
|
|
64
|
+
<desc id={`${id}-description`}>{context.description};{points.length} 个时段,{points.length - finite.length} 个未知值。完整标签与数值见下方数据表。</desc>
|
|
65
|
+
<path className="rhine-chart__grid" d={`M0,${y(0)}H600 M0,10H600 M0,170H600`} />
|
|
66
|
+
{activePoint && <rect className="rhine-chart__highlight" x={activeIndex * 600 / points.length} y={0} width={600 / points.length} height={180} />}
|
|
67
|
+
{kind === 'line' && <path className="rhine-chart__line" d={path} vectorEffect="non-scaling-stroke" />}
|
|
68
|
+
{points.map((point, index) => !known(point.value) ? <path key={point.id} className="rhine-chart__missing" d={`M${x(index) - 3},174l6,6m0,-6l-6,6`} vectorEffect="non-scaling-stroke" /> : kind === 'bar' ?
|
|
69
|
+
<rect key={point.id} className="rhine-chart__bar" x={x(index) - 210 / points.length} y={Math.min(y(0), y(point.value))} width={420 / points.length} height={Math.abs(y(point.value) - y(0))}><title>{point.label}:{point.value} {context.unit}</title></rect> :
|
|
70
|
+
<circle key={point.id} className="rhine-chart__point" cx={x(index)} cy={y(point.value)} r={3}><title>{point.label}:{point.value} {context.unit}</title></circle>)}
|
|
71
|
+
</svg>
|
|
72
|
+
<div className="rhine-chart__range"><span>{points[0]?.label}</span><span>{points.at(-1)?.label}</span></div>
|
|
73
|
+
<p className="rhine-chart__legend"><span aria-hidden="true" />{context.label} · × 表示缺失;折线在缺失处断开。</p>
|
|
74
|
+
<p id={`${id}-inspection`} className="rhine-chart__inspection" aria-live="polite" aria-atomic="true">{activePoint ? `${activePoint.label}:${format(activePoint.value)}${known(activePoint.value) ? ` ${context.unit}` : ''}` : '聚焦图形后用左右键、Home / End 查看采样;也可悬停或轻触。'}</p>
|
|
75
|
+
</>}
|
|
76
|
+
<details className="rhine-chart__data"><summary>查看{context.label}数据表</summary><DataTable caption={`${context.label}原始采样`} rows={points} rowKey={point => point.id} columns={[
|
|
77
|
+
{ key: 'label', header: '时段', render: point => point.label }, { key: 'value', header: `值(${context.unit})`, render: point => known(point.value) ? point.value : '未知' },
|
|
78
|
+
]} /></details>
|
|
79
|
+
</ChartFrame>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ChartPart extends ChartPoint { tone?: 1 | 2 | 3 | 4 }
|
|
83
|
+
export interface StackedBarProps extends ChartContext { parts: ChartPart[] }
|
|
84
|
+
export function StackedBar({ parts, ...context }: StackedBarProps) {
|
|
85
|
+
const incomplete = parts.some(part => !known(part.value)), invalid = parts.some(part => known(part.value) && part.value < 0);
|
|
86
|
+
const total = parts.reduce((sum, part) => sum + (known(part.value) ? part.value : 0), 0);
|
|
87
|
+
const complete = parts.length > 0 && !incomplete && !invalid && Number.isFinite(total) && total > 0;
|
|
88
|
+
return <ChartFrame {...context}>
|
|
89
|
+
{complete ? <div className="rhine-chart__stack" role="img" aria-label={`${context.label}:${parts.map(part => `${part.label} ${part.value} ${context.unit}`).join(';')}。完整比例见图例。`}>
|
|
90
|
+
{parts.map((part, index) => part.value! > 0 && <span key={part.id} data-tone={part.tone ?? index % 4 + 1} style={{ flexGrow: part.value! / total } as CSSProperties} />)}
|
|
91
|
+
</div> : <p role="status">{invalid ? '包含负数,无法绘制占比。' : incomplete ? '总量未知,不计算占比。' : total === 0 && parts.length ? '已知总量为 0,无占比。' : '暂无可绘制的占比。'}</p>}
|
|
92
|
+
<ul className="rhine-chart__parts" aria-label={`${context.label}图例`}>{parts.map((part, index) => <li key={part.id}><span aria-hidden="true" data-tone={part.tone ?? index % 4 + 1} />{part.label}:{context.valueFormatter ? context.valueFormatter(known(part.value) ? part.value : null) : known(part.value) ? part.value : '未知'}{known(part.value) ? ` ${context.unit}` : ''}{complete ? `(${(part.value! / total * 100).toFixed(1)}%)` : ''}</li>)}</ul>
|
|
93
|
+
</ChartFrame>;
|
|
94
|
+
}
|
package/components/Data.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Fragment, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
2
|
import { Button, EmptyState, Notice, Skeleton } from './components';
|
|
3
3
|
import { Text } from './Typography';
|
|
4
|
+
import { Checkbox } from './Forms';
|
|
5
|
+
import { Select } from './Select';
|
|
6
|
+
import './data.css';
|
|
4
7
|
|
|
5
8
|
export function Metric({ label, value, unit, description, loading = false }: { label: string; value: ReactNode; unit?: string; description?: string; loading?: boolean }) {
|
|
6
9
|
return <div className="rhine-metric" aria-busy={loading}><Text variant="caption" tone="muted">{label}</Text>{loading ? <Skeleton lines={1} label={`正在读取${label}`} /> : <Text variant="metric">{value ?? '—'}{unit && <span className="rhine-metric__unit"> {unit}</span>}</Text>}{description && <Text variant="caption" tone="muted">{description}</Text>}</div>;
|
|
@@ -8,35 +11,112 @@ export function Metric({ label, value, unit, description, loading = false }: { l
|
|
|
8
11
|
export function KeyValue({ items }: { items: { key: string; label: string; value: ReactNode }[] }) {
|
|
9
12
|
return <dl className="rhine-key-value">{items.map(item => <div key={item.key}><dt>{item.label}</dt><dd>{item.value ?? '—'}</dd></div>)}</dl>;
|
|
10
13
|
}
|
|
11
|
-
export interface DataColumn<T> { key: string; header: string; render: (row: T) => ReactNode; align?: 'left' | 'right'; sortable?: boolean }
|
|
14
|
+
export interface DataColumn<T> { key: string; header: string; render: (row: T) => ReactNode; align?: 'left' | 'right'; sortable?: boolean; width?: number; sticky?: 'left' | 'right'; stickyOffset?: number; rowHeader?: boolean }
|
|
12
15
|
export interface TableSort { key: string; direction: 'asc' | 'desc' }
|
|
13
16
|
export interface DataTableProps<T> {
|
|
14
17
|
caption: string; columns: DataColumn<T>[]; rows: T[]; rowKey: (row: T) => string;
|
|
15
18
|
loading?: boolean; error?: string; emptyTitle?: string; sort?: TableSort;
|
|
16
|
-
onSortChange?: (sort: TableSort) => void; onRetry?: () => void;
|
|
19
|
+
onSortChange?: (sort: TableSort) => void; onSortReset?: () => void; onRetry?: () => void;
|
|
20
|
+
selection?: { keys: string[]; onChange: (keys: string[]) => void; isDisabled?: (row: T) => boolean; mode?: 'multiple' | 'single'; hideSelectAll?: boolean };
|
|
21
|
+
expansion?: { keys: string[]; onChange: (keys: string[]) => void; getChildren?: (row: T) => T[]; renderDetail?: (row: T) => ReactNode; canExpand?: (row: T) => boolean };
|
|
22
|
+
rowLabel?: (row: T) => string; density?: 'comfortable' | 'compact'; stickyHeader?: boolean; maxHeight?: number;
|
|
17
23
|
}
|
|
18
|
-
export function DataTable<T>({ caption, columns, rows, rowKey, loading = false, error, emptyTitle = '暂无记录', sort, onSortChange, onRetry }: DataTableProps<T>) {
|
|
19
|
-
|
|
24
|
+
export function DataTable<T>({ caption, columns, rows, rowKey, loading = false, error, emptyTitle = '暂无记录', sort, onSortChange, onSortReset, onRetry, selection, expansion, rowLabel = rowKey, density = 'comfortable', stickyHeader, maxHeight }: DataTableProps<T>) {
|
|
25
|
+
const id = useId();
|
|
26
|
+
const table = useRef<HTMLTableElement>(null);
|
|
27
|
+
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (!table.current || !columns.some(column => column.sticky)) return;
|
|
30
|
+
const headers = Array.from(table.current.querySelectorAll<HTMLElement>('thead [data-column-key]'));
|
|
31
|
+
const measure = () => {
|
|
32
|
+
// Layout widths remain in CSS pixels under zoom/transform; visual bounds do not.
|
|
33
|
+
const widths = Object.fromEntries(headers.map(header => [header.dataset.columnKey!, header.offsetWidth]));
|
|
34
|
+
setColumnWidths(previous => Object.keys(widths).length === Object.keys(previous).length && Object.entries(widths).every(([key, width]) => previous[key] === width) ? previous : widths);
|
|
35
|
+
};
|
|
36
|
+
measure();
|
|
37
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
38
|
+
const observer = new ResizeObserver(measure);
|
|
39
|
+
headers.forEach(header => observer.observe(header));
|
|
40
|
+
return () => observer.disconnect();
|
|
41
|
+
}, [columns]);
|
|
42
|
+
const offsets: Record<string, number> = {};
|
|
43
|
+
for (const side of ['left', 'right'] as const) {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
for (const column of side === 'left' ? columns : [...columns].reverse()) {
|
|
46
|
+
if (column.sticky !== side) continue;
|
|
47
|
+
offsets[column.key] = column.stickyOffset ?? offset;
|
|
48
|
+
offset = offsets[column.key] + (columnWidths[column.key] || column.width || 0);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const visible: { row: T; depth: number; expandable: boolean }[] = [];
|
|
52
|
+
const visit = (items: T[], depth: number, ancestors: Set<string>) => items.forEach(row => {
|
|
53
|
+
const key = rowKey(row);
|
|
54
|
+
if (ancestors.has(key)) return;
|
|
55
|
+
const children = expansion?.getChildren?.(row) ?? [];
|
|
56
|
+
const expandable = !!expansion && (expansion.canExpand ? expansion.canExpand(row) : !!children.length || !!expansion.renderDetail);
|
|
57
|
+
visible.push({ row, depth, expandable });
|
|
58
|
+
if (expandable && expansion?.keys.includes(key)) visit(children, depth + 1, new Set([...ancestors, key]));
|
|
59
|
+
});
|
|
60
|
+
visit(rows, 0, new Set());
|
|
61
|
+
const eligible = visible.filter(({ row }) => !selection?.isDisabled?.(row)).map(({ row }) => rowKey(row));
|
|
62
|
+
const selectedCount = eligible.filter(key => selection?.keys.includes(key)).length;
|
|
63
|
+
const toggle = (keys: string[], key: string) => keys.includes(key) ? keys.filter(item => item !== key) : [...keys, key];
|
|
64
|
+
const columnStyle = (column: DataColumn<T>): CSSProperties => ({ width: column.width, minWidth: column.width, [column.sticky ?? 'left']: column.sticky ? offsets[column.key] : undefined });
|
|
65
|
+
return <div className="rhine-data" aria-busy={loading} data-density={density}>
|
|
20
66
|
{error && <Notice tone="error" title="读取失败" action={onRetry && <Button variant="secondary" onClick={onRetry} disabled={loading}>重试</Button>}>{error}{rows.length > 0 && ' · 以下保留上次读取的记录。'}</Notice>}
|
|
21
67
|
{loading && <p role="status" className="rhine-text rhine-text--caption rhine-text--muted">{rows.length ? '正在刷新,保留当前记录…' : '正在读取…'}</p>}
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
68
|
+
{sort && onSortReset && <Button variant="quiet" disabled={loading} onClick={() => {
|
|
69
|
+
const header = Array.from(table.current?.querySelectorAll<HTMLElement>('thead [data-column-key]') ?? []).find(cell => cell.dataset.columnKey === sort.key);
|
|
70
|
+
(header?.querySelector<HTMLButtonElement>('button') ?? table.current?.parentElement)?.focus();
|
|
71
|
+
onSortReset();
|
|
72
|
+
}}>清除排序</Button>}
|
|
73
|
+
<div className="rhine-table-scroll" role="region" aria-label={`${caption},可横向滚动`} tabIndex={0} style={{ maxHeight }}>
|
|
74
|
+
<table ref={table} className="rhine-table" data-sticky-header={stickyHeader}><caption>{caption}</caption><thead><tr>
|
|
75
|
+
{selection && <th scope="col">{selection.mode === 'single' || selection.hideSelectAll ? '选择' : <Checkbox label="选择当前可见行" checked={!!eligible.length && selectedCount === eligible.length} indeterminate={selectedCount > 0 && selectedCount < eligible.length} disabled={loading || !eligible.length} onChange={() => selection.onChange(selectedCount === eligible.length ? selection.keys.filter(key => !eligible.includes(key)) : [...new Set([...selection.keys, ...eligible])])} />}</th>}
|
|
76
|
+
{expansion && <th scope="col">层级 / 详情</th>}
|
|
77
|
+
{columns.map(column => <th scope="col" key={column.key} data-column-key={column.key} data-align={column.align} data-sticky={column.sticky} style={columnStyle(column)} aria-sort={column.sortable ? sort?.key === column.key ? sort.direction === 'asc' ? 'ascending' : 'descending' : 'none' : undefined}>{column.sortable && onSortChange ? <button type="button" disabled={loading} title={`按${sort?.key === column.key && sort.direction === 'asc' ? '降' : '升'}序排列${column.header}`} onClick={() => onSortChange({ key: column.key, direction: sort?.key === column.key && sort.direction === 'asc' ? 'desc' : 'asc' })}>{column.header}<span aria-hidden="true">{sort?.key === column.key ? sort.direction === 'asc' ? ' ↑' : ' ↓' : ' ↕'}</span></button> : column.header}</th>)}</tr></thead>
|
|
78
|
+
<tbody>{visible.map(({ row, depth, expandable }) => {
|
|
79
|
+
const key = rowKey(row), expanded = expandable && expansion?.keys.includes(key);
|
|
80
|
+
const detailId = `${id}-detail-${encodeURIComponent(key)}`;
|
|
81
|
+
const detail = expanded ? expansion?.renderDetail?.(row) : null;
|
|
82
|
+
const hasDetail = detail !== null && detail !== undefined && typeof detail !== 'boolean';
|
|
83
|
+
return <Fragment key={key}><tr data-selected={selection?.mode === 'single' ? selection.keys[0] === key : selection?.keys.includes(key)}>
|
|
84
|
+
{selection && <td>{selection.mode === 'single' ? <label className="rhine-table__radio"><input type="radio" name={`${id}-selection`} checked={selection.keys[0] === key} disabled={loading || selection.isDisabled?.(row)} onChange={() => selection.onChange([key])} /><span>{`选择${rowLabel(row)}`}</span></label> : <Checkbox label={`选择${rowLabel(row)}`} checked={selection.keys.includes(key)} disabled={loading || selection.isDisabled?.(row)} onChange={() => selection.onChange(toggle(selection.keys, key))} />}</td>}
|
|
85
|
+
{expansion && <td><span className="rhine-table__depth">第 {depth + 1} 层</span>{expandable && <Button variant="quiet" aria-label={`${expanded ? '收起' : '展开'}${rowLabel(row)}`} aria-expanded={!!expanded} aria-controls={hasDetail ? detailId : undefined} onClick={() => expansion.onChange(toggle(expansion.keys, key))}>{expanded ? '−' : '+'}</Button>}</td>}
|
|
86
|
+
{columns.map(column => { const Cell = column.rowHeader ? 'th' : 'td'; return <Cell scope={column.rowHeader ? 'row' : undefined} key={column.key} data-align={column.align} data-sticky={column.sticky} style={columnStyle(column)}>{column.render(row) ?? '—'}</Cell>; })}
|
|
87
|
+
</tr>{hasDetail && <tr><td colSpan={columns.length + Number(!!selection) + Number(!!expansion)}><div id={detailId} role="region" aria-label={`${rowLabel(row)}详情`}>{detail}</div></td></tr>}</Fragment>;
|
|
88
|
+
})}</tbody></table>
|
|
25
89
|
</div>
|
|
26
90
|
{!rows.length && !loading && !error && <EmptyState title={emptyTitle} description="当前条件下没有可展示的数据。" />}
|
|
27
91
|
</div>;
|
|
28
92
|
}
|
|
29
|
-
export
|
|
30
|
-
page: number; pageSize: number; total?: number; hasNext?: boolean; loading?: boolean; onPageChange: (page: number) => void;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
93
|
+
export interface PaginationProps {
|
|
94
|
+
page: number; pageSize: number; total?: number; hasNext?: boolean; loading?: boolean; disabled?: boolean; onPageChange: (page: number) => void;
|
|
95
|
+
pageSizeOptions?: number[]; onPageSizeChange?: (size: number) => void; showFirstButton?: boolean; showLastButton?: boolean; label?: string;
|
|
96
|
+
}
|
|
97
|
+
export function Pagination({ page, pageSize, total, hasNext = false, loading = false, disabled = false, onPageChange, pageSizeOptions = [10, 25, 50], onPageSizeChange, showFirstButton = false, showLastButton = false, label = '分页' }: PaginationProps) {
|
|
98
|
+
const root = useRef<HTMLElement>(null), focusedAction = useRef<HTMLButtonElement | null>(null);
|
|
99
|
+
const size = Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : 1;
|
|
100
|
+
const count = total === undefined || !Number.isFinite(total) || total < 0 ? undefined : Math.floor(total);
|
|
34
101
|
const pages = count === undefined ? undefined : Math.max(1, Math.ceil(count / size));
|
|
35
|
-
const current = Math.max(1, Math.floor(page));
|
|
102
|
+
const current = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
|
|
36
103
|
const next = pages === undefined ? hasNext : current < pages;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
104
|
+
const unavailable = loading || disabled;
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const focused = focusedAction.current;
|
|
107
|
+
if (!root.current || !focused || !focused.disabled || !root.current.contains(focused)) return;
|
|
108
|
+
if (document.activeElement !== focused && document.activeElement !== document.body) return;
|
|
109
|
+
const actions = Array.from(root.current.querySelectorAll<HTMLButtonElement>('[data-pagination-action]'));
|
|
110
|
+
const index = actions.indexOf(focused);
|
|
111
|
+
const fallback = actions.slice(index + 1).find(action => !action.disabled) ?? actions.slice(0, index).reverse().find(action => !action.disabled);
|
|
112
|
+
fallback?.focus();
|
|
113
|
+
}, [current, pages, next, unavailable, showFirstButton, showLastButton]);
|
|
114
|
+
return <nav ref={root} className="rhine-pagination" aria-label={label} aria-busy={loading} onFocusCapture={event => { focusedAction.current = event.target instanceof HTMLButtonElement && event.target.hasAttribute('data-pagination-action') ? event.target : null; }} onBlurCapture={event => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) focusedAction.current = null; }}>
|
|
115
|
+
<span className="rhine-text rhine-text--caption rhine-text--numeric" role="status">{count === undefined ? `第 ${current} 页` : current > pages! ? `第 ${current} 页(已超出 ${pages} 页) · 共 ${count} 条` : `第 ${current} / ${pages} 页 · 共 ${count} 条`}</span>
|
|
116
|
+
{onPageSizeChange && <Select label="每页条数" value={size} disabled={unavailable} onChange={event => onPageSizeChange(Number(event.target.value))}>{[...new Set([size, ...pageSizeOptions.filter(value => Number.isInteger(value) && value > 0)])].sort((a, b) => a - b).map(value => <option value={value} key={value}>{value} 条</option>)}</Select>}
|
|
117
|
+
{showFirstButton && <Button variant="secondary" data-pagination-action="first" disabled={unavailable || current <= 1} onClick={() => onPageChange(1)}>首页</Button>}
|
|
118
|
+
<Button variant="secondary" data-pagination-action="previous" disabled={unavailable || current <= 1} onClick={() => onPageChange(Math.min(current - 1, pages ?? current - 1))}>上一页</Button>
|
|
119
|
+
<Button variant="secondary" data-pagination-action="next" disabled={unavailable || !next} onClick={() => onPageChange(current + 1)}>下一页</Button>
|
|
120
|
+
{showLastButton && <Button variant="secondary" data-pagination-action="last" disabled={unavailable || pages === undefined || current === pages} onClick={() => { if (pages !== undefined) onPageChange(pages); }}>末页</Button>}
|
|
41
121
|
</nav>;
|
|
42
122
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, type FieldsetHTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface FieldsetProps extends FieldsetHTMLAttributes<HTMLFieldSetElement> { legend: string; hint?: string; children: ReactNode }
|
|
4
|
+
export function Fieldset({ legend, hint, children, className = '', 'aria-describedby': describedBy, ...props }: FieldsetProps) {
|
|
5
|
+
const id = useId();
|
|
6
|
+
return <fieldset {...props} className={`rhine-fieldset ${className}`} aria-describedby={[describedBy, hint ? id : undefined].filter(Boolean).join(' ') || undefined}>
|
|
7
|
+
<legend>{legend}</legend>{hint && <p id={id}>{hint}</p>}<div className="rhine-fieldset__body">{children}</div>
|
|
8
|
+
</fieldset>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FieldError { fieldId: string; message: string }
|
|
12
|
+
export interface ErrorSummaryProps { errors: readonly FieldError[]; title?: string; focusKey?: number }
|
|
13
|
+
export function ErrorSummary({ errors, title = '请修正以下问题', focusKey }: ErrorSummaryProps) {
|
|
14
|
+
const ref = useRef<HTMLDivElement>(null), id = useId();
|
|
15
|
+
const hasErrors = errors.length > 0;
|
|
16
|
+
// Focus only on a failed submission, never on each keystroke. Increment focusKey for retries.
|
|
17
|
+
useEffect(() => { if (hasErrors && focusKey !== undefined) ref.current?.focus(); }, [focusKey, hasErrors]);
|
|
18
|
+
if (!hasErrors) return null;
|
|
19
|
+
return <div className="rhine-error-summary" ref={ref} tabIndex={-1} role="region" aria-labelledby={id}>
|
|
20
|
+
<h3 id={id}>{title}</h3><ul>{errors.map(error => <li key={error.fieldId}><a href={`#${error.fieldId}`} onClick={event => {
|
|
21
|
+
const field = document.getElementById(error.fieldId);
|
|
22
|
+
if (field) { event.preventDefault(); field.focus(); field.scrollIntoView?.({ block: 'center' }); }
|
|
23
|
+
}}>{error.message}</a></li>)}</ul>
|
|
24
|
+
</div>;
|
|
25
|
+
}
|
package/components/Forms.tsx
CHANGED
|
@@ -33,32 +33,38 @@ export interface ComboboxProps {
|
|
|
33
33
|
label: string; query: string; onQueryChange: (query: string) => void;
|
|
34
34
|
value: string | null; onValueChange: (option: Choice) => void; options: Choice[];
|
|
35
35
|
loading?: boolean; error?: string; disabled?: boolean;
|
|
36
|
+
readOnly?: boolean; placeholder?: string; hint?: string; id?: string;
|
|
37
|
+
selectedValues?: string[]; closeOnSelect?: boolean; onEmptyBackspace?: () => void;
|
|
36
38
|
}
|
|
37
|
-
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false }: ComboboxProps) {
|
|
38
|
-
const
|
|
39
|
+
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false, readOnly = false, placeholder, hint, id: suppliedId, selectedValues, closeOnSelect = true, onEmptyBackspace }: ComboboxProps) {
|
|
40
|
+
const generatedId = useId(), id = suppliedId || generatedId; const composing = useRef(false);
|
|
39
41
|
const root = useRef<HTMLDivElement>(null);
|
|
40
42
|
const [container, setContainer] = useState<HTMLElement>();
|
|
41
43
|
useLayoutEffect(() => { setContainer((root.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
42
44
|
const [open, setOpen] = useState(false), [active, setActive] = useState<string | null>(null), [draft, setDraft] = useState<string | null>(null);
|
|
45
|
+
useEffect(() => { if (disabled || readOnly) { setOpen(false); setActive(null); setDraft(null); composing.current = false; } }, [disabled, readOnly]);
|
|
43
46
|
const available = loading || error ? [] : options.filter(option => !option.disabled);
|
|
44
47
|
const activeIndex = options.findIndex(option => option.value === active && !option.disabled);
|
|
45
48
|
const activeId = activeIndex >= 0 && !loading && !error ? `${id}-option-${activeIndex}` : undefined;
|
|
46
49
|
useEffect(() => { if (!open || !activeId) return; document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' }); }, [activeId, open]);
|
|
47
|
-
const choose = (option: Choice) => { onValueChange(option); setOpen(false); setActive(null); };
|
|
50
|
+
const choose = (option: Choice) => { if (readOnly || disabled || loading || error) return; onValueChange(option); if (closeOnSelect) setOpen(false); setActive(null); };
|
|
51
|
+
const visibleOpen = open && !disabled && !readOnly;
|
|
48
52
|
return <div ref={root} className="rhine-combobox rhine-field" onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}><label htmlFor={id}>{label}</label>
|
|
49
|
-
<PopoverBase.Root open={
|
|
50
|
-
onClick={() => setOpen(true)} onFocus={() => setOpen(true)} onChange={event => { if (composing.current) setDraft(event.target.value); else onQueryChange(event.target.value); setActive(null); setOpen(true); }}
|
|
51
|
-
onCompositionStart={() => { composing.current = true; setDraft(query); }} onCompositionEnd={event => { composing.current = false; setDraft(null); onQueryChange(event.currentTarget.value); }}
|
|
53
|
+
<PopoverBase.Root open={visibleOpen} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-combobox__input"><input id={id} role="combobox" aria-autocomplete="list" aria-expanded={visibleOpen} aria-controls={visibleOpen ? `${id}-list` : undefined} aria-activedescendant={visibleOpen ? activeId : undefined} aria-invalid={!!error || undefined} aria-describedby={[`${id}-status`, hint && `${id}-hint`].filter(Boolean).join(' ')} value={draft ?? query} disabled={disabled} readOnly={readOnly} placeholder={placeholder}
|
|
54
|
+
onClick={() => { if (!readOnly && !disabled) setOpen(true); }} onFocus={() => { if (!readOnly && !disabled) setOpen(true); }} onChange={event => { if (readOnly || disabled) return; if (composing.current) setDraft(event.target.value); else onQueryChange(event.target.value); setActive(null); setOpen(true); }}
|
|
55
|
+
onCompositionStart={() => { if (!readOnly && !disabled) { composing.current = true; setDraft(query); } }} onCompositionEnd={event => { composing.current = false; setDraft(null); if (!readOnly && !disabled) onQueryChange(event.currentTarget.value); }}
|
|
52
56
|
onKeyDown={event => {
|
|
53
|
-
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
57
|
+
if (readOnly || disabled || composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
58
|
+
if (event.key === 'Backspace' && !event.currentTarget.value && onEmptyBackspace) { event.preventDefault(); onEmptyBackspace(); }
|
|
54
59
|
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false); setActive(null); }
|
|
55
60
|
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true); const index = available.findIndex(option => option.value === active); const next = event.key === 'ArrowDown' ? (index + 1) % available.length : (index <= 0 ? available.length : index) - 1; setActive(available[next]?.value ?? null); }
|
|
56
61
|
if (open && ['Home', 'End'].includes(event.key)) { event.preventDefault(); setActive((event.key === 'Home' ? available[0] : available.at(-1))?.value ?? null); }
|
|
57
62
|
if (open && event.key === 'Enter') { event.preventDefault(); const option = available.find(item => item.value === active); if (option) choose(option); }
|
|
58
63
|
}} /><span className="rhine-combobox__arrow"><Icon name="chevronDown" size={16} /></span></div></PopoverBase.Anchor>
|
|
59
64
|
<PopoverBase.Portal container={container}><PopoverBase.Content asChild sideOffset={4} collisionPadding={12} onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event => event.preventDefault()} onInteractOutside={event => { if (root.current?.contains(event.target as Node)) event.preventDefault(); }}>
|
|
60
|
-
<div className="rhine-combobox__list" role="listbox" id={`${id}-list`} aria-label={label} aria-busy={loading}>{!loading && !error && options.map((option, index) => <div role="option" id={`${id}-option-${index}`} key={option.value} aria-selected={option.value === value} aria-disabled={option.disabled || undefined} data-active={option.value === active} onMouseDown={event => event.preventDefault()} onMouseMove={() => { if (!option.disabled) setActive(option.value); }} onClick={() => { if (!option.disabled) choose(option); }}>{option.label}</div>)}{(loading || error || !options.length) && <div className="rhine-combobox__empty" role="presentation">{loading ? '正在读取…' : error ? '暂不可用' : '暂无匹配结果'}</div>}</div>
|
|
65
|
+
<div className="rhine-combobox__list" role="listbox" id={`${id}-list`} aria-label={label} aria-multiselectable={selectedValues ? true : undefined} aria-busy={loading}>{!loading && !error && options.map((option, index) => <div role="option" id={`${id}-option-${index}`} key={option.value} aria-selected={selectedValues ? selectedValues.includes(option.value) : option.value === value} aria-disabled={option.disabled || undefined} data-active={option.value === active} onMouseDown={event => event.preventDefault()} onMouseMove={() => { if (!option.disabled) setActive(option.value); }} onClick={() => { if (!option.disabled) choose(option); }}>{option.label}</div>)}{(loading || error || !options.length) && <div className="rhine-combobox__empty" role="presentation">{loading ? '正在读取…' : error ? '暂不可用' : '暂无匹配结果'}</div>}</div>
|
|
61
66
|
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root>
|
|
62
67
|
<span id={`${id}-status`} role="status" className={`rhine-text rhine-text--caption ${error ? 'rhine-text--error' : 'rhine-text--muted'}`}>{loading ? '正在读取候选项…' : error || (options.length ? `${options.length} 个候选项` : '暂无匹配结果')}</span>
|
|
68
|
+
{hint && <span id={`${id}-hint`} className="rhine-field__hint">{hint}</span>}
|
|
63
69
|
</div>;
|
|
64
70
|
}
|
package/components/MOTION.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
2026-10-22,用户指定 [Transitions.dev](https://transitions.dev/) 作为UX与动画参考。本文件补充[组件规范](./README.md#动效规范),保留Rhine Design的苔绿、暖白、三角几何与清晰文字。以下参数是本项目设计决定,不是对参考站时长或曲线的测量结果。
|
|
6
6
|
|
|
7
|
-
用户已进一步授权实现展台:`/#motion
|
|
7
|
+
用户已进一步授权实现展台:`/#motion`目前提供12组可播放、重播、复位的真实示例,支持原速/4倍慢放、逐例减少动态及全局偏好。main组件共46个(npm0.3.0为40个);实验性配方仍限定于展台,后续按“展台评审 → 通用组件采用 → App接入”推进。
|
|
8
8
|
|
|
9
9
|
## 参考依据
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
4. **动效可被主动比较。** 展台后续为每种动效提供静态起点、播放/切换、重播、参数说明及减少动态对照。重播只改变本地演示状态,不重新执行复制、请求、确认等真实动作。
|
|
21
21
|
5. **解释适用条件。** 示例卡写清“触发 → 变化 → 结束状态”,并显示已实现/设计候选;筛选只改变展台展示范围,不改变组件业务值。宽屏并排对照,窄屏单列,播放按钮位置稳定。
|
|
22
22
|
|
|
23
|
-
动效实验室已为
|
|
23
|
+
动效实验室已为12组配方提供上述播放控制、状态说明、参数和逐例减少动态开关;并排卡片可分别设置偏好作比较。既有组件目录保留普通交互示例,不给每个无动画组件添加播放按钮。
|
|
24
24
|
|
|
25
25
|
## Rhine Design 动效映射
|
|
26
26
|
|
|
@@ -70,3 +70,7 @@
|
|
|
70
70
|
首次内容切换不闪空白、不让操作目标跳位;刷新不丢旧数据;退出后无透明遮挡层和遗留焦点;无隐藏控件可Tab到。文档参数、DOM存在与单张截图不能代替完整动效或宿主原生平台原生验收。
|
|
71
71
|
|
|
72
72
|
2026-10-22展台验证:Chrome通过400/520/880/1200宽度、200%缩放、10组播放/复位、快速重播取消旧结果、四向位置/位移采样、菜单键盘、弹窗内重播保焦点与关闭归还、折叠中途反向、通知关闭、Tooltip Escape、逐例/全局/系统减少动态。采样确认4倍慢放的内容淡入720ms、反馈480ms、浮层640ms、折叠800ms及品牌960ms;未将合成浏览器结果扩展为原生或业务验收。
|
|
73
|
+
|
|
74
|
+
## 导航与流程动效(main,未发布)
|
|
75
|
+
|
|
76
|
+
新增抽屉与步骤两组示例,实验室现为12组。Drawer继承160ms浮层token,从右侧3px淡入;弹层内重播只重启CSS动画,焦点与内容不重建。Steps当前段使用180ms/3px进入,由业务校验推进。表单错误直接呈现并定位,无抖动效果。完整适用场景、减少动态与告警规则见[交互规范](../docs/INTERACTION_PATTERNS.md)。
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import * as DialogBase from '@radix-ui/react-dialog';
|
|
3
|
+
import { Checkbox } from './Forms';
|
|
4
|
+
import './media-parity.css';
|
|
5
|
+
|
|
6
|
+
export interface MediaCardProps extends Omit<ComponentPropsWithRef<'article'>, 'title' | 'children'> {
|
|
7
|
+
title: string; date?: string; kind: 'photo' | 'video' | 'RAW' | 'Live';
|
|
8
|
+
preview?: ReactNode; state?: 'ready' | 'loading' | 'error' | 'empty'; aspectRatio?: string;
|
|
9
|
+
onOpen: () => void; selected?: boolean; onSelectedChange?: (selected: boolean) => void;
|
|
10
|
+
disabled?: boolean; actions?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
export function MediaCard({ title, date, kind, preview, state = preview != null && preview !== false ? 'ready' : 'empty', aspectRatio = '4 / 3', onOpen, selected = false, onSelectedChange, disabled, actions, className = '', ...props }: MediaCardProps) {
|
|
13
|
+
const statusId = useId();
|
|
14
|
+
return <article {...props} className={`rhine-media-card ${className}`} data-selected={selected}>
|
|
15
|
+
<button type="button" className="rhine-media-card__open" onClick={event => { event.currentTarget.focus({ preventScroll: true }); onOpen(); }} disabled={disabled} aria-label={`打开${title}`} aria-describedby={statusId}>
|
|
16
|
+
<span className="rhine-media-card__preview" style={{ aspectRatio }} aria-busy={state === 'loading'}>
|
|
17
|
+
{(state === 'ready' || state === 'loading') && <span className="rhine-media-card__asset" hidden={state === 'loading'}>{preview}</span>}
|
|
18
|
+
{state !== 'ready' && <span>{state === 'loading' ? '正在加载预览…' : state === 'error' ? '预览无法解码' : '暂无预览'}</span>}
|
|
19
|
+
<span className="rhine-media-card__kind">{kind === 'photo' ? '照片' : kind === 'video' ? '视频' : kind}</span>
|
|
20
|
+
</span><span className="rhine-media-card__caption"><strong>{title}</strong><span>{date || '日期未知'}</span></span>
|
|
21
|
+
</button>
|
|
22
|
+
<span id={statusId} role="status" className="rhine-sr-only">{state === 'loading' ? `${title}:预览加载中` : state === 'error' ? `${title}:预览失败` : state === 'empty' ? `${title}:缺少预览` : ''}</span>
|
|
23
|
+
{onSelectedChange && <Checkbox label={`选择${title}`} checked={selected} disabled={disabled} onChange={event => onSelectedChange(event.target.checked)} />}
|
|
24
|
+
{actions != null && <div className="rhine-media-card__actions">{actions}</div>}
|
|
25
|
+
</article>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MediaViewerProps {
|
|
29
|
+
trigger: ReactElement; title: string; description: string; children: ReactNode;
|
|
30
|
+
open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void;
|
|
31
|
+
onPrevious?: () => void; onNext?: () => void; actions?: ReactNode;
|
|
32
|
+
previousDisabled?: boolean; nextDisabled?: boolean; keyboardNavigation?: boolean;
|
|
33
|
+
metadata?: ReactNode; metadataOpen?: boolean; defaultMetadataOpen?: boolean; onMetadataOpenChange?: (open: boolean) => void;
|
|
34
|
+
className?: string; style?: CSSProperties;
|
|
35
|
+
}
|
|
36
|
+
export function MediaViewer({ trigger, title, description, children, open, defaultOpen, onOpenChange, onPrevious, onNext, previousDisabled = false, nextDisabled = false, keyboardNavigation = true, actions, metadata, metadataOpen, defaultMetadataOpen = false, onMetadataOpenChange, className = '', style }: MediaViewerProps) {
|
|
37
|
+
const root = useRef<HTMLSpanElement>(null), [container, setContainer] = useState<HTMLElement>();
|
|
38
|
+
const opener = useRef<HTMLElement | null>(null);
|
|
39
|
+
const contentRef = useRef<HTMLDivElement>(null), previousRef = useRef<HTMLButtonElement>(null), nextRef = useRef<HTMLButtonElement>(null);
|
|
40
|
+
const focusedNavigation = useRef<'previous' | 'next' | null>(null);
|
|
41
|
+
const [localMetadataOpen, setLocalMetadataOpen] = useState(defaultMetadataOpen);
|
|
42
|
+
const showMetadata = metadataOpen ?? localMetadataOpen, metadataId = useId();
|
|
43
|
+
const hasMetadata = metadata != null && metadata !== false;
|
|
44
|
+
const previousEnabled = !!onPrevious && !previousDisabled, nextEnabled = !!onNext && !nextDisabled;
|
|
45
|
+
const changeMetadata = () => {
|
|
46
|
+
if (metadataOpen === undefined) setLocalMetadataOpen(!showMetadata);
|
|
47
|
+
onMetadataOpenChange?.(!showMetadata);
|
|
48
|
+
};
|
|
49
|
+
useLayoutEffect(() => {
|
|
50
|
+
const focused = focusedNavigation.current;
|
|
51
|
+
if ((focused === 'previous' && !previousEnabled) || (focused === 'next' && !nextEnabled)) {
|
|
52
|
+
const button = focused === 'previous' ? previousRef.current : nextRef.current;
|
|
53
|
+
// Browsers blur a focused button when it becomes disabled at the collection boundary.
|
|
54
|
+
if (document.activeElement === button || document.activeElement === document.body) contentRef.current?.focus({ preventScroll: true });
|
|
55
|
+
}
|
|
56
|
+
}, [previousEnabled, nextEnabled]);
|
|
57
|
+
useLayoutEffect(() => { setContainer(root.current?.closest<HTMLElement>('.rhine-theme') ?? document.body); }, []);
|
|
58
|
+
return <span ref={root} className="rhine-overlay-anchor"><DialogBase.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
|
59
|
+
<DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
|
|
60
|
+
<DialogBase.Overlay className="rhine-overlay" />
|
|
61
|
+
<DialogBase.Content ref={contentRef} className={`rhine-media-viewer ${className}`} onOpenAutoFocus={() => { opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; }} onCloseAutoFocus={event => { if (opener.current?.isConnected && opener.current !== document.body) { event.preventDefault(); opener.current.focus(); } }} style={{ '--rhine-media-columns': hasMetadata && showMetadata ? 'minmax(0, 1fr) min(320px, 32%)' : 'minmax(0, 1fr)', ...style } as CSSProperties}
|
|
62
|
+
onFocusCapture={event => { const target = event.target as HTMLElement; focusedNavigation.current = target === previousRef.current ? 'previous' : target === nextRef.current ? 'next' : null; }}
|
|
63
|
+
onBlurCapture={event => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) focusedNavigation.current = null; }}
|
|
64
|
+
onKeyDown={event => {
|
|
65
|
+
if (!keyboardNavigation || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return;
|
|
66
|
+
const target = event.target;
|
|
67
|
+
// Embedded editors, media controls and nested portals own their arrow keys.
|
|
68
|
+
if (!(target instanceof HTMLElement) || !event.currentTarget.contains(target) || target.closest('input, textarea, select, video, audio, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="listbox"], [role="tree"], [role="tablist"], [role="menu"]')) return;
|
|
69
|
+
if (event.key === 'ArrowLeft' && previousEnabled) { event.preventDefault(); onPrevious?.(); }
|
|
70
|
+
if (event.key === 'ArrowRight' && nextEnabled) { event.preventDefault(); onNext?.(); }
|
|
71
|
+
}}>
|
|
72
|
+
<header className="rhine-media-viewer__header"><div><DialogBase.Title>{title}</DialogBase.Title><DialogBase.Description>{description}</DialogBase.Description></div>
|
|
73
|
+
<div className="rhine-media-viewer__actions"><button ref={previousRef} type="button" disabled={!previousEnabled} aria-keyshortcuts={keyboardNavigation ? 'ArrowLeft' : undefined} onClick={onPrevious}>上一项</button><button ref={nextRef} type="button" disabled={!nextEnabled} aria-keyshortcuts={keyboardNavigation ? 'ArrowRight' : undefined} onClick={onNext}>下一项</button>
|
|
74
|
+
{hasMetadata && <button type="button" aria-controls={metadataId} aria-expanded={showMetadata} onClick={changeMetadata}>{showMetadata ? '收起信息' : '显示信息'}</button>}
|
|
75
|
+
{actions}<DialogBase.Close asChild><button type="button" aria-label="关闭媒体查看器">关闭</button></DialogBase.Close>
|
|
76
|
+
</div>
|
|
77
|
+
</header>
|
|
78
|
+
<div className="rhine-media-viewer__body"><div className="rhine-media-viewer__canvas">{children}</div>{hasMetadata && <aside id={metadataId} hidden={!showMetadata} className="rhine-media-viewer__metadata" aria-label="媒体信息">{metadata}</aside>}</div>
|
|
79
|
+
</DialogBase.Content>
|
|
80
|
+
</DialogBase.Portal>
|
|
81
|
+
</DialogBase.Root></span>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { HTMLAttributes, MouseEvent } from 'react';
|
|
2
|
+
import { Icon, type IconName } from './Actions';
|
|
3
|
+
|
|
4
|
+
export interface NavItem { id: string; label: string; href: string; icon?: IconName }
|
|
5
|
+
export interface NavGroup { label: string; items: readonly NavItem[] }
|
|
6
|
+
export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
groups: readonly NavGroup[]; currentId: string;
|
|
8
|
+
onNavigate?: (item: NavItem, event: MouseEvent<HTMLAnchorElement>) => void;
|
|
9
|
+
}
|
|
10
|
+
export function SideNav({ groups, currentId, onNavigate, className = '', 'aria-label': label = '主导航', ...props }: SideNavProps) {
|
|
11
|
+
return <nav {...props} aria-label={label} className={`rhine-side-nav ${className}`}>
|
|
12
|
+
{groups.map(group => <div className="rhine-side-nav__group" key={group.label}><p>{group.label}</p><ul>
|
|
13
|
+
{group.items.map(item => <li key={item.id}><a href={item.href} aria-current={currentId === item.id ? 'page' : undefined} onClick={event => onNavigate?.(item, event)}>{item.icon && <Icon name={item.icon} size={20}/>}<span className="rhine-side-nav__label">{item.label}</span></a></li>)}
|
|
14
|
+
</ul></div>)}
|
|
15
|
+
</nav>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BreadcrumbItem { label: string; href?: string }
|
|
19
|
+
export interface BreadcrumbsProps { items: readonly BreadcrumbItem[]; label?: string }
|
|
20
|
+
export function Breadcrumbs({ items, label = '当前位置' }: BreadcrumbsProps) {
|
|
21
|
+
return <nav className="rhine-breadcrumbs" aria-label={label}><ol>{items.map((item, index) => <li key={index}>
|
|
22
|
+
{index > 0 && <span aria-hidden="true" className="rhine-breadcrumbs__separator">/</span>}
|
|
23
|
+
{item.href ? <a href={item.href} aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</a> : <span aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</span>}
|
|
24
|
+
</li>)}</ol></nav>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StepItem { id: string; label: string; description?: string }
|
|
28
|
+
export interface StepsProps {
|
|
29
|
+
items: readonly StepItem[]; current: number; label?: string; error?: string;
|
|
30
|
+
onStepChange?: (index: number) => void;
|
|
31
|
+
}
|
|
32
|
+
export function Steps({ items, current, label = '任务步骤', error, onStepChange }: StepsProps) {
|
|
33
|
+
const position = Math.max(0, Math.min(current, items.length));
|
|
34
|
+
return <ol className="rhine-steps" aria-label={label}>{items.map((item, index) => {
|
|
35
|
+
const state = index < position ? 'complete' : index === position ? (error ? 'error' : 'current') : 'pending';
|
|
36
|
+
const status = { complete: '已完成', current: '进行中', error: '需要修正', pending: '未开始' }[state];
|
|
37
|
+
const content = <><span className="rhine-steps__number" aria-hidden="true">{state === 'complete' ? '✓' : String(index + 1).padStart(2, '0')}</span><span><strong>{item.label}</strong><small>{status}{item.description ? ` · ${item.description}` : ''}{state === 'error' ? ` · ${error}` : ''}</small></span></>;
|
|
38
|
+
return <li key={item.id} data-state={state} aria-current={index === position ? 'step' : undefined}>
|
|
39
|
+
{onStepChange && index < position ? <button type="button" onClick={() => onStepChange(index)} aria-label={`返回${item.label}`}>{content}</button> : <div>{content}</div>}
|
|
40
|
+
</li>;
|
|
41
|
+
})}</ol>;
|
|
42
|
+
}
|
package/components/Overlays.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import * as MenuBase from '@radix-ui/react-dropdown-menu';
|
|
|
6
6
|
import * as TooltipBase from '@radix-ui/react-tooltip';
|
|
7
7
|
import { Button, Notice } from './components';
|
|
8
8
|
import { IconButton } from './Actions';
|
|
9
|
+
import './drawer-placement.css';
|
|
9
10
|
|
|
10
11
|
// Keep portal content inside the root theme so tokens and reduced-motion overrides inherit.
|
|
11
12
|
function useScope() {
|
|
@@ -14,11 +15,17 @@ function useScope() {
|
|
|
14
15
|
useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
15
16
|
return { ref, container };
|
|
16
17
|
}
|
|
17
|
-
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void }
|
|
18
|
-
export function Dialog(
|
|
18
|
+
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode) }
|
|
19
|
+
export function Dialog(props: DialogProps) {
|
|
20
|
+
return <DialogFrame {...props}/>;
|
|
21
|
+
}
|
|
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' }) {
|
|
19
24
|
const { ref, container } = useScope();
|
|
25
|
+
const sideNavigation = typeof sidePanel === 'function';
|
|
26
|
+
const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
|
|
20
27
|
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open} onOpenChange={onOpenChange}><DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
|
|
21
|
-
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content className=
|
|
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>
|
|
22
29
|
</DialogBase.Portal></DialogBase.Root></span>;
|
|
23
30
|
}
|
|
24
31
|
export function ConfirmDialog({ trigger, title, description, confirmLabel = '确认操作', danger = false, onConfirm }: { trigger: ReactElement; title: string; description: string; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void }) {
|