@linhey/rhine-design 0.9.0 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -1
  3. package/components/AdvancedForms.tsx +24 -10
  4. package/components/COMMON_COMPONENTS.md +1 -0
  5. package/components/Forms.tsx +14 -1
  6. package/components/ListFilterBar.tsx +5 -3
  7. package/components/Overlays.tsx +3 -2
  8. package/components/README.md +3 -3
  9. package/components/SPACING.md +6 -0
  10. package/components/TYPOGRAPHY.md +2 -0
  11. package/components/checkbox-row.css +12 -0
  12. package/components/common.css +1 -1
  13. package/components/compact-forms.css +20 -0
  14. package/components/components.tsx +7 -4
  15. package/components/extensions.css +10 -0
  16. package/components/index.ts +2 -1
  17. package/components/media-parity.css +4 -4
  18. package/components/patterns.css +3 -3
  19. package/components/styles.css +6 -2
  20. package/components/theme-picker.css +2 -2
  21. package/components/timeline.css +2 -1
  22. package/docs/APPLICATION_COMPONENTS.md +6 -2
  23. package/docs/CHECKBOX_ROWS.md +36 -0
  24. package/docs/COMPACT_ACCOUNT_PATTERNS.md +3 -1
  25. package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
  26. package/docs/COMPACT_LAYOUT_VERIFICATION.md +53 -0
  27. package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
  28. package/docs/DESIGN_SYSTEM.md +2 -0
  29. package/docs/INTERACTION_PATTERNS.md +6 -0
  30. package/docs/LIST_FILTER_BAR.md +8 -1
  31. package/docs/assets/compact-layouts/account.png +0 -0
  32. package/docs/assets/compact-layouts/date-arknights.png +0 -0
  33. package/docs/assets/compact-layouts/date-neo-brutalism.png +0 -0
  34. package/docs/assets/compact-layouts/date-rhine.png +0 -0
  35. package/docs/assets/compact-layouts/table.png +0 -0
  36. package/package.json +1 -1
  37. package/preview/CheckboxRowGallery.tsx +26 -0
  38. package/preview/CompactAccountGallery.tsx +18 -18
  39. package/preview/CompactFilterBarGallery.tsx +19 -0
  40. package/preview/ComponentTypographyGallery.tsx +23 -0
  41. package/preview/DateRangeFilterGallery.tsx +18 -1
  42. package/preview/DenseFilterControlsGallery.tsx +21 -0
  43. package/preview/GalleryShell.tsx +1 -1
  44. package/preview/SpacingGallery.tsx +15 -1
  45. package/preview/main.tsx +7 -0
  46. package/preview/preview.css +9 -2
  47. package/scripts/check-checkbox-rows.js +65 -0
  48. package/scripts/check-compact-layouts.js +136 -0
  49. package/scripts/check-component-typography.js +76 -0
  50. package/scripts/check-dense-filters.js +60 -0
  51. package/scripts/check-filter-native-date.js +1 -1
  52. package/skills/rhine-design/SKILL.md +3 -3
  53. package/skills/rhine-design/references/API_MAP.md +3 -1
  54. package/skills/rhine-design/references/integration.md +1 -1
  55. package/tests/CheckboxRow.test.tsx +53 -0
  56. package/tests/CompactAccountGallery.test.tsx +2 -1
  57. package/tests/CompactFilterBar.test.tsx +50 -0
  58. package/tests/DenseFilterControls.test.tsx +79 -0
  59. package/tests/DesignSystem.test.tsx +12 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
