@linhey/rhine-design 0.9.1 → 0.10.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 +8 -0
- package/README.md +2 -2
- package/components/AdvancedForms.tsx +22 -8
- package/components/COMMON_COMPONENTS.md +1 -0
- package/components/Forms.tsx +14 -1
- package/components/ListFilterBar.tsx +5 -3
- package/components/Overlays.tsx +3 -2
- package/components/README.md +1 -1
- package/components/TYPOGRAPHY.md +2 -0
- package/components/checkbox-row.css +12 -0
- package/components/common.css +1 -1
- package/components/compact-forms.css +20 -0
- package/components/components.tsx +2 -2
- package/components/index.ts +1 -0
- package/components/media-parity.css +4 -4
- package/components/patterns.css +3 -3
- package/components/styles.css +5 -2
- package/components/theme-picker.css +2 -2
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/CHECKBOX_ROWS.md +36 -0
- package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
- package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
- package/docs/LIST_FILTER_BAR.md +5 -1
- package/package.json +1 -1
- package/preview/CheckboxRowGallery.tsx +26 -0
- package/preview/CompactFilterBarGallery.tsx +19 -0
- package/preview/ComponentTypographyGallery.tsx +23 -0
- package/preview/DenseFilterControlsGallery.tsx +21 -0
- package/preview/GalleryShell.tsx +1 -1
- package/preview/main.tsx +7 -0
- package/scripts/check-checkbox-rows.js +65 -0
- package/scripts/check-component-typography.js +76 -0
- package/scripts/check-dense-filters.js +60 -0
- package/skills/rhine-design/SKILL.md +2 -2
- package/skills/rhine-design/references/API_MAP.md +2 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/CheckboxRow.test.tsx +53 -0
- package/tests/CompactFilterBar.test.tsx +50 -0
- package/tests/DenseFilterControls.test.tsx +79 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.10.0(2026-10-26)
|
|
4
|
+
|
|
5
|
+
- #31:MediaViewer 说明补齐 caption 12/18/400,EmptyState 标题采用 heading 16/24 与主题标题字重;同类复制反馈、步骤/表单组说明、错误摘要和主题预览状态补齐语义配对,保留既有密度例外。真实回归入口 `#component-typography`。
|
|
6
|
+
- #29:Checkbox 新增可选 `layout="row"` 与公开 `CheckboxProps`,提供窄侧栏双行文字对齐、对称内距、整行原生点击和主题选择/焦点,保留默认表单布局;展台见 `#checkbox-rows`。
|
|
7
|
+
- #27:SegmentedControl 新增等宽 `fullWidth`;ListFilterBar 支持用 `drawerDescription` / `drawerResult` 省略重复说明与摘要,保留加载和错误反馈。
|
|
8
|
+
- DateRangePicker 新增 `compact` 一体日期边框,窄容器内部重排;RemoteMultiSelect 新增 `maxVisibleTags`、总数与有界展开,保留完整表单值和键盘移除,极窄容器标签换行,清空后重新选择恢复折叠。
|
|
9
|
+
- 新增 `#compact-filters` 真实组件展台与回归。上述能力自 0.10.0 提供,默认行为保持兼容,无需迁移;启用新选项需升级。修复分别见 [PR #28](https://github.com/linhay/rhine-design/pull/28)、[PR #30](https://github.com/linhay/rhine-design/pull/30) 和 [PR #32](https://github.com/linhay/rhine-design/pull/32)。
|
|
10
|
+
|
|
3
11
|
## 0.9.1(2026-10-25)
|
|
4
12
|
|
|
5
13
|
- #23:移除 StatusTimelineList 末项重复留白,保留 44px 命中区;紧凑账号展台的基础信息与今日/本周期读数按稳定列对齐,窄容器自然换行。
|
package/README.md
CHANGED
|
@@ -46,12 +46,12 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
46
46
|
在你的 React 项目中安装:
|
|
47
47
|
|
|
48
48
|
```sh
|
|
49
|
-
npm install --save-exact @linhey/rhine-design@0.
|
|
49
|
+
npm install --save-exact @linhey/rhine-design@0.10.0
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
|
|
53
53
|
|
|
54
|
-
**0.
|
|
54
|
+
**0.10.0 新增与修复**:紧凑日期、有限多选标签与等宽分段控件;可省略重复说明的筛选抽屉;侧栏双行 `Checkbox layout="row"`;MediaViewer、EmptyState 等组件的字号/行高/字重配对。默认行为保持兼容,完整说明见[版本记录](CHANGELOG.md#01002026-10-26)。
|
|
55
55
|
|
|
56
56
|
```tsx
|
|
57
57
|
import { Button, Inline, Stack, Surface, Text } from '@linhey/rhine-design';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type SyntheticEvent } from 'react';
|
|
2
2
|
import { Button, TextField, type Choice } from './components';
|
|
3
3
|
import { Combobox } from './Forms';
|
|
4
|
+
import './compact-forms.css';
|
|
4
5
|
|
|
5
6
|
export interface DateFieldProps {
|
|
6
7
|
label: string; value: string; onValueChange: (value: string) => void;
|
|
@@ -55,10 +56,11 @@ export interface DateRangePickerProps {
|
|
|
55
56
|
min?: string; max?: string; disabled?: boolean; error?: string;
|
|
56
57
|
presets?: { label: string; value: DateRange }[];
|
|
57
58
|
readOnly?: boolean; required?: boolean; clearable?: boolean; hint?: string;
|
|
59
|
+
compact?: boolean;
|
|
58
60
|
startLabel?: string; endLabel?: string; startName?: string; endName?: string;
|
|
59
61
|
onValidationChange?: (error: string | null) => void;
|
|
60
62
|
}
|
|
61
|
-
export function DateRangePicker({ label, value, onValueChange, min, max, disabled, error, presets = [], readOnly = false, required, clearable = true, hint, startLabel = '开始日期', endLabel = '结束日期', startName, endName, onValidationChange }: DateRangePickerProps) {
|
|
63
|
+
export function DateRangePicker({ label, value, onValueChange, min, max, disabled, error, presets = [], readOnly = false, required, clearable = true, compact = false, hint, startLabel = '开始日期', endLabel = '结束日期', startName, endName, onValidationChange }: DateRangePickerProps) {
|
|
62
64
|
const id = useId();
|
|
63
65
|
const [fieldErrors, setFieldErrors] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
64
66
|
const [resetRevision, setResetRevision] = useState(0);
|
|
@@ -71,14 +73,15 @@ export function DateRangePicker({ label, value, onValueChange, min, max, disable
|
|
|
71
73
|
setResetRevision(revision => revision + 1);
|
|
72
74
|
onValueChange(next);
|
|
73
75
|
};
|
|
74
|
-
return <div className="rhine-date-range-container"><fieldset className=
|
|
76
|
+
return <div className="rhine-date-range-container"><fieldset className={`rhine-date-range${compact ? ' rhine-date-range--compact' : ''}`} disabled={disabled} aria-describedby={[hint && `${id}-hint`, invalid && id].filter(Boolean).join(' ') || undefined}>
|
|
75
77
|
<legend>{label}</legend><div className="rhine-date-range__fields">
|
|
76
78
|
<DateField key={`start-${resetRevision}`} label={startLabel} name={startName} value={value.start} min={min} max={max} readOnly={readOnly} required={required} disabled={disabled} onValueChange={start => onValueChange({ ...value, start })} onValidationChange={message => fieldValidation('start', message)} />
|
|
79
|
+
{compact && <span className="rhine-date-range__separator" aria-hidden="true">至</span>}
|
|
77
80
|
<DateField key={`end-${resetRevision}`} label={endLabel} name={endName} value={value.end} min={value.start && (!min || value.start > min) ? value.start : min} max={max} readOnly={readOnly} required={required} disabled={disabled} onValueChange={end => onValueChange({ ...value, end })} onValidationChange={message => fieldValidation('end', message)} />
|
|
78
|
-
</div
|
|
81
|
+
</div>{(presets.length > 0 || clearable) && <div className="rhine-inline">
|
|
79
82
|
{presets.map(preset => <Button key={preset.label} variant="secondary" disabled={disabled || readOnly || !!rangeError(preset.value) || !!(required && (!preset.value.start || !preset.value.end))} onClick={() => replace(preset.value)}>{preset.label}</Button>)}
|
|
80
83
|
{clearable && <Button variant="quiet" disabled={disabled || readOnly || (!value.start && !value.end && !fieldErrors.start && !fieldErrors.end)} onClick={() => replace({ start: '', end: '' })}>清空日期</Button>}
|
|
81
|
-
</div>{hint && <p id={`${id}-hint`} className="rhine-field__hint">{hint}</p>}{invalid && <p id={id} role="alert" className="rhine-field__error">{invalid}</p>}
|
|
84
|
+
</div>}{hint && <p id={`${id}-hint`} className="rhine-field__hint">{hint}</p>}{invalid && <p id={id} role="alert" className="rhine-field__error">{invalid}</p>}
|
|
82
85
|
</fieldset></div>;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -125,19 +128,30 @@ export interface RemoteMultiSelectProps {
|
|
|
125
128
|
loading?: boolean; error?: string; disabled?: boolean;
|
|
126
129
|
readOnly?: boolean; clearable?: boolean; closeOnSelect?: boolean; clearQueryOnSelect?: boolean;
|
|
127
130
|
placeholder?: string; hint?: string; name?: string;
|
|
131
|
+
maxVisibleTags?: number;
|
|
128
132
|
}
|
|
129
|
-
export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, ...props }: RemoteMultiSelectProps) {
|
|
133
|
+
export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, maxVisibleTags, ...props }: RemoteMultiSelectProps) {
|
|
130
134
|
const inputId = useId();
|
|
135
|
+
const [expanded, setExpanded] = useState(false);
|
|
136
|
+
const limit = maxVisibleTags !== undefined && Number.isFinite(maxVisibleTags) ? Math.max(0, Math.floor(maxVisibleTags)) : undefined;
|
|
137
|
+
const collapsible = limit !== undefined && value.length > limit;
|
|
138
|
+
// Clearing or replacing the selection below the limit starts a new compact view.
|
|
139
|
+
if (expanded && !collapsible) setExpanded(false);
|
|
140
|
+
const shown = collapsible && !expanded ? value.slice(0, limit) : value;
|
|
131
141
|
const focusInput = () => document.getElementById(inputId)?.focus();
|
|
132
142
|
const remove = (key: string) => {
|
|
133
143
|
if (props.disabled || readOnly || value.find(item => item.value === key)?.disabled) return;
|
|
134
144
|
onValueChange(value.filter(item => item.value !== key)); focusInput();
|
|
135
145
|
};
|
|
136
|
-
return <div className=
|
|
146
|
+
return <div className={`rhine-multi-select${limit !== undefined ? ' rhine-multi-select--compact' : ''}`}>
|
|
137
147
|
<Combobox {...props} id={inputId} readOnly={readOnly} selectedValues={value.map(item => item.value)} closeOnSelect={closeOnSelect} onEmptyBackspace={() => { const last = [...value].reverse().find(item => !item.disabled); if (last) remove(last.value); }} options={options.map(option => ({ ...option, disabled: option.disabled || value.some(item => item.value === option.value) }))} value={null} onValueChange={option => { const original = options.find(item => item.value === option.value); if (original && !value.some(item => item.value === original.value)) { onValueChange([...value, original]); if (clearQueryOnSelect) props.onQueryChange(''); } }} />
|
|
138
148
|
{name && value.map(item => <input key={item.value} type="hidden" name={name} value={item.value} disabled={props.disabled} />)}
|
|
139
|
-
<ul className="rhine-multi-select__tags" aria-label={`${props.label}已选项`}>{
|
|
140
|
-
|
|
149
|
+
<ul id={`${inputId}-tags`} className="rhine-multi-select__tags" aria-label={`${props.label}已选项`} data-expanded={collapsible && expanded || undefined}>{shown.map(item => <li key={item.value}><span>{item.label}</span><Button variant="quiet" disabled={props.disabled || readOnly || item.disabled} aria-label={`移除${item.label}`} onClick={() => remove(item.value)}>×</Button></li>)}</ul>
|
|
150
|
+
<div className="rhine-multi-select__actions">
|
|
151
|
+
{limit !== undefined && <span className="rhine-text rhine-text--caption rhine-text--muted">已选 {value.length} 项</span>}
|
|
152
|
+
{collapsible && <Button variant="quiet" aria-expanded={expanded} aria-controls={`${inputId}-tags`} onClick={() => setExpanded(current => !current)}>{expanded ? '收起已选项' : '展开已选项'}</Button>}
|
|
153
|
+
{clearable && <Button variant="quiet" disabled={props.disabled || readOnly || !value.some(item => !item.disabled)} onClick={() => { onValueChange(value.filter(item => item.disabled)); focusInput(); }}>清空选择</Button>}
|
|
154
|
+
</div>
|
|
141
155
|
</div>;
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -18,6 +18,7 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
18
18
|
| Icon / IconButton | 少量常用16/20px线性图标、44px操作目标、可访问名称 |
|
|
19
19
|
| CopyButton | 显式点击后复制;等待真实Promise成功才提示,失败可重试;可注入原生clipboard适配器 |
|
|
20
20
|
| Checkbox | 原生复选框、混合态、label/hint;选中由调用方控制 |
|
|
21
|
+
| Checkbox `layout="row"`(自 0.10.0) | 侧栏整行选择,名称/说明同列、对称内距与主题选中/焦点;默认 form 不变,见[双行多选契约](../docs/CHECKBOX_ROWS.md) |
|
|
21
22
|
| RadioGroup | fieldset/legend与原生radio,独立组名、禁用项、键盘导航 |
|
|
22
23
|
| Switch | 原生checkbox加switch语义;只用于二元偏好,不替代账号选择 |
|
|
23
24
|
| Textarea | 可见label、hint/error、只读/禁用;允许垂直调整,不做富文本 |
|
package/components/Forms.tsx
CHANGED
|
@@ -2,11 +2,24 @@ import { useEffect, useId, useLayoutEffect, useRef, useState, type InputHTMLAttr
|
|
|
2
2
|
import * as PopoverBase from '@radix-ui/react-popover';
|
|
3
3
|
import { Button, type Choice } from './components';
|
|
4
4
|
import { Icon, IconButton } from './Actions';
|
|
5
|
+
import './checkbox-row.css';
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
+
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
8
|
+
label: string;
|
|
9
|
+
hint?: string;
|
|
10
|
+
indeterminate?: boolean;
|
|
11
|
+
layout?: 'form' | 'row';
|
|
12
|
+
}
|
|
13
|
+
export function Checkbox({ label, hint, indeterminate = false, layout = 'form', id: suppliedId, className = '', ...props }: CheckboxProps) {
|
|
7
14
|
const generatedId = useId(), id = suppliedId || generatedId;
|
|
8
15
|
const ref = useRef<HTMLInputElement>(null);
|
|
9
16
|
useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);
|
|
17
|
+
if (layout === 'row') return <div className={`rhine-check rhine-check--row ${className}`}><label htmlFor={id}>
|
|
18
|
+
<input {...props} ref={ref} id={id} type="checkbox" aria-checked={indeterminate ? 'mixed' : undefined}
|
|
19
|
+
aria-labelledby={props['aria-labelledby'] || (!props['aria-label'] ? `${id}-label` : undefined)}
|
|
20
|
+
aria-describedby={[props['aria-describedby'], hint && `${id}-hint`].filter(Boolean).join(' ') || undefined} />
|
|
21
|
+
<span className="rhine-check__text"><span id={`${id}-label`} className="rhine-check__name">{label}</span>{hint && <span id={`${id}-hint`} className="rhine-check__hint">{hint}</span>}</span>
|
|
22
|
+
</label></div>;
|
|
10
23
|
return <div className={`rhine-check ${className}`}><label htmlFor={id}><input {...props} ref={ref} id={id} type="checkbox" aria-checked={indeterminate ? 'mixed' : undefined} aria-describedby={[props['aria-describedby'], hint && `${id}-hint`].filter(Boolean).join(' ') || undefined} /><span>{label}</span></label>{hint && <p id={`${id}-hint`} className="rhine-text rhine-text--caption rhine-text--muted">{hint}</p>}</div>;
|
|
11
24
|
}
|
|
12
25
|
export function RadioGroup({ label, items, value, onValueChange, disabled = false, name }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void; disabled?: boolean; name?: string }) {
|
|
@@ -20,6 +20,8 @@ export interface ListFilterBarProps {
|
|
|
20
20
|
refresh?: { onClick: () => void; busy?: boolean; disabled?: boolean };
|
|
21
21
|
action?: ReactElement;
|
|
22
22
|
result?: ReactNode;
|
|
23
|
+
drawerDescription?: ReactNode;
|
|
24
|
+
drawerResult?: ReactNode;
|
|
23
25
|
pending?: boolean;
|
|
24
26
|
error?: string;
|
|
25
27
|
className?: string;
|
|
@@ -37,7 +39,7 @@ function Feedback({ result, pending, error, announce = true }: Pick<ListFilterBa
|
|
|
37
39
|
|
|
38
40
|
const focusable = 'input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), a[href]';
|
|
39
41
|
|
|
40
|
-
export function ListFilterBar({ label, search, primary = [], filters = [], onReset, refresh, action, result, pending = false, error, className = '' }: ListFilterBarProps) {
|
|
42
|
+
export function ListFilterBar({ label, search, primary = [], filters = [], onReset, refresh, action, result, drawerDescription = '更改后立即更新结果。', drawerResult, pending = false, error, className = '' }: ListFilterBarProps) {
|
|
41
43
|
const [open, setOpen] = useState(false);
|
|
42
44
|
const root = useRef<HTMLDivElement>(null);
|
|
43
45
|
const drawerBody = useRef<HTMLDivElement>(null);
|
|
@@ -77,13 +79,13 @@ export function ListFilterBar({ label, search, primary = [], filters = [], onRes
|
|
|
77
79
|
{(drawerFields.length > 0 || refresh || action) && <div className="rhine-list-filter-bar__actions">
|
|
78
80
|
{drawerFields.length > 0 && <Drawer
|
|
79
81
|
title={label}
|
|
80
|
-
description=
|
|
82
|
+
description={drawerDescription}
|
|
81
83
|
open={open}
|
|
82
84
|
onOpenChange={setOpen}
|
|
83
85
|
trigger={<Button variant="secondary" aria-label={activeDrawerCount > 0 ? `更多筛选,已选 ${activeDrawerCount} 项` : undefined}>更多筛选{activeDrawerCount > 0 && <span className="rhine-list-filter-bar__count" aria-hidden="true">{activeDrawerCount}</span>}</Button>}
|
|
84
86
|
>
|
|
85
87
|
<div ref={drawerBody} className="rhine-list-filter-bar__drawer">
|
|
86
|
-
<Feedback result={result} pending={pending} error={error} />
|
|
88
|
+
<Feedback result={drawerResult === undefined ? result : drawerResult} pending={pending} error={error} />
|
|
87
89
|
{drawerFields.map(field => <div key={field.id} data-filter-id={field.id} className="rhine-list-filter-bar__field" role="group" aria-label={field.label}>{field.control}</div>)}
|
|
88
90
|
{canReset && <div className="rhine-list-filter-bar__drawer-actions"><Button variant="quiet" onClick={reset}>重置筛选</Button></div>}
|
|
89
91
|
</div>
|
package/components/Overlays.tsx
CHANGED
|
@@ -19,7 +19,7 @@ function useScope() {
|
|
|
19
19
|
useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
20
20
|
return { ref, container };
|
|
21
21
|
}
|
|
22
|
-
export interface DialogProps { trigger: ReactElement; title: string; description:
|
|
22
|
+
export interface DialogProps { trigger: ReactElement; title: string; description: ReactNode; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
|
|
23
23
|
export function Dialog(props: DialogProps) {
|
|
24
24
|
return <DialogFrame {...props}/>;
|
|
25
25
|
}
|
|
@@ -44,11 +44,12 @@ function DialogFrame({ trigger, title, description, children, open, onOpenChange
|
|
|
44
44
|
dialogDismiss.get(owner)?.();
|
|
45
45
|
};
|
|
46
46
|
const sideNavigation = typeof sidePanel === 'function';
|
|
47
|
+
const hasDescription = description != null && description !== false && description !== '';
|
|
47
48
|
const settings = navigation != null && navigation !== false;
|
|
48
49
|
if (settings && sidePanel) throw new Error('Dialog navigation and sidePanel are mutually exclusive.');
|
|
49
50
|
const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
|
|
50
51
|
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open ?? localOpen} onOpenChange={changeOpen}><DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
|
|
51
|
-
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${settings ? ' rhine-dialog--settings' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div
|
|
52
|
+
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} {...(!hasDescription ? { 'aria-describedby': undefined } : {})} data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${settings ? ' rhine-dialog--settings' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div>{hasDescription && <DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description>}<div className="rhine-dialog__body">{settings ? <div className="rhine-dialog__settings-grid"><div className="rhine-dialog__navigation">{navigation}</div><div className="rhine-dialog__settings-content">{children}</div></div> : children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</DialogBase.Content>
|
|
52
53
|
</DialogBase.Portal></DialogBase.Root></span>;
|
|
53
54
|
}
|
|
54
55
|
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
|
@@ -65,7 +65,7 @@ export function Example() {
|
|
|
65
65
|
| SelectionCard | label;selected;onSelectedChange;disabled;showIndicator=true;children | 原生 checkbox 可取消;是否单选由上层控制;showIndicator=false 仅视觉隐藏勾选框,保留键盘与读屏,卡片边界表达选中;内部不嵌套按钮/链接/其他输入 |
|
|
66
66
|
| TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
|
|
67
67
|
| Tabs | label;items[{value,label,content,disabled?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树 |
|
|
68
|
-
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange | 原生 radio,独立 name
|
|
68
|
+
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange;fullWidth? | 原生 radio,独立 name 防多组干扰;方向键和禁用行为由浏览器提供;fullWidth 为整行等宽选项,标签和选中底色撑满且文字居中 |
|
|
69
69
|
| Notice | tone;title;children;action | error 使用 alert,其余 status;调用方决定显示/移除,不自动倒计时关闭错误 |
|
|
70
70
|
| Progress | label;value:number/null;valueText? | null/NaN 为未知进度;数值钳制 0–100;组件不执行或估算真实进度 |
|
|
71
71
|
| Skeleton | label;lines | 1–8 行,默认 3 行;单一可访问加载描述,装饰行隐藏 |
|
package/components/TYPOGRAPHY.md
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
|
|
39
39
|
字号与行高成对使用,`Text`按variant同时设置两者;不在业务页单改字号而继承父级行高,不使用`normal`或固定容器高度猜测文本尺寸。上表是100%缩放的CSS尺寸,缩放后保持比例与自然换行,不以压低行高塞回原高度。
|
|
40
40
|
|
|
41
|
+
公共组件从正文切换到辅助说明或标题时,同样显式配对字号、行高和字重。MediaViewer 说明为 caption 12/18/400,EmptyState 标题为 heading 16/24 与主题标题字重(默认 600,橙趣 750);完整检查范围、保留例外与真实浏览器回归见[组件文字复核](../docs/COMPONENT_TYPOGRAPHY_AUDIT.md)。此修复自 npm 0.10.0 提供。
|
|
42
|
+
|
|
41
43
|
| 内容 | 字号 / 行高 | Token | 多行规则 |
|
|
42
44
|
| --- | --- | --- | --- |
|
|
43
45
|
| 大标题 | 30 / 38px | `--rhine-leading-title` | 换行仍为38px,不压成单行 |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.rhine-check--row { min-width:0; }
|
|
2
|
+
.rhine-check.rhine-check--row > label { display:grid; grid-template-columns:18px minmax(0,1fr); align-items:start; align-content:center; gap:var(--rhine-space-2); min-height:44px; padding:var(--rhine-space-2); border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); background:var(--rhine-surface); color:var(--rhine-ink); }
|
|
3
|
+
.rhine-check--row input { margin-top:2px; }
|
|
4
|
+
.rhine-check__text { display:grid; gap:var(--rhine-space-1); min-width:0; overflow-wrap:anywhere; }
|
|
5
|
+
.rhine-check__name { font-size:var(--rhine-text-body); line-height:var(--rhine-leading-body); font-weight:var(--rhine-weight-label); }
|
|
6
|
+
.rhine-check__hint { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:400; color:var(--rhine-muted); }
|
|
7
|
+
.rhine-check--row > label:has(input:checked),.rhine-check--row > label:has(input:indeterminate) { background:var(--rhine-selected); border-color:var(--rhine-green); }
|
|
8
|
+
.rhine-check--row > label:hover:has(input:not(:disabled)) { border-color:var(--rhine-green); }
|
|
9
|
+
.rhine-check--row > label:has(input:focus-visible) { box-shadow:inset 0 0 0 2px var(--rhine-green); }
|
|
10
|
+
.rhine-theme .rhine-check.rhine-check--row input:focus-visible { outline:none; }
|
|
11
|
+
.rhine-check--row > label:has(input:disabled) { cursor:default; }
|
|
12
|
+
.rhine-check--row input:disabled + .rhine-check__text { color:var(--rhine-muted); }
|
package/components/common.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
.rhine-link:hover { text-decoration-thickness:2px; }
|
|
5
5
|
.rhine-link>.rhine-icon { margin-left:4px; }
|
|
6
6
|
.rhine-copy { display:inline-flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
|
7
|
-
.rhine-copy>[role] { font-size:var(--rhine-text-caption); }
|
|
7
|
+
.rhine-copy>[role] { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); }
|
|
8
8
|
.rhine-stack { display:flex; flex-direction:column; min-width:0; }
|
|
9
9
|
.rhine-inline { display:flex; align-items:center; flex-wrap:wrap; gap:var(--rhine-space-3); min-width:0; }
|
|
10
10
|
.rhine-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,var(--rhine-grid-min)),1fr)); min-width:0; }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.rhine-date-range.rhine-date-range--compact { padding:0; border:0; background:transparent; }
|
|
2
|
+
.rhine-date-range--compact > legend,
|
|
3
|
+
.rhine-date-range--compact .rhine-date-field > label { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
|
|
4
|
+
.rhine-date-range--compact .rhine-date-range__fields { grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:start; gap:var(--rhine-space-1); margin-bottom:0; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); background:var(--rhine-surface); }
|
|
5
|
+
.rhine-date-range--compact .rhine-date-field { min-width:0; }
|
|
6
|
+
.rhine-theme .rhine-date-range--compact .rhine-date-field input { border:0; border-radius:0; background:transparent; }
|
|
7
|
+
.rhine-date-range--compact .rhine-date-range__separator { display:grid; place-items:center; min-height:44px; color:var(--rhine-muted); }
|
|
8
|
+
.rhine-date-range--compact .rhine-inline:not(:empty) { margin-top:var(--rhine-space-2); }
|
|
9
|
+
.rhine-date-range--compact .rhine-field__error { margin:var(--rhine-space-1); }
|
|
10
|
+
.rhine-theme[data-rhine-theme=neo-brutalism] .rhine-date-range--compact .rhine-date-range__fields { border-width:2px; }
|
|
11
|
+
@container rhine-date-range (max-width:340px) {
|
|
12
|
+
.rhine-date-range--compact .rhine-date-range__fields { grid-template-columns:minmax(0,1fr); gap:0; }
|
|
13
|
+
.rhine-date-range--compact .rhine-date-range__separator { min-height:var(--rhine-space-4); }
|
|
14
|
+
}
|
|
15
|
+
.rhine-multi-select__actions { display:flex; flex-wrap:wrap; align-items:center; gap:var(--rhine-space-2); }
|
|
16
|
+
.rhine-multi-select--compact .rhine-multi-select__tags { flex-wrap:wrap; min-width:0; }
|
|
17
|
+
.rhine-multi-select--compact .rhine-multi-select__tags > li { min-width:0; flex-shrink:0; }
|
|
18
|
+
.rhine-multi-select--compact .rhine-multi-select__tags > li > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
19
|
+
.rhine-multi-select--compact .rhine-multi-select__tags[data-expanded=true] { flex-wrap:wrap; max-height:224px; overflow:auto; overscroll-behavior:contain; }
|
|
20
|
+
.rhine-multi-select--compact .rhine-multi-select__tags[data-expanded=true] > li > span { white-space:normal; overflow-wrap:anywhere; }
|
|
@@ -67,9 +67,9 @@ export function Tabs({ label, items, value, onValueChange }: { label: string; it
|
|
|
67
67
|
hidden={value !== item.value} tabIndex={0} className="rhine-tabs__panel">{item.content}</div>)}
|
|
68
68
|
</div>;
|
|
69
69
|
}
|
|
70
|
-
export function SegmentedControl({ label, items, value, onValueChange }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void }) {
|
|
70
|
+
export function SegmentedControl({ label, items, value, onValueChange, fullWidth = false }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void; fullWidth?: boolean }) {
|
|
71
71
|
const name = useId();
|
|
72
|
-
return <fieldset className=
|
|
72
|
+
return <fieldset className={classes('rhine-segmented', fullWidth && 'rhine-segmented--full-width')}><legend className="rhine-sr-only">{label}</legend>
|
|
73
73
|
{items.map(item => <label key={item.value} className="rhine-segmented__option">
|
|
74
74
|
<input type="radio" name={name} value={item.value} checked={value === item.value} disabled={item.disabled} onChange={() => onValueChange(item.value)} />
|
|
75
75
|
<span>{item.label}</span>
|
package/components/index.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
.rhine-media-card__footer .rhine-check { min-width:0; overflow-wrap:anywhere; }
|
|
18
18
|
.rhine-media-card__footer .rhine-check label { min-height:44px; }
|
|
19
19
|
.rhine-media-card__actions { display:flex; flex-wrap:wrap; gap:8px; }
|
|
20
|
-
.rhine-media-card__status { display:grid; justify-items:center; gap:12px; padding:40px 16px; font-size:var(--rhine-text-body); }
|
|
20
|
+
.rhine-media-card__status { display:grid; justify-items:center; gap:12px; padding:40px 16px; font-size:var(--rhine-text-body); line-height:var(--rhine-leading-body); font-weight:var(--rhine-weight-regular); }
|
|
21
21
|
.rhine-media-card__status-mark { display:grid; place-items:center; width:44px; height:44px; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); font:24px var(--rhine-mono); }
|
|
22
22
|
.rhine-media-card[data-state=error] .rhine-media-card__status { color:var(--rhine-error); }
|
|
23
23
|
.rhine-theme[data-rhine-theme=neo-brutalism] .rhine-media-card { --media-card-border:var(--rhine-ink); --media-card-shadow:var(--rhine-hard-shadow); border-width:2px; }
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
.rhine-media-viewer { position:fixed; inset:0; z-index:41; width:100%; height:100%; display:flex; flex-direction:column; background:var(--rhine-surface); color:var(--rhine-ink); overflow:auto; }
|
|
29
29
|
.rhine-media-viewer__header { position:sticky; top:0; z-index:1; flex:none; display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:16px; padding:16px 24px; background:var(--rhine-surface); border-bottom:1px solid var(--rhine-control); }
|
|
30
30
|
.rhine-media-viewer__header>div { min-width:0; }
|
|
31
|
-
.rhine-media-viewer__header h2 { font-size:var(--rhine-text-section); line-height:var(--rhine-leading-section); overflow-wrap:anywhere; margin:0; }
|
|
32
|
-
.rhine-media-viewer__header p { color:var(--rhine-muted); font-size:var(--rhine-text-caption); margin:4px 0 0; }
|
|
31
|
+
.rhine-media-viewer__header h2 { font-size:var(--rhine-text-section); line-height:var(--rhine-leading-section); font-weight:var(--rhine-weight-heading); overflow-wrap:anywhere; margin:0; }
|
|
32
|
+
.rhine-media-viewer__header p { color:var(--rhine-muted); font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); overflow-wrap:anywhere; margin:4px 0 0; }
|
|
33
33
|
.rhine-media-viewer__actions { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
|
|
34
|
-
.rhine-media-viewer__actions label { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:var(--rhine-text-caption); }
|
|
34
|
+
.rhine-media-viewer__actions label { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); }
|
|
35
35
|
.rhine-media-viewer :is(button,select,a):not(.rhine-button) { min-height:44px; min-width:44px; max-width:100%; padding:8px 12px; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); background:var(--rhine-surface); color:var(--rhine-ink); font:inherit; overflow-wrap:anywhere; }
|
|
36
36
|
.rhine-media-viewer button { cursor:pointer; }
|
|
37
37
|
.rhine-media-viewer button:disabled { cursor:default; }
|
package/components/patterns.css
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
.rhine-steps button { border:0; background:none; font:inherit; cursor:pointer; }
|
|
21
21
|
.rhine-steps button:hover { background:var(--rhine-selected); }
|
|
22
22
|
.rhine-steps strong,.rhine-steps small { display:block; overflow-wrap:anywhere; }
|
|
23
|
-
.rhine-steps small { margin-top:4px; font-size:
|
|
23
|
+
.rhine-steps small { margin-top:4px; font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); }
|
|
24
24
|
.rhine-steps__number { flex:0 0 28px; font:14px/24px var(--rhine-mono); }
|
|
25
25
|
.rhine-steps li[data-state=current],.rhine-steps li[data-state=complete] { border-color:var(--rhine-green); }
|
|
26
26
|
.rhine-steps li[data-state=current]>div { background:var(--rhine-selected); color:var(--rhine-green); animation:rhine-pattern-enter var(--rhine-duration-panel) var(--rhine-ease-standard); }
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
.rhine-steps li[data-state=error]>div { color:var(--rhine-error); background:var(--rhine-error-bg); }
|
|
29
29
|
.rhine-fieldset { background:var(--rhine-surface-bg); color:var(--rhine-surface-text); min-width:0; margin:0; padding:20px; border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-card); }
|
|
30
30
|
.rhine-fieldset legend { padding:0 8px; font-weight:600; }
|
|
31
|
-
.rhine-fieldset>p { margin:0 0 20px; color:var(--rhine-muted); font-size:
|
|
31
|
+
.rhine-fieldset>p { margin:0 0 20px; color:var(--rhine-muted); font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); }
|
|
32
32
|
.rhine-fieldset__body { display:grid; gap:16px; }
|
|
33
33
|
.rhine-error-summary { padding:20px; border:1px solid var(--rhine-error); border-inline-start:3px solid var(--rhine-error); background:var(--rhine-error-bg); }
|
|
34
|
-
.rhine-error-summary h3 { margin:0 0 8px; font-size:
|
|
34
|
+
.rhine-error-summary h3 { margin:0 0 8px; font-size:var(--rhine-text-heading); line-height:var(--rhine-leading-heading); font-weight:var(--rhine-weight-heading); }
|
|
35
35
|
.rhine-error-summary ul { padding-inline-start:20px; margin:0; }
|
|
36
36
|
.rhine-error-summary a { display:inline-block; padding:10px 0; color:var(--rhine-error); text-underline-offset:3px; overflow-wrap:anywhere; }
|
|
37
37
|
.rhine-dialog.rhine-drawer { inset-block:0; inset-inline-end:0; inset-inline-start:auto; width:min(440px,100%); max-height:100dvh; margin:0; transform:none; border-radius:0; animation:rhine-drawer-enter var(--rhine-duration-overlay) var(--rhine-ease-standard); }
|
package/components/styles.css
CHANGED
|
@@ -64,7 +64,10 @@
|
|
|
64
64
|
.rhine-segmented { display:inline-flex; flex-wrap:wrap; gap:3px; padding:3px; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); margin:0; min-width:0; }
|
|
65
65
|
.rhine-segmented__option { position:relative; display:flex; flex:1 0 auto; cursor:pointer; }
|
|
66
66
|
.rhine-segmented input { position:absolute; opacity:0; width:1px; height:1px; }
|
|
67
|
-
.rhine-segmented__option span { display:flex; justify-content:center; align-items:center; min-height:40px; padding:8px 12px; color:var(--rhine-muted); font-size:14px; transition:background-color var(--rhine-duration-hover) var(--rhine-ease-standard),color var(--rhine-duration-hover) var(--rhine-ease-standard); }
|
|
67
|
+
.rhine-segmented__option span { display:flex; flex:1; justify-content:center; align-items:center; min-height:40px; padding:8px 12px; text-align:center; color:var(--rhine-muted); font-size:14px; transition:background-color var(--rhine-duration-hover) var(--rhine-ease-standard),color var(--rhine-duration-hover) var(--rhine-ease-standard); }
|
|
68
|
+
.rhine-segmented--full-width { display:flex; width:100%; flex-wrap:nowrap; }
|
|
69
|
+
.rhine-segmented--full-width .rhine-segmented__option { flex:1 1 0; min-width:0; }
|
|
70
|
+
.rhine-segmented--full-width .rhine-segmented__option span { min-width:0; overflow-wrap:anywhere; }
|
|
68
71
|
.rhine-segmented input:checked+span { background:var(--rhine-ink); color:var(--rhine-surface); }
|
|
69
72
|
.rhine-segmented input:focus-visible+span { outline:2px solid var(--rhine-green); outline-offset:3px; }
|
|
70
73
|
.rhine-segmented input:disabled+span { opacity:.6; cursor:default; }
|
|
@@ -88,7 +91,7 @@
|
|
|
88
91
|
.rhine-disclosure[data-expanded=true] summary>span { transform:rotate(45deg); }
|
|
89
92
|
.rhine-disclosure__content { padding:4px 0 20px; }
|
|
90
93
|
.rhine-empty { padding:32px 24px; text-align:center; border:1px solid var(--rhine-line); background:var(--rhine-surface); }
|
|
91
|
-
.rhine-empty strong { font-size:
|
|
94
|
+
.rhine-empty strong { display:block; font-size:var(--rhine-text-heading); line-height:var(--rhine-leading-heading); font-weight:var(--rhine-weight-heading); overflow-wrap:anywhere; }.rhine-empty p { max-width:400px; margin:8px auto 20px; color:var(--rhine-muted); }
|
|
92
95
|
@keyframes rhine-spin { to { transform:rotate(360deg); } }
|
|
93
96
|
.rhine-logo { display:inline-flex; flex:none; align-items:center; vertical-align:middle; color:var(--rhine-green); line-height:1; }
|
|
94
97
|
.rhine-logo__symbol { display:block; width:var(--rhine-logo-size); height:var(--rhine-logo-size); flex:none; fill:currentColor; }
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
.rhine-theme-option:hover:not(:has(input:disabled)) { background:var(--rhine-selected); }
|
|
9
9
|
.rhine-theme-option__copy { display:grid; gap:var(--rhine-space-2); min-width:0; overflow-wrap:anywhere; }
|
|
10
10
|
.rhine-theme-option__description { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); color:var(--rhine-muted); }
|
|
11
|
-
.rhine-theme-option__status { font-size:var(--rhine-text-caption); min-height:24px; }
|
|
12
|
-
.rhine-theme-preview { position:relative; display:grid; place-items:center; aspect-ratio:8/5; min-width:0; overflow:hidden; border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-control); background:var(--rhine-bg); color:var(--rhine-muted); font-size:var(--rhine-text-caption); }
|
|
11
|
+
.rhine-theme-option__status { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); min-height:24px; }
|
|
12
|
+
.rhine-theme-preview { position:relative; display:grid; place-items:center; aspect-ratio:8/5; min-width:0; overflow:hidden; border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-control); background:var(--rhine-bg); color:var(--rhine-muted); font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); }
|
|
13
13
|
.rhine-theme-preview img { display:block; width:100%; height:100%; object-fit:contain; }
|
|
14
14
|
.rhine-theme-preview__loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--rhine-bg); }
|
|
15
15
|
.rhine-theme[data-rhine-theme=neo-brutalism] .rhine-theme-option { border:2px solid var(--rhine-ink); box-shadow:var(--rhine-hard-shadow); }
|
|
@@ -105,10 +105,10 @@ SideNav 继续负责当前项、长名称和 aria-current;Drawer 继续负责
|
|
|
105
105
|
| 组件 | 主要 API | 数据与键盘契约 |
|
|
106
106
|
| --- | --- | --- |
|
|
107
107
|
| DateField | `label/value/onValueChange/min/max/disabled/error` | 原生 `input type=date`;ISO 日期字符串或空字符串,无 UTC/时区转换,原生日历和键盘由浏览器实现 |
|
|
108
|
-
| DateRangePicker | 上述范围 + `value={start,end}`、`presets=[{label,value}]
|
|
108
|
+
| DateRangePicker | 上述范围 + `value={start,end}`、`presets=[{label,value}]`、`compact?` | 两个原生日期输入,自定义范围、受控预设、清空;compact 合并为一体区间框;结束日期下界取 start/min 较晚者;反向范围显示错误,不静默调换 |
|
|
109
109
|
| NumberField | `value: number\|null`、`onValueChange/min/max/step/precision/disabled/error` | 编辑中保存字符串;失焦/Enter 提交;无效及越界输入保留并报错;上下键/按钮步进与钳制边界;空字符串提交 null |
|
|
110
110
|
| TreeSelect | `nodes/value/onValueChange/expandedKeys/onExpandedChange/multiple`、loading/error/disabled | 节点为 `{id,label,disabled?,children?}`;父子独立选择,默认单选;调用方提供搜索后的树,不做全量过滤 |
|
|
111
|
-
| RemoteMultiSelect | `query/onQueryChange/value/onValueChange/options`、loading/error/disabled | value 是完整 `{value,label,disabled?}[]
|
|
111
|
+
| RemoteMultiSelect | `query/onQueryChange/value/onValueChange/options`、loading/error/disabled、`maxVisibleTags?` | value 是完整 `{value,label,disabled?}[]`,已选标签不依赖本次结果;不重复选中;清空保留禁选标签;maxVisibleTags 限制常显数量并提供展开/收起,展开区域有界滚动 |
|
|
112
112
|
|
|
113
113
|
日期 min/max 约束原生日历和表单有效性;手动编辑、业务校验及最终提交由宿主验证。原生日期面板位于浏览器 UI,不放在受裁剪的自定义浮层。日历外观、屏幕阅读器和移动 WebView 行为由具体浏览器决定,不声称提供自绘日历。预设必须使用宿主计算好的日期;不得以本机时区代替产品时区。没有“今天”隐式计算。
|
|
114
114
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# 侧栏双行多选
|
|
2
|
+
|
|
3
|
+
`Checkbox layout="row"` 自 0.10.0 提供,npm 0.9.1 尚不包含。适用于侧栏、来源列表和筛选项;导航仍用 SideNav,需要内容卡片时用 SelectionCard。
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<Checkbox layout="row" label="示例项目 A" hint="内存卡 · 14 项"
|
|
7
|
+
checked={selectedIds.includes('a')}
|
|
8
|
+
onChange={event => updateSelection('a', event.target.checked)} />
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- 默认 `layout="form"` 保留原表单布局;`CheckboxProps` 从公共入口导出,继续接受原生 name/value、受控与非受控状态及事件。
|
|
12
|
+
- row 的复选框单独占 18px 列,与文字列间隔 8px。名称 14/22px,使用标签字重 token(默认 500,橙趣主题 600);说明 12/18px、400,二者相隔 4px,具有同一文字起点。长名称与长连续字符串自然换行,不缩字或截断。
|
|
13
|
+
- 行使用上下左右对称 8px 内距、最小 44px 命中高度,随文字自然增高。两行基础高度为 62px(22+4+18+16+2),不用 44px 名称行的透明余量充当文字间距。宿主用 Stack gap={2} 排列,避免覆盖内部 label CSS。
|
|
14
|
+
- 外边框内的说明、文字及留白全部属于同一个原生 label;一次点击仅触发一次原生 change,Space 保留原生键盘选择。说明用 aria-describedby 关联,默认可访问名称只包含名称文字;调用方的 aria-label / aria-labelledby 继续生效。
|
|
15
|
+
- checked 与 indeterminate 使用主题选中背景、强调边界及原生勾选/混合态;focus-visible 使用行内焦点框,紧贴滚动边缘也不会因外扩而裁切。disabled 保持可读并禁止选择,仍可展示已选状态。
|
|
16
|
+
- 混合态由宿主根据选中 IDs 计算;原生 checkbox 没有只读行为,禁用交互请使用 disabled。label/hint 保持纯文本,不在整行 label 内嵌链接或其他交互控件。
|
|
17
|
+
|
|
18
|
+
真实展台 `#checkbox-rows` 提供 160/240/320px 侧栏、单行/长名称、说明有无、混合态、禁用与默认表单对照。验收以实际文字行框、对称内距、完整点击区域和键盘焦点为准;CSS zoom 与原生浏览器菜单缩放分别记录。
|
|
19
|
+
|
|
20
|
+
## 验证(2026-10-26)
|
|
21
|
+
|
|
22
|
+
Node 24 下 259 测试、类型检查与生产构建通过。Chromium 三主题 × 400/520/880px 视口 × 100%/200% CSS zoom × 160/240/320px 侧栏,共 54 组几何检查通过;侧栏实际宽度受可用空间限制。验证名称/说明行框、字重、4px 间隔、同列起点、对称留白、最小 44px、无横溢出,并执行说明点击、行内留白点击、Tab 焦点、Space、混合态与禁用检查。三主题 400px / 200% 截图已检查,长名称、说明与焦点可见。未覆盖原生浏览器菜单缩放或所有浏览器。
|
|
23
|
+
|
|
24
|
+
先执行 `npm run check`,再在一个终端启动并保留预览:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx vite preview --host 127.0.0.1 --port 18529 --strictPort
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
在另一终端运行浏览器回归:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
playwright-cli -s=checkbox-rows open 'http://127.0.0.1:18529/#checkbox-rows'
|
|
34
|
+
mkdir -p output/playwright/issue-29
|
|
35
|
+
playwright-cli -s=checkbox-rows run-code --filename=scripts/check-checkbox-rows.js
|
|
36
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# 紧凑筛选控件
|
|
2
|
+
|
|
3
|
+
本节 API 自 0.10.0 提供,0.9.1 不包含这些选项。真实组件组合为 `preview/CompactFilterBarGallery.tsx`。
|
|
4
|
+
|
|
5
|
+
统一展台入口 `#compact-filters`,同时运行 `preview/DenseFilterControlsGallery.tsx`:100 个候选、90 个已选项及一体日期区间。`RemoteMultiSelect maxVisibleTags={2}` 默认只显示两个标签与总数,展开后使用有界滚动,不将大量已选项常驻到筛选抽屉。`DateRangePicker compact` 将两端原生日期放在同一边框内,保留无障碍标签、原生日期键盘与错误关联;极窄容器允许内部换行,不能裁切日期。
|
|
6
|
+
|
|
7
|
+
- `SegmentedControl fullWidth` 占满可用行宽,选项等宽;长标签在所属选项中换行。选中背景和可见文本区域共同撑满 label,文字水平/垂直居中,不把扩大点击区域误认为扩大选中背景。
|
|
8
|
+
- 默认 `fullWidth=false` 保持既有按内容布局。原生 radio 的 checked、禁用、Tab 和方向键行为不变;独立实例继续使用独立 name。
|
|
9
|
+
- 有限标签在极窄容器内允许换行,保留完整的移除按钮;展开只影响呈现,不裁减表单值。清空或宿主将选择数降至限制以内后,再次批量选择恢复折叠。
|
|
10
|
+
- `ListFilterBar drawerDescription={null} drawerResult={null}` 删除可省略说明与重复统计。没有内容时不留下空段落/反馈容器;关闭后仍显示栏内完整摘要。
|
|
11
|
+
- 正在更新和错误不是冗余说明,抽屉打开时继续提供反馈。减少文字不能掩盖请求失败或把旧结果解释为当前条件成功。
|
|
12
|
+
- `Dialog` / `Drawer` 的 description 接收 ReactNode,`null` 不渲染描述,同时移除 aria-describedby;标题和关闭入口始终保留。
|
|
13
|
+
|
|
14
|
+
验收三主题、400/520/880px、200% 内容缩放及长标签;检查每个选项的几何宽度和文字中心、整个选中底色的宽度、键盘原生单选、抽屉关闭归焦、无描述时的可访问名称。JSDOM 验证语义与回调,真实浏览器验证几何。
|
|
15
|
+
|
|
16
|
+
## 回归记录(2026-10-25)
|
|
17
|
+
|
|
18
|
+
Node 24:254 项测试、类型检查和生产构建通过。生产预览下三主题 × 400/520/880px × 100%/200% CSS zoom 的 18 组检查通过,覆盖有限标签与完整移除入口、有界展开、清空后重新折叠、原生日期最后一段编辑、等宽单选键盘操作、可省略摘要及保留加载/错误、关闭归焦。400px / 200% 的三主题日期截图人工核对完整年月日与日历入口;不外推为原生菜单缩放或所有浏览器验收。
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm run check
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
在一个终端启动预览,并在浏览器检查期间保持运行:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx vite preview --host 127.0.0.1 --port 18527 --strictPort
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
预览就绪后,在另一个终端执行:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
playwright-cli -s=dense-filters open 'http://127.0.0.1:18527/#compact-filters'
|
|
34
|
+
mkdir -p output/playwright/issue-27
|
|
35
|
+
playwright-cli -s=dense-filters run-code --filename=scripts/check-dense-filters.js
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
每个矩阵场景使用独立导航参数重建示例状态,避免前次未关闭浮层导致下轮读到隐藏的底层内容。全程只使用手写合成数据。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 公共组件文字配对复核(#31)
|
|
2
|
+
|
|
3
|
+
2026-10-26。本轮修复自 npm 0.10.0 提供,0.9.1 不包含。范围为 `components/*.css` 中的字号声明及对应真实组件,重点检查组件从正文切换到 caption/heading 后是否仍依赖父级行高或浏览器默认字重;不全局重写 strong、p 或所有字体。
|
|
4
|
+
|
|
5
|
+
| 组件位置 | 本轮处理 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| MediaViewer header 说明、操作标签 | caption 12/18/400,长说明自然换行 |
|
|
8
|
+
| EmptyState 标题 | heading 16/24,显式块级行框与自然换行 |
|
|
9
|
+
| MediaViewer header 标题 | section 18/26,显式主题标题字重 |
|
|
10
|
+
| MediaCard 加载/空/错误文案 | body 14/22/400,符号标记继续独立 |
|
|
11
|
+
| CopyButton 反馈、Steps 说明、Fieldset 提示 | caption 12/18/400 |
|
|
12
|
+
| ErrorSummary 标题 | heading 16/24 与主题标题字重 |
|
|
13
|
+
| ThemePicker 状态、ThemePreview 加载/失败文案 | caption 12/18/400,保留最小区域高度 |
|
|
14
|
+
|
|
15
|
+
标题字重使用 `--rhine-weight-heading`:莱茵与战术 600,橙趣主题已有覆盖为 750。这里保留主题语义,不强制全部主题变成 600。辅助说明使用 `--rhine-weight-regular`,避免宿主强调字重泄入。
|
|
16
|
+
|
|
17
|
+
## 保留与排除
|
|
18
|
+
|
|
19
|
+
- Text、Checkbox row、表格 caption、媒体卡片 caption、导航、筛选反馈等已有字号/行高配对,保持不变。
|
|
20
|
+
- Badge、Notice 说明、图表辅助说明的既有 12/20、表格普通文本的既有密度,以及主题表格/表单标签覆盖不机械统一。正文同层的分段控件与 Notice 标题没有切换到 caption/heading,不在本次跨层级修复中重设字重。
|
|
21
|
+
- Metric 单位属于指标与辅助字号的行内混排;整行由指标控制,不把整体压成 caption 高度。状态符号、编号、Logo 等图形/品牌表达不当作正文。
|
|
22
|
+
- 消费应用自己的标题与辅助文字组合不在设计库修复范围;本轮组件修复不迁移业务页面;npm 发布单独执行版本流程。
|
|
23
|
+
|
|
24
|
+
## 复现与回归
|
|
25
|
+
|
|
26
|
+
真实展台 `#component-typography` 使用纯合成内容,覆盖上述规则。复制动作使用异步空实现,不访问剪贴板;主题预览使用故意损坏的内嵌图片验证失败文案,不请求外部资源。
|
|
27
|
+
|
|
28
|
+
先运行 `npm run check`,然后在一个终端保持预览运行:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx vite preview --host 127.0.0.1 --port 18531 --strictPort
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
另一终端执行:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
playwright-cli -s=component-type open 'http://127.0.0.1:18531/#component-typography'
|
|
38
|
+
mkdir -p output/playwright/issue-31
|
|
39
|
+
playwright-cli -s=component-type run-code --filename=scripts/check-component-typography.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
回归直接断言计算字号、行高与字重,并改变宿主行高/字重确认局部配对;检查三主题、400/520/880px、100%/200% CSS zoom、长文、44px 操作区域、关闭与焦点归还。400px、200% CSS zoom 下额外测量 EmptyState 标题与 MediaViewer 说明的文字 Range,确认实际多行且文字边界无水平或垂直裁切,不仅检查外围容器。不将 CSS zoom 外推为原生菜单缩放或所有浏览器覆盖。
|
|
43
|
+
|
|
44
|
+
本轮结果:259 项组件测试、类型检查与生产构建通过;上述 18 组 Chromium 场景通过,无页面异常。另人工复核三主题在 400px、200% CSS zoom 下的空状态与媒体头部截图,长文和操作仍完整可读。
|
package/docs/LIST_FILTER_BAR.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- `primary` 只常显前两项,超出的项目自动进入“更多筛选”。选择高频且互相独立的条件,例如状态和排序;不要为了展开所有控件增加第二个筛选栏。
|
|
12
12
|
- 其他条件进入同一个 Drawer。字段自己的 label 持续可见,组件不重复加可见标题,也不提供任意 `children` 让页面插回一排控件。
|
|
13
13
|
- 非默认条件用短摘要说明当前限制;单项清除与整体重置都由宿主定义。默认排序、默认统计日期等是否需要摘要由宿主的 `activeLabel` 决定,不把所有非空值都视为筛选。
|
|
14
|
-
- 结果数量、更新中与错误反馈在栏内呈现;Drawer
|
|
14
|
+
- 结果数量、更新中与错误反馈在栏内呈现;Drawer 默认重复结果摘要,信息较多时用 `drawerResult={null}` 移除重复统计。更新中与错误仍必须在 Drawer 内反馈,失败不能藏在遮罩后的页面上,也不能把旧数量当作新条件已经成功。
|
|
15
15
|
- 刷新之外最多一个业务操作。多记录的选择、批量操作属于结果区,录制启停等对象设置也不塞进筛选栏。
|
|
16
16
|
- 平级工作区已有侧栏身份时不重复大标题。组件的 `label` 提供可访问分组名称,宿主仍负责页面标题、主内容名称及路由焦点。
|
|
17
17
|
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
| `refresh` | `{onClick,busy?,disabled?}`;原生共享 Button,不自动执行请求 |
|
|
30
30
|
| `action` | 可选单个 ReactElement,代表一个业务操作;消费方不能用 Fragment 或自建按钮组绕过数量限制 |
|
|
31
31
|
| `result` | 宿主提供的结果摘要;数量未知时明确显示未知,不用已加载条数冒充总数 |
|
|
32
|
+
| `drawerDescription` | 可选 ReactNode;省略时保留默认即时更新说明,传 `null` 不渲染说明,也不留下空段落或失效的 aria-describedby |
|
|
33
|
+
| `drawerResult` | 可选 ReactNode;省略时沿用 `result`,传 `null` 仅移除抽屉重复摘要;pending/error 始终保留 |
|
|
32
34
|
| `pending` / `error` | 正在更新 / 失败说明;在栏与已展开 Drawer 中保持一致 |
|
|
33
35
|
| `className` | 只用于外层组合布局,不覆盖组件内部视觉或状态 |
|
|
34
36
|
|
|
@@ -59,6 +61,8 @@
|
|
|
59
61
|
|
|
60
62
|
示例中的状态、函数与额外字段由宿主提供。使用受控 `search.value` 才能准确决定搜索是否需要重置;组件不解析其他控件的值。
|
|
61
63
|
|
|
64
|
+
`drawerDescription` / `drawerResult` 自 0.10.0 提供,0.9.1 不支持。紧凑示例见 `preview/CompactFilterBarGallery.tsx`。无必要解释时,标题后直接展示筛选字段,不以另一段帮助文字替代被移除的重复说明。
|
|
65
|
+
|
|
62
66
|
## 状态与查询边界
|
|
63
67
|
|
|
64
68
|
更多筛选是同一组状态的展开视图,不建立另一套草稿/应用流程。普通选择即时回传宿主;关闭、点击外部与 Escape 只收起抽屉,不撤销已经接受的条件。业务本来要求完整范围显式应用的日期控件可以保留其局部草稿,但应在 Drawer 内内联展示,不再嵌套日期 Drawer。
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Checkbox, Stack, Text } from '../components';
|
|
3
|
+
|
|
4
|
+
const choices = [
|
|
5
|
+
{ id: 'a', label: '示例项目 A', hint: '内存卡 · 14 项' },
|
|
6
|
+
{ id: 'b', label: '跨团队拍摄与素材整理的较长示例项目名称', hint: '移动设备 · 128 项,包含待整理素材' },
|
|
7
|
+
{ id: 'c', label: 'Archive_With_A_Very_Long_Unbroken_Synthetic_Project_Name' },
|
|
8
|
+
{ id: 'd', label: '其他来源' },
|
|
9
|
+
];
|
|
10
|
+
export function CheckboxRowGallery() {
|
|
11
|
+
const [selected, setSelected] = useState(['a']);
|
|
12
|
+
const [width, setWidth] = useState('240');
|
|
13
|
+
return <Stack gap={4}>
|
|
14
|
+
<Text as="h2" variant="heading">侧栏双行多选</Text>
|
|
15
|
+
<Text tone="muted">纯合成项目。整行、说明和留白均可选择;长名称自然换行。</Text>
|
|
16
|
+
<label>侧栏宽度 <select value={width} onChange={event => setWidth(event.target.value)}>{['160', '240', '320'].map(value => <option key={value} value={value}>{value}px</option>)}</select></label>
|
|
17
|
+
<div data-checkbox-rows style={{ width: Number(width), maxWidth: '100%' }}><Stack gap={2}>
|
|
18
|
+
<Checkbox layout="row" label="全部项目" hint="选择四个示例项目" checked={selected.length === choices.length} indeterminate={selected.length > 0 && selected.length < choices.length} onChange={event => setSelected(event.target.checked ? choices.map(item => item.id) : [])} />
|
|
19
|
+
{choices.map(item => <Checkbox key={item.id} layout="row" label={item.label} hint={item.hint} checked={selected.includes(item.id)} onChange={event => setSelected(current => event.target.checked ? [...current, item.id] : current.filter(id => id !== item.id))} />)}
|
|
20
|
+
<Checkbox layout="row" label="不可用项目" hint="当前无法读取" disabled />
|
|
21
|
+
<Checkbox layout="row" label="已选但不可用" hint="等待重新连接" disabled checked />
|
|
22
|
+
</Stack></div>
|
|
23
|
+
<Text variant="caption">已选 {selected.length} 项</Text>
|
|
24
|
+
<Checkbox label="默认表单布局" hint="保留旧表单说明的位置和命中范围。" />
|
|
25
|
+
</Stack>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Button, Inline, ListFilterBar, SegmentedControl, Select, Stack, Text } from '../components';
|
|
3
|
+
|
|
4
|
+
export function CompactFilterBarGallery() {
|
|
5
|
+
const [range, setRange] = useState('today');
|
|
6
|
+
const [feedback, setFeedback] = useState('normal');
|
|
7
|
+
return <Stack gap={4}>
|
|
8
|
+
<Text>筛选抽屉仅保留必要字段;进行中与失败仍然可见。</Text>
|
|
9
|
+
<Inline gap={2}>
|
|
10
|
+
{['normal', 'pending', 'error'].map(state => <Button key={state} variant="secondary" aria-pressed={feedback === state} onClick={() => setFeedback(state)}>{({ normal: '正常', pending: '加载', error: '失败' })[state]}</Button>)}
|
|
11
|
+
</Inline>
|
|
12
|
+
<ListFilterBar label="紧凑筛选示例" result="120 条合成记录" drawerDescription={null} drawerResult={null}
|
|
13
|
+
pending={feedback === 'pending'} error={feedback === 'error' ? '暂时无法更新合成结果,请重试。' : undefined}
|
|
14
|
+
primary={[{ id: 'platform', label: '平台', control: <Select label="平台" defaultValue="all"><option value="all">全部平台</option></Select> }]}
|
|
15
|
+
filters={[{ id: 'range', label: '时间快捷', control: <SegmentedControl fullWidth label="时间快捷" value={range} onValueChange={setRange} items={[{ value: 'today', label: '今天' }, { value: 'yesterday', label: '昨天' }, { value: 'week', label: '本周' }]} /> }]}
|
|
16
|
+
/>
|
|
17
|
+
<SegmentedControl fullWidth label="等宽长文本与禁用示例" value="first" onValueChange={() => {}} items={[{ value: 'first', label: '较长的第一个范围' }, { value: 'second', label: '第二范围' }, { value: 'third', label: '不可选择', disabled: true }]} />
|
|
18
|
+
</Stack>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Button, CopyButton, EmptyState, ErrorSummary, Fieldset, MediaCard, MediaViewer, Stack, Steps, Text, TextField, ThemePicker } from '../components';
|
|
3
|
+
|
|
4
|
+
const description = '示例辅助说明:这是一段用于检查长文本自然换行的合成内容,来源、时间与读取状态应保持完整可读。';
|
|
5
|
+
export function ComponentTypographyGallery() {
|
|
6
|
+
const [open, setOpen] = useState(false);
|
|
7
|
+
const [next, setNext] = useState(false);
|
|
8
|
+
return <Stack gap={4}>
|
|
9
|
+
<Text as="h2" variant="heading">公共组件文字配对</Text>
|
|
10
|
+
<EmptyState title="没有匹配结果:请检查名称、日期与当前范围" description={description} action={<Button onClick={() => setOpen(true)}>打开文字检查</Button>} />
|
|
11
|
+
<MediaViewer open={open} onOpenChange={setOpen} trigger={<Button>打开媒体示例</Button>} title={next ? '下一项合成媒体' : '示例媒体与较长标题:检查字号、行高和字重'} description={description}
|
|
12
|
+
onNext={() => setNext(true)} onPrevious={() => setNext(false)}
|
|
13
|
+
actions={<label>显示方式 <select aria-label="显示方式"><option>适应窗口</option><option>原始尺寸</option></select></label>}>
|
|
14
|
+
<Text>合成画布:不加载真实媒体。</Text>
|
|
15
|
+
</MediaViewer>
|
|
16
|
+
<MediaCard title="读取状态" kind="photo" state="loading" onOpen={() => setOpen(true)} />
|
|
17
|
+
<CopyButton value="synthetic-example" label="模拟复制" copy={async () => {}} />
|
|
18
|
+
<Steps current={0} items={[{ id:'one',label:'检查说明',description }]} />
|
|
19
|
+
<Fieldset legend="分组提示" hint={description}><TextField id="typography-example" label="示例字段" /></Fieldset>
|
|
20
|
+
<ErrorSummary title="请检查以下字段的完整内容" errors={[{fieldId:'typography-example',message:'定位示例字段'}]} />
|
|
21
|
+
<ThemePicker value="rhine" onValueChange={() => {}} options={[{value:'rhine',label:'仅展示文字状态',previewSrc:'data:image/png;base64,invalid'}]} />
|
|
22
|
+
</Stack>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Button, DateRangePicker, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
3
|
+
|
|
4
|
+
const departments: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: `synthetic-${i}`, label: `合成部门 ${i + 1}${i % 8 === 0 ? ' · 跨团队协作与长期计划' : ''}` }));
|
|
5
|
+
|
|
6
|
+
export function DenseFilterControlsGallery() {
|
|
7
|
+
const [selected, setSelected] = useState(departments.slice(0, 90));
|
|
8
|
+
const [query, setQuery] = useState('');
|
|
9
|
+
const [date, setDate] = useState<DateRange>({ start: '2026-10-01', end: '2026-10-24' });
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
// Fixture filtering is local; production hosts keep server-side query ownership.
|
|
12
|
+
const options = departments.filter(item => item.label.includes(query));
|
|
13
|
+
return <Surface><Stack gap={4}>
|
|
14
|
+
<Text as="h2" variant="heading">密集筛选控件</Text>
|
|
15
|
+
<Text tone="muted">100 个合成部门、90 个已选项;可搜索、展开移除或清空。日期保留原生键盘和日历。</Text>
|
|
16
|
+
<Inline gap={2}><Button variant="secondary" onClick={() => setSelected(departments)}>全选部门</Button><Button variant="quiet" onClick={() => setSelected([])}>清空部门</Button></Inline>
|
|
17
|
+
<RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2} placeholder="搜索部门" />
|
|
18
|
+
<DateRangePicker compact label="日期区间" value={date} onValueChange={setDate} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={setError} />
|
|
19
|
+
<Text variant="caption" tone="muted">{error || `${date.start} 至 ${date.end}`}</Text>
|
|
20
|
+
</Stack></Surface>;
|
|
21
|
+
}
|
package/preview/GalleryShell.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import './theme-arknights.css';
|
|
|
6
6
|
import './theme-neo-brutalism.css';
|
|
7
7
|
|
|
8
8
|
export const galleryGroups = [
|
|
9
|
-
{ label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例']] },
|
|
9
|
+
{ label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例'], ['compact-filters', '紧凑与大量筛选'], ['checkbox-rows', '侧栏双行多选'], ['component-typography', '组件文字配对']] },
|
|
10
10
|
{ label: '应用能力', items: [['app-shell', '背景与多侧栏'], ['advanced-forms', '复杂表单'], ['advanced-data', '受控数据表'], ['media', '媒体卡片与查看器'], ['monitoring', '健康详情与图表']] },
|
|
11
11
|
{ label: '设计语言', items: [['foundation', '视觉基础'], ['backgrounds', '场景背景'], ['spacing', '间距与组合'], ['typography', '字体与文字'], ['brand', '品牌标志']] },
|
|
12
12
|
{ label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['overlay-bounds', '浮层缩放与边界'], ['settings-dialog', '设置导航弹窗'], ['theme-picker', '主题预览与选择'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
|
package/preview/main.tsx
CHANGED
|
@@ -22,6 +22,10 @@ import { MonitoringGallery } from './MonitoringGallery';
|
|
|
22
22
|
import { DateRangeFilterGallery } from './DateRangeFilterGallery';
|
|
23
23
|
import { ListFilterBarGallery } from './ListFilterBarGallery';
|
|
24
24
|
import { CompactAccountGallery } from './CompactAccountGallery';
|
|
25
|
+
import { CompactFilterBarGallery } from './CompactFilterBarGallery';
|
|
26
|
+
import { DenseFilterControlsGallery } from './DenseFilterControlsGallery';
|
|
27
|
+
import { CheckboxRowGallery } from './CheckboxRowGallery';
|
|
28
|
+
import { ComponentTypographyGallery } from './ComponentTypographyGallery';
|
|
25
29
|
|
|
26
30
|
function Gallery() {
|
|
27
31
|
const [motion, setMotion] = useState('system');
|
|
@@ -48,6 +52,9 @@ function Gallery() {
|
|
|
48
52
|
{section === 'date-range-filter' && <DateRangeFilterGallery />}
|
|
49
53
|
{section === 'list-filter-bar' && <ListFilterBarGallery />}
|
|
50
54
|
{section === 'compact-account' && <CompactAccountGallery />}
|
|
55
|
+
{section === 'compact-filters' && <><CompactFilterBarGallery /><DenseFilterControlsGallery /></>}
|
|
56
|
+
{section === 'checkbox-rows' && <CheckboxRowGallery />}
|
|
57
|
+
{section === 'component-typography' && <ComponentTypographyGallery />}
|
|
51
58
|
{section === 'backgrounds' && <BackgroundGallery />}
|
|
52
59
|
{section === 'app-shell' && <ShellGallery />}
|
|
53
60
|
{section === 'orbit' && <OrbitGallery />}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Use a production preview and run with playwright-cli run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(10000);
|
|
4
|
+
const origin = new URL(page.url()).origin;
|
|
5
|
+
const failures = [], errors = [];
|
|
6
|
+
let scenarios = 0;
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const check = (pass, context) => { if (!pass) failures.push(context); };
|
|
9
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
10
|
+
for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
|
|
11
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
12
|
+
await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#checkbox-rows`);
|
|
13
|
+
await page.getByRole('heading', { name: '侧栏双行多选', exact: true }).waitFor();
|
|
14
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
15
|
+
const container = page.locator('[data-checkbox-rows]');
|
|
16
|
+
for (const requestedWidth of [160, 240, 320]) {
|
|
17
|
+
const context = { theme, width, zoom, requestedWidth };
|
|
18
|
+
await page.getByRole('combobox', { name: '侧栏宽度', exact: true }).selectOption(String(requestedWidth));
|
|
19
|
+
const geometry = await container.evaluate((root, zoom) => {
|
|
20
|
+
const rows = [...root.querySelectorAll('.rhine-check--row > label')];
|
|
21
|
+
return rows.map(row => {
|
|
22
|
+
const name = row.querySelector('.rhine-check__name'), hint = row.querySelector('.rhine-check__hint');
|
|
23
|
+
const text = row.querySelector('.rhine-check__text');
|
|
24
|
+
const r = row.getBoundingClientRect(), n = name.getBoundingClientRect(), h = hint?.getBoundingClientRect(), t = text.getBoundingClientRect();
|
|
25
|
+
const style = getComputedStyle(row), ns = getComputedStyle(name), hs = hint && getComputedStyle(hint);
|
|
26
|
+
const border = parseFloat(style.borderTopWidth);
|
|
27
|
+
return { height:r.height / zoom, fits:row.scrollWidth <= row.clientWidth + 1, nameSize:ns.fontSize, nameLine:ns.lineHeight, nameWeight:ns.fontWeight, hintSize:hs?.fontSize, hintLine:hs?.lineHeight,
|
|
28
|
+
aligned:!h || Math.abs(n.left-h.left)<1, gap:h ? (h.top-n.bottom)/zoom : null, top:(t.top-r.top)/zoom-border, bottom:(r.bottom-t.bottom)/zoom-border };
|
|
29
|
+
});
|
|
30
|
+
}, zoom);
|
|
31
|
+
check(geometry.every(row => row.fits && row.height >= 44 && row.nameSize === '14px' && row.nameLine === '22px' && row.nameWeight === (theme === 'neo-brutalism' ? '600' : '500') && row.aligned && (row.gap === null || Math.abs(row.gap-4)<.1) && (!row.hintSize || row.hintSize === '12px' && row.hintLine === '18px') && Math.abs(row.top-row.bottom)<.1), { ...context, kind:'geometry', geometry });
|
|
32
|
+
check(await container.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind:'container-fit' });
|
|
33
|
+
scenarios++;
|
|
34
|
+
}
|
|
35
|
+
const all = container.getByRole('checkbox', { name:'全部项目', exact:true });
|
|
36
|
+
const input = container.getByRole('checkbox', { name:'示例项目 A', exact:true });
|
|
37
|
+
check(await all.evaluate(e => e.indeterminate), { theme,width,zoom,kind:'mixed' });
|
|
38
|
+
const row = input.locator('..');
|
|
39
|
+
const selectedStyle = await row.evaluate(e => ({ background:getComputedStyle(e).backgroundColor,border:getComputedStyle(e).borderColor }));
|
|
40
|
+
await row.getByText('内存卡 · 14 项', { exact:true }).click();
|
|
41
|
+
check(!await input.isChecked(), { theme,width,zoom,kind:'hint-click' });
|
|
42
|
+
await page.mouse.move(0, 0);
|
|
43
|
+
const defaultStyle = await row.evaluate(e => ({ background:getComputedStyle(e).backgroundColor,border:getComputedStyle(e).borderColor }));
|
|
44
|
+
check(defaultStyle.background !== selectedStyle.background && defaultStyle.border !== selectedStyle.border, { theme,width,zoom,kind:'selection-style' });
|
|
45
|
+
const rowBox = await row.boundingBox();
|
|
46
|
+
await row.click({ position:{x:rowBox.width / 2,y:4} });
|
|
47
|
+
check(await input.isChecked(), { theme,width,zoom,kind:'padding-click' });
|
|
48
|
+
await all.focus(); await page.keyboard.press('Tab');
|
|
49
|
+
check(await input.evaluate(e => e === document.activeElement) && await row.evaluate(e => getComputedStyle(e).boxShadow.includes('inset')), { theme,width,zoom,kind:'keyboard-focus' });
|
|
50
|
+
await page.keyboard.press('Space');
|
|
51
|
+
check(!await input.isChecked(), { theme,width,zoom,kind:'space-toggle' });
|
|
52
|
+
const disabled = container.getByRole('checkbox', { name:'不可用项目',exact:true });
|
|
53
|
+
await disabled.locator('..').scrollIntoViewIfNeeded();
|
|
54
|
+
const disabledBox = await disabled.locator('..').boundingBox();
|
|
55
|
+
await page.mouse.click(disabledBox.x + disabledBox.width / 2, disabledBox.y + disabledBox.height / 2);
|
|
56
|
+
check(!await disabled.isChecked(), { theme,width,zoom,kind:'disabled' });
|
|
57
|
+
if (width === 400 && zoom === 2) {
|
|
58
|
+
await page.getByRole('combobox', { name:'侧栏宽度',exact:true }).selectOption('160');
|
|
59
|
+
await all.focus(); await page.keyboard.press('Tab');
|
|
60
|
+
await container.screenshot({ path:`output/playwright/issue-29/${theme}.png` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { pass:!failures.length && !errors.length, scenarios, failures, errors, scope:'Chromium, three themes, 400/520/880px, 160/240/320px sidebar capped by available space, CSS zoom 100/200%; native browser menu zoom not covered.' };
|
|
65
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Run against the production preview with playwright-cli run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(10000);
|
|
4
|
+
const origin = new URL(page.url()).origin;
|
|
5
|
+
const failures = [], errors = [];
|
|
6
|
+
let scenarios = 0;
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const check = (pass, context) => { if (!pass) failures.push(context); };
|
|
9
|
+
const typography = async (selector, size, leading, weight, context) => {
|
|
10
|
+
const actual = await page.locator(selector).first().evaluate(e => {
|
|
11
|
+
const s = getComputedStyle(e);
|
|
12
|
+
return { size:s.fontSize, leading:s.lineHeight, weight:s.fontWeight };
|
|
13
|
+
});
|
|
14
|
+
check(actual.size === `${size}px` && actual.leading === `${leading}px` && actual.weight === String(weight), { ...context, selector, actual, expected:{size,leading,weight} });
|
|
15
|
+
};
|
|
16
|
+
const wrappedText = async (selector, context) => {
|
|
17
|
+
const actual = await page.locator(selector).evaluate(e => {
|
|
18
|
+
const range = document.createRange();
|
|
19
|
+
range.selectNodeContents(e);
|
|
20
|
+
const lines = Array.from(range.getClientRects());
|
|
21
|
+
const box = e.getBoundingClientRect();
|
|
22
|
+
return {
|
|
23
|
+
lineCount:new Set(lines.map(r => Math.round(r.top))).size,
|
|
24
|
+
unclipped:e.scrollWidth <= e.clientWidth+1 && e.scrollHeight <= e.clientHeight+1 &&
|
|
25
|
+
lines.every(r => r.left >= box.left-1 && r.right <= box.right+1 && r.top >= box.top-1 && r.bottom <= box.bottom+1)
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
check(actual.lineCount > 1 && actual.unclipped, {...context,selector,kind:'text-wrap',actual});
|
|
29
|
+
};
|
|
30
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
31
|
+
for (const width of [400,520,880]) for (const zoom of [1,2]) {
|
|
32
|
+
const context = {theme,width,zoom}, heading = theme === 'neo-brutalism' ? 750 : 600;
|
|
33
|
+
await page.setViewportSize({width,height:1000});
|
|
34
|
+
await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#component-typography`);
|
|
35
|
+
await page.getByRole('heading',{name:'公共组件文字配对',exact:true}).waitFor();
|
|
36
|
+
await page.evaluate(value => { document.documentElement.style.zoom=String(value); },zoom);
|
|
37
|
+
await page.getByRole('button',{name:'模拟复制',exact:true}).click();
|
|
38
|
+
await page.locator('.rhine-copy>[role]').filter({hasText:'已复制'}).waitFor();
|
|
39
|
+
await page.locator('.rhine-theme-preview[data-state=error]').waitFor();
|
|
40
|
+
for (const inherited of [false,true]) {
|
|
41
|
+
if (inherited) await page.locator('.rhine-theme').evaluate(e => { e.style.lineHeight='38px'; e.style.fontWeight='700'; });
|
|
42
|
+
const state = {...context,inherited};
|
|
43
|
+
await typography('.rhine-empty strong',16,24,heading,state);
|
|
44
|
+
await typography('.rhine-error-summary h3',16,24,heading,state);
|
|
45
|
+
for (const selector of ['.rhine-copy>[role]','.rhine-steps small','.rhine-fieldset>p','.rhine-theme-option__status','.rhine-theme-preview']) await typography(selector,12,18,400,state);
|
|
46
|
+
await typography('.rhine-media-card__status',14,22,400,state);
|
|
47
|
+
}
|
|
48
|
+
await page.locator('.rhine-theme').evaluate(e => { e.style.lineHeight=''; e.style.fontWeight=''; });
|
|
49
|
+
check(await page.locator('.rhine-empty').evaluate(e => e.scrollWidth <= e.clientWidth+1), {...context,kind:'empty-wrap'});
|
|
50
|
+
if (width === 400 && zoom === 2) await wrappedText('.rhine-empty strong',context);
|
|
51
|
+
if (width === 400 && zoom === 2) await page.locator('.rhine-empty').screenshot({path:`output/playwright/issue-31/empty-${theme}.png`});
|
|
52
|
+
const opener=page.getByRole('button',{name:'打开媒体示例',exact:true});
|
|
53
|
+
await opener.click();
|
|
54
|
+
const dialog=page.getByRole('dialog');
|
|
55
|
+
await dialog.waitFor();
|
|
56
|
+
await typography('.rhine-media-viewer__header p',12,18,400,context);
|
|
57
|
+
await typography('.rhine-media-viewer__header h2',18,26,heading,context);
|
|
58
|
+
await typography('.rhine-media-viewer__actions label',12,18,400,context);
|
|
59
|
+
if (width === 400 && zoom === 2) await wrappedText('.rhine-media-viewer__header p',context);
|
|
60
|
+
check(await dialog.evaluate(e => e.scrollWidth <= e.clientWidth+1), {...context,kind:'viewer-wrap'});
|
|
61
|
+
check(await dialog.locator('button,select').evaluateAll((elements, zoom) => elements.every(e => e.getBoundingClientRect().height/zoom >= 44),zoom), {...context,kind:'hit-targets'});
|
|
62
|
+
await dialog.getByRole('button',{name:'下一项',exact:true}).click();
|
|
63
|
+
check(await dialog.getByRole('heading',{name:'下一项合成媒体',exact:true}).isVisible(), {...context,kind:'next'});
|
|
64
|
+
if (width === 400 && zoom === 2) await page.locator('.rhine-media-viewer__header').screenshot({path:`output/playwright/issue-31/media-${theme}.png`});
|
|
65
|
+
const close=dialog.getByRole('button',{name:'关闭媒体查看器',exact:true});
|
|
66
|
+
await close.scrollIntoViewIfNeeded();
|
|
67
|
+
check(await close.evaluate(e => { const r=e.getBoundingClientRect(); return r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth; }), {...context,kind:'close-visible'});
|
|
68
|
+
await close.click(); await dialog.waitFor({state:'detached'});
|
|
69
|
+
await page.waitForFunction(() => document.activeElement?.textContent === '打开媒体示例');
|
|
70
|
+
await opener.click(); await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
|
71
|
+
await page.waitForFunction(() => document.activeElement?.textContent === '打开媒体示例');
|
|
72
|
+
scenarios++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {pass:!failures.length&&!errors.length,scenarios,failures,errors,scope:'Chromium, three themes, 400/520/880px, CSS zoom 100/200%; synthetic content and inherited-style stress. Native menu zoom is not covered.'};
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Run against a production preview: playwright-cli run-code --filename=scripts/check-dense-filters.js
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(10000);
|
|
4
|
+
const origin = new URL(page.url()).origin;
|
|
5
|
+
const failures = [], errors = [];
|
|
6
|
+
let scenarios = 0;
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const check = (pass, context) => { if (!pass) failures.push(context); };
|
|
9
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
10
|
+
for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
|
|
11
|
+
const context = { theme, width, zoom };
|
|
12
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
13
|
+
await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}-${scenarios}#compact-filters`);
|
|
14
|
+
await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
|
|
15
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
16
|
+
const tags = page.getByRole('list', { name: '部门已选项' });
|
|
17
|
+
check(await tags.getByRole('listitem').count() === 2, { ...context, kind: 'bounded-tags' });
|
|
18
|
+
check(await tags.evaluate(e => e.scrollWidth <= e.clientWidth + 1 && [...e.children].every(li => li.scrollWidth <= li.clientWidth + 1)), { ...context, kind: 'tags-fit' });
|
|
19
|
+
await page.getByRole('button', { name: '展开已选项' }).click();
|
|
20
|
+
check(await tags.getByRole('listitem').count() === 90 && await tags.evaluate(e => e.clientHeight <= 224 && e.scrollHeight > e.clientHeight), { ...context, kind: 'bounded-expansion' });
|
|
21
|
+
await page.getByRole('button', { name: '移除合成部门 90', exact: true }).click();
|
|
22
|
+
check(await page.getByRole('combobox', { name: '部门', exact: true }).evaluate(e => document.activeElement === e), { ...context, kind: 'remove-focus' });
|
|
23
|
+
await page.getByRole('button', { name: '清空部门', exact: true }).click();
|
|
24
|
+
await page.getByRole('button', { name: '全选部门', exact: true }).click();
|
|
25
|
+
check(await tags.getByRole('listitem').count() === 2, { ...context, kind: 'reset-expansion' });
|
|
26
|
+
const range = page.getByRole('group', { name: '日期区间', exact: true });
|
|
27
|
+
const start = range.getByLabel('开始日期', { exact: true });
|
|
28
|
+
await start.fill('2026-10-01'); await start.focus();
|
|
29
|
+
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowUp');
|
|
30
|
+
check(await start.inputValue() === '2026-10-02', { ...context, kind: 'native-last-segment' });
|
|
31
|
+
check(await range.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind: 'date-fit' });
|
|
32
|
+
if (width === 400 && zoom === 2) await range.screenshot({ path: `output/playwright/issue-27/date-${theme}.png` });
|
|
33
|
+
const trigger = page.getByRole('button', { name: '更多筛选', exact: true });
|
|
34
|
+
await trigger.click();
|
|
35
|
+
const dialog = page.getByRole('dialog', { name: '紧凑筛选示例', exact: true });
|
|
36
|
+
await dialog.evaluate(e => Promise.all(e.getAnimations().map(a => a.finished)));
|
|
37
|
+
check(!await dialog.getAttribute('aria-describedby') && await dialog.locator('.rhine-list-filter-bar__feedback').count() === 0, { ...context, kind: 'omitted-feedback' });
|
|
38
|
+
const segments = dialog.locator('fieldset.rhine-segmented');
|
|
39
|
+
check(await segments.evaluate(e => {
|
|
40
|
+
const items = [...e.querySelectorAll('label')].map(label => ({ label: label.getBoundingClientRect(), span: label.querySelector('span').getBoundingClientRect() }));
|
|
41
|
+
return items.every(item => Math.abs(item.label.width - items[0].label.width) < 1 && Math.abs(item.label.width - item.span.width) < 1 && Math.abs(item.label.height - item.span.height) < 1);
|
|
42
|
+
}), { ...context, kind: 'equal-segment-backgrounds' });
|
|
43
|
+
await segments.getByRole('radio', { name: '今天', exact: true }).focus();
|
|
44
|
+
await page.keyboard.press('ArrowRight');
|
|
45
|
+
check(await segments.getByRole('radio', { name: '昨天', exact: true }).isChecked(), { ...context, kind: 'native-radio-keyboard' });
|
|
46
|
+
check(await dialog.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind: 'drawer-fit' });
|
|
47
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
|
48
|
+
await trigger.waitFor();
|
|
49
|
+
await page.waitForFunction(() => document.activeElement?.textContent?.includes('更多筛选'));
|
|
50
|
+
for (const [state, text] of [['加载', '正在更新结果…'], ['失败', '暂时无法更新合成结果,请重试。']]) {
|
|
51
|
+
await page.getByRole('button', { name: state, exact: true }).click();
|
|
52
|
+
await trigger.click();
|
|
53
|
+
check(await dialog.getByText(text, { exact: true }).isVisible(), { ...context, kind: `feedback-${state}` });
|
|
54
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
|
55
|
+
}
|
|
56
|
+
scenarios++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { pass: failures.length === 0 && errors.length === 0, scenarios, failures, errors, scope: 'Chromium, three themes, 400/520/880px, 100/200% CSS zoom; synthetic data only. Native menu zoom is not covered.' };
|
|
60
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rhine-design
|
|
3
3
|
description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.
|
|
5
|
+
version: "0.10.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Rhine Design · 莱茵设计
|
|
@@ -13,7 +13,7 @@ Use the installed version's components and contracts to build consistent interfa
|
|
|
13
13
|
|
|
14
14
|
## Locate the matching source
|
|
15
15
|
|
|
16
|
-
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.
|
|
16
|
+
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.10.0, including compact filter controls, Checkbox row layout, explicit component typography, ListFilterBar, Surface padding, narrow date layouts, OrbitBackdrop, application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; application APIs require 0.5.0 and OrbitBackdrop requires 0.6.0. Check installed exports before adoption.
|
|
17
17
|
|
|
18
18
|
Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
|
|
19
19
|
|
|
@@ -4,12 +4,14 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
4
4
|
|
|
5
5
|
| Exports | Source |
|
|
6
6
|
| --- | --- |
|
|
7
|
+
| Checkbox `layout="row"`, CheckboxProps(自 0.10.0) | `components/Forms.tsx`、`docs/CHECKBOX_ROWS.md`;整行双行多选,默认 form 保持兼容;npm 0.9.1 不含此选项 |
|
|
7
8
|
| TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
|
|
8
9
|
| Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
|
|
9
10
|
| AppShell.contentHeader(0.8.0) | `components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;正文列常驻导航插槽,与正文滚动区分离,组合 AppShellToggle / Inline / 标题与上下文操作 |
|
|
10
11
|
| MediaCard, MediaViewer(0.5.0) | `components/Media.tsx`;中性画布,不嵌套主题 |
|
|
11
12
|
| Media 主题重设计(0.8.0) | `components/media-parity.css`;`docs/APPLICATION_COMPONENTS.md`;卡片、标签与查看器操作/信息栏随宿主主题,仅媒体画布中性;选择与重试独立 |
|
|
12
13
|
| DateField, DateRangePicker, NumberField, TreeSelect, RemoteMultiSelect(0.5.0) | `components/AdvancedForms.tsx`;请求、时区和查询由宿主负责;只读/校验/多选键盘及AntD/MUI保留差异见 `docs/ANTD_MUI_AUDIT.md` |
|
|
14
|
+
| 紧凑筛选(自 0.10.0,npm 0.9.1 不含) | `docs/COMPACT_FILTER_CONTROLS.md`;SegmentedControl `fullWidth`、DateRangePicker `compact`、RemoteMultiSelect `maxVisibleTags`、ListFilterBar `drawerDescription` / `drawerResult`;保持默认兼容、完整选择值及加载/错误反馈 |
|
|
13
15
|
| Date presets + confirmed custom range(组合,无新增导出) | `docs/DATE_RANGE_FILTER.md`、`preview/DateRangeFilterGallery.tsx`;复用 SegmentedControl + Button + Popover + DateRangePicker,宿主负责时区、预设和查询 |
|
|
14
16
|
| ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
|
|
15
17
|
| OrbitBackdrop, OrbitBackdropProps (0.6.0) | `components/OrbitBackdrop.tsx`;契约 `docs/ORBIT_BACKDROP.md`;`components/orbitRenderer.ts` 为内部绘制实现 |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported baseline
|
|
4
4
|
|
|
5
|
-
Rhine Design 0.
|
|
5
|
+
Rhine Design 0.10.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
8
|
import { Button, Surface, Text } from '@linhey/rhine-design';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Checkbox, type CheckboxProps } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('Checkbox row layout', () => {
|
|
7
|
+
it('toggles once when the controlled checkbox description is clicked', () => {
|
|
8
|
+
const change = vi.fn();
|
|
9
|
+
function Example() {
|
|
10
|
+
const [checked, setChecked] = useState(false);
|
|
11
|
+
return <Checkbox layout="row" label="项目 A" hint="内存卡 · 14 项" checked={checked} onChange={event => { setChecked(event.target.checked); change(event.target.checked); }} />;
|
|
12
|
+
}
|
|
13
|
+
render(<Example />);
|
|
14
|
+
const input = screen.getByRole('checkbox', { name: '项目 A' });
|
|
15
|
+
expect(input).toHaveAccessibleDescription('内存卡 · 14 项');
|
|
16
|
+
fireEvent.click(screen.getByText('内存卡 · 14 项'));
|
|
17
|
+
expect(input).toBeChecked();
|
|
18
|
+
expect(change).toHaveBeenCalledExactlyOnceWith(true);
|
|
19
|
+
});
|
|
20
|
+
it('keeps mixed and disabled states on the native input', () => {
|
|
21
|
+
const change = vi.fn();
|
|
22
|
+
const { rerender } = render(<Checkbox layout="row" label="全部" hint="三个项目" indeterminate disabled onChange={change} />);
|
|
23
|
+
expect(screen.getByRole('checkbox')).toBePartiallyChecked();
|
|
24
|
+
fireEvent.click(screen.getByText('三个项目'));
|
|
25
|
+
expect(change).not.toHaveBeenCalled();
|
|
26
|
+
rerender(<Checkbox layout="row" label="全部" checked disabled onChange={change} />);
|
|
27
|
+
expect(screen.getByRole('checkbox')).toBeChecked();
|
|
28
|
+
expect(screen.getByRole('checkbox')).not.toBePartiallyChecked();
|
|
29
|
+
});
|
|
30
|
+
it('preserves native form submission and reset for uncontrolled selection', () => {
|
|
31
|
+
render(<form aria-label="项目"><Checkbox layout="row" label="项目 A" name="source" value="a" defaultChecked /><button type="reset">重置</button></form>);
|
|
32
|
+
const form = screen.getByRole('form') as HTMLFormElement;
|
|
33
|
+
expect(new FormData(form).getAll('source')).toEqual(['a']);
|
|
34
|
+
fireEvent.click(screen.getByRole('checkbox'));
|
|
35
|
+
expect(new FormData(form).getAll('source')).toEqual([]);
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
|
37
|
+
expect(screen.getByRole('checkbox')).toBeChecked();
|
|
38
|
+
});
|
|
39
|
+
it('preserves caller accessible names and combines descriptions', () => {
|
|
40
|
+
const props: CheckboxProps = { layout: 'row', label: '名称', hint: '说明', 'aria-label': '自定义名称', 'aria-describedby': 'external' };
|
|
41
|
+
render(<><span id="external">附加信息</span><Checkbox {...props} /></>);
|
|
42
|
+
expect(screen.getByRole('checkbox', { name: '自定义名称' })).toHaveAccessibleDescription('附加信息 说明');
|
|
43
|
+
});
|
|
44
|
+
it('keeps the default form hint outside its original label', () => {
|
|
45
|
+
const change = vi.fn();
|
|
46
|
+
render(<Checkbox label="表单字段" hint="原布局" onChange={change} />);
|
|
47
|
+
expect(screen.getByText('原布局').closest('label')).toBeNull();
|
|
48
|
+
fireEvent.click(screen.getByText('原布局'));
|
|
49
|
+
expect(change).not.toHaveBeenCalled();
|
|
50
|
+
fireEvent.click(screen.getByText('表单字段'));
|
|
51
|
+
expect(change).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ListFilterBar, SegmentedControl, Select } from '../components';
|
|
5
|
+
|
|
6
|
+
const field = { id: 'state', label: '状态', control: <Select label="状态" defaultValue="all"><option value="all">全部</option></Select> };
|
|
7
|
+
|
|
8
|
+
describe('compact filter presentation', () => {
|
|
9
|
+
it('omits redundant drawer copy while keeping pending and error feedback', async () => {
|
|
10
|
+
const { rerender } = render(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={null} drawerResult={null} />);
|
|
11
|
+
fireEvent.click(screen.getByRole('button', { name: '更多筛选' }));
|
|
12
|
+
const dialog = await screen.findByRole('dialog', { name: '记录筛选' });
|
|
13
|
+
expect(within(dialog).queryByText('更改后立即更新结果。')).not.toBeInTheDocument();
|
|
14
|
+
expect(within(dialog).queryByText('120 条结果')).not.toBeInTheDocument();
|
|
15
|
+
expect(dialog).not.toHaveAttribute('aria-describedby');
|
|
16
|
+
expect(document.querySelectorAll('.rhine-list-filter-bar__feedback')).toHaveLength(1);
|
|
17
|
+
rerender(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={null} drawerResult={null} pending error="连接暂时不可用" />);
|
|
18
|
+
expect(within(dialog).getByText('正在更新结果…')).toBeInTheDocument();
|
|
19
|
+
expect(within(dialog).getByRole('alert')).toHaveTextContent('连接暂时不可用');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves legacy drawer feedback and supports distinct drawer content', async () => {
|
|
23
|
+
const { rerender } = render(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" />);
|
|
24
|
+
fireEvent.click(screen.getByRole('button', { name: '更多筛选' }));
|
|
25
|
+
const dialog = await screen.findByRole('dialog', { name: '记录筛选' });
|
|
26
|
+
expect(within(dialog).getByText('120 条结果')).toBeInTheDocument();
|
|
27
|
+
expect(dialog).toHaveAccessibleDescription('更改后立即更新结果。');
|
|
28
|
+
rerender(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={<span>按需选择范围</span>} drawerResult="已选 3 项" />);
|
|
29
|
+
expect(dialog).toHaveAccessibleDescription('按需选择范围');
|
|
30
|
+
expect(within(dialog).getByText('已选 3 项')).toBeInTheDocument();
|
|
31
|
+
expect(within(dialog).queryByText('120 条结果')).not.toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('full-width segments remain controlled native radios with disabled state', () => {
|
|
35
|
+
const onChange = vi.fn();
|
|
36
|
+
function Example() {
|
|
37
|
+
const [value, setValue] = useState('today');
|
|
38
|
+
return <SegmentedControl fullWidth label="时间快捷" value={value} onValueChange={next => { setValue(next); onChange(next); }} items={[
|
|
39
|
+
{ value: 'today', label: '今天' }, { value: 'yesterday', label: '昨天' }, { value: 'week', label: '本周', disabled: true },
|
|
40
|
+
]} />;
|
|
41
|
+
}
|
|
42
|
+
render(<Example />);
|
|
43
|
+
expect(screen.getByRole('group', { name: '时间快捷' })).toHaveClass('rhine-segmented--full-width');
|
|
44
|
+
fireEvent.click(screen.getByRole('radio', { name: '昨天' }));
|
|
45
|
+
expect(screen.getByRole('radio', { name: '昨天' })).toBeChecked();
|
|
46
|
+
expect(screen.getByRole('radio', { name: '今天' })).not.toBeChecked();
|
|
47
|
+
expect(screen.getByRole('radio', { name: '本周' })).toBeDisabled();
|
|
48
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith('yesterday');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { DateRangePicker, RemoteMultiSelect, type Choice } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('compact date range', () => {
|
|
7
|
+
it('keeps both native fields and range validation inside one named group', () => {
|
|
8
|
+
const validation = vi.fn();
|
|
9
|
+
function Example() {
|
|
10
|
+
const [value, setValue] = useState({ start: '2026-10-01', end: '2026-10-24' });
|
|
11
|
+
return <DateRangePicker compact label="日期范围" value={value} onValueChange={setValue} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={validation} />;
|
|
12
|
+
}
|
|
13
|
+
render(<Example />);
|
|
14
|
+
const group = screen.getByRole('group', { name: '日期范围' });
|
|
15
|
+
expect(group).toHaveClass('rhine-date-range--compact');
|
|
16
|
+
expect(within(group).getByLabelText('开始日期')).toBeRequired();
|
|
17
|
+
expect(within(group).getByLabelText('结束日期')).toHaveAttribute('type', 'date');
|
|
18
|
+
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-09-30' } });
|
|
19
|
+
expect(validation).toHaveBeenLastCalledWith('结束日期不能早于开始日期。');
|
|
20
|
+
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-10-24' } });
|
|
21
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
22
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2025-12-31' } });
|
|
23
|
+
expect(validation).toHaveBeenLastCalledWith('日期不能早于 2026-01-01。');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves readonly and external value replacement in compact mode', () => {
|
|
27
|
+
const change = vi.fn();
|
|
28
|
+
const { rerender } = render(<DateRangePicker compact label="范围" value={{ start: '2026-10-01', end: '2026-10-24' }} onValueChange={change} readOnly />);
|
|
29
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-10-02' } });
|
|
30
|
+
expect(change).not.toHaveBeenCalled();
|
|
31
|
+
expect(screen.getByRole('button', { name: '清空日期' })).toBeDisabled();
|
|
32
|
+
rerender(<DateRangePicker compact label="范围" value={{ start: '2026-11-01', end: '2026-11-24' }} onValueChange={change} readOnly />);
|
|
33
|
+
expect(screen.getByLabelText('开始日期')).toHaveValue('2026-11-01');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('bounded multi-select tags', () => {
|
|
38
|
+
const choices: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: String(i), label: `合成部门 ${i + 1}`, disabled: i === 0 }));
|
|
39
|
+
function Example() {
|
|
40
|
+
const [value, setValue] = useState(choices.slice(0, 90));
|
|
41
|
+
const [query, setQuery] = useState('');
|
|
42
|
+
return <RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} options={choices} value={value} onValueChange={setValue} maxVisibleTags={2} name="department" />;
|
|
43
|
+
}
|
|
44
|
+
it('shows a bounded summary without dropping hidden selections and expands for removal', () => {
|
|
45
|
+
render(<Example />);
|
|
46
|
+
const tags = screen.getByRole('list', { name: '部门已选项' });
|
|
47
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(2);
|
|
48
|
+
expect(screen.getByText('已选 90 项')).toBeInTheDocument();
|
|
49
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(90);
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: '展开已选项' }));
|
|
51
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(90);
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: '移除合成部门 90' }));
|
|
53
|
+
expect(screen.getByText('已选 89 项')).toBeInTheDocument();
|
|
54
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
55
|
+
fireEvent.click(screen.getByRole('button', { name: '收起已选项' }));
|
|
56
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(2);
|
|
57
|
+
expect(screen.getByRole('button', { name: '移除合成部门 1' })).toBeDisabled();
|
|
58
|
+
});
|
|
59
|
+
it('retains hidden-item keyboard removal and locked selections when clearing', () => {
|
|
60
|
+
render(<Example />);
|
|
61
|
+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
|
|
62
|
+
expect(screen.getByText('已选 89 项')).toBeInTheDocument();
|
|
63
|
+
expect(document.querySelector('input[type=hidden][value="89"]')).toBeNull();
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: '清空选择' }));
|
|
65
|
+
expect(screen.getByText('已选 1 项')).toBeInTheDocument();
|
|
66
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(1);
|
|
67
|
+
expect(screen.queryByRole('button', { name: '展开已选项' })).not.toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
it('collapses a new selection after the host clears an expanded selection', () => {
|
|
70
|
+
const props = { label: '部门', query: '', onQueryChange: vi.fn(), options: choices, onValueChange: vi.fn(), maxVisibleTags: 2, name: 'department' };
|
|
71
|
+
const { rerender } = render(<RemoteMultiSelect {...props} value={choices.slice(0, 90)} />);
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: '展开已选项' }));
|
|
73
|
+
rerender(<RemoteMultiSelect {...props} value={[]} />);
|
|
74
|
+
rerender(<RemoteMultiSelect {...props} value={choices} />);
|
|
75
|
+
expect(within(screen.getByRole('list', { name: '部门已选项' })).getAllByRole('listitem')).toHaveLength(2);
|
|
76
|
+
expect(screen.getByRole('button', { name: '展开已选项' })).toHaveAttribute('aria-expanded', 'false');
|
|
77
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(100);
|
|
78
|
+
});
|
|
79
|
+
});
|