@linhey/rhine-design 0.11.0 → 0.11.1
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 +11 -0
- package/README.md +1 -1
- package/components/AdvancedForms.tsx +13 -5
- package/components/Forms.tsx +66 -6
- package/components/common.css +2 -0
- package/components/useFloatingScale.ts +7 -3
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/FLOATING_OVERLAYS.md +16 -0
- package/package.json +1 -1
- package/preview/DenseFilterControlsGallery.tsx +5 -2
- package/scripts/check-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-multiselect-completion.js +46 -0
- package/tests/FormParity.test.tsx +104 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.11.1(2026-10-27)
|
|
4
|
+
|
|
5
|
+
- 补齐嵌套下拉退出到父Modal焦点约束恢复之间的快速Tab保护;连续Shift+Tab不再把焦点留在背景,正常导航恢复后仍可循环。
|
|
6
|
+
|
|
7
|
+
- 候选列表保持240px上限并按视口缩放约束,避免可用高度反馈;多选内部动作等click完成再收起,避免窄屏缩放中pointerdown/抬起之间位置移动丢失清空。
|
|
8
|
+
|
|
9
|
+
- 修复 Combobox / RemoteMultiSelect 在祖先 CSS zoom 下的候选浮层定位与视口边界,复用既有浮层缩放处理;保留主题 Portal、搜索、多选与键盘行为。
|
|
10
|
+
- RemoteMultiSelect 可再次点击或方向键+Enter取消已选项,保留真正禁用/锁定项;“完成选择”只收起候选并归焦,不提交宿主表单;支持输入框Tab进入完成、Shift+Tab返回及继续Tab到后继控件;标签×移除和清空后保持收起,输入框Backspace保留原展开态,无可移除项时隐藏清空按钮。
|
|
11
|
+
- `#compact-filters` 补充弹窗内多选与真实浏览器回归;参见[浮层契约](docs/FLOATING_OVERLAYS.md)。
|
|
12
|
+
- 补丁制品从 `v0.11.0` 回补上述修复,主入口仍为67项;main 后续未发布新功能不包含在0.11.1中。消费方固定版本并提交 registry lockfile。
|
|
13
|
+
|
|
3
14
|
## 0.11.0(2026-10-27)
|
|
4
15
|
|
|
5
16
|
本版本新增产品评审工具及以下已合并能力;npm 0.10.0 不包含这些新增 API。组件主入口仍为 67 项,默认行为保持兼容;消费方固定 0.11.0 并提交 lockfile。实际发布可用性以 registry 为准。
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
42
42
|
在你的 React 项目中安装:
|
|
43
43
|
|
|
44
44
|
```sh
|
|
45
|
-
npm install --save-exact @linhey/rhine-design@0.11.
|
|
45
|
+
npm install --save-exact @linhey/rhine-design@0.11.1
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
|
|
@@ -132,25 +132,33 @@ export interface RemoteMultiSelectProps {
|
|
|
132
132
|
}
|
|
133
133
|
export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, maxVisibleTags, ...props }: RemoteMultiSelectProps) {
|
|
134
134
|
const inputId = useId();
|
|
135
|
+
const [open, setOpen] = useState(false);
|
|
135
136
|
const [expanded, setExpanded] = useState(false);
|
|
136
137
|
const limit = maxVisibleTags !== undefined && Number.isFinite(maxVisibleTags) ? Math.max(0, Math.floor(maxVisibleTags)) : undefined;
|
|
137
138
|
const collapsible = limit !== undefined && value.length > limit;
|
|
138
139
|
// Clearing or replacing the selection below the limit starts a new compact view.
|
|
139
140
|
if (expanded && !collapsible) setExpanded(false);
|
|
140
141
|
const shown = collapsible && !expanded ? value.slice(0, limit) : value;
|
|
141
|
-
const focusInput = () => document.getElementById(inputId)?.focus();
|
|
142
|
-
const remove = (key: string) => {
|
|
142
|
+
const focusInput = (nextOpen = false) => { document.getElementById(inputId)?.focus(); setOpen(nextOpen); };
|
|
143
|
+
const remove = (key: string, nextOpen = false) => {
|
|
143
144
|
if (props.disabled || readOnly || value.find(item => item.value === key)?.disabled) return;
|
|
144
|
-
onValueChange(value.filter(item => item.value !== key)); focusInput();
|
|
145
|
+
onValueChange(value.filter(item => item.value !== key)); focusInput(nextOpen);
|
|
145
146
|
};
|
|
146
147
|
return <div className={`rhine-multi-select${limit !== undefined ? ' rhine-multi-select--compact' : ''}`}>
|
|
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 => {
|
|
148
|
+
<Combobox {...props} id={inputId} open={open} onOpenChange={setOpen} readOnly={readOnly} selectedValues={value.map(item => item.value)} closeOnSelect={closeOnSelect} completionLabel={closeOnSelect ? undefined : '完成选择'} onEmptyBackspace={() => { const last = [...value].reverse().find(item => !item.disabled); if (last) remove(last.value, open); }} options={options.map(option => ({ ...option, disabled: option.disabled || value.some(item => item.value === option.value && item.disabled) }))} value={null} onValueChange={option => {
|
|
149
|
+
const original = options.find(item => item.value === option.value);
|
|
150
|
+
if (!original || original.disabled || props.disabled || readOnly) return;
|
|
151
|
+
const selected = value.find(item => item.value === original.value);
|
|
152
|
+
if (selected?.disabled) return;
|
|
153
|
+
onValueChange(selected ? value.filter(item => item.value !== original.value) : [...value, original]);
|
|
154
|
+
if (clearQueryOnSelect) props.onQueryChange('');
|
|
155
|
+
}} />
|
|
148
156
|
{name && value.map(item => <input key={item.value} type="hidden" name={name} value={item.value} disabled={props.disabled} />)}
|
|
149
157
|
<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
158
|
<div className="rhine-multi-select__actions">
|
|
151
159
|
{limit !== undefined && <span className="rhine-text rhine-text--caption rhine-text--muted">已选 {value.length} 项</span>}
|
|
152
160
|
{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
|
|
161
|
+
{clearable && value.some(item => !item.disabled) && <Button type="button" variant="quiet" disabled={props.disabled || readOnly} onClick={() => { onValueChange(value.filter(item => item.disabled)); focusInput(); }}>清空选择</Button>}
|
|
154
162
|
</div>
|
|
155
163
|
</div>;
|
|
156
164
|
}
|
package/components/Forms.tsx
CHANGED
|
@@ -2,6 +2,7 @@ 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 { useFloatingScale } from './useFloatingScale';
|
|
5
6
|
import './checkbox-row.css';
|
|
6
7
|
|
|
7
8
|
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
@@ -47,14 +48,21 @@ export interface ComboboxProps {
|
|
|
47
48
|
value: string | null; onValueChange: (option: Choice) => void; options: Choice[];
|
|
48
49
|
loading?: boolean; error?: string; disabled?: boolean;
|
|
49
50
|
readOnly?: boolean; placeholder?: string; hint?: string; id?: string;
|
|
50
|
-
selectedValues?: string[]; closeOnSelect?: boolean; onEmptyBackspace?: () => void;
|
|
51
|
+
selectedValues?: string[]; closeOnSelect?: boolean; onEmptyBackspace?: () => void; completionLabel?: string;
|
|
52
|
+
open?: boolean; onOpenChange?: (open: boolean) => void;
|
|
51
53
|
}
|
|
52
|
-
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false, readOnly = false, placeholder, hint, id: suppliedId, selectedValues, closeOnSelect = true, onEmptyBackspace }: ComboboxProps) {
|
|
54
|
+
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false, readOnly = false, placeholder, hint, id: suppliedId, selectedValues, closeOnSelect = true, onEmptyBackspace, completionLabel, open: controlledOpen, onOpenChange }: ComboboxProps) {
|
|
53
55
|
const generatedId = useId(), id = suppliedId || generatedId; const composing = useRef(false);
|
|
56
|
+
const floatingRef = useFloatingScale(undefined, 'popover', 240);
|
|
57
|
+
const exitingWithTab = useRef(false);
|
|
58
|
+
const releaseTabGuard = useRef<(() => void) | undefined>(undefined);
|
|
59
|
+
useEffect(() => () => releaseTabGuard.current?.(), []);
|
|
54
60
|
const root = useRef<HTMLDivElement>(null);
|
|
55
61
|
const [container, setContainer] = useState<HTMLElement>();
|
|
56
62
|
useLayoutEffect(() => { setContainer((root.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
57
|
-
const [
|
|
63
|
+
const [internalOpen, setInternalOpen] = useState(false), [active, setActive] = useState<string | null>(null), [draft, setDraft] = useState<string | null>(null);
|
|
64
|
+
const open = controlledOpen ?? internalOpen;
|
|
65
|
+
const setOpen = (next: boolean) => { if (controlledOpen === undefined) setInternalOpen(next); onOpenChange?.(next); };
|
|
58
66
|
useEffect(() => { if (disabled || readOnly) { setOpen(false); setActive(null); setDraft(null); composing.current = false; } }, [disabled, readOnly]);
|
|
59
67
|
const available = loading || error ? [] : options.filter(option => !option.disabled);
|
|
60
68
|
const activeIndex = options.findIndex(option => option.value === active && !option.disabled);
|
|
@@ -62,20 +70,72 @@ export function Combobox({ label, query, onQueryChange, value, onValueChange, op
|
|
|
62
70
|
useEffect(() => { if (!open || !activeId) return; document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' }); }, [activeId, open]);
|
|
63
71
|
const choose = (option: Choice) => { if (readOnly || disabled || loading || error) return; onValueChange(option); if (closeOnSelect) setOpen(false); setActive(null); };
|
|
64
72
|
const visibleOpen = open && !disabled && !readOnly;
|
|
65
|
-
|
|
73
|
+
// Keep a multiselect action in place until its click finishes. Closing on
|
|
74
|
+
// pointerdown/blur can shift a zoomed page before pointerup reaches the button.
|
|
75
|
+
const containsInteractionTarget = (target: EventTarget | null) => target instanceof Node && (root.current?.closest('.rhine-multi-select') ?? root.current)?.contains(target);
|
|
76
|
+
const beginTabExit = () => {
|
|
77
|
+
exitingWithTab.current = true;
|
|
78
|
+
releaseTabGuard.current?.();
|
|
79
|
+
const dialog = root.current?.closest<HTMLElement>('.rhine-dialog[data-state="open"]');
|
|
80
|
+
if (!dialog) return;
|
|
81
|
+
const doc = dialog.ownerDocument;
|
|
82
|
+
let lastInside = doc.activeElement instanceof HTMLElement && dialog.contains(doc.activeElement) ? doc.activeElement : document.getElementById(id);
|
|
83
|
+
// The nested Radix scope resumes its parent asynchronously on unmount.
|
|
84
|
+
// Keep that short interval contained even for repeated keyboard events.
|
|
85
|
+
const guard = (event: FocusEvent) => {
|
|
86
|
+
if (!dialog.isConnected || dialog.dataset.state !== 'open') return;
|
|
87
|
+
const target = event.target;
|
|
88
|
+
if (!(target instanceof HTMLElement)) return;
|
|
89
|
+
if (dialog.contains(target)) lastInside = target;
|
|
90
|
+
else if (!document.getElementById(`${id}-popup`)?.contains(target) && lastInside?.isConnected) {
|
|
91
|
+
lastInside.focus({ preventScroll: true });
|
|
92
|
+
if (lastInside.id === id) { setOpen(false); setActive(null); }
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
doc.addEventListener('focusin', guard);
|
|
96
|
+
releaseTabGuard.current = () => { doc.removeEventListener('focusin', guard); releaseTabGuard.current = undefined; };
|
|
97
|
+
};
|
|
98
|
+
const complete = () => { document.getElementById(id)?.focus(); setOpen(false); setActive(null); };
|
|
99
|
+
return <div ref={root} className="rhine-combobox rhine-field" onBlur={event => { if (!containsInteractionTarget(event.relatedTarget) && !document.getElementById(`${id}-popup`)?.contains(event.relatedTarget)) setOpen(false); }}><label htmlFor={id}>{label}</label>
|
|
66
100
|
<PopoverBase.Root open={visibleOpen} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-combobox__input"><input id={id} role="combobox" aria-autocomplete="list" aria-expanded={visibleOpen} aria-controls={visibleOpen ? `${id}-list` : undefined} aria-activedescendant={visibleOpen ? activeId : undefined} aria-invalid={!!error || undefined} aria-describedby={[`${id}-status`, hint && `${id}-hint`].filter(Boolean).join(' ')} value={draft ?? query} disabled={disabled} readOnly={readOnly} placeholder={placeholder}
|
|
67
101
|
onClick={() => { if (!readOnly && !disabled) setOpen(true); }} onFocus={() => { if (!readOnly && !disabled) setOpen(true); }} onChange={event => { if (readOnly || disabled) return; if (composing.current) setDraft(event.target.value); else onQueryChange(event.target.value); setActive(null); setOpen(true); }}
|
|
68
102
|
onCompositionStart={() => { if (!readOnly && !disabled) { composing.current = true; setDraft(query); } }} onCompositionEnd={event => { composing.current = false; setDraft(null); if (!readOnly && !disabled) onQueryChange(event.currentTarget.value); }}
|
|
69
103
|
onKeyDown={event => {
|
|
70
104
|
if (readOnly || disabled || composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
105
|
+
if (event.key === 'Tab' && open) {
|
|
106
|
+
if (!event.shiftKey && completionLabel) {
|
|
107
|
+
const completion = document.getElementById(`${id}-complete`);
|
|
108
|
+
if (completion) { event.preventDefault(); completion.focus(); return; }
|
|
109
|
+
}
|
|
110
|
+
beginTabExit();
|
|
111
|
+
}
|
|
71
112
|
if (event.key === 'Backspace' && !event.currentTarget.value && onEmptyBackspace) { event.preventDefault(); onEmptyBackspace(); }
|
|
72
113
|
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false); setActive(null); }
|
|
73
114
|
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true); const index = available.findIndex(option => option.value === active); const next = event.key === 'ArrowDown' ? (index + 1) % available.length : (index <= 0 ? available.length : index) - 1; setActive(available[next]?.value ?? null); }
|
|
74
115
|
if (open && ['Home', 'End'].includes(event.key)) { event.preventDefault(); setActive((event.key === 'Home' ? available[0] : available.at(-1))?.value ?? null); }
|
|
75
116
|
if (open && event.key === 'Enter') { event.preventDefault(); const option = available.find(item => item.value === active); if (option) choose(option); }
|
|
76
117
|
}} /><span className="rhine-combobox__arrow"><Icon name="chevronDown" size={16} /></span></div></PopoverBase.Anchor>
|
|
77
|
-
<PopoverBase.Portal container={container}><PopoverBase.Content asChild sideOffset={4} collisionPadding={12} onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event =>
|
|
78
|
-
|
|
118
|
+
<PopoverBase.Portal container={container}><PopoverBase.Content asChild updatePositionStrategy="always" sideOffset={4} collisionPadding={12} onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event => {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
releaseTabGuard.current?.();
|
|
121
|
+
if (!exitingWithTab.current) return;
|
|
122
|
+
exitingWithTab.current = false;
|
|
123
|
+
// Radix resumes the parent scope after this callback. Rapid Tab can
|
|
124
|
+
// reach a guard or the background during that interval; restore focus
|
|
125
|
+
// only for a keyboard exit from a still-open Rhine modal.
|
|
126
|
+
const dialog = root.current?.closest('.rhine-dialog[data-state="open"]');
|
|
127
|
+
const active = document.activeElement;
|
|
128
|
+
if (active?.hasAttribute('data-radix-focus-guard') || (dialog && !dialog.contains(active))) complete();
|
|
129
|
+
}} onInteractOutside={event => { if (containsInteractionTarget(event.target)) event.preventDefault(); }}>
|
|
130
|
+
<div ref={floatingRef} className="rhine-combobox__list" id={`${id}-popup`} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); complete(); } }}>
|
|
131
|
+
<div role="listbox" id={`${id}-list`} aria-label={label} aria-multiselectable={selectedValues ? true : undefined} aria-busy={loading}>{!loading && !error && options.map((option, index) => <div role="option" id={`${id}-option-${index}`} key={option.value} aria-selected={selectedValues ? selectedValues.includes(option.value) : option.value === value} aria-disabled={option.disabled || undefined} data-active={option.value === active} onMouseDown={event => event.preventDefault()} onMouseMove={() => { if (!option.disabled) setActive(option.value); }} onClick={() => { if (!option.disabled) choose(option); }}>{selectedValues && <span className="rhine-combobox__check" aria-hidden="true">{selectedValues.includes(option.value) ? '✓' : ''}</span>}{option.label}</div>)}{(loading || error || !options.length) && <div className="rhine-combobox__empty" role="presentation">{loading ? '正在读取…' : error ? '暂不可用' : '暂无匹配结果'}</div>}</div>
|
|
132
|
+
{completionLabel && <div className="rhine-combobox__completion"><Button id={`${id}-complete`} type="button" variant="quiet" onMouseDown={event => event.preventDefault()} onClick={complete} onKeyDown={event => {
|
|
133
|
+
if (event.key !== 'Tab') return;
|
|
134
|
+
if (event.shiftKey) { event.preventDefault(); document.getElementById(id)?.focus(); }
|
|
135
|
+
// Restore the input as the origin of the browser's normal forward Tab.
|
|
136
|
+
else { beginTabExit(); complete(); }
|
|
137
|
+
}}>{completionLabel}</Button></div>}
|
|
138
|
+
</div>
|
|
79
139
|
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root>
|
|
80
140
|
<span id={`${id}-status`} role="status" className={`rhine-text rhine-text--caption ${error ? 'rhine-text--error' : 'rhine-text--muted'}`}>{loading ? '正在读取候选项…' : error || (options.length ? `${options.length} 个候选项` : '暂无匹配结果')}</span>
|
|
81
141
|
{hint && <span id={`${id}-hint`} className="rhine-field__hint">{hint}</span>}
|
package/components/common.css
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
.rhine-combobox__list [role=option],.rhine-combobox__empty { padding:12px; min-height:44px; cursor:pointer; overflow-wrap:anywhere; }
|
|
48
48
|
.rhine-combobox__list [role=option][data-active=true],.rhine-combobox__list [role=option][aria-selected=true] { background:var(--rhine-selected); }
|
|
49
49
|
.rhine-combobox__list [role=option][aria-disabled=true],.rhine-combobox__empty { color:var(--rhine-muted); cursor:default; }
|
|
50
|
+
.rhine-combobox__check { display:inline-block; width:1.5em; font-weight:600; }
|
|
51
|
+
.rhine-combobox__completion { position:sticky; bottom:0; display:flex; justify-content:flex-end; padding:var(--rhine-space-1) var(--rhine-space-2); border-top:1px solid var(--rhine-control); background:var(--rhine-overlay-bg); }
|
|
50
52
|
.rhine-overlay-anchor { display:inline-block; vertical-align:middle; max-width:100%; }
|
|
51
53
|
.rhine-overlay { position:fixed; inset:0; background:var(--rhine-backdrop-bg); z-index:40; }
|
|
52
54
|
.rhine-dialog { position:fixed; z-index:41; top:50%; left:50%; transform:translate(-50%,-50%); width:min(480px,calc(100% - 32px)); max-height:calc(100% - 32px); overflow:auto; padding:24px; border:1px solid var(--rhine-line); border-radius:4px; background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); box-shadow:0 16px 48px #242b2826; }
|
|
@@ -29,7 +29,7 @@ export function useFloatingSide(anchor: RefObject<HTMLSpanElement | null>, side:
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Keep Radix's viewport coordinates unscaled while preserving the user's content zoom. */
|
|
32
|
-
export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind = 'popover') {
|
|
32
|
+
export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind = 'popover', maxHeight?: number) {
|
|
33
33
|
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
|
34
34
|
const attach = useCallback((element: HTMLDivElement | null) => {
|
|
35
35
|
if (ref) ref.current = element;
|
|
@@ -62,7 +62,11 @@ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind =
|
|
|
62
62
|
content.style.zoom = String(scale);
|
|
63
63
|
const width = `calc((100vw - 24px) / ${scale})`;
|
|
64
64
|
content.style.maxWidth = kind === 'tooltip' ? `min(280px, ${width})` : width;
|
|
65
|
-
|
|
65
|
+
// Bounded suggestion lists retain their own height cap. Feeding Radix's
|
|
66
|
+
// available-height variable back into their size can cause resize loops.
|
|
67
|
+
content.style.maxHeight = maxHeight === undefined
|
|
68
|
+
? `calc(var(--radix-${kind}-content-available-height) / ${scale})`
|
|
69
|
+
: `min(${maxHeight}px, calc((100vh - 24px) / ${scale}))`;
|
|
66
70
|
if (kind === 'dropdown-menu') content.style.minWidth = `min(180px, ${width})`;
|
|
67
71
|
constrain();
|
|
68
72
|
};
|
|
@@ -91,6 +95,6 @@ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind =
|
|
|
91
95
|
content.style.maxHeight = original.maxHeight;
|
|
92
96
|
content.style.minWidth = original.minWidth;
|
|
93
97
|
};
|
|
94
|
-
}, [content, kind]);
|
|
98
|
+
}, [content, kind, maxHeight]);
|
|
95
99
|
return attach;
|
|
96
100
|
}
|
|
@@ -108,7 +108,7 @@ SideNav 继续负责当前项、长名称和 aria-current;Drawer 继续负责
|
|
|
108
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、`maxVisibleTags?` | 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
|
|
|
@@ -126,7 +126,7 @@ TreeSelect 使用 tree/treeitem/group,Tab 进入一个可见节点,上下/Ho
|
|
|
126
126
|
|
|
127
127
|
首次进入优先已选可见节点;字符前缀定位不改变选择,`*` 展开同层分支,Ctrl/⌘+A 仅切换可见可选节点。外部收起时焦点回可见祖先。节点 `selectable=false` 用于结构分组;readOnly 允许浏览展开而禁止选择。`name` 为选中 key 生成同名 hidden inputs,disabled 不提交。
|
|
128
128
|
|
|
129
|
-
RemoteMultiSelect 复用 Combobox 的方向键、Enter、Escape 和 IME
|
|
129
|
+
RemoteMultiSelect 复用 Combobox 的方向键、Enter、Escape 和 IME 防误提交。加载或失败时禁止选择旧候选,已选项仍显示且可移除;默认选中后保持候选列表打开,可再次点击或方向键+Enter取消已选候选;真正禁用/锁定项不可更改。打开多选候选时,输入框Tab进入“完成选择”,Shift+Tab返回输入框;从完成按钮继续Tab关闭候选并沿输入框后的正常控件前进。面板底部“完成选择”只收起候选并归焦输入框,保留查询和值,不提交宿主表单;最终保存由宿主负责。Escape或点击外部也保留草稿。`closeOnSelect=true` 恢复选完关闭。`clearQueryOnSelect` 默认 false;新增 readOnly/clearable/placeholder/hint/name。空查询 Backspace 删除最后一个可移除标签,标签×移除与清空统一归焦输入框并收起候选;输入框内空查询Backspace删除则保留原展开/收起状态,不因归焦改变候选状态。仅重新点击、输入或方向键才展开。没有可移除项(含0项或只有锁定项)时隐藏“清空选择”;锁定标签保留。name 以同名 hidden inputs 提交受控 keys。单选 Combobox 的默认关闭行为不变。异步取消、序列检查、服务端查询和时区解释全部归宿主。展台有可取消的本地延迟结果演示,但不是生产数据层。
|
|
130
130
|
|
|
131
131
|
分组底色 `--rhine-field-group-bg`;树缩进 `--rhine-tree-indent`;其余输入、选择、错误和焦点继续用既有语义 tokens,字号不低于 12px。
|
|
132
132
|
|
|
@@ -12,4 +12,20 @@ Popover、Menu、Tooltip、StatusTimeline 与 TimelineLegend 的 Portal 保留
|
|
|
12
12
|
|
|
13
13
|
真实展台 `#overlay-bounds` 使用独立 iframe,提供三主题、400/520/880px、100%/200%、文档/主题根/正文、四角锚点和四个优先方向。账号长文本、日期字段、取消/应用、菜单末项均为合成数据。
|
|
14
14
|
|
|
15
|
+
0.11.1 补丁:Combobox/RemoteMultiSelect也接入同一useFloatingScale,修正0.11.0直接使用Radix Content遗漏坐标归一化的问题。`#compact-filters`增加弹窗内多选,核对400/520/880px与100/200% CSS zoom时列表边界、鼠标选择及Escape。旧0.11.0不包含此修复;产品查询、分页和取消仍由宿主负责。追踪 #16 / #40。回归脚本 `scripts/check-combobox-zoom.js` 使用当前 Playwright 页面 origin,需先打开该版本的构建展台;结果必须 `pass: true`,不能只检查 CLI 退出码。CSS zoom 证据不外推浏览器原生缩放。
|
|
16
|
+
|
|
15
17
|
浏览器回归入口为 `scripts/check-overlay-bounds.js`,使用 Playwright CLI 的 `run-code --filename`。先运行 `npm run build && npx vite preview --host 127.0.0.1 --port 18480 --strictPort`;脚本固定使用 18480,并非 `npm run dev` 的默认端口。检查实际浮层和操作边界、内部滚动后的真实点击、焦点归还,以及打开期间动态缩放的输入节点与草稿保持;仅 `document.scrollWidth` 无法证明浮层没有越界。
|
|
18
|
+
|
|
19
|
+
0.11.1 同时保留多选反选、明确完成操作和焦点归还。候选打开期间使用 Radix 的 `updatePositionStrategy="always"` 跟随锚点矩形变化:底层按帧比较矩形,有变化才重算,卸载时清理。此设置用于动态 CSS zoom;默认策略在 WebKit 的200%→100%检查出现 ResizeObserver 告警,改为该策略后定向三主题12组清零。一次将共享hook的ResizeObserver延后到rAF的试验无效,未保留该修改;不把告警无条件过滤掉。
|
|
20
|
+
|
|
21
|
+
验证记录(2026-10-27):维护基线292项测试与全量构建/包清单通过。main 首次并行315项中中子星异步初始化用例一次未读到预期标签(loading),此前312项通过;保留首次日志,另以单worker复验,不据此推定根因。WebKit旧候选的36组边界/交互通过但有ResizeObserver告警,rAF试改无效并撤回;原0.11.0的400px动态缩放对照确认浮层越界,未把旧失败算作新版本验收。
|
|
22
|
+
|
|
23
|
+
首个候选(发布前被标签×修复取代):Chromium 与 WebKit 各36组三主题×400/520/880px×100%/200% CSS zoom×页内/弹窗场景通过,包含打开时动态缩放、鼠标/键盘反选、空结果、长列表完成按钮、焦点/节点/草稿保留、打开/关闭两态清空后不展开,pageerror均0。main单worker315项、类型/全部构建/包清单复验通过;首次异步用例失败仍保留,未修改该用例或推定失败根因。浏览器回归使用 `scripts/check-combobox-zoom.js`,额外完成行为脚本为 `scripts/check-multiselect-completion.js`。
|
|
24
|
+
|
|
25
|
+
标签移除收口:首个制品候选在发布前发现标签×也会因归焦重新展开,因此撤出发布,原制品与记录保留。RemoteMultiSelect 统一归焦策略:标签移除/清空收起,输入框空查询Backspace保留原状态;新单测覆盖原开/关两态、剩余值、查询、焦点与不提交。最终维护基线296项、main319项全量检查通过;发布制品和消费项目以最终registry摘要及#40回读为准。
|
|
26
|
+
|
|
27
|
+
完成按钮的Tab路径由#57补齐:输入Tab进入、Shift+Tab返回、继续Tab沿输入框原后继前进;嵌套WebKit关闭时如停在Radix焦点保护节点才归焦恢复,普通外点关闭不抢焦点。浏览器断言按本机原生Tab顺序比对,不能假定各平台都将标签按钮纳入原生Tab。Combobox保留240px内容高度上限并按视口缩放约束,不把Radix可用高度变量反馈到候选列表尺寸;其他浮层默认策略保持原样。
|
|
28
|
+
|
|
29
|
+
多选的标签和清空按钮属于同一交互区域:pointerdown/焦点转移不提前销毁浮层,等按钮click完成后收起。否则窄屏缩放时滚动布局可能在按下与抬起之间移动,让click落在公共祖先而丢失清空动作;浏览器用真实指针路径验证。
|
|
30
|
+
|
|
31
|
+
快速键盘退出嵌套下拉时,Radix在异步卸载回调后恢复父FocusScope;这一间隙用临时focusin保护维持最近的Modal内焦点,父约束恢复即清理,组件卸载也清理。快速连续键可能保持当前内部焦点,但不能落到背景;正常Tab循环随后恢复。check-combobox-modal-focus.js先断言即时包含关系,再单独检查恢复后的循环,不用预先延时掩盖失败。
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { Button, DateRangePicker, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
2
|
+
import { Button, DateRangePicker, Dialog, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
3
3
|
|
|
4
4
|
const departments: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: `synthetic-${i}`, label: `合成部门 ${i + 1}${i % 8 === 0 ? ' · 跨团队协作与长期计划' : ''}` }));
|
|
5
5
|
|
|
@@ -12,9 +12,12 @@ export function DenseFilterControlsGallery() {
|
|
|
12
12
|
const options = departments.filter(item => item.label.includes(query));
|
|
13
13
|
return <Surface><Stack gap={4}>
|
|
14
14
|
<Text as="h2" variant="heading">密集筛选控件</Text>
|
|
15
|
-
<Text tone="muted">100 个合成部门、90
|
|
15
|
+
<Text tone="muted">100 个合成部门、90 个已选项;可搜索、再次点选取消、完成选择收起、展开移除或清空。日期保留原生键盘和日历。</Text>
|
|
16
16
|
<Inline gap={2}><Button variant="secondary" onClick={() => setSelected(departments)}>全选部门</Button><Button variant="quiet" onClick={() => setSelected([])}>清空部门</Button></Inline>
|
|
17
17
|
<RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2} placeholder="搜索部门" />
|
|
18
|
+
<Dialog trigger={<Button variant="secondary">在弹窗内选择</Button>} title="搜索多选" description="再次点击已选项可取消;完成选择只收起列表,不提交表单。">
|
|
19
|
+
<RemoteMultiSelect label="弹窗部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2}/>
|
|
20
|
+
</Dialog>
|
|
18
21
|
<DateRangePicker compact label="日期区间" value={date} onValueChange={setDate} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={setError} />
|
|
19
22
|
<Text variant="caption" tone="muted">{error || `${date.start} 至 ${date.end}`}</Text>
|
|
20
23
|
</Stack></Surface>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Regression for the interval between a nested popup closing and its modal scope resuming.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin = new URL(page.url()).origin, cases = [], errors = [];
|
|
4
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
5
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 880]) for (const zoom of [1, 2]) {
|
|
6
|
+
await page.setViewportSize({ width, height: 960 });
|
|
7
|
+
await page.goto(`${origin}/?theme=${theme}#compact-filters`); await page.reload();
|
|
8
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
9
|
+
await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
|
|
10
|
+
const input = page.getByRole('combobox', { name: '弹窗部门', exact: true });
|
|
11
|
+
await input.click(); await input.press('Shift+Tab');
|
|
12
|
+
await page.keyboard.press('Shift+Tab');
|
|
13
|
+
const immediateInside = await page.getByRole('dialog').evaluate(element => element.contains(document.activeElement));
|
|
14
|
+
// Assert the fast path before waiting; then separately verify normal navigation resumes.
|
|
15
|
+
await page.waitForTimeout(50);
|
|
16
|
+
const close = page.getByRole('button', { name: '关闭对话框', exact: true });
|
|
17
|
+
await close.focus(); await page.keyboard.press('Shift+Tab');
|
|
18
|
+
const resumed = await page.getByRole('dialog').evaluate(element => element.contains(document.activeElement) && document.activeElement?.getAttribute('aria-label') !== '关闭对话框');
|
|
19
|
+
cases.push({ theme, width, zoom, immediateInside, resumed });
|
|
20
|
+
}
|
|
21
|
+
return { pass: cases.every(item => item.immediateInside && item.resumed) && !errors.length, cases, errors };
|
|
22
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Open the built gallery, then run with Playwright CLI run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin = new URL(page.url()).origin;
|
|
4
|
+
page.setDefaultTimeout(10000);
|
|
5
|
+
const failures = [], errors = [], cases = [];
|
|
6
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
7
|
+
const bounds = locator => locator.evaluate(element => {
|
|
8
|
+
const r = element.getBoundingClientRect();
|
|
9
|
+
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight };
|
|
10
|
+
});
|
|
11
|
+
const contained = r => r.left >= -1 && r.top >= -1 && r.right <= r.width + 1 && r.bottom <= r.height + 1;
|
|
12
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
13
|
+
for (const width of [400, 520, 880]) for (const zoom of [1, 2]) for (const modal of [false, true]) {
|
|
14
|
+
const spec = { theme, width, zoom, modal };
|
|
15
|
+
await page.setViewportSize({ width, height: 960 });
|
|
16
|
+
await page.goto(`${origin}/?theme=${theme}#compact-filters`);
|
|
17
|
+
await page.reload();
|
|
18
|
+
await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
|
|
19
|
+
await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
|
|
20
|
+
if (modal) await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
|
|
21
|
+
const label = modal ? '弹窗部门' : '部门';
|
|
22
|
+
const input = page.getByRole('combobox', { name: label, exact: true });
|
|
23
|
+
await input.fill('合成部门 99');
|
|
24
|
+
const list = page.getByRole('listbox', { name: label, exact: true });
|
|
25
|
+
await list.getByRole('option', { name: '合成部门 99', exact: true }).waitFor();
|
|
26
|
+
await page.waitForTimeout(200);
|
|
27
|
+
// Native Tab targets differ with browser/platform keyboard preferences.
|
|
28
|
+
// Compare the exit with the same input's ordinary closed-popup Tab path.
|
|
29
|
+
await input.press('Escape'); await input.press('Tab');
|
|
30
|
+
const naturalNext = await page.evaluateHandle(() => document.activeElement);
|
|
31
|
+
await input.click(); await list.waitFor();
|
|
32
|
+
const popup = list.locator('..');
|
|
33
|
+
const completion = popup.getByRole('button', { name: '完成选择', exact: true });
|
|
34
|
+
await input.press('Tab');
|
|
35
|
+
const tabIn = await completion.evaluate(element => element === document.activeElement);
|
|
36
|
+
await completion.press('Shift+Tab');
|
|
37
|
+
const tabBack = await input.evaluate(element => element === document.activeElement);
|
|
38
|
+
await input.press('Tab'); await completion.press('Tab');
|
|
39
|
+
await list.waitFor({ state: 'detached' });
|
|
40
|
+
const tabOut = await page.waitForFunction(element => element === document.activeElement, naturalNext, { timeout: 2000 }).then(() => true, () => false);
|
|
41
|
+
await naturalNext.dispose();
|
|
42
|
+
await input.click(); await list.waitFor();
|
|
43
|
+
|
|
44
|
+
const rect = await bounds(popup);
|
|
45
|
+
await list.getByRole('option', { name: '合成部门 99', exact: true }).click();
|
|
46
|
+
const selected = await list.getByRole('option', { name: '合成部门 99', exact: true }).getAttribute('aria-selected');
|
|
47
|
+
await input.press('Escape');
|
|
48
|
+
await list.waitFor({ state: 'detached' });
|
|
49
|
+
const focusRetained = await input.evaluate(element => element === document.activeElement);
|
|
50
|
+
await input.fill('没有匹配的合成部门');
|
|
51
|
+
await list.getByText('暂无匹配结果', { exact: true }).waitFor();
|
|
52
|
+
await input.fill('合成部门 98');
|
|
53
|
+
await input.press('ArrowDown');
|
|
54
|
+
await input.press('Enter');
|
|
55
|
+
const keyboardSelected = await list.getByRole('option', { name: '合成部门 98', exact: true }).getAttribute('aria-selected');
|
|
56
|
+
const node = await input.elementHandle();
|
|
57
|
+
await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom === 1 ? 2 : 1); }, zoom);
|
|
58
|
+
await page.waitForTimeout(200);
|
|
59
|
+
const stable = await node.evaluate(element => element.isConnected && element.value === '合成部门 98');
|
|
60
|
+
const dynamic = await bounds(popup);
|
|
61
|
+
const option = list.getByRole('option', { name: '合成部门 98', exact: true });
|
|
62
|
+
await option.click();
|
|
63
|
+
const toggledOff = await option.getAttribute('aria-selected') === 'false';
|
|
64
|
+
await input.press('ArrowDown');
|
|
65
|
+
await input.press('Enter');
|
|
66
|
+
const toggledOn = await option.getAttribute('aria-selected') === 'true';
|
|
67
|
+
await input.fill('');
|
|
68
|
+
await list.getByRole('option').last().waitFor();
|
|
69
|
+
const done = popup.getByRole('button', { name: '完成选择', exact: true });
|
|
70
|
+
await done.scrollIntoViewIfNeeded();
|
|
71
|
+
const doneBounds = await bounds(done);
|
|
72
|
+
const longListBounds = await bounds(popup);
|
|
73
|
+
const boundedList = longListBounds.bottom - longListBounds.top <= Math.min(240 * (zoom === 1 ? 2 : 1), 936) + 1;
|
|
74
|
+
await done.click();
|
|
75
|
+
await list.waitFor({ state: 'detached' });
|
|
76
|
+
const completed = await input.evaluate(element => element === document.activeElement && element.value === '' && element.getAttribute('aria-expanded') === 'false');
|
|
77
|
+
const field = page.locator('.rhine-multi-select').filter({ has: input });
|
|
78
|
+
let cleared = true;
|
|
79
|
+
for (const initiallyOpen of [false, true]) {
|
|
80
|
+
await input.fill('合成部门 99');
|
|
81
|
+
await input.press('ArrowDown'); await input.press('Enter');
|
|
82
|
+
if (!initiallyOpen) await popup.getByRole('button', { name: '完成选择', exact: true }).click();
|
|
83
|
+
await field.getByRole('button', { name: '清空选择', exact: true }).click();
|
|
84
|
+
await list.waitFor({ state: 'detached' });
|
|
85
|
+
cleared = cleared && await input.evaluate(element => element === document.activeElement && element.value === '合成部门 99' && element.getAttribute('aria-expanded') === 'false') && await field.getByRole('button', { name: '清空选择', exact: true }).count() === 0;
|
|
86
|
+
}
|
|
87
|
+
for (const name of ['合成部门 99', '合成部门 98']) {
|
|
88
|
+
await input.fill(name); await input.press('ArrowDown');
|
|
89
|
+
await list.getByRole('option', { name, exact: true }).waitFor();
|
|
90
|
+
await input.press('Enter');
|
|
91
|
+
await page.waitForFunction(({ id, name }) => [...document.getElementById(id).querySelectorAll('[role=option]')].some(element => element.textContent.trim().replace(/^✓/, '').trim() === name && element.getAttribute('aria-selected') === 'true'), { id: await list.getAttribute('id'), name });
|
|
92
|
+
}
|
|
93
|
+
await popup.getByRole('button', { name: '完成选择', exact: true }).click();
|
|
94
|
+
await field.getByRole('button', { name: '移除合成部门 99', exact: true }).click();
|
|
95
|
+
const tagRemoved = await input.evaluate(element => element === document.activeElement && element.value === '合成部门 98' && element.getAttribute('aria-expanded') === 'false');
|
|
96
|
+
await input.fill(''); await input.press('Backspace');
|
|
97
|
+
const backspaceRetained = await input.getAttribute('aria-expanded') === 'true' && await field.getByRole('button', { name: '清空选择', exact: true }).count() === 0;
|
|
98
|
+
await input.press('Escape');
|
|
99
|
+
const pass = boundedList && tabIn && tabBack && tabOut && cleared && tagRemoved && backspaceRetained && contained(rect) && contained(dynamic) && selected === 'true' && keyboardSelected === 'true' && focusRetained && stable && toggledOff && toggledOn && contained(doneBounds) && completed;
|
|
100
|
+
cases.push({ ...spec, pass });
|
|
101
|
+
if (!pass) failures.push({ ...spec, rect, dynamic, selected, keyboardSelected, focusRetained, stable, toggledOff, toggledOn, doneBounds, completed, cleared, tagRemoved, backspaceRetained, tabIn, tabBack, tabOut, boundedList, longListBounds });
|
|
102
|
+
if (modal) {
|
|
103
|
+
await input.press('Escape');
|
|
104
|
+
await page.getByRole('dialog', { name: '搜索多选', exact: true }).waitFor({ state: 'detached' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { pass: !failures.length && !errors.length, cases: cases.length, failures, errors };
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Open the built gallery first; run with playwright-cli run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin = await page.evaluate(() => location.origin);
|
|
4
|
+
const cases = [], errors = [];
|
|
5
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
6
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
|
|
7
|
+
await page.setViewportSize({ width, height: 960 });
|
|
8
|
+
await page.goto(`${origin}/?theme=${theme}#compact-filters`);
|
|
9
|
+
await page.reload();
|
|
10
|
+
await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
|
|
11
|
+
await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
|
|
12
|
+
await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
|
|
13
|
+
const dialog = page.getByRole('dialog', { name: '搜索多选', exact: true });
|
|
14
|
+
const input = dialog.getByRole('combobox', { name: '弹窗部门', exact: true });
|
|
15
|
+
await input.fill('合成部门 99');
|
|
16
|
+
const option = page.getByRole('option', { name: '合成部门 99', exact: true });
|
|
17
|
+
await option.click();
|
|
18
|
+
if (await option.getAttribute('aria-selected') !== 'true') throw Error('selection failed');
|
|
19
|
+
await option.click();
|
|
20
|
+
if (await option.getAttribute('aria-selected') !== 'false') throw Error('pointer deselection failed');
|
|
21
|
+
await input.press('ArrowDown'); await input.press('Enter');
|
|
22
|
+
if (await option.getAttribute('aria-selected') !== 'true') throw Error('keyboard selection failed');
|
|
23
|
+
await input.press('ArrowDown'); await input.press('Enter');
|
|
24
|
+
if (await option.getAttribute('aria-selected') !== 'false') throw Error('keyboard deselection failed');
|
|
25
|
+
await option.click();
|
|
26
|
+
const done = page.getByRole('button', { name: '完成选择', exact: true });
|
|
27
|
+
const popup = await done.evaluate(el => { const r = el.closest('.rhine-combobox__list').getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight }; });
|
|
28
|
+
if (popup.left < -1 || popup.right > popup.width + 1 || popup.top < -1 || popup.bottom > popup.height + 1) throw Error(`popup outside viewport: ${JSON.stringify({ theme, width, zoom, popup })}`);
|
|
29
|
+
await done.click();
|
|
30
|
+
await page.getByRole('listbox').waitFor({ state: 'detached' });
|
|
31
|
+
if (!await input.evaluate(el => el === document.activeElement)) throw Error('completion lost focus');
|
|
32
|
+
if (!await dialog.isVisible() || await input.inputValue() !== '合成部门 99') throw Error('completion changed draft/dialog');
|
|
33
|
+
await input.fill('');
|
|
34
|
+
await page.getByRole('option', { name: '合成部门 99', exact: true }).waitFor();
|
|
35
|
+
await done.scrollIntoViewIfNeeded();
|
|
36
|
+
const rect = await done.evaluate(el => { const r = el.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight }; });
|
|
37
|
+
if (rect.left < -1 || rect.right > rect.width + 1 || rect.top < -1 || rect.bottom > rect.height + 1) throw Error(`completion outside viewport: ${JSON.stringify({ theme, width, zoom, rect })}`);
|
|
38
|
+
await done.focus(); await done.press('Enter');
|
|
39
|
+
await page.getByRole('listbox').waitFor({ state: 'detached' });
|
|
40
|
+
await input.click(); await done.focus(); await done.press('Escape');
|
|
41
|
+
await page.getByRole('listbox').waitFor({ state: 'detached' });
|
|
42
|
+
if (!await dialog.isVisible()) throw Error('Escape closed parent dialog');
|
|
43
|
+
cases.push({ theme, width, zoom, pass: true });
|
|
44
|
+
}
|
|
45
|
+
return { pass: !errors.length, cases: cases.length, errors };
|
|
46
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { Combobox, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
|
|
4
|
+
import { Combobox, Dialog, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
|
|
5
5
|
|
|
6
6
|
describe('date field parity', () => {
|
|
7
7
|
it('reports controlled invalid dates and changed bounds without rewriting the value', () => {
|
|
@@ -106,6 +106,64 @@ describe('number field parity', () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
describe('remote multi-select parity', () => {
|
|
109
|
+
it.each([false, true])('removes a tag without reopening candidates (initially open: %s)', initiallyOpen => {
|
|
110
|
+
const query = vi.fn(), submit = vi.fn(event => event.preventDefault());
|
|
111
|
+
function Demo() {
|
|
112
|
+
const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]);
|
|
113
|
+
return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="保留查询" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]} /></form>;
|
|
114
|
+
}
|
|
115
|
+
render(<Demo />); const input = screen.getByRole('combobox');
|
|
116
|
+
if (initiallyOpen) act(() => input.focus());
|
|
117
|
+
fireEvent.click(screen.getByRole('button', { name: '移除Beta' }));
|
|
118
|
+
expect(screen.queryByRole('button', { name: '移除Beta' })).not.toBeInTheDocument();
|
|
119
|
+
expect(screen.getByRole('button', { name: '移除Alpha' })).toBeVisible();
|
|
120
|
+
expect(input).toHaveFocus(); expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
121
|
+
expect(input).toHaveValue('保留查询'); expect(query).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: '移除Alpha' }));
|
|
123
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
124
|
+
expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.each([false, true])('Backspace retains the current candidate state (open: %s)', initiallyOpen => {
|
|
128
|
+
function Demo() {
|
|
129
|
+
const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }]);
|
|
130
|
+
return <RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }]} />;
|
|
131
|
+
}
|
|
132
|
+
render(<Demo />); const input = screen.getByRole('combobox'); act(() => input.focus());
|
|
133
|
+
if (!initiallyOpen) fireEvent.keyDown(input, { key: 'Escape' });
|
|
134
|
+
fireEvent.keyDown(input, { key: 'Backspace' });
|
|
135
|
+
expect(screen.queryByRole('button', { name: '移除Alpha' })).not.toBeInTheDocument();
|
|
136
|
+
expect(input).toHaveAttribute('aria-expanded', String(initiallyOpen)); expect(input).toHaveFocus();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each([false, true])('clears without opening candidates (initially open: %s) and hides empty actions', initiallyOpen => {
|
|
140
|
+
const query = vi.fn(), submit = vi.fn(event => event.preventDefault());
|
|
141
|
+
function Demo() {
|
|
142
|
+
const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }]);
|
|
143
|
+
return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="保留查询" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }]} maxVisibleTags={2} /></form>;
|
|
144
|
+
}
|
|
145
|
+
render(<Demo />); const input = screen.getByRole('combobox');
|
|
146
|
+
if (initiallyOpen) act(() => input.focus());
|
|
147
|
+
const clear = screen.getByRole('button', { name: '清空选择' });
|
|
148
|
+
fireEvent.pointerDown(clear); act(() => clear.focus());
|
|
149
|
+
if (initiallyOpen) expect(screen.getByRole('listbox', { name: '成员' })).toBeVisible();
|
|
150
|
+
else expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
151
|
+
fireEvent.pointerUp(clear); fireEvent.click(clear);
|
|
152
|
+
expect(screen.getByText('已选 0 项')).toBeVisible();
|
|
153
|
+
expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
|
|
154
|
+
expect(input).toHaveFocus(); expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
155
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
156
|
+
expect(input).toHaveValue('保留查询'); expect(query).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
|
|
157
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
158
|
+
expect(screen.getByRole('button', { name: '清空选择' })).toBeVisible();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('hides clear when only locked choices remain', () => {
|
|
162
|
+
render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'locked', label: '固定', disabled: true }]} onValueChange={vi.fn()} options={[]} />);
|
|
163
|
+
expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
|
|
164
|
+
expect(screen.getByRole('button', { name: '移除固定' })).toBeDisabled();
|
|
165
|
+
});
|
|
166
|
+
|
|
109
167
|
it('keeps the list open for consecutive selection and exposes the selected list semantics', () => {
|
|
110
168
|
const query = vi.fn();
|
|
111
169
|
function Demo() {
|
|
@@ -117,12 +175,41 @@ describe('remote multi-select parity', () => {
|
|
|
117
175
|
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
118
176
|
expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true');
|
|
119
177
|
expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
|
|
120
|
-
fireEvent.keyDown(input, { key: '
|
|
178
|
+
fireEvent.keyDown(input, { key: 'End' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
121
179
|
expect(screen.getByRole('button', { name: '移除Beta' })).toBeVisible();
|
|
122
180
|
expect(container.querySelectorAll('input[type="hidden"][name="member"]')).toHaveLength(3);
|
|
123
181
|
expect(query).not.toHaveBeenCalled();
|
|
124
182
|
});
|
|
125
183
|
|
|
184
|
+
it('toggles selected candidates with pointer and keyboard, then completes without submitting', () => {
|
|
185
|
+
const submit = vi.fn(event => event.preventDefault());
|
|
186
|
+
function Demo() {
|
|
187
|
+
const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }, { value: 'locked', label: 'Locked', disabled: true }]);
|
|
188
|
+
return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="查询" onQueryChange={() => {}} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: 'Locked' }, { value: 'off', label: 'Disabled', disabled: true }]} /></form>;
|
|
189
|
+
}
|
|
190
|
+
render(<Demo />);
|
|
191
|
+
const input = screen.getByRole('combobox'); act(() => input.focus());
|
|
192
|
+
fireEvent.click(screen.getByRole('option', { name: 'Alpha' }));
|
|
193
|
+
expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false');
|
|
194
|
+
expect(screen.queryByRole('button', { name: '移除Alpha' })).not.toBeInTheDocument();
|
|
195
|
+
fireEvent.click(screen.getByRole('option', { name: 'Locked' }));
|
|
196
|
+
fireEvent.click(screen.getByRole('option', { name: 'Disabled' }));
|
|
197
|
+
expect(screen.getByRole('button', { name: '移除Locked' })).toBeDisabled();
|
|
198
|
+
expect(screen.queryByRole('button', { name: '移除Disabled' })).not.toBeInTheDocument();
|
|
199
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
200
|
+
expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
|
|
201
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
202
|
+
expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false');
|
|
203
|
+
const done = screen.getByRole('button', { name: '完成选择' }); act(() => done.focus());
|
|
204
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
205
|
+
fireEvent.click(done);
|
|
206
|
+
expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveFocus();
|
|
207
|
+
expect(input).toHaveValue('查询'); expect(submit).not.toHaveBeenCalled();
|
|
208
|
+
fireEvent.click(input); act(() => screen.getByRole('button', { name: '完成选择' }).focus());
|
|
209
|
+
fireEvent.keyDown(screen.getByRole('button', { name: '完成选择' }), { key: 'Escape' });
|
|
210
|
+
expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveFocus();
|
|
211
|
+
});
|
|
212
|
+
|
|
126
213
|
it('removes the last removable tag with Backspace while respecting IME and locked values', () => {
|
|
127
214
|
const change = vi.fn(); render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: '固定', disabled: true }]} onValueChange={change} options={[]} />);
|
|
128
215
|
const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.compositionStart(input);
|
|
@@ -183,3 +270,18 @@ describe('tree keyboard parity', () => {
|
|
|
183
270
|
fireEvent.keyDown(group, { key: 'ArrowRight' }); expect(expand).toHaveBeenLastCalledWith(['root']);
|
|
184
271
|
});
|
|
185
272
|
});
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
describe('combobox modal keyboard exit', () => {
|
|
276
|
+
it('keeps focus inside while the nested scope is resuming its parent', () => {
|
|
277
|
+
render(<><button data-testid="background-action">背景动作</button><Dialog trigger={<button>打开选择</button>} title="成员选择" description="合成测试" open><RemoteMultiSelect label="弹窗成员" query="" onQueryChange={() => {}} value={[]} onValueChange={() => {}} options={[{ value: 'a', label: 'Alpha' }]} /></Dialog></>);
|
|
278
|
+
const input = screen.getByRole('combobox', { name: '弹窗成员' });
|
|
279
|
+
act(() => input.focus());
|
|
280
|
+
fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
|
|
281
|
+
const close = screen.getByRole('button', { name: '关闭对话框' });
|
|
282
|
+
act(() => close.focus());
|
|
283
|
+
act(() => screen.getByTestId('background-action').focus());
|
|
284
|
+
expect(close).toHaveFocus();
|
|
285
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
286
|
+
});
|
|
287
|
+
});
|