11
+ ## 0.9.1(2026-10-25)
12
+
13
+ - #23:移除 StatusTimelineList 末项重复留白,保留 44px 命中区;紧凑账号展台的基础信息与今日/本周期读数按稳定列对齐,窄容器自然换行。
14
+ - #24:Surface 新增 `padding="default" | "none"` 和公开 `SurfaceProps`;纯表格可取消外围内距,默认 24px、单元格间距与浮层/焦点行为保持不变。
15
+ - #25:DateRangePicker 按实际容器宽度取消窄容器重复横向内距,DateField 收紧窄编辑区内距并保留 Inline 中的固有宽度;增加 150/200/280 CSS px 的范围与独立日期展台,保留原生日期、日历入口和受控草稿语义。
16
+ - 修正固定合成历史的摘要为“24小时窗口”,避免将 2026-10-23~24 的示例误解为当前最近一天。以上修复见 [PR #26](https://github.com/linhay/rhine-design/pull/26)。
17
+ - 默认 API 行为保持兼容,无需迁移;使用 `Surface padding="none"` 需升级至 0.9.1。验证范围与重跑步骤见[紧凑布局回归](docs/COMPACT_LAYOUT_VERIFICATION.md)。
18
+
3
19
  ## 0.9.0(2026-10-24)
4
20
 
5
21
  - #17:OrbitBackdrop 新增 controls、composition 和 framed;背景画布与静态降级同置右下角,新增按容器宽度重排的入口组合,保留输入和减少动态。
package/README.md CHANGED
@@ -46,11 +46,13 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
46
46
  在你的 React 项目中安装:
47
47
 
48
48
  ```sh
49
- npm install --save-exact @linhey/rhine-design@0.8.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.10.0 新增与修复**:紧凑日期、有限多选标签与等宽分段控件;可省略重复说明的筛选抽屉;侧栏双行 `Checkbox layout="row"`;MediaViewer、EmptyState 等组件的字号/行高/字重配对。默认行为保持兼容,完整说明见[版本记录](CHANGELOG.md#01002026-10-26)。
55
+
54
56
  ```tsx
55
57
  import { Button, Inline, Stack, Surface, Text } from '@linhey/rhine-design';
56
58
 
@@ -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;
@@ -47,7 +48,7 @@ export function DateField({ onValueChange, onValidationChange, error, ...props }
47
48
  };
48
49
  // Chromium can change an empty calendar fragment without firing input/change.
49
50
  const checkFragments = (event: SyntheticEvent<HTMLInputElement>) => { if (event.currentTarget.validity.badInput !== nativeBadInput.current) change(event); };
50
- return <TextField {...props} value={current.draft} error={invalid} type="date" onInput={change} onChange={change} onKeyUp={checkFragments} onBlur={checkFragments} />;
51
+ return <TextField {...props} className="rhine-date-field" value={current.draft} error={invalid} type="date" onInput={change} onChange={change} onKeyUp={checkFragments} onBlur={checkFragments} />;
51
52
  }
52
53
  export interface DateRange { start: string; end: string }
53
54
  export interface DateRangePickerProps {
@@ -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,15 +73,16 @@ export function DateRangePicker({ label, value, onValueChange, min, max, disable
71
73
  setResetRevision(revision => revision + 1);
72
74
  onValueChange(next);
73
75
  };
74
- return <fieldset className="rhine-date-range" disabled={disabled} aria-describedby={[hint && `${id}-hint`, invalid && id].filter(Boolean).join(' ') || undefined}>
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><div className="rhine-inline">
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>}
82
- </fieldset>;
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>}
85
+ </fieldset></div>;
83
86
  }
84
87
 
85
88
  export interface NumberFieldProps {
@@ -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="rhine-multi-select">
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}已选项`}>{value.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>
140
- {clearable && <Button variant="quiet" disabled={props.disabled || readOnly || !value.some(item => !item.disabled)} onClick={() => { onValueChange(value.filter(item => item.disabled)); focusInput(); }}>清空选择</Button>}
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、只读/禁用;允许垂直调整,不做富文本 |
@@ -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 function Checkbox({ label, hint, indeterminate = false, id: suppliedId, className = '', ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & { label: string; hint?: string; indeterminate?: boolean }) {
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>
@@ -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: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
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><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
+ <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 }) {
@@ -10,7 +10,7 @@
10
10
 
11
11
  ## 间距组合
12
12
 
13
- [间距规范](SPACING.md)定义尺度、分组与卡片配方:Surface负责24px内边距,嵌套Stack提供8px标题/说明和16px内容/操作间隔,Inline提供8px按钮间隔;Surface不自动给子元素加间距。展台`#spacing`可检查默认、加载、错误和长文案。
13
+ [间距规范](SPACING.md)定义尺度、分组与卡片配方:Surface默认负责24px内边距,嵌套Stack提供8px标题/说明和16px内容/操作间隔,Inline提供8px按钮间隔;Surface不自动给子元素加间距。单独承载表格时使用 0.9.1 的 `padding="none"`,只取消容器外围内距,保留表头/单元格间距。展台`#spacing`可检查默认、加载、错误、长文案及无外围内距表格。
14
14
 
15
15
  ## 使用
16
16
 
@@ -60,12 +60,12 @@ export function Example() {
60
60
  | Toolbar | title;description;children;HTML div属性 | 自适应控件组合,role=group与普通Tab顺序;不伪装为需要方向键导航的ARIA toolbar;不含业务状态 |
61
61
  | ListFilterBar(0.9.0) | label;search;primary/filters 字段;onReset;refresh;一个 action;result/pending/error | 最多两个常显条件,超出项进入同一 Drawer;宿主维护即时状态、查询和权限;[契约与版本边界](../docs/LIST_FILTER_BAR.md) |
62
62
  | Badge | tone=neutral/success/warning/error;children | 只读短状态,颜色必须配文字,不做可点击按钮 |
63
- | Surface | HTML div 属性 | 静态分区,无点击语义;不是弹窗或导航 |
63
+ | Surface | padding=default/none;HTML div 属性 | 默认24px内距;none仅取消容器padding,适合纯表格;静态分区,无点击语义;padding自0.9.1提供,见[#24](https://github.com/linhay/rhine-design/issues/24) |
64
64
  | Background(0.5.0) | roleTone=canvas/content/sidebar/surface/overlay/media;decoration=auto/theme/none;appearance={color,textColor,texture,textureSize};HTML div 属性 | 默认正文纯色;仅画布默认使用主题纹理;局部覆盖不创建主题根或定位上下文;完整配置见[场景背景](../docs/THEMING.md#场景背景050) |
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 行;单一可访问加载描述,装饰行隐藏 |
@@ -52,6 +52,12 @@
52
52
 
53
53
  页面已有h1时卡片通常用h2;标题等级由结构决定,不能用字号决定。不要为布局引入空Text、空段落或连续br。
54
54
 
55
+ ### 只承载表格的 Surface
56
+
57
+ 纯表格使用 `<Surface padding="none"><DataTable … /></Surface>`,取消容器外围24px内距,表头和单元格仍使用 DataTable 自身的阅读间距。该公开 prop 自 0.9.1 提供,见 [#24](https://github.com/linhay/rhine-design/issues/24);默认 `padding="default"` 保持原来行为。
58
+
59
+ 这不是所有卡片的紧凑开关:正文、表单与混合内容继续采用默认配方。表格上方有标题/操作时,将其放到表格之外或显式分区,不把表头、单元格、分页和焦点空间一并清零;不为了边缘贴合设置 `overflow:hidden` 裁掉焦点。真实选择与长名称示例见 `#spacing`。
60
+
55
61
  行高与间距分别管理:正文14/22px、标题16/24px,独立正文段落默认12px段间距;不是在每一行之间再加gap。详见[行高规范](TYPOGRAPHY.md#行高段落与控件)。
56
62
 
57
63
  ## 常见组合
@@ -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); }
@@ -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; }
@@ -19,8 +19,11 @@ export function Button({ variant = 'primary', busy = false, busyLabel = '处理
19
19
  export function Badge({ tone = 'neutral', children }: { tone?: Tone; children: ReactNode }) {
20
20
  return <span className={`rhine-badge rhine-tone-${tone}`}>{children}</span>;
21
21
  }
22
- export function Surface({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
23
- return <div {...props} className={classes('rhine-surface', className)} />;
22
+ export interface SurfaceProps extends HTMLAttributes<HTMLDivElement> {
23
+ padding?: 'default' | 'none';
24
+ }
25
+ export function Surface({ className, padding = 'default', ...props }: SurfaceProps) {
26
+ return <div {...props} className={classes('rhine-surface', padding === 'none' && 'rhine-surface--padding-none', className)} />;
24
27
  }
25
28
  export function SelectionCard({ label, selected, onSelectedChange, disabled = false, showIndicator = true, children }: {
26
29
  label: string; selected: boolean; onSelectedChange: (value: boolean) => void; disabled?: boolean; showIndicator?: boolean; children: ReactNode;
@@ -64,9 +67,9 @@ export function Tabs({ label, items, value, onValueChange }: { label: string; it
64
67
  hidden={value !== item.value} tabIndex={0} className="rhine-tabs__panel">{item.content}</div>)}
65
68
  </div>;
66
69
  }
67
- 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 }) {
68
71
  const name = useId();
69
- return <fieldset className="rhine-segmented"><legend className="rhine-sr-only">{label}</legend>
72
+ return <fieldset className={classes('rhine-segmented', fullWidth && 'rhine-segmented--full-width')}><legend className="rhine-sr-only">{label}</legend>
70
73
  {items.map(item => <label key={item.value} className="rhine-segmented__option">
71
74
  <input type="radio" name={name} value={item.value} checked={value === item.value} disabled={item.disabled} onChange={() => onValueChange(item.value)} />
72
75
  <span>{item.label}</span>
@@ -13,10 +13,20 @@
13
13
  .rhine-theme[data-rhine-theme=arknights] .rhine-shell__content-header { border-bottom-color:var(--rhine-green); }
14
14
  .rhine-shell__content { flex:1; min-width:0; min-height:0; overflow:auto; padding:24px; overflow-wrap:anywhere; }
15
15
  @media(max-width:760px) { .rhine-shell__content-header { padding:8px 16px; } .rhine-shell__content { padding:16px; } }
16
+ /* Inline-size containment must retain an intrinsic width in wrapping Inline layouts. */
17
+ .rhine-date-field { container:rhine-date-field / inline-size; contain-intrinsic-inline-size:180px; }
18
+ @container rhine-date-field (max-width:180px) {
19
+ .rhine-date-field input[type=date] { padding-inline:var(--rhine-space-1); }
20
+ }
21
+ .rhine-date-range-container { width:100%; min-width:0; container:rhine-date-range / inline-size; }
16
22
  .rhine-date-range { margin:0; min-width:0; padding:16px; border:1px solid var(--rhine-line); background:var(--rhine-field-group-bg); }
17
23
  .rhine-date-range legend { padding:0 4px; font-weight:600; }
18
24
  .rhine-date-range__fields { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr)); gap:16px; margin-bottom:16px; }
19
25
  .rhine-date-range input { max-width:100%; min-width:0; }
26
+ @container rhine-date-range (max-width:280px) {
27
+ .rhine-date-range { padding-inline:0; border-inline:0; }
28
+ .rhine-date-range__fields { grid-template-columns:minmax(0,1fr); }
29
+ }
20
30
  .rhine-number-field { display:grid; gap:8px; min-width:0; }
21
31
  .rhine-multi-select { min-width:0; }
22
32
  .rhine-multi-select__tags { display:flex; flex-wrap:wrap; gap:8px; list-style:none; padding:0; margin:8px 0; }
@@ -1,4 +1,5 @@
1
1
  import './styles.css';
2
+ export type { CheckboxProps } from './Forms';
2
3
  export { NeutronStarBackdrop } from './NeutronStarBackdrop';
3
4
  export type { NeutronStarBackdropProps } from './NeutronStarBackdrop';
4
5
  export { OrbitBackdrop } from './OrbitBackdrop';
@@ -44,6 +45,6 @@ export { Fieldset, ErrorSummary } from './FormWorkflow';
44
45
  export type { FieldsetProps, FieldError, ErrorSummaryProps } from './FormWorkflow';
45
46
 
46
47
  export { Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState } from './components';
47
- export type { ButtonProps, Tone, Choice, TabItem } from './components';
48
+ export type { ButtonProps, SurfaceProps, Tone, Choice, TabItem } from './components';
48
49
 
49
50
  export { ThemePicker, ThemePreview, type ThemePickerProps, type ThemePickerOption, type ThemePreviewProps } from './ThemePicker';
@@ -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; }
@@ -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:12px; font-weight:400; }
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:12px; }
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:16px; }
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); }
@@ -35,6 +35,7 @@
35
35
  .rhine-tone-error { --rhine-tone:var(--rhine-error); --rhine-tone-bg:var(--rhine-error-bg); }
