Commit 677c3711 authored by Andy-bubu's avatar Andy-bubu

fix: stabilize controlled autocomplete selections

parent 963009de
import { import {
ChevronDown, ChevronDown,
Check,
Close, Close,
FIELD_SUFFIX_ICON_STYLE, FIELD_SUFFIX_ICON_STYLE,
Search, Search,
...@@ -17,12 +18,16 @@ function defaultFilter(textValue, inputValue) { ...@@ -17,12 +18,16 @@ function defaultFilter(textValue, inputValue) {
return !query || String(textValue || '').toLocaleLowerCase().includes(query); return !query || String(textValue || '').toLocaleLowerCase().includes(query);
} }
function normalizeValue(selectionMode, value) { function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') { if (selectionMode === 'multiple') {
return Array.from(value || []).map(String); return new Set(selectedKeys || []);
} }
return value === null || value === undefined ? null : String(value); return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
}
function toSelectionKey(value) {
return String(value ?? '');
} }
const FEEDBACK_OPTION_ID = '__xinyuan-autocomplete-feedback__'; const FEEDBACK_OPTION_ID = '__xinyuan-autocomplete-feedback__';
...@@ -41,31 +46,32 @@ export default function XinyuanAutocomplete({ ...@@ -41,31 +46,32 @@ export default function XinyuanAutocomplete({
isOpen, isOpen,
isRequired = false, isRequired = false,
name, name,
onChange,
onInputChange, onInputChange,
onOpenChange, onOpenChange,
onSelectionChange,
options = [], options = [],
placeholder = '请选择', placeholder = '请选择',
placement = 'bottom start', placement = 'bottom start',
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single', selectionMode = 'single',
shouldFilter = true, shouldFilter = true,
value,
valueText, valueText,
variant, variant,
}) { }) {
const normalizedValue = normalizeValue(selectionMode, value); const selectedKeySet = getSelectedKeySet(
const selectedKeySet = new Set( selectionMode,
selectionMode === 'multiple' selectedKey,
? normalizedValue selectedKeys,
: normalizedValue === null );
? [] const selectedKeyStringSet = new Set(
: [normalizedValue], Array.from(selectedKeySet).map(toSelectionKey),
); );
const selectedOptions = options.filter((option) => const selectedOptions = options.filter((option) =>
selectedKeySet.has(String(option.id)), selectedKeyStringSet.has(toSelectionKey(option.id)),
); );
const resolvedValueText = const resolvedValueText =
valueText ?? valueText ??
...@@ -74,13 +80,49 @@ export default function XinyuanAutocomplete({ ...@@ -74,13 +80,49 @@ export default function XinyuanAutocomplete({
: placeholder); : placeholder);
const showClearButton = const showClearButton =
isClearable && selectedKeySet.size > 0 && !isDisabled && !isRequired; isClearable && selectedKeySet.size > 0 && !isDisabled && !isRequired;
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const listBoxSelectionProps =
selectionMode === 'multiple'
? {
selectionMode,
selectedKeys,
}
: {};
const feedbackText = isLoading ? '加载中...' : errorText || emptyText; const feedbackText = isLoading ? '加载中...' : errorText || emptyText;
const visibleOptions = options.length > 0 && !isLoading && !errorText const visibleOptions = options.length > 0 && !isLoading && !errorText
? options ? options
: [{ id: FEEDBACK_OPTION_ID, isDisabled: true, isFeedback: true, label: feedbackText }]; : [{ id: FEEDBACK_OPTION_ID, isDisabled: true, isFeedback: true, label: feedbackText }];
const handleMultipleOptionPress = (option) => {
if (
selectionMode !== 'multiple' ||
!onSelectionChange ||
option?.isDisabled
) {
return;
}
const optionKey = toSelectionKey(option.id);
if (!optionKey) {
return;
}
const nextKeys = new Set(
Array.from(selectedKeyStringSet).filter(Boolean),
);
if (nextKeys.has(optionKey)) {
nextKeys.delete(optionKey);
} else {
nextKeys.add(optionKey);
}
onSelectionChange(nextKeys);
};
return ( return (
<Autocomplete <Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
className={className} className={className}
fullWidth={fullWidth} fullWidth={fullWidth}
...@@ -89,10 +131,9 @@ export default function XinyuanAutocomplete({ ...@@ -89,10 +131,9 @@ export default function XinyuanAutocomplete({
isRequired={isRequired} isRequired={isRequired}
name={name} name={name}
selectionMode={selectionMode} selectionMode={selectionMode}
value={normalizedValue}
variant={variant} variant={variant}
onChange={onChange}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
> >
<Autocomplete.Trigger> <Autocomplete.Trigger>
<Autocomplete.Value> <Autocomplete.Value>
...@@ -103,7 +144,13 @@ export default function XinyuanAutocomplete({ ...@@ -103,7 +144,13 @@ export default function XinyuanAutocomplete({
{resolvedValueText} {resolvedValueText}
</span> </span>
</Autocomplete.Value> </Autocomplete.Value>
{showClearButton ? <Autocomplete.ClearButton aria-label="清除选择" /> : null} {showClearButton ? (
<Autocomplete.ClearButton
aria-hidden={selectionMode === 'multiple' ? false : undefined}
aria-label="清除选择"
data-empty={selectionMode === 'multiple' ? false : undefined}
/>
) : null}
<Autocomplete.Indicator> <Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} /> <ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator> </Autocomplete.Indicator>
...@@ -130,28 +177,48 @@ export default function XinyuanAutocomplete({ ...@@ -130,28 +177,48 @@ export default function XinyuanAutocomplete({
</SearchField.ClearButton> </SearchField.ClearButton>
</SearchField.Group> </SearchField.Group>
</SearchField> </SearchField>
<ListBox aria-label={ariaLabel || placeholder} items={visibleOptions}> <ListBox
{(option) => ( aria-label={ariaLabel || placeholder}
<ListBox.Item items={visibleOptions}
id={option.id} {...listBoxSelectionProps}
isDisabled={option.isDisabled} >
textValue={option.textValue || option.label} {(option) => {
> const isSelected = selectedKeyStringSet.has(toSelectionKey(option.id));
{option.isFeedback ? (
<span className="flex min-h-16 items-center justify-center gap-2 text-sm text-muted"> return (
{isLoading ? <Spinner size="sm" /> : null} <ListBox.Item
{option.label} id={option.id}
</span> isDisabled={option.isDisabled}
) : renderOption ? ( textValue={option.textValue || option.label}
renderOption(option) {...(selectionMode === 'multiple'
) : ( ? { onPress: () => handleMultipleOptionPress(option) }
<Label className="truncate" title={option.label}> : {})}
{option.label} >
</Label> {option.isFeedback ? (
)} <span className="flex min-h-16 items-center justify-center gap-2 text-sm text-muted">
<ListBox.ItemIndicator /> {isLoading ? <Spinner size="sm" /> : null}
</ListBox.Item> {option.label}
)} </span>
) : renderOption ? (
renderOption(option)
) : (
<Label className="truncate" title={option.label}>
{option.label}
</Label>
)}
{selectionMode === 'multiple' ? (
<span
aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground"
>
{isSelected ? <Check size={14} /> : null}
</span>
) : (
<ListBox.ItemIndicator />
)}
</ListBox.Item>
);
}}
</ListBox> </ListBox>
</Autocomplete.Filter> </Autocomplete.Filter>
</Autocomplete.Popover> </Autocomplete.Popover>
......
...@@ -87,6 +87,7 @@ export default function XinyuanUserAutocomplete({ ...@@ -87,6 +87,7 @@ export default function XinyuanUserAutocomplete({
const valueText = selectedLabels.length <= 2 const valueText = selectedLabels.length <= 2
? selectedLabels.join('、') || placeholder ? selectedLabels.join('、') || placeholder
: `${selectedLabels.slice(0, 2).join('、')} 等 ${selectedLabels.length} 人`; : `${selectedLabels.slice(0, 2).join('、')} 等 ${selectedLabels.length} 人`;
const selectedKeySet = useMemo(() => new Set(selectedIds), [selectedIds]);
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
...@@ -98,12 +99,13 @@ export default function XinyuanUserAutocomplete({ ...@@ -98,12 +99,13 @@ export default function XinyuanUserAutocomplete({
options={autocompleteOptions} options={autocompleteOptions}
placeholder={placeholder} placeholder={placeholder}
searchPlaceholder="输入姓名或账号" searchPlaceholder="输入姓名或账号"
selectedKey={multiple ? undefined : selectedIds[0] || null}
selectedKeys={multiple ? selectedKeySet : undefined}
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
shouldFilter={false} shouldFilter={false}
value={multiple ? selectedIds : selectedIds[0] || null}
valueText={valueText} valueText={valueText}
variant={variant} variant={variant}
onChange={(keys) => { onSelectionChange={(keys) => {
const nextIds = multiple const nextIds = multiple
? Array.from(keys || []).map(String) ? Array.from(keys || []).map(String)
: keys ? [String(keys)] : []; : keys ? [String(keys)] : [];
......
...@@ -109,9 +109,9 @@ function OptionAutocomplete({ ...@@ -109,9 +109,9 @@ function OptionAutocomplete({
isRequired={isRequired} isRequired={isRequired}
options={normalizedOptions} options={normalizedOptions}
placeholder={placeholder} placeholder={placeholder}
value={value ? String(value) : null} selectedKey={value ? String(value) : null}
variant="secondary" variant="secondary"
onChange={(key) => onChange?.(key ? String(key) : '')} onSelectionChange={(key) => onChange?.(key ? String(key) : '')}
/> />
); );
} }
......
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