@linhey/rhine-design 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +24 -7
- package/components/AdvancedForms.tsx +184 -0
- package/components/AppShell.tsx +71 -0
- package/components/Background.tsx +29 -0
- package/components/COMMON_COMPONENTS.md +2 -2
- package/components/Charts.tsx +94 -0
- package/components/Data.tsx +98 -18
- package/components/Forms.tsx +14 -8
- package/components/MOTION.md +9 -1
- package/components/Media.tsx +82 -0
- package/components/OrbitBackdrop.tsx +33 -0
- package/components/Overlays.tsx +4 -3
- package/components/README.md +12 -1
- package/components/StatusTimeline.tsx +50 -12
- package/components/background.css +43 -0
- package/components/charts.css +27 -0
- package/components/common.css +5 -5
- package/components/data.css +6 -0
- package/components/drawer-placement.css +4 -0
- package/components/extensions.css +70 -0
- package/components/index.ts +14 -2
- package/components/media-parity.css +3 -0
- package/components/orbit.css +42 -0
- package/components/orbitRenderer.ts +230 -0
- package/components/patterns.css +1 -1
- package/components/styles.css +5 -1
- package/components/theme-arknights.css +10 -5
- package/components/theme-neo-brutalism.css +10 -4
- package/components/timeline.css +2 -2
- package/components/tokens.css +43 -0
- package/docs/ANTD_MUI_AUDIT.md +38 -0
- package/docs/APPLICATION_COMPONENTS.md +175 -0
- package/docs/DESIGN_SYSTEM_AUDIT.md +2 -0
- package/docs/ISSUE_4_VALIDATION.md +21 -0
- package/docs/ISSUE_VALIDATION.md +52 -0
- package/docs/ORBIT_BACKDROP.md +81 -0
- package/docs/REFERENCE_RESEARCH.md +2 -0
- package/docs/THEMING.md +41 -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/assets/orbit-backdrop.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 +1 -1
- package/preview/BackgroundGallery.tsx +46 -0
- package/preview/ExtensionsGallery.tsx +95 -0
- package/preview/GalleryShell.tsx +4 -3
- package/preview/MonitoringGallery.tsx +27 -0
- package/preview/OrbitGallery.tsx +47 -0
- package/preview/backgrounds.css +10 -0
- package/preview/extensions.css +6 -0
- package/preview/main.tsx +12 -1
- package/skills/rhine-design/SKILL.md +24 -3
- package/skills/rhine-design/references/API_MAP.md +6 -0
- package/skills/rhine-design/references/TASK_MAP.md +3 -1
- package/skills/rhine-design/references/integration.md +4 -2
- 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/Monitoring.test.tsx +38 -0
- package/tests/MonitoringParity.test.tsx +125 -0
- package/tests/OrbitBackdrop.test.tsx +183 -0
- package/tests/ShellMediaParity.test.tsx +180 -0
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
|
}
|
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
|
@@ -63,6 +63,14 @@
|
|
|
63
63
|
|
|
64
64
|
参考站是设计来源,组件复用入口仍是本仓库。当前CSS/Web Animations及Radix已能承载上述方案,本次不引入额外动画库、CLI、远程Builder或评分流程。未来需要复用外部源码时单独核对许可与依赖,参考授权不等于已取得代码授权。
|
|
65
65
|
|
|
66
|
+
### 轨道装饰的复用边界(0.6.0)
|
|
67
|
+
|
|
68
|
+
授权移交的 `OrbitBackdrop` 是独立艺术背景,保留原有环节点、曲线、发射箭头、光晕与透视;不修改通用 Logo、加载或交互配方。组件拥有 Canvas 绘制和生命周期,默认提供暂停 / 继续按钮;业务只组合布局,不另写一套动画。它不表示加载、进度或操作成功,普通业务刷新不触发重播。
|
|
69
|
+
|
|
70
|
+
系统、显式 prop 和祖先的减少动态偏好均可停帧,内层跟随系统不覆盖外层减少动态,`reducedMotion={false}` 不能覆盖系统偏好。窗口不超过 760px、页面隐藏、组件离屏或零尺寸时也保持静态;恢复沿冻结时间继续,不追赶。resize 静态重绘,DPR 上限为 2,卸载清理 rAF、监听器与 observers;Canvas 不可用时保留静态艺术和内容。
|
|
71
|
+
|
|
72
|
+
展台 `/#orbit` 使用真实公开组件,提供暂停 / 继续、局部重播、手动减少动态及系统 / 全局偏好继承。重播只重建艺术组件,外层业务内容不重建。此艺术背景不计入 `/#motion` 的 12 组配方;npm 0.5.0 及以前不包含它。API、三主题可读性与验收清单见[轨道背景契约](../docs/ORBIT_BACKDROP.md)。
|
|
73
|
+
|
|
66
74
|
## 后续验收
|
|
67
75
|
|
|
68
76
|
每项候选先在真实组件展台展示,再更新实现状态。检查鼠标/键盘、快速反向、重复点击、加载成功与失败、长文本、400/520/880宽度、200%缩放、系统/手动减少动态;浮层额外检查四向避让与嵌套Escape。对比前后截图核对布局,对动画全过程录屏或采样核对时序,中间帧与最终帧分别记录。
|
|
@@ -71,6 +79,6 @@
|
|
|
71
79
|
|
|
72
80
|
2026-10-22展台验证:Chrome通过400/520/880/1200宽度、200%缩放、10组播放/复位、快速重播取消旧结果、四向位置/位移采样、菜单键盘、弹窗内重播保焦点与关闭归还、折叠中途反向、通知关闭、Tooltip Escape、逐例/全局/系统减少动态。采样确认4倍慢放的内容淡入720ms、反馈480ms、浮层640ms、折叠800ms及品牌960ms;未将合成浏览器结果扩展为原生或业务验收。
|
|
73
81
|
|
|
74
|
-
## 导航与流程动效(
|
|
82
|
+
## 导航与流程动效(0.4.0)
|
|
75
83
|
|
|
76
84
|
新增抽屉与步骤两组示例,实验室现为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,33 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type HTMLAttributes } from 'react';
|
|
2
|
+
import { Button } from './components';
|
|
3
|
+
import { mountOrbit, type OrbitRenderer, type OrbitState } from './orbitRenderer';
|
|
4
|
+
|
|
5
|
+
export interface OrbitBackdropProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/** Adds a static preference; false never overrides the system or an ancestor. */
|
|
7
|
+
reducedMotion?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function OrbitBackdrop({ reducedMotion = false, children, className = '', ...props }: OrbitBackdropProps) {
|
|
11
|
+
const canvas = useRef<HTMLCanvasElement>(null);
|
|
12
|
+
const renderer = useRef<OrbitRenderer | null>(null);
|
|
13
|
+
const [paused, setPaused] = useState(false);
|
|
14
|
+
const [state, setState] = useState<OrbitState>('empty');
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const scene = mountOrbit(canvas.current!, setState);
|
|
18
|
+
renderer.current = scene;
|
|
19
|
+
return () => { scene.dispose(); renderer.current = null; };
|
|
20
|
+
}, []);
|
|
21
|
+
useEffect(() => { renderer.current?.setPaused(paused); }, [paused]);
|
|
22
|
+
useEffect(() => { renderer.current?.setReducedMotion(reducedMotion); }, [reducedMotion]);
|
|
23
|
+
|
|
24
|
+
const canControl = state === 'playing' || state === 'paused';
|
|
25
|
+
const label = state === 'reduced' ? '已减少动态' : state === 'compact' ? '窄屏静态画面'
|
|
26
|
+
: canControl ? paused ? '继续动效' : '暂停动效' : '静态画面';
|
|
27
|
+
|
|
28
|
+
return <div {...props} className={`rhine-orbit ${className}`} data-rhine-orbit-state={state}>
|
|
29
|
+
<canvas ref={canvas} className="rhine-orbit-canvas" aria-hidden="true" />
|
|
30
|
+
<div className="rhine-orbit-content">{children}</div>
|
|
31
|
+
<div className="rhine-orbit-controls"><Button variant="secondary" disabled={!canControl} onClick={() => setPaused(value => !value)}>{label}</Button></div>
|
|
32
|
+
</div>;
|
|
33
|
+
}
|
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() {
|
|
@@ -18,13 +19,13 @@ export interface DialogProps { trigger: ReactElement; title: string; description
|
|
|
18
19
|
export function Dialog(props: DialogProps) {
|
|
19
20
|
return <DialogFrame {...props}/>;
|
|
20
21
|
}
|
|
21
|
-
export function Drawer(props: Omit<DialogProps, 'sidePanel'>) { return <DialogFrame {...props} drawer />; }
|
|
22
|
-
function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, drawer = false }: DialogProps & { drawer?: boolean }) {
|
|
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' }) {
|
|
23
24
|
const { ref, container } = useScope();
|
|
24
25
|
const sideNavigation = typeof sidePanel === 'function';
|
|
25
26
|
const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
|
|
26
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}>
|
|
27
|
-
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content 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>
|
|
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>
|
|
28
29
|
</DialogBase.Portal></DialogBase.Root></span>;
|
|
29
30
|
}
|
|
30
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 }) {
|
package/components/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
本目录提供[莱茵设计 · Rhine Design](../README.md)的 React 组件、设计变量和 API 契约。组件接收应用传入的数据与动作,不绑定特定后端或原生平台。包名为`@linhey/rhine-design`;样式变量和类名使用`rhine-`命名空间。
|
|
4
4
|
|
|
5
|
-
0.
|
|
5
|
+
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
6
|
|
|
7
7
|
页面改动遵循:优先复用组件 → 先在设计稿/展台修改并验证 → 再按任务授权实现到App。缺组件先补组件与展台,不在业务页另写同用途样式;同一任务已授权设计和实现时连续推进,无需重复确认。
|
|
8
8
|
|
|
@@ -46,6 +46,8 @@ export function Example() {
|
|
|
46
46
|
|
|
47
47
|
## 组件契约
|
|
48
48
|
|
|
49
|
+
0.5.0 的 Background、AppShell、MediaCard、MediaViewer、DateField、DateRangePicker、NumberField、TreeSelect、RemoteMultiSelect、TrendChart、StackedBar,以及 DataTable/Pagination 和 StatusTimeline 详情扩展见[应用组件契约](../docs/APPLICATION_COMPONENTS.md)。新增 11 个,总数 59;npm 0.4.0 为 48 个。
|
|
50
|
+
|
|
49
51
|
| 组件 | 关键输入 | 状态与边界 |
|
|
50
52
|
| --- | --- | --- |
|
|
51
53
|
| Button | variant=primary/secondary/quiet/danger;busy;busyLabel;原生按钮属性 | 默认 type=button;busy 保留焦点并拦截再次 click,不自动把业务标记成功;disabled 使用原生禁用 |
|
|
@@ -55,6 +57,7 @@ export function Example() {
|
|
|
55
57
|
| Toolbar | title;description;children;HTML div属性 | 自适应控件组合,role=group与普通Tab顺序;不伪装为需要方向键导航的ARIA toolbar;不含业务状态 |
|
|
56
58
|
| Badge | tone=neutral/success/warning/error;children | 只读短状态,颜色必须配文字,不做可点击按钮 |
|
|
57
59
|
| Surface | HTML div 属性 | 静态分区,无点击语义;不是弹窗或导航 |
|
|
60
|
+
| 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) |
|
|
58
61
|
| SelectionCard | label;selected;onSelectedChange;disabled;children | 原生 checkbox 可取消;是否单选由上层控制;内部不嵌套按钮/链接/其他输入 |
|
|
59
62
|
| TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
|
|
60
63
|
| Tabs | label;items[{value,label,content,disabled?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树 |
|
|
@@ -143,3 +146,11 @@ export function Example() {
|
|
|
143
146
|
在仓库根执行npm run check。浏览器核对400/520/880/1200宽度、200%缩放、键盘/焦点、展开反向、减少动态中断与加载宽度。JSDOM不代替视觉或原生辅助技术验收。
|
|
144
147
|
|
|
145
148
|
本仓构建产物是独立展台;消费项目只导入组件入口,避免把preview中的模拟流程打入业务产物。版本变化和迁移见[CHANGELOG](../CHANGELOG.md)。
|
|
149
|
+
|
|
150
|
+
## 背景与外壳(0.5.0)
|
|
151
|
+
|
|
152
|
+
新增 `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
|
+
|
|
154
|
+
## 轨道背景(0.6.0)
|
|
155
|
+
|
|
156
|
+
`OrbitBackdrop` 接收 `HTMLAttributes<HTMLDivElement>` 和 `reducedMotion?: boolean`,包含可选 `children`。它提供固定深色轨道艺术与内置暂停 / 继续按钮,Canvas 为 `aria-hidden` 装饰。系统、显式减少动态、祖先偏好、窄窗、页面隐藏、离屏及零尺寸时停帧;恢复沿冻结时间继续,不追赶。Canvas 不可用时保留静态艺术与内容。宿主只组合布局,不复制动画或覆盖组件状态。完整生命周期、主题及接入约束见[轨道背景契约](../docs/ORBIT_BACKDROP.md),真实组件展台为 `/#orbit`。自 0.6.0 起共 60 个组件。
|
|
@@ -1,26 +1,64 @@
|
|
|
1
|
-
import { useId, useState } from 'react';
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as PopoverBase from '@radix-ui/react-popover';
|
|
2
3
|
import { Text } from './Typography';
|
|
4
|
+
import { KeyValue } from './Data';
|
|
5
|
+
import { Button } from './components';
|
|
3
6
|
|
|
4
7
|
export type TimelineStatus = 'healthy' | 'recovered' | 'degraded' | 'failed' | 'unknown';
|
|
5
|
-
export interface
|
|
8
|
+
export interface TimelineDetail {
|
|
9
|
+
start: string; end: string; reason?: string;
|
|
10
|
+
counts?: { id: string; label: string; value: number | null; unit?: string }[];
|
|
11
|
+
methodology?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TimelineSegment { id: string; label: string; status: TimelineStatus; detail?: TimelineDetail }
|
|
14
|
+
export interface StatusTimelineProps {
|
|
15
|
+
label: string; segments: TimelineSegment[]; startLabel: string; endLabel: string; compact?: boolean; disabled?: boolean;
|
|
16
|
+
activeId?: string; defaultActiveId?: string; onActiveChange?: (id: string) => void;
|
|
17
|
+
open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void;
|
|
18
|
+
}
|
|
6
19
|
export const timelineLabels: Record<TimelineStatus, string> = { healthy: '有输出·未见异常', recovered: '重试已恢复', degraded: '异常·结果待确认', failed: '存在最终失败', unknown: '未知 / 无数据' };
|
|
7
20
|
|
|
8
21
|
/** Host supplies ordered, validated evidence; this component never computes a health score. */
|
|
9
|
-
export function StatusTimeline({ label, segments, startLabel, endLabel, compact = false
|
|
10
|
-
const [
|
|
22
|
+
export function StatusTimeline({ label, segments, startLabel, endLabel, compact = false, disabled = false, activeId, defaultActiveId, onActiveChange, open: controlledOpen, defaultOpen = false, onOpenChange }: StatusTimelineProps) {
|
|
23
|
+
const [localId, setLocalId] = useState(defaultActiveId);
|
|
24
|
+
const [localOpen, setLocalOpen] = useState(defaultOpen), [container, setContainer] = useState<HTMLElement>();
|
|
25
|
+
const open = controlledOpen ?? localOpen;
|
|
26
|
+
const setOpen = (next: boolean) => { if (disabled && next) return; if (controlledOpen === undefined) setLocalOpen(next); if (next !== open) onOpenChange?.(next); };
|
|
27
|
+
const root = useRef<HTMLDivElement>(null), slider = useRef<HTMLInputElement>(null), popup = useRef<HTMLDivElement>(null), returning = useRef(false), focusDetail = useRef(false);
|
|
28
|
+
useLayoutEffect(() => { setContainer(root.current?.closest<HTMLElement>('.rhine-theme') ?? document.body); }, []);
|
|
11
29
|
const detailId = useId();
|
|
12
|
-
const
|
|
30
|
+
const matched = segments.findIndex(item => item.id === (activeId ?? localId));
|
|
31
|
+
const selected = matched < 0 ? segments.length - 1 : matched;
|
|
32
|
+
const select = (index: number) => { const next = segments[index]?.id; if (!next || disabled) return; if (activeId === undefined) setLocalId(next); if (next !== segments[selected]?.id) onActiveChange?.(next); };
|
|
13
33
|
const segment = segments[selected];
|
|
14
|
-
|
|
15
|
-
|
|
34
|
+
const summary = segment ? `${segment.label} · ${timelineLabels[segment.status]}` : '暂无记录';
|
|
35
|
+
const hasPopup = !disabled && !!segment && (compact || !!segment.detail);
|
|
36
|
+
return <div ref={root} className={`rhine-timeline${compact ? ' rhine-timeline--compact' : ''}`}>
|
|
37
|
+
<PopoverBase.Root open={open && hasPopup} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-timeline__track">
|
|
16
38
|
<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>
|
|
17
|
-
<input type="range" min={0} max={Math.max(0, segments.length - 1)} value={Math.max(0, selected)} disabled={!segments.length}
|
|
39
|
+
<input ref={slider} type="range" min={0} max={Math.max(0, segments.length - 1)} value={Math.max(0, selected)} disabled={disabled || !segments.length}
|
|
18
40
|
aria-label={label} aria-valuetext={segment ? `${segment.label} · ${timelineLabels[segment.status]}` : '暂无记录'} aria-describedby={detailId}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
41
|
+
aria-details={open && hasPopup ? `${detailId}-popup` : undefined} aria-controls={open && hasPopup ? `${detailId}-popup` : undefined} aria-haspopup={hasPopup ? 'dialog' : undefined}
|
|
42
|
+
onFocus={() => { if (!returning.current) setOpen(true); returning.current = false; }}
|
|
43
|
+
onKeyDown={event => {
|
|
44
|
+
if (event.key === 'Escape' && open && hasPopup) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
|
|
45
|
+
if ((event.key === 'Enter' || event.key === ' ') && hasPopup) { event.preventDefault(); if (open) popup.current?.querySelector<HTMLButtonElement>('button')?.focus(); else { focusDetail.current = true; setOpen(true); } }
|
|
46
|
+
}}
|
|
47
|
+
onChange={event => { select(Number(event.target.value)); setOpen(true); }}
|
|
48
|
+
onPointerDown={() => setOpen(true)}
|
|
49
|
+
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={popup} id={`${detailId}-popup`} className="rhine-timeline-popover" side="top" sideOffset={8} collisionPadding={12} aria-label={`${label}时段详情`}
|
|
51
|
+
onOpenAutoFocus={event => { event.preventDefault(); if (focusDetail.current) { focusDetail.current = false; popup.current?.querySelector<HTMLButtonElement>('button')?.focus(); } }} onCloseAutoFocus={event => { event.preventDefault(); focusDetail.current = false; if (document.activeElement === document.body || popup.current?.contains(document.activeElement)) { returning.current = true; slider.current?.focus(); } }}
|
|
52
|
+
onInteractOutside={event => { if (root.current?.contains(event.target as Node)) event.preventDefault(); }}>
|
|
53
|
+
<div className="rhine-dialog__head"><strong>{summary}</strong><PopoverBase.Close asChild><Button variant="quiet" aria-label="关闭时段详情">关闭</Button></PopoverBase.Close></div>
|
|
54
|
+
{segment?.detail && <><KeyValue items={[
|
|
55
|
+
{ key: 'start', label: '开始时间', value: segment.detail.start }, { key: 'end', label: '结束时间', value: segment.detail.end },
|
|
56
|
+
{ key: 'reason', label: '状态原因', value: segment.detail.reason || '未提供' },
|
|
57
|
+
...(segment.detail.counts ?? []).map(item => ({ key: `count-${item.id}`, label: item.label, value: item.value === null || !Number.isFinite(item.value) ? '未知' : `${item.value}${item.unit ? ` ${item.unit}` : ''}` })),
|
|
58
|
+
]} /><p className="rhine-text rhine-text--caption">统计口径:{segment.detail.methodology || '未提供,需向数据来源确认。'}</p></>}
|
|
59
|
+
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root>
|
|
22
60
|
<div className={compact ? 'rhine-sr-only' : 'rhine-timeline__range'}><Text variant="caption" tone="muted">{startLabel}</Text><Text variant="caption" tone="muted">{endLabel}</Text></div>
|
|
23
|
-
<Text id={detailId} className=
|
|
61
|
+
<Text id={detailId} className={compact && segment ? 'rhine-sr-only' : 'rhine-timeline__summary'} variant="caption">{summary}</Text>
|
|
24
62
|
</div>;
|
|
25
63
|
}
|
|
26
64
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Each container resets its paint so local overrides never leak into nested scenes.
|
|
2
|
+
Background paint does not create a containing block or intercept pointer events. */
|
|
3
|
+
.rhine-background {
|
|
4
|
+
--rhine-background-color: var(--rhine-content-bg);
|
|
5
|
+
--rhine-background-foreground: var(--rhine-content-text);
|
|
6
|
+
--rhine-background-image: var(--rhine-content-texture);
|
|
7
|
+
--rhine-background-size: var(--rhine-background-scale);
|
|
8
|
+
min-width: 0;
|
|
9
|
+
color: var(--rhine-background-foreground);
|
|
10
|
+
background-color: var(--rhine-background-color);
|
|
11
|
+
background-image: var(--rhine-background-image);
|
|
12
|
+
background-size: var(--rhine-background-size);
|
|
13
|
+
background-position: 0 0;
|
|
14
|
+
background-attachment: scroll;
|
|
15
|
+
}
|
|
16
|
+
.rhine-background[data-tone=canvas] {
|
|
17
|
+
--rhine-background-color: var(--rhine-canvas-bg);
|
|
18
|
+
--rhine-background-foreground: var(--rhine-canvas-text);
|
|
19
|
+
--rhine-background-image: var(--rhine-canvas-texture);
|
|
20
|
+
}
|
|
21
|
+
.rhine-background[data-tone=sidebar] {
|
|
22
|
+
--rhine-background-color: var(--rhine-sidebar-bg);
|
|
23
|
+
--rhine-background-foreground: var(--rhine-sidebar-text);
|
|
24
|
+
--rhine-background-image: var(--rhine-sidebar-texture);
|
|
25
|
+
}
|
|
26
|
+
.rhine-background[data-tone=surface] {
|
|
27
|
+
--rhine-background-color: var(--rhine-surface-bg);
|
|
28
|
+
--rhine-background-foreground: var(--rhine-surface-text);
|
|
29
|
+
--rhine-background-image: var(--rhine-surface-texture);
|
|
30
|
+
}
|
|
31
|
+
.rhine-background[data-tone=overlay] {
|
|
32
|
+
--rhine-background-color: var(--rhine-overlay-bg);
|
|
33
|
+
--rhine-background-foreground: var(--rhine-overlay-text);
|
|
34
|
+
--rhine-background-image: var(--rhine-overlay-texture);
|
|
35
|
+
}
|
|
36
|
+
.rhine-background[data-tone=media] {
|
|
37
|
+
--rhine-background-color: var(--rhine-media-bg);
|
|
38
|
+
--rhine-background-foreground: var(--rhine-media-text);
|
|
39
|
+
--rhine-background-image: var(--rhine-media-texture);
|
|
40
|
+
color-scheme: dark;
|
|
41
|
+
}
|
|
42
|
+
.rhine-background[data-decoration=theme] { --rhine-background-image: var(--rhine-background-texture); }
|
|
43
|
+
.rhine-background[data-decoration=none] { background-image: none; }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.rhine-chart { min-width:0; margin:0; display:grid; gap:16px; }
|
|
2
|
+
.rhine-chart figcaption { display:grid; gap:4px; overflow-wrap:anywhere; }
|
|
3
|
+
.rhine-chart figcaption strong { font-size:16px; line-height:24px; }
|
|
4
|
+
.rhine-chart figcaption>span,.rhine-chart figcaption p { font-size:12px; line-height:20px; color:var(--rhine-muted); margin:0; }
|
|
5
|
+
.rhine-chart__plot { width:100%; height:180px; overflow:visible; }
|
|
6
|
+
.rhine-chart__scale,.rhine-chart__range { display:flex; justify-content:space-between; gap:16px; font-size:12px; line-height:20px; color:var(--rhine-muted); overflow-wrap:anywhere; }
|
|
7
|
+
.rhine-chart__range>* { min-width:0; }
|
|
8
|
+
.rhine-chart__range>:last-child { text-align:right; }
|
|
9
|
+
.rhine-chart__grid { fill:none; stroke:var(--rhine-chart-grid); stroke-width:1; }
|
|
10
|
+
.rhine-chart__highlight { fill:var(--rhine-selected); stroke:var(--rhine-green); stroke-width:1; vector-effect:non-scaling-stroke; }
|
|
11
|
+
.rhine-chart__inspection { margin:0; min-height:44px; font-size:14px; line-height:22px; overflow-wrap:anywhere; }
|
|
12
|
+
.rhine-chart__line { fill:none; stroke:var(--rhine-chart-series-1); stroke-width:2; }
|
|
13
|
+
.rhine-chart__bar,.rhine-chart__point { fill:var(--rhine-chart-series-1); }
|
|
14
|
+
.rhine-chart__missing { fill:none; stroke:var(--rhine-chart-missing); stroke-width:2; }
|
|
15
|
+
.rhine-chart__legend { margin:0; font-size:12px; line-height:20px; }
|
|
16
|
+
.rhine-chart__legend>span { display:inline-block; width:12px; height:12px; background:var(--rhine-chart-series-1); margin-right:8px; }
|
|
17
|
+
.rhine-chart__data { min-width:0; }
|
|
18
|
+
.rhine-chart__data summary { min-height:44px; padding:12px 4px; cursor:pointer; }
|
|
19
|
+
.rhine-chart__stack { height:32px; display:flex; gap:2px; border:1px solid var(--rhine-line); }
|
|
20
|
+
.rhine-chart__stack>span { flex-basis:0; min-width:0; }
|
|
21
|
+
.rhine-chart [data-tone="1"] { background:var(--rhine-chart-series-1); }
|
|
22
|
+
.rhine-chart [data-tone="2"] { background:var(--rhine-chart-series-2); }
|
|
23
|
+
.rhine-chart [data-tone="3"] { background:var(--rhine-chart-series-3); }
|
|
24
|
+
.rhine-chart [data-tone="4"] { background:var(--rhine-chart-series-4); }
|
|
25
|
+
.rhine-chart__parts { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px 16px; font-size:14px; line-height:22px; }
|
|
26
|
+
.rhine-chart__parts li { min-width:0; overflow-wrap:anywhere; }
|
|
27
|
+
.rhine-chart__parts li>span { display:inline-block; width:12px; height:12px; margin-right:8px; border:1px solid var(--rhine-control); }
|