36
36
  .rhine-badge { display:inline-block; padding:2px 8px; border-radius:2px; background:var(--rhine-tone-bg); color:var(--rhine-tone); font:500 12px/20px var(--rhine-font); }
37
37
  .rhine-surface { padding:var(--rhine-space-6); border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-card); background:var(--rhine-surface-bg); color:var(--rhine-surface-text); min-width:0; }
38
+ .rhine-surface--padding-none { padding:0; }
38
39
  .rhine-selection { display:block; border:1px solid var(--rhine-control); background:var(--rhine-surface); border-radius:var(--rhine-radius-card); padding:var(--rhine-space-6); cursor:pointer; min-width:0; transition:background-color var(--rhine-duration-hover) var(--rhine-ease-standard),border-color var(--rhine-duration-hover) var(--rhine-ease-standard),box-shadow var(--rhine-duration-hover) var(--rhine-ease-standard); }
39
40
  .rhine-selection:hover:not([data-disabled=true]) { border-color:var(--rhine-green); background:#f4f7ef; }
40
41
  .rhine-selection[data-selected=true] { border-color:var(--rhine-green); background:var(--rhine-selected); box-shadow:inset 0 0 0 1px var(--rhine-green); }
@@ -63,7 +64,10 @@
63
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; }
64
65
  .rhine-segmented__option { position:relative; display:flex; flex:1 0 auto; cursor:pointer; }
