Commit 7fe72c77 authored by Andy-bubu's avatar Andy-bubu

fix: restore dialog select interactions

parent aff4fdb3
......@@ -112,19 +112,16 @@ export default function FormSelect({
isDisabled: disabled,
isRequired: required,
selectionMode: multiple ? 'multiple' : 'single',
shouldCloseOnSelect: !multiple,
variant: 'secondary',
};
if (multiple) {
sharedProps.selectedKeys = new Set(selectedValues.map(toOptionKey));
sharedProps.onSelectionChange = (keys) => {
const nextKeys =
keys === 'all' ? options.map((option) => option.value) : [...keys];
emitChange(nextKeys);
};
sharedProps.value = selectedValues.map(toOptionKey);
sharedProps.onChange = (keys) => emitChange(Array.isArray(keys) ? keys : []);
} else {
sharedProps.selectedKey = toOptionKey(selectedValues[0]);
sharedProps.onSelectionChange = (key) => emitChange([key]);
sharedProps.value = toOptionKey(selectedValues[0]);
sharedProps.onChange = (key) => emitChange([key]);
}
return (
......@@ -141,7 +138,9 @@ export default function FormSelect({
{displayText}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
<Select.Indicator className="rc-filter-select__indicator">
<ChevronDown />
</Select.Indicator>
</Select.Trigger>
<Select.Popover
className="rc-form-select__popover rc-filter-select__popover"
......
......@@ -48,6 +48,8 @@ Map component structures as follows:
All enum filter dropdowns must use the shared HeroUI `Select + ListBox` adapter (`FilterSelect.jsx`). All enum fields in business forms and dialogs must use the shared HeroUI compatibility adapter (`FormSelect.jsx`). Visible native HTML/Catalyst Select popups are prohibited throughout a Xinyuan application because their operating-system surface, selected slab, theme and viewport behavior cannot meet this contract.
HeroUI v3.2 Selects must use the current controlled `value` / `onChange` contract. Single selection passes one key or `null`; multiple selection passes a key array. Do not control a Select root with deprecated `selectedKey` / `selectedKeys` and `onSelectionChange`: multi-select state will split from the trigger value, and dialog edits can appear selected in the popup without updating the draft. Render the arrow through `Select.Indicator` with the shared `ChevronDown` adapter so it is always visible and receives the component's open state. Single-select popups close after selection; multi-select popups remain open until the user dismisses them.
Closed-trigger parity is insufficient. The first-pass audit must open every Select, page-size Select, remote-reference popup, multi-select, DatePicker and dialog form dropdown in the browser. Verify the visible popup surface, 12px radius, option height, selected row, checkmark, chevron, internal scroll, viewport bounds, Portal stacking and dark theme before approving the page. A page that still has a native/Catalyst/OS popup, HeroUI default blue selected option, old page-local chevron, white dark-mode island, or uninspected dropdown state is not migrated.
Remote reference and multi-select controls must not be implemented as a HeroUI `Select` root when the popup contains multiple interactive rows. Use the shared `ReferenceSearchSelect.jsx` pattern: HeroUI `Popover` controlled by a HeroUI `Button` trigger, with only the HeroUI `ListBox` and necessary lightweight actions inside the popover. Do not render a second full-width search/input field inside the dropdown; the closed trigger is the only visible field surface. Keep `selectedKeys` / `onSelectionChange` on the `ListBox`, preserve the original ID-only callback protocol, and verify `aria-expanded` changes to `true` on click before approving the field.
......
......@@ -49,6 +49,7 @@
- 页面 Tabs 和普通命令不放装饰图标或无决策价值的数量 Badge。
- 实时筛选不显示查询按钮。首行最多三个字段,右侧只保留重置 icon 和轻量展开控制;不显示泛化“筛选条件”标题。
- 所有 Select、Reference、Date/DateTime、Upload、Checkbox 和 Textarea 走共享 HeroUI 适配器。禁止原生 `<select>`、OS 日期弹窗、Catalyst 下拉和页面私有 Popover。
- HeroUI Select 统一使用当前 `value / onChange` 受控接口:单选值为一个 key 或 `null`,多选值为 key 数组;禁止使用旧 `selectedKey / selectedKeys / onSelectionChange` 控制 Select。Trigger 必须渲染共享 `Select.Indicator + ChevronDown` 箭头,选项变化应立即写入弹窗草稿,单选后关闭、多选时保持弹层打开。
- DateTime 必须保留真实时间编辑;Placeholder 写期望值或示例,不写裸“可选”。
## 7. DataGrid 与列表
......
import { ChevronDown } from '@/components/AppIcons';
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
const EMPTY_ARRAY = [];
function normalizeValue(value, multiple) {
if (multiple) {
return (Array.isArray(value) ? value : value === null || value === undefined || value === '' ? [] : [value])
.map(String);
}
return value === null || value === undefined || value === '' ? null : String(value);
}
function getDisplayText(options, value, multiple, placeholder) {
const selectedValues = multiple ? value : value === null ? EMPTY_ARRAY : [value];
const labels = options
.filter((option) => selectedValues.includes(String(option.value)))
.map((option) => String(option.label ?? option.value));
if (labels.length === 0) return placeholder;
if (!multiple || labels.length <= 2) return labels.join('、');
return `${labels.slice(0, 2).join('、')} 等 ${labels.length} 项`;
}
export default function FormSelect({
ariaLabel,
className,
disabled = false,
multiple = false,
onChange,
options = EMPTY_ARRAY,
placeholder = '请选择',
value,
}) {
const normalizedValue = normalizeValue(value, multiple);
const displayText = getDisplayText(options, normalizedValue, multiple, placeholder);
const hasSelection = multiple ? normalizedValue.length > 0 : normalizedValue !== null;
export default function FormSelect({ ariaLabel, options, value, onChange, className = '' }) {
const selected = String(value ?? '');
const current = options.find((option) => String(option.value) === selected);
return (
<Select aria-label={ariaLabel} className={`rc-form-select ${className}`} selectedKey={selected} onSelectionChange={(key) => onChange?.(String(key))}>
<Select.Trigger><Select.Value>{current?.label || current?.value || '请选择'}</Select.Value></Select.Trigger>
<Select.Popover placement="bottom start">
<ListBox aria-label={ariaLabel}>{options.map((option) => <ListBox.Item id={String(option.value)} key={option.value} textValue={String(option.label || option.value)}><Label>{option.label || option.value}</Label><ListBox.ItemIndicator /></ListBox.Item>)}</ListBox>
<Select
aria-label={ariaLabel || placeholder}
className={clsx('rc-form-select', className)}
fullWidth
isDisabled={disabled}
selectionMode={multiple ? 'multiple' : 'single'}
shouldCloseOnSelect={!multiple}
value={normalizedValue}
variant="secondary"
onChange={(nextValue) => {
if (multiple) {
onChange?.((Array.isArray(nextValue) ? nextValue : []).map(String));
return;
}
onChange?.(nextValue === null || nextValue === undefined || nextValue === '' ? null : String(nextValue));
}}
>
<Select.Trigger className="rc-form-select__trigger">
<Select.Value className="rc-form-select__value">
<span className={clsx('rc-form-select__text', !hasSelection && 'is-placeholder')}>{displayText}</span>
</Select.Value>
<Select.Indicator className="rc-form-select__indicator"><ChevronDown size={16} /></Select.Indicator>
</Select.Trigger>
<Select.Popover className="rc-form-select__popover" placement="bottom start">
<ListBox aria-label={ariaLabel || placeholder} className="rc-form-select__listbox">
{options.map((option) => (
<ListBox.Item
className="rc-form-select__option"
id={String(option.value)}
isDisabled={Boolean(option.disabled)}
key={String(option.value)}
textValue={String(option.label ?? option.value)}
>
<Label className="rc-form-select__option-label">{option.label ?? option.value}</Label>
<ListBox.ItemIndicator className="rc-form-select__option-indicator" />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
......
......@@ -129,10 +129,10 @@ function WorkbenchPagination({ pagination }) {
</Pagination.Content>
</Pagination>
<div className="pm-pagination-controls">
<Select aria-label="每页条数" className="pm-pagination-page-size-select" isDisabled={isDisabled} selectedKey={String(pageSize)} variant="secondary" onSelectionChange={(key) => pagination.onPageSizeChange?.(Number(key))}>
<Select aria-label="每页条数" className="pm-pagination-page-size-select" isDisabled={isDisabled} value={String(pageSize)} variant="secondary" onChange={(key) => pagination.onPageSizeChange?.(Number(key))}>
<Select.Trigger>
<span className="pm-pagination-page-size-copy"><span className="pm-pagination-page-size-label">每页</span><Select.Value className="pm-pagination-page-size-value">{pageSize} 条/页</Select.Value></span>
<ChevronDown className="pm-pagination-page-size-indicator" size={14} />
<Select.Indicator className="pm-pagination-page-size-indicator"><ChevronDown size={14} /></Select.Indicator>
</Select.Trigger>
<Select.Popover className="rc-form-select__popover rc-filter-select__popover" placement="bottom start">
<ListBox aria-label="每页条数">
......
......@@ -207,10 +207,23 @@ button, input, textarea, select { font: inherit; letter-spacing: 0; }
.pm-pagination-page-size-label::after { content: ':'; }
.pm-pagination-page-size-select .select__indicator, .pm-pagination-page-size-indicator { display: inline-grid !important; width: 16px; height: 16px; flex: none; align-self: center; place-items: center; color: var(--rc-text-muted); font-size: 14px; line-height: 0 !important; }
.pm-pagination-page-size-indicator :is(svg, .anticon), .pm-pagination-page-size-indicator svg, .pm-pagination-page-size-select .select__indicator svg { display: block !important; width: 1em !important; height: 1em !important; margin: 0 !important; color: currentColor !important; fill: currentColor !important; }
.rc-form-select .select__trigger { align-items: center; }
.rc-form-select .select__value { display: flex; min-height: 20px; align-items: center; line-height: 20px; }
.rc-form-select .select__indicator { line-height: 0; }
.rc-form-select .popover, .rc-form-select [data-slot='popover'] { border-radius: 12px; background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-overlay); }
.rc-form-select { width: 100%; min-width: 0; }
.rc-form-select .select__trigger, .rc-form-select__trigger { display: flex !important; width: 100%; min-height: 40px; align-items: center !important; padding: 0 40px 0 12px !important; border: 1px solid var(--rc-field-border) !important; border-radius: 8px !important; color: var(--rc-text-primary); background: var(--rc-field-background) !important; box-shadow: var(--field-shadow) !important; }
.rc-form-select .select__trigger:hover, .rc-form-select__trigger:hover, .rc-form-select__trigger[data-hovered='true'] { border-color: var(--rc-field-border-hover) !important; }
.rc-form-select .select__trigger:focus-visible, .rc-form-select__trigger:focus-visible, .rc-form-select__trigger[data-focus-visible='true'] { border-color: var(--rc-field-border-focus) !important; box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft) !important; }
.rc-form-select .select__value, .rc-form-select__value { display: flex; min-width: 0; min-height: 20px; flex: 1; align-items: center; color: var(--rc-text-primary); font-size: 14px; line-height: 20px; }
.rc-form-select__text { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-form-select__text.is-placeholder { color: var(--rc-text-muted); }
.rc-form-select .select__indicator, .rc-form-select__indicator { position: absolute !important; top: 50% !important; right: 12px !important; display: inline-grid !important; width: 16px; height: 16px; place-items: center; margin: 0 !important; color: var(--rc-text-muted); line-height: 0 !important; transform: translateY(-50%); transition: color 150ms ease, transform 150ms ease; }
.rc-form-select__indicator[data-open='true'], .rc-form-select__trigger[aria-expanded='true'] .rc-form-select__indicator { transform: translateY(-50%) rotate(180deg); }
.rc-form-select__indicator .anticon, .rc-form-select__indicator svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-form-select .popover, .rc-form-select [data-slot='popover'], .rc-form-select__popover { z-index: 210; min-width: var(--trigger-width); max-height: min(320px, calc(100dvh - 32px)); overflow-y: auto; padding: 4px !important; border: 1px solid var(--rc-border-default); border-radius: 12px !important; color: var(--rc-text-secondary); background: var(--rc-bg-surface) !important; box-shadow: var(--rc-shadow-overlay) !important; }
.rc-form-select__listbox { display: grid; gap: 2px; outline: none; }
.rc-form-select__option { display: flex; min-height: 40px; cursor: pointer; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; color: var(--rc-text-secondary); font-size: 14px; line-height: 20px; }
.rc-form-select__option:hover, .rc-form-select__option[data-hovered='true'], .rc-form-select__option[data-focused='true'] { color: var(--rc-text-primary); background: var(--default); }
.rc-form-select__option[data-selected='true'] { color: var(--rc-brand-primary); background: var(--rc-brand-primary-soft); }
.rc-form-select__option-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-form-select__option-indicator { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--rc-brand-primary); }
.rc-dialog-backdrop { width: 100%; height: 100%; background: var(--backdrop); -webkit-backdrop-filter: blur(var(--rc-modal-backdrop-blur)); backdrop-filter: blur(var(--rc-modal-backdrop-blur)); }
.rc-dialog-panel { --rc-dialog-current-width: var(--rc-modal-width-lg); position: relative; display: flex; width: min(calc(100vw - 80px), var(--rc-dialog-current-width)) !important; max-width: calc(100vw - 80px) !important; max-height: calc(100dvh - 32px); flex-direction: column; padding: var(--rc-modal-padding); overflow: hidden; border: 0 !important; border-radius: var(--rc-modal-radius) !important; background: var(--overlay); box-shadow: var(--rc-shadow-overlay); outline: none; }
.rc-dialog-panel--xs { --rc-dialog-current-width: var(--rc-modal-width-xs); }
......
import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import { Button, Checkbox, Input, Label, ListBox, Radio, RadioGroup, Select, TextArea } from '@heroui/react';
import { Button, Checkbox, Input, Radio, RadioGroup, TextArea } from '@heroui/react';
import FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker';
import FormSelect from '@/components/FormSelect';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import ReferenceRecordsTable from './ReferenceRecordsTable';
......@@ -210,23 +211,13 @@ function ControlledFieldEditor({ value, fieldDef, disabled, onChange }) {
if (fieldType === 'select') {
const options = getSelectOptions(fieldDef);
return (
<Select
aria-label={fieldDef?.label || fieldDef?.name || '请选择'}
className="rc-form-select"
selectedKey={fieldDef?.multiple ? undefined : String(value ?? '')}
selectedKeys={fieldDef?.multiple ? toArray(value).map(String) : undefined}
selectionMode={fieldDef?.multiple ? 'multiple' : 'single'}
onSelectionChange={(keys) => {
if (fieldDef?.multiple) {
onChange(Array.from(keys === 'all' ? [] : keys || []).map(String));
} else {
onChange(keys === null || keys === undefined || keys === '' ? null : String(keys));
}
}}
>
<Select.Trigger><Select.Value>{formatFieldValue(value, fieldDef) || '请选择'}</Select.Value></Select.Trigger>
<Select.Popover placement="bottom start"><ListBox aria-label={fieldDef?.label || '请选择'}>{options.map((option) => <ListBox.Item id={String(option.value)} key={option.value} textValue={String(option.label)}><Label>{option.label}</Label><ListBox.ItemIndicator /></ListBox.Item>)}</ListBox></Select.Popover>
</Select>
<FormSelect
ariaLabel={fieldDef?.label || fieldDef?.name || '请选择'}
multiple={Boolean(fieldDef?.multiple)}
options={options}
value={value}
onChange={onChange}
/>
);
}
......@@ -480,17 +471,13 @@ export default function TailwindEditableField({
const options = getSelectOptions(fieldDef);
return (
<div className="space-y-3">
<Select
aria-label={fieldDef?.label || fieldDef?.name || '请选择'}
className="rc-form-select"
selectedKey={fieldDef?.multiple ? undefined : String(draft || '')}
selectedKeys={fieldDef?.multiple ? toArray(draft).map(String) : undefined}
selectionMode={fieldDef?.multiple ? 'multiple' : 'single'}
onSelectionChange={(keys) => setDraft(fieldDef?.multiple ? Array.from(keys).map(String) : String(keys))}
>
<Select.Trigger><Select.Value>{formatFieldValue(draft, fieldDef) || '请选择'}</Select.Value></Select.Trigger>
<Select.Popover placement="bottom start"><ListBox aria-label={fieldDef?.label || '请选择'}>{options.map((option) => <ListBox.Item id={String(option.value)} key={option.value} textValue={String(option.label)}><Label>{option.label}</Label><ListBox.ItemIndicator /></ListBox.Item>)}</ListBox></Select.Popover>
</Select>
<FormSelect
ariaLabel={fieldDef?.label || fieldDef?.name || '请选择'}
multiple={Boolean(fieldDef?.multiple)}
options={options}
value={draft}
onChange={setDraft}
/>
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
......
......@@ -27,6 +27,7 @@
5. 关键词查找通过 `/record/query` 的 `keyword` 查询标题;清空筛选后回到第 1 页。
6. 列定义、列顺序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定。
7. 点击列表首列打开通用详情弹窗并进入只读查看态;点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
8. 编辑态的单选和多选属性使用共享 HeroUI Select;选择后立即更新当前弹窗草稿。单选完成后关闭选项层,多选允许连续选择,最终仍由弹窗底部统一“保存”提交。
异常处理:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment