@cloud-ru/ds-fields 2.2.3-preview-a0540d7f.0 → 2.2.3
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 +4 -0
- package/README.md +1 -3
- package/dist/cjs/components/FieldSelect/FieldSelect.js +43 -98
- package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
- package/dist/cjs/components/FieldSelect/constants.js +1 -20
- package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
- package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
- package/dist/cjs/components/FieldSelect/utils.js +103 -0
- package/dist/esm/components/FieldSelect/FieldSelect.js +45 -100
- package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
- package/dist/esm/components/FieldSelect/constants.js +0 -19
- package/dist/esm/components/FieldSelect/types.d.ts +3 -26
- package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
- package/dist/esm/components/FieldSelect/utils.js +92 -0
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +20 -20
- package/src/components/FieldSelect/FieldSelect.tsx +63 -151
- package/src/components/FieldSelect/constants.ts +0 -22
- package/src/components/FieldSelect/types.ts +48 -82
- package/src/components/FieldSelect/utils.ts +132 -0
- package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -33
- package/dist/cjs/components/FieldSelect/hooks.js +0 -95
- package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
- package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
- package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
- package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
- package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
- package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
- package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
- package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
- package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
- package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
- package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
- package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
- package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
- package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
- package/dist/esm/components/FieldSelect/hooks.d.ts +0 -33
- package/dist/esm/components/FieldSelect/hooks.js +0 -92
- package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
- package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
- package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
- package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
- package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
- package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
- package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
- package/dist/esm/components/FieldSelect/utils/index.js +0 -6
- package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
- package/dist/esm/components/FieldSelect/utils/items.js +0 -41
- package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
- package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
- package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
- package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
- package/src/components/FieldSelect/hooks.ts +0 -177
- package/src/components/FieldSelect/utils/customOption.ts +0 -77
- package/src/components/FieldSelect/utils/extract.ts +0 -49
- package/src/components/FieldSelect/utils/filter.ts +0 -51
- package/src/components/FieldSelect/utils/index.ts +0 -6
- package/src/components/FieldSelect/utils/items.ts +0 -58
- package/src/components/FieldSelect/utils/selection.ts +0 -25
- package/src/components/FieldSelect/utils/tagSize.ts +0 -5
|
@@ -3,7 +3,7 @@ import { ChevronDownSVG } from '@cloud-ru/ds-icons/interface/system';
|
|
|
3
3
|
import { InputPrivate, useButtonNavigation, useClearButton } from '@cloud-ru/ds-input-private';
|
|
4
4
|
import { Droplist } from '@cloud-ru/ds-list';
|
|
5
5
|
import { Tag } from '@cloud-ru/ds-tag';
|
|
6
|
-
import {
|
|
6
|
+
import { useLayoutEffect } from '@cloud-ru/ds-utils';
|
|
7
7
|
import cn from 'classnames';
|
|
8
8
|
import mergeRefs from 'merge-refs';
|
|
9
9
|
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
@@ -14,22 +14,18 @@ import { TEST_IDS } from '../../constants.js';
|
|
|
14
14
|
import { useAdaptiveAutoFocus } from '../../hooks/index.js';
|
|
15
15
|
import { copyTextToClipboard, getAcrylicProps, preventSlotMouseDown, stopSlotClickPropagation, toInputSize, useCopyButton, } from '../shared/index.js';
|
|
16
16
|
import fieldStyles from '../shared/styles.module.css';
|
|
17
|
-
import { useCustomOption } from './hooks.js';
|
|
18
17
|
import styles from './styles.module.css';
|
|
19
|
-
import { extractAppearance, extractLabel, filterItems, findItem, flatten,
|
|
18
|
+
import { extractAppearance, extractLabel, filterItems, findItem, flatten, isMultiple, TAG_SIZE_MAP, } from './utils.js';
|
|
20
19
|
export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
21
20
|
const { label = '', labelTooltip, caption, hint, error, validationState = VALIDATION_STATE.Default, showHintIcon, length, required, size = SIZE.M, className, fieldClassName, items, pinTop, pinBottom, placeholder = '', iconBefore, prefix, postfix, disabled, readonly: readOnly, background = true, open: openProp, onOpenChange, placement, widthStrategy = 'eq', searchable = true, search, autocomplete = false, addOptionByEnter = false, resetSearchOnOptionSelection = true, showClearButton = true, showCopyButton = true, onCopyButtonClick, id, name, autoFocus, layoutPresets, onFocus: onFocusProp, onBlur: onBlurProp, enableFuzzySearch = true, selectedOptionFormatter, footer, footerActiveElementsRefs, loading, noDataState, noResultsState, errorDataState, dataError, dataFiltered, virtualized, scrollToSelectedItem, limitedScrollHeight, untouchableScrollbars, closeOnPopstate, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId = TEST_IDS.fieldSelect, } = props;
|
|
22
21
|
const multiple = isMultiple(props);
|
|
23
22
|
const chips = multiple ? (props.chips ?? true) : false;
|
|
24
23
|
const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
|
|
25
24
|
const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
|
|
26
|
-
const allowCustomOption = props.allowCustomOption === true;
|
|
27
|
-
const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
|
|
28
25
|
const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
|
|
29
26
|
const inputRef = useRef(null);
|
|
30
27
|
// Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
|
|
31
28
|
const inputElementRef = useRef(null);
|
|
32
|
-
const restoreInputFocusRef = useRef(false);
|
|
33
29
|
const clearButtonRef = useRef(null);
|
|
34
30
|
const copyButtonRef = useRef(null);
|
|
35
31
|
const contentRef = useRef(null);
|
|
@@ -71,20 +67,16 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
71
67
|
}, [multiple, props.value, multipleLocal]);
|
|
72
68
|
const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
|
|
73
69
|
const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
|
|
74
|
-
const selectedIds = useMemo(() => getSelectedIds({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
|
|
75
|
-
// Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
|
|
76
|
-
const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
|
|
77
|
-
const itemsWithPlaceholders = useMemo(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
|
|
78
70
|
// Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
|
|
79
71
|
const seenItems = useRef(new Map());
|
|
80
72
|
const resolveItem = useMemo(() => {
|
|
81
|
-
for (const item of flatten(
|
|
73
|
+
for (const item of flatten(allItems)) {
|
|
82
74
|
if (item.id !== undefined) {
|
|
83
75
|
seenItems.current.set(item.id, item);
|
|
84
76
|
}
|
|
85
77
|
}
|
|
86
|
-
return (id) => findItem(allItems, id) ??
|
|
87
|
-
}, [allItems
|
|
78
|
+
return (id) => findItem(allItems, id) ?? seenItems.current.get(id);
|
|
79
|
+
}, [allItems]);
|
|
88
80
|
const selectedPairs = useMemo(() => {
|
|
89
81
|
if (!multiple || multipleValue.length === 0) {
|
|
90
82
|
return [];
|
|
@@ -139,13 +131,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
139
131
|
syncedLabelRef.current = selectedLabel;
|
|
140
132
|
setInputValue(selectedLabel);
|
|
141
133
|
}, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
|
|
142
|
-
// При autocomplete фильтрует сервер — items берутся как
|
|
134
|
+
// При autocomplete фильтрует сервер — items берутся как есть.
|
|
143
135
|
const filteredItems = useMemo(() => {
|
|
144
136
|
if (!searchable || autocomplete || !typing) {
|
|
145
|
-
return
|
|
137
|
+
return items;
|
|
146
138
|
}
|
|
147
|
-
return filterItems(
|
|
148
|
-
}, [searchable, autocomplete, typing,
|
|
139
|
+
return filterItems(items, inputValue, enableFuzzySearch);
|
|
140
|
+
}, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
|
|
149
141
|
const filteredPinTop = useMemo(() => {
|
|
150
142
|
if (!searchable || autocomplete || !typing || !pinTop) {
|
|
151
143
|
return pinTop;
|
|
@@ -158,7 +150,22 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
158
150
|
}
|
|
159
151
|
return filterItems(pinBottom, inputValue, enableFuzzySearch);
|
|
160
152
|
}, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
|
|
161
|
-
const
|
|
153
|
+
const handleOpenChange = useCallback((next) => {
|
|
154
|
+
// readonly/disabled поле открывать нельзя, закрывать — можно.
|
|
155
|
+
if (next && (disabled || readOnly))
|
|
156
|
+
return;
|
|
157
|
+
if (openProp === undefined) {
|
|
158
|
+
setOpenLocal(next);
|
|
159
|
+
}
|
|
160
|
+
onOpenChange?.(next);
|
|
161
|
+
if (!next) {
|
|
162
|
+
setTyping(false);
|
|
163
|
+
if (resetSearchOnOptionSelection) {
|
|
164
|
+
setInputValue(selectedLabel);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}, [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel]);
|
|
168
|
+
const handleSingleChange = (next) => {
|
|
162
169
|
if (multiple) {
|
|
163
170
|
return;
|
|
164
171
|
}
|
|
@@ -167,12 +174,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
167
174
|
}
|
|
168
175
|
props.onChange?.(next);
|
|
169
176
|
setTyping(false);
|
|
170
|
-
}
|
|
171
|
-
const handleMultipleChange =
|
|
177
|
+
};
|
|
178
|
+
const handleMultipleChange = (next) => {
|
|
172
179
|
if (!multiple) {
|
|
173
180
|
return;
|
|
174
181
|
}
|
|
175
|
-
restoreInputFocusRef.current = isBrowser() && inputElementRef.current === document.activeElement;
|
|
176
182
|
if (props.value === undefined) {
|
|
177
183
|
setMultipleLocal(next);
|
|
178
184
|
}
|
|
@@ -183,57 +189,8 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
183
189
|
setInputValue('');
|
|
184
190
|
}
|
|
185
191
|
}
|
|
186
|
-
}
|
|
187
|
-
const resetCustomOptionSearch = useCallback(() => {
|
|
188
|
-
setTyping(false);
|
|
189
|
-
if (resetSearchOnOptionSelection) {
|
|
190
|
-
setInputValue(multiple ? '' : selectedLabel);
|
|
191
|
-
}
|
|
192
|
-
}, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
|
|
193
|
-
const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = useCustomOption({
|
|
194
|
-
allowCustomOption,
|
|
195
|
-
addCustomOptionTriggers,
|
|
196
|
-
addOptionByEnter,
|
|
197
|
-
multiple,
|
|
198
|
-
inputValue,
|
|
199
|
-
typing,
|
|
200
|
-
multipleValue,
|
|
201
|
-
commitSingle: handleSingleChange,
|
|
202
|
-
commitMultiple: handleMultipleChange,
|
|
203
|
-
resetSearch: resetCustomOptionSearch,
|
|
204
|
-
inputElementRef,
|
|
205
|
-
clearButtonRef,
|
|
206
|
-
copyButtonRef,
|
|
207
|
-
footerActiveElementsRefs,
|
|
208
|
-
});
|
|
209
|
-
const handleOpenChange = useCallback((next) => {
|
|
210
|
-
// readonly/disabled поле открывать нельзя, закрывать — можно.
|
|
211
|
-
if (next && (disabled || readOnly))
|
|
212
|
-
return;
|
|
213
|
-
if (openProp === undefined) {
|
|
214
|
-
setOpenLocal(next);
|
|
215
|
-
}
|
|
216
|
-
onOpenChange?.(next);
|
|
217
|
-
if (!next) {
|
|
218
|
-
setTyping(false);
|
|
219
|
-
const committed = tryCommitOnClose();
|
|
220
|
-
// Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
|
|
221
|
-
if (resetSearchOnOptionSelection && !committed) {
|
|
222
|
-
setInputValue(selectedLabel);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}, [
|
|
226
|
-
openProp,
|
|
227
|
-
onOpenChange,
|
|
228
|
-
disabled,
|
|
229
|
-
readOnly,
|
|
230
|
-
resetSearchOnOptionSelection,
|
|
231
|
-
setInputValue,
|
|
232
|
-
selectedLabel,
|
|
233
|
-
tryCommitOnClose,
|
|
234
|
-
]);
|
|
192
|
+
};
|
|
235
193
|
const handleInputChange = (next) => {
|
|
236
|
-
enableCloseCommit();
|
|
237
194
|
setInputValue(next);
|
|
238
195
|
setTyping(true);
|
|
239
196
|
if (!open) {
|
|
@@ -332,12 +289,19 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
332
289
|
}
|
|
333
290
|
if (event.key === 'Escape' && open) {
|
|
334
291
|
event.preventDefault();
|
|
335
|
-
suppressCloseCommit();
|
|
336
292
|
handleOpenChange(false);
|
|
337
293
|
}
|
|
338
|
-
if (
|
|
294
|
+
if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
|
|
295
|
+
// Введённый текст становится новым значением (создание опции «на лету»).
|
|
339
296
|
event.preventDefault();
|
|
340
|
-
|
|
297
|
+
if (multiple) {
|
|
298
|
+
if (!multipleValue.includes(inputValue)) {
|
|
299
|
+
handleMultipleChange([...multipleValue, inputValue]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
handleSingleChange(inputValue);
|
|
304
|
+
}
|
|
341
305
|
return;
|
|
342
306
|
}
|
|
343
307
|
if (event.key === 'Enter' && !open) {
|
|
@@ -350,11 +314,6 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
350
314
|
removeChip(last);
|
|
351
315
|
}
|
|
352
316
|
};
|
|
353
|
-
const handleTriggerKeyDownCapture = (event) => {
|
|
354
|
-
if (event.key === 'Escape') {
|
|
355
|
-
suppressCloseCommit();
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
317
|
const handleTriggerMouseEnter = useCallback(() => {
|
|
359
318
|
if (!readOnly && !disabled) {
|
|
360
319
|
setHover(true);
|
|
@@ -379,28 +338,14 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
379
338
|
}
|
|
380
339
|
onFocusProp?.(event);
|
|
381
340
|
}, [searchable, onFocusProp]);
|
|
382
|
-
const handleInputBlur = (event) => {
|
|
341
|
+
const handleInputBlur = useCallback((event) => {
|
|
383
342
|
setFocusVisible(false);
|
|
384
|
-
const blurResult = tryCommitOnBlur(event);
|
|
385
|
-
// Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
|
|
386
|
-
// Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
|
|
387
|
-
if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
|
|
388
|
-
setTyping(false);
|
|
389
|
-
setInputValue(selectedLabel);
|
|
390
|
-
}
|
|
391
343
|
onBlurProp?.(event);
|
|
392
|
-
};
|
|
344
|
+
}, [onBlurProp]);
|
|
393
345
|
const droplistSelection = multiple
|
|
394
|
-
? { mode: 'multiple', value: multipleValue, onChange:
|
|
395
|
-
: { mode: 'single', value: singleValue, onChange:
|
|
346
|
+
? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
|
|
347
|
+
: { mode: 'single', value: singleValue, onChange: handleSingleChange };
|
|
396
348
|
const hasChips = chips && selectedPairs.length > 0;
|
|
397
|
-
useLayoutEffect(() => {
|
|
398
|
-
if (!restoreInputFocusRef.current) {
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
restoreInputFocusRef.current = false;
|
|
402
|
-
inputElementRef.current?.focus();
|
|
403
|
-
}, [multipleValue.length]);
|
|
404
349
|
// minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
|
|
405
350
|
// Замер до paint, иначе input мигает при вводе.
|
|
406
351
|
useLayoutEffect(() => {
|
|
@@ -413,11 +358,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
413
358
|
setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
|
|
414
359
|
}, [hasChips, inputValue, selectedPairs.length]);
|
|
415
360
|
const searchInputField = (_jsx(InputPrivate, { ref: mergeRefs(ref, inputRef, inputElementRef), className: cn(fieldStyles.fieldInput, !searchable && styles.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel || hasChips ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": TEST_IDS.fieldSelectInput }));
|
|
416
|
-
const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField }
|
|
361
|
+
const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
|
|
417
362
|
const trigger = (
|
|
418
363
|
// Focusable-элемент — InputPrivate внутри, обёртка только собирает combobox.
|
|
419
364
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
420
|
-
_jsxs("div", { className: cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName), "data-size": size, "data-validation-state": effectiveValidationState, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-withbackground": background || undefined, "data-focusvisible": focusVisible || open || undefined, "data-hover": !readOnly && !disabled && hover ? true : undefined, "data-test-id": dataTestId, onMouseEnter: handleTriggerMouseEnter, onMouseLeave: handleTriggerMouseLeave,
|
|
365
|
+
_jsxs("div", { className: cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName), "data-size": size, "data-validation-state": effectiveValidationState, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-withbackground": background || undefined, "data-focusvisible": focusVisible || open || undefined, "data-hover": !readOnly && !disabled && hover ? true : undefined, "data-test-id": dataTestId, onMouseEnter: handleTriggerMouseEnter, onMouseLeave: handleTriggerMouseLeave, onClick: handleTriggerClick, children: [_jsxs("div", { className: fieldStyles.backgroundWrapper, children: [_jsx("div", { className: fieldStyles.materialLayer, ...getAcrylicProps({
|
|
421
366
|
validationState: effectiveValidationState,
|
|
422
367
|
disabled,
|
|
423
368
|
readonly: readOnly,
|
|
@@ -426,5 +371,5 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
426
371
|
}), children: _jsx("div", { className: fieldStyles.acrylicBg, "aria-hidden": true }) }), _jsx("div", { className: fieldStyles.borderStateLayer, "data-state": 'borderOnBackground' }), _jsx("div", { className: fieldStyles.focusLayer })] }), _jsx("div", { className: fieldStyles.fieldContainer, children: _jsxs("div", { className: fieldStyles.contentWrapper, children: [iconBefore && _jsx("div", { className: fieldStyles.iconSlot, children: iconBefore }), _jsxs("div", { className: fieldStyles.inputLine, children: [prefix && _jsx("span", { className: fieldStyles.prefix, children: prefix }), hasChips ? (_jsxs("div", { className: styles.chipsRow, ref: contentRef, "data-test-id": TEST_IDS.fieldSelectChips, children: [selectedPairs.map(pair => (_jsx(Tag, { label: formatPair(pair), size: TAG_SIZE_MAP[size], appearance: pair.appearance ?? 'neutral', onDelete: disabled || readOnly || pair.disabled ? undefined : handleRemoveChip(pair.id) }, String(pair.id)))), searchInput, _jsx("span", { ref: inputPlugRef, className: styles.inputPlug, "aria-hidden": true, children: inputValue })] })) : (searchInput), postfixButtons && (
|
|
427
372
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
428
373
|
_jsx("span", { className: fieldStyles.postfixButtonsSlot, onMouseDown: preventSlotMouseDown, onClick: stopSlotClickPropagation, children: postfixButtons })), postfix && _jsx("span", { className: fieldStyles.postfix, children: postfix })] }), _jsx("div", { className: fieldStyles.iconSlot, children: _jsx("span", { className: styles.chevron, "data-open": open || undefined, "aria-hidden": true, children: _jsx(ChevronDownSVG, {}) }) })] }) })] }));
|
|
429
|
-
return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, size: size, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, children: _jsx(Droplist, { items: filteredItems, label: label, pinTop: filteredPinTop, pinBottom: filteredPinBottom, trigger: 'click', placement: placement, widthStrategy: widthStrategy, triggerElemRef: inputRef,
|
|
374
|
+
return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, size: size, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, children: _jsx(Droplist, { items: filteredItems, label: label, pinTop: filteredPinTop, pinBottom: filteredPinBottom, trigger: 'click', placement: placement, widthStrategy: widthStrategy, triggerElemRef: inputRef, size: size, open: open, onOpenChange: handleOpenChange, selection: droplistSelection, closeDroplistOnItemClick: closeDroplistOnItemClick, footer: footer, footerActiveElementsRefs: footerActiveElementsRefs, loading: loading, noDataState: noDataState, noResultsState: noResultsState, errorDataState: errorDataState, dataError: dataError, dataFiltered: dataFiltered ?? (searchable && typing && inputValue !== ''), virtualized: virtualized, scroll: true, scrollToSelectedItem: scrollToSelectedItem, limitedScrollHeight: limitedScrollHeight, untouchableScrollbars: untouchableScrollbars, closeOnPopstate: closeOnPopstate, children: trigger }) }));
|
|
430
375
|
});
|
|
@@ -2,14 +2,3 @@ export declare const SELECTION_MODE: {
|
|
|
2
2
|
readonly Single: "single";
|
|
3
3
|
readonly Multiple: "multiple";
|
|
4
4
|
};
|
|
5
|
-
/** События, по которым произвольный ввод фиксируется как выбранная опция. */
|
|
6
|
-
export declare const ADD_CUSTOM_OPTION_TRIGGER: {
|
|
7
|
-
readonly Enter: "enter";
|
|
8
|
-
readonly Blur: "blur";
|
|
9
|
-
readonly Space: "space";
|
|
10
|
-
readonly Comma: "comma";
|
|
11
|
-
};
|
|
12
|
-
/** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
|
|
13
|
-
export declare const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE: readonly ["enter", "blur"];
|
|
14
|
-
/** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
|
|
15
|
-
export declare const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE: readonly ["enter", "blur", "space", "comma"];
|
|
@@ -2,22 +2,3 @@ export const SELECTION_MODE = {
|
|
|
2
2
|
Single: 'single',
|
|
3
3
|
Multiple: 'multiple',
|
|
4
4
|
};
|
|
5
|
-
/** События, по которым произвольный ввод фиксируется как выбранная опция. */
|
|
6
|
-
export const ADD_CUSTOM_OPTION_TRIGGER = {
|
|
7
|
-
Enter: 'enter',
|
|
8
|
-
Blur: 'blur',
|
|
9
|
-
Space: 'space',
|
|
10
|
-
Comma: 'comma',
|
|
11
|
-
};
|
|
12
|
-
/** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
|
|
13
|
-
export const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
|
|
14
|
-
ADD_CUSTOM_OPTION_TRIGGER.Enter,
|
|
15
|
-
ADD_CUSTOM_OPTION_TRIGGER.Blur,
|
|
16
|
-
];
|
|
17
|
-
/** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
|
|
18
|
-
export const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
|
|
19
|
-
ADD_CUSTOM_OPTION_TRIGGER.Enter,
|
|
20
|
-
ADD_CUSTOM_OPTION_TRIGGER.Blur,
|
|
21
|
-
ADD_CUSTOM_OPTION_TRIGGER.Space,
|
|
22
|
-
ADD_CUSTOM_OPTION_TRIGGER.Comma,
|
|
23
|
-
];
|
|
@@ -4,30 +4,9 @@ import { Appearance as TagAppearance } from '@cloud-ru/ds-tag';
|
|
|
4
4
|
import { ValueOf } from '@cloud-ru/ds-utils';
|
|
5
5
|
import { FocusEvent, KeyboardEvent, ReactNode } from 'react';
|
|
6
6
|
import { FieldLayoutPresets } from '../../hooks/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { SELECTION_MODE } from './constants.js';
|
|
8
8
|
/** Режим выбора FieldSelect: одно значение или несколько. */
|
|
9
9
|
export type Selection = ValueOf<typeof SELECTION_MODE>;
|
|
10
|
-
/** Событие, по которому произвольный ввод фиксируется как выбранная опция. */
|
|
11
|
-
export type FieldSelectAddCustomOptionTrigger = ValueOf<typeof ADD_CUSTOM_OPTION_TRIGGER>;
|
|
12
|
-
/** Триггеры кастомной опции в режиме `single`. */
|
|
13
|
-
export type FieldSelectSingleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_SINGLE)[number];
|
|
14
|
-
/** Триггеры кастомной опции в режиме `multiple`. */
|
|
15
|
-
export type FieldSelectMultipleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE)[number];
|
|
16
|
-
type CustomOptionProps<TTrigger extends FieldSelectAddCustomOptionTrigger> = {
|
|
17
|
-
/**
|
|
18
|
-
* Разрешить фиксировать произвольный ввод из строки поиска как выбранную опцию
|
|
19
|
-
* (значение, которого нет в `items`).
|
|
20
|
-
* @default false
|
|
21
|
-
*/
|
|
22
|
-
allowCustomOption?: boolean;
|
|
23
|
-
/**
|
|
24
|
-
* События, по которым произвольный ввод фиксируется как выбранная опция.
|
|
25
|
-
* Учитывается только при `allowCustomOption={true}`.
|
|
26
|
-
* Если не задан — используются все триггеры режима (`single`: enter, blur; `multiple`: enter, blur, space, comma).
|
|
27
|
-
* Триггеры, недоступные в текущем `selection`, игнорируются.
|
|
28
|
-
*/
|
|
29
|
-
addCustomOptionTriggers?: TTrigger[];
|
|
30
|
-
};
|
|
31
10
|
type WithTagAppearance<T> = T extends {
|
|
32
11
|
items: ItemProps[];
|
|
33
12
|
} ? Omit<T, 'items'> & {
|
|
@@ -119,8 +98,6 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
|
|
|
119
98
|
autocomplete?: boolean;
|
|
120
99
|
/**
|
|
121
100
|
* Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»).
|
|
122
|
-
*
|
|
123
|
-
* @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`.
|
|
124
101
|
* @default false
|
|
125
102
|
*/
|
|
126
103
|
addOptionByEnter?: boolean;
|
|
@@ -177,7 +154,7 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
|
|
|
177
154
|
/** Тестовый id корня */
|
|
178
155
|
'data-test-id'?: string;
|
|
179
156
|
};
|
|
180
|
-
export type FieldSelectSingleProps = CommonSelectProps &
|
|
157
|
+
export type FieldSelectSingleProps = CommonSelectProps & {
|
|
181
158
|
/** Режим выбора. По умолчанию `'single'`. */
|
|
182
159
|
selection?: typeof SELECTION_MODE.Single;
|
|
183
160
|
/** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
|
|
@@ -192,7 +169,7 @@ export type FieldSelectSingleProps = CommonSelectProps & CustomOptionProps<Field
|
|
|
192
169
|
*/
|
|
193
170
|
closeDroplistOnItemClick?: boolean;
|
|
194
171
|
};
|
|
195
|
-
export type FieldSelectMultipleProps = CommonSelectProps &
|
|
172
|
+
export type FieldSelectMultipleProps = CommonSelectProps & {
|
|
196
173
|
/** Режим выбора */
|
|
197
174
|
selection: typeof SELECTION_MODE.Multiple;
|
|
198
175
|
/** Управляемые значения */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Size } from '@cloud-ru/ds-field-decorator';
|
|
2
|
+
import { ItemId } from '@cloud-ru/ds-list';
|
|
3
|
+
import { Appearance as TagAppearance, Size as TagSize } from '@cloud-ru/ds-tag';
|
|
4
|
+
import { FieldSelectItem, FieldSelectMultipleProps, FieldSelectProps } from './types.js';
|
|
5
|
+
export declare const TAG_SIZE_MAP: Record<Size, TagSize>;
|
|
6
|
+
export type WithIdContent = {
|
|
7
|
+
id?: ItemId;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
appearance?: TagAppearance;
|
|
11
|
+
};
|
|
12
|
+
export declare function extractLabel(item: WithIdContent): string;
|
|
13
|
+
export declare function extractSearchText(item: WithIdContent): string;
|
|
14
|
+
export declare function extractAppearance(item?: WithIdContent): TagAppearance | undefined;
|
|
15
|
+
export declare function flatten(items: FieldSelectItem[]): WithIdContent[];
|
|
16
|
+
export declare function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined;
|
|
17
|
+
export declare function isFuzzyMatch(haystack: string, needle: string): boolean;
|
|
18
|
+
export declare function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[];
|
|
19
|
+
export declare function isMultiple(props: FieldSelectProps): props is FieldSelectMultipleProps;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SELECTION_MODE } from './constants.js';
|
|
2
|
+
export const TAG_SIZE_MAP = { s: 'xs', m: 'xs', l: 's' };
|
|
3
|
+
export function extractLabel(item) {
|
|
4
|
+
const { content, id } = item;
|
|
5
|
+
if (!content) {
|
|
6
|
+
return String(id ?? '');
|
|
7
|
+
}
|
|
8
|
+
if (typeof content === 'string' || typeof content === 'number') {
|
|
9
|
+
return String(content);
|
|
10
|
+
}
|
|
11
|
+
if (typeof content === 'object' && content !== null && 'label' in content) {
|
|
12
|
+
return String(content.label);
|
|
13
|
+
}
|
|
14
|
+
return String(id ?? '');
|
|
15
|
+
}
|
|
16
|
+
// Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
|
|
17
|
+
// который матчил запрос по всем трём полям контента, а не только по лейблу.
|
|
18
|
+
export function extractSearchText(item) {
|
|
19
|
+
const parts = [extractLabel(item)];
|
|
20
|
+
const { content } = item;
|
|
21
|
+
if (content && typeof content === 'object') {
|
|
22
|
+
const c = content;
|
|
23
|
+
if (typeof c.caption === 'string') {
|
|
24
|
+
parts.push(c.caption);
|
|
25
|
+
}
|
|
26
|
+
if (typeof c.description === 'string') {
|
|
27
|
+
parts.push(c.description);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return parts.join(' ');
|
|
31
|
+
}
|
|
32
|
+
// Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
|
|
33
|
+
// Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
|
|
34
|
+
export function extractAppearance(item) {
|
|
35
|
+
return typeof item?.appearance === 'string' ? item.appearance : undefined;
|
|
36
|
+
}
|
|
37
|
+
export function flatten(items) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const item of items) {
|
|
40
|
+
out.push(item);
|
|
41
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
42
|
+
out.push(...flatten(item.items));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function findItem(items, id) {
|
|
48
|
+
for (const item of flatten(items)) {
|
|
49
|
+
if (item.id === id) {
|
|
50
|
+
return item;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
// Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
|
|
56
|
+
// Пример: query=`lge` matches `Large` (L → r → arg → e).
|
|
57
|
+
export function isFuzzyMatch(haystack, needle) {
|
|
58
|
+
if (!needle) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
let i = 0;
|
|
62
|
+
for (const ch of haystack) {
|
|
63
|
+
if (ch === needle[i]) {
|
|
64
|
+
i += 1;
|
|
65
|
+
if (i === needle.length) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
export function filterItems(items, query, fuzzy) {
|
|
73
|
+
if (!query) {
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
const q = query.toLowerCase();
|
|
77
|
+
const matches = (item) => {
|
|
78
|
+
const text = extractSearchText(item).toLowerCase();
|
|
79
|
+
return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
|
|
80
|
+
};
|
|
81
|
+
const walk = (list) => list.flatMap(item => {
|
|
82
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
83
|
+
const filteredChildren = walk(item.items);
|
|
84
|
+
return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren }] : [];
|
|
85
|
+
}
|
|
86
|
+
return matches(item) ? [item] : [];
|
|
87
|
+
});
|
|
88
|
+
return walk(items);
|
|
89
|
+
}
|
|
90
|
+
export function isMultiple(props) {
|
|
91
|
+
return props.selection === SELECTION_MODE.Multiple;
|
|
92
|
+
}
|