65
66
  .rhine-segmented input { position:absolute; opacity:0; width:1px; height:1px; }
66
- .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; }
67
71
  .rhine-segmented input:checked+span { background:var(--rhine-ink); color:var(--rhine-surface); }
68
72
  .rhine-segmented input:focus-visible+span { outline:2px solid var(--rhine-green); outline-offset:3px; }
69
73
  .rhine-segmented input:disabled+span { opacity:.6; cursor:default; }
@@ -87,7 +91,7 @@
87
91
  .rhine-disclosure[data-expanded=true] summary>span { transform:rotate(45deg); }
88
92
  .rhine-disclosure__content { padding:4px 0 20px; }
89
93
  .rhine-empty { padding:32px 24px; text-align:center; border:1px solid var(--rhine-line); background:var(--rhine-surface); }
90
- .rhine-empty strong { font-size:16px; }.rhine-empty p { max-width:400px; margin:8px auto 20px; color:var(--rhine-muted); }
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); }
91
95
  @keyframes rhine-spin { to { transform:rotate(360deg); } }
92
96
  .rhine-logo { display:inline-flex; flex:none; align-items:center; vertical-align:middle; color:var(--rhine-green); line-height:1; }
93
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); }
@@ -26,9 +26,10 @@
26
26
  .rhine-timeline-popover { z-index:50; width:min(360px,calc(100vw - 24px)); max-height:var(--radix-popover-content-available-height); overflow:auto; padding:16px; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-card); background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); box-shadow:0 4px 12px #242b2814; overflow-wrap:anywhere; }
27
27
  .rhine-timeline-popover .rhine-key-value { margin-block:16px; }
28
28
  .rhine-timeline--compact:not(:hover):not(:focus-within) .rhine-timeline__bars [data-selected=true] { height:28px; outline:none; }
