@linhey/rhine-design 0.4.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 +13 -0
- package/README.md +11 -1
- 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/Media.tsx +82 -0
- package/components/Overlays.tsx +4 -3
- package/components/README.md +8 -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 +12 -2
- package/components/media-parity.css +3 -0
- package/components/patterns.css +1 -1
- package/components/styles.css +4 -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/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/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 +3 -2
- package/preview/MonitoringGallery.tsx +27 -0
- package/preview/backgrounds.css +10 -0
- package/preview/extensions.css +6 -0
- package/preview/main.tsx +10 -1
- package/skills/rhine-design/SKILL.md +24 -3
- package/skills/rhine-design/references/API_MAP.md +4 -0
- package/skills/rhine-design/references/TASK_MAP.md +1 -0
- 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/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
|
}
|
|
@@ -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
|
+
}
|
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.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,7 @@ 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)。
|
|
@@ -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); }
|
package/components/common.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
.rhine-table caption { text-align:left; font-size:var(--rhine-text-heading); line-height:var(--rhine-leading-heading); font-weight:var(--rhine-weight-heading); margin-bottom:var(--rhine-space-4); }
|
|
22
22
|
.rhine-table th,.rhine-table td { padding:12px; border-bottom:1px solid var(--rhine-line); max-width:240px; overflow-wrap:anywhere; }
|
|
23
23
|
.rhine-table th { color:var(--rhine-muted); font-weight:500; }.rhine-table td { font-variant-numeric:tabular-nums; }
|
|
24
|
-
.rhine-table [data-align=right] { text-align:right; }.rhine-table tbody tr:hover { background:var(--rhine-
|
|
24
|
+
.rhine-table [data-align=right] { text-align:right; }.rhine-table tbody tr:hover { background:var(--rhine-table-hover-bg); }
|
|
25
25
|
.rhine-table th button { font:inherit; color:inherit; background:none; border:0; min-height:44px; cursor:pointer; padding:0; }
|
|
26
26
|
.rhine-table th button:disabled { cursor:default; }
|
|
27
27
|
.rhine-pagination { display:flex; flex-wrap:wrap; justify-content:flex-end; align-items:center; gap:12px; }
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
.rhine-combobox { position:relative; }
|
|
44
44
|
.rhine-combobox__input { position:relative; }.rhine-combobox__input input { padding-right:40px; }
|
|
45
45
|
.rhine-combobox__arrow { position:absolute; right:12px; top:50%; transform:translateY(-50%); pointer-events:none; }
|
|
46
|
-
.rhine-combobox__list { z-index:50; width:var(--radix-popover-trigger-width); max-width:calc(100vw - 24px); max-height:min(240px,var(--radix-popover-content-available-height)); overflow:auto; background:var(--rhine-
|
|
46
|
+
.rhine-combobox__list { z-index:50; width:var(--radix-popover-trigger-width); max-width:calc(100vw - 24px); max-height:min(240px,var(--radix-popover-content-available-height)); overflow:auto; background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); border:1px solid var(--rhine-control); border-radius:4px; box-shadow:0 8px 24px #242b2818; }
|
|
47
47
|
.rhine-combobox__list [role=option],.rhine-combobox__empty { padding:12px; min-height:44px; cursor:pointer; overflow-wrap:anywhere; }
|
|
48
48
|
.rhine-combobox__list [role=option][data-active=true],.rhine-combobox__list [role=option][aria-selected=true] { background:var(--rhine-selected); }
|
|
49
49
|
.rhine-combobox__list [role=option][aria-disabled=true],.rhine-combobox__empty { color:var(--rhine-muted); cursor:default; }
|
|
50
50
|
.rhine-overlay-anchor { display:inline-block; vertical-align:middle; max-width:100%; }
|
|
51
|
-
.rhine-overlay { position:fixed; inset:0; background
|
|
52
|
-
.rhine-dialog { position:fixed; z-index:41; top:50%; left:50%; transform:translate(-50%,-50%); width:min(480px,calc(100vw - 32px)); max-height:calc(100dvh - 32px); overflow:auto; padding:24px; border:1px solid var(--rhine-line); border-radius:4px; background:var(--rhine-
|
|
51
|
+
.rhine-overlay { position:fixed; inset:0; background:var(--rhine-backdrop-bg); z-index:40; }
|
|
52
|
+
.rhine-dialog { position:fixed; z-index:41; top:50%; left:50%; transform:translate(-50%,-50%); width:min(480px,calc(100vw - 32px)); max-height:calc(100dvh - 32px); overflow:auto; padding:24px; border:1px solid var(--rhine-line); border-radius:4px; background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); box-shadow:0 16px 48px #242b2826; }
|
|
53
53
|
.rhine-dialog>p,.rhine-dialog__main>p { margin-top:12px; }.rhine-dialog>.rhine-notice { margin-top:16px; }
|
|
54
54
|
.rhine-dialog__main { min-width:0; }
|
|
55
55
|
.rhine-dialog--split { width:min(960px,calc(100vw - 32px)); display:grid; grid-template-columns:minmax(240px,0.8fr) minmax(0,1.2fr); gap:24px; }
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
.rhine-dialog__head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
|
64
64
|
.rhine-dialog__head>:first-child { min-width:0; overflow-wrap:anywhere; }
|
|
65
65
|
.rhine-dialog__body { margin-top:24px; }.rhine-dialog__actions { display:flex; justify-content:flex-end; flex-wrap:wrap; gap:12px; margin-top:24px; }
|
|
66
|
-
.rhine-popover,.rhine-menu { z-index:50; background:var(--rhine-
|
|
66
|
+
.rhine-popover,.rhine-menu { z-index:50; background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); border:1px solid var(--rhine-control); border-radius:4px; box-shadow:0 8px 24px #242b2820; max-width:calc(100vw - 24px); overflow:auto; }
|
|
67
67
|
.rhine-popover { width:300px; padding:16px; max-height:var(--radix-popover-content-available-height); }
|
|
68
68
|
.rhine-menu { min-width:180px; max-height:var(--radix-dropdown-menu-content-available-height); padding:4px; }
|
|
69
69
|
.rhine-menu__item { min-height:44px; padding:10px 12px; cursor:pointer; outline:none; overflow-wrap:anywhere; }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
.rhine-data .rhine-table tbody th[scope=row] { position:static; top:auto; z-index:auto; color:var(--rhine-ink); background:var(--rhine-surface); font-weight:var(--rhine-weight-label); }
|
|
2
|
+
.rhine-data .rhine-table tbody th[scope=row][data-sticky] { position:sticky; z-index:1; }
|
|
3
|
+
.rhine-data .rhine-table tr[data-selected=true] th[scope=row] { background:var(--rhine-table-selected-bg); }
|
|
4
|
+
.rhine-table__radio { display:flex; align-items:center; gap:8px; min-height:44px; cursor:pointer; }
|
|
5
|
+
.rhine-table__radio input { flex:none; width:18px; height:18px; accent-color:var(--rhine-green); }
|
|
6
|
+
.rhine-table__radio:has(input:disabled) { color:var(--rhine-muted); cursor:default; }
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
.rhine-dialog.rhine-drawer[data-side=left] { left:0; right:auto; animation-name:rhine-drawer-enter-left; }
|
|
2
|
+
.rhine-dialog.rhine-drawer[data-side=right] { left:auto; right:0; }
|
|
3
|
+
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-drawer[data-side=left] { border-radius:0 24px 24px 0; box-shadow:5px 0 0 var(--rhine-ink); }
|
|
4
|
+
@keyframes rhine-drawer-enter-left { from { opacity:0; transform:translateX(-3px); } to { opacity:1; transform:translateX(0); } }
|