29
- .rhine-timeline-list { list-style:none; margin:0; padding:4px 4px var(--rhine-space-4); min-width:0; }
29
+ .rhine-timeline-list { list-style:none; margin:0; padding:4px 4px var(--rhine-space-2); min-width:0; }
30
30
  .rhine-timeline-list__item { display:grid; gap:var(--rhine-space-2); min-width:0; padding-block:var(--rhine-space-4); }
31
31
  .rhine-timeline-list__item:first-child { padding-top:0; }
32
+ .rhine-timeline-list__item:last-child { padding-bottom:0; }
32
33
  .rhine-timeline-list__item+.rhine-timeline-list__item { border-top:1px solid var(--rhine-line); }
33
34
  .rhine-timeline-list__heading { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:baseline; gap:var(--rhine-space-2); min-width:0; overflow-wrap:anywhere; }
34
35
  .rhine-timeline-list__heading>* { min-width:0; }
@@ -105,15 +105,19 @@ 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}]` | 两个原生日期输入,自定义范围、受控预设、清空;结束日期下界取 start/min 较晚者;反向范围显示错误,不静默调换 |
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
 
115
115
  DateField 新增 `readOnly/required/id/name/hint/onValidationChange`;外部日期或 min/max 改变会更新格式/范围错误,不自动回写值。DateRangePicker 同样支持 readOnly/required/hint/onValidationChange,另有 `clearable`(默认 true)、`startLabel/endLabel/startName/endName`。预设含非法日期、反向区间或任一端越界时禁用。onValidationChange 回传格式/范围/显式 error,required 仍走提交时的原生表单校验。只读时保留浏览与聚焦,预设、清空、步进和选择不能更改值。
116
116
 
117
+ 原生日期的未完成片段可能同时表现为 `value=""` 与 `validity.badInput=true`,不能把它当作用户清空边界。DateField 保留此局部编辑状态并回传“请填写完整有效的日期。”,完整有效提议仍由宿主受控接受或拒绝;DateRangePicker 汇总两端校验。内置清空/预设同时清掉原生草稿;宿主在已接受值不变时强制恢复整个字段,须用独立 reset revision/key 明确重置,不能期待同值 props 推断重置意图。这些行为已包含于 #22 主线修复,消费旧副本需单独升级。
118
+
119
+ 窄容器的重复横向内距由共享日期组处理:0.9.1 的 [#25](https://github.com/linhay/rhine-design/issues/25) 修复在实际容器不超过280 CSS px时取消日期组横向 padding/边框,并单列排列两端;DateField 自身不超过180 CSS px时仅将水平 padding 收到已有4px token,保持输入字号、44px 高度、完整日期和原生日历入口。此尺寸修复不更改日期值/API。400px视口、200% CSS zoom 的 Drawer 内输入曾仅116 CSS px,且 `scrollWidth === clientWidth`,因此必须实际读写最后日期片段,不能仅以无 DOM 溢出验收。单个 DateField 的宽度仍由宿主容器提供,同样需核对实际编辑区;本修复不承诺任意小宽度都能完整容纳浏览器原生日期。
120
+
117
121
  NumberField 支持小数步进(默认 1);precision 限制为 0–15 位,未指定时普通提交保留解析后的 JS 数值,步进清除至 12 位的浮点尾差;IEEE-754 数值精度不适用于高精度货币账本。`-`、`1.` 等草稿在编辑时不改写;外部 value 真正变化时覆盖草稿。未改变值的失焦/Enter 不触发 onValueChange;输入法组词时不提交或步进。
118
122
 
119
123
  NumberField 另有 `readOnly/required/id/name/hint/controls`(按钮默认显示);Escape 取消未提交草稿并恢复受控值,第二次 Escape 可交给外层浮层。min/max 变化会直接标明当前越界,不伪造用户输入或自动修改宿主值。空值步进按有效范围处理,不再把空字符串当成已达到零边界。
@@ -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
+ ```