@cloud-ru/ds-fields 2.2.3-preview-8025692f.0 → 2.2.3-preview-a0540d7f.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
- package/dist/cjs/components/FieldDate/mask.d.ts +2 -3
- package/dist/cjs/components/FieldDate/mask.js +3 -4
- package/dist/cjs/components/FieldSelect/FieldSelect.js +98 -43
- package/dist/cjs/components/FieldSelect/constants.d.ts +11 -0
- package/dist/cjs/components/FieldSelect/constants.js +20 -1
- package/dist/cjs/components/FieldSelect/hooks.d.ts +33 -0
- package/dist/cjs/components/FieldSelect/hooks.js +95 -0
- package/dist/cjs/components/FieldSelect/types.d.ts +26 -3
- package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +21 -0
- package/dist/cjs/components/FieldSelect/utils/customOption.js +55 -0
- package/dist/cjs/components/FieldSelect/utils/extract.d.ts +11 -0
- package/dist/cjs/components/FieldSelect/utils/extract.js +39 -0
- package/dist/cjs/components/FieldSelect/utils/filter.d.ts +3 -0
- package/dist/cjs/components/FieldSelect/utils/filter.js +40 -0
- package/dist/cjs/components/FieldSelect/utils/index.d.ts +6 -0
- package/dist/cjs/components/FieldSelect/utils/index.js +22 -0
- package/dist/cjs/components/FieldSelect/utils/items.d.ts +9 -0
- package/dist/cjs/components/FieldSelect/utils/items.js +47 -0
- package/dist/cjs/components/FieldSelect/utils/selection.d.ts +11 -0
- package/dist/cjs/components/FieldSelect/utils/selection.js +18 -0
- package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +4 -0
- package/dist/cjs/components/FieldSelect/utils/tagSize.js +5 -0
- package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
- package/dist/cjs/segments/useSegmentedMask.d.ts +0 -2
- package/dist/cjs/segments/useSegmentedMask.js +56 -146
- package/dist/esm/components/FieldDate/FieldDate.js +3 -3
- package/dist/esm/components/FieldDate/mask.d.ts +2 -3
- package/dist/esm/components/FieldDate/mask.js +3 -4
- package/dist/esm/components/FieldSelect/FieldSelect.js +100 -45
- package/dist/esm/components/FieldSelect/constants.d.ts +11 -0
- package/dist/esm/components/FieldSelect/constants.js +19 -0
- package/dist/esm/components/FieldSelect/hooks.d.ts +33 -0
- package/dist/esm/components/FieldSelect/hooks.js +92 -0
- package/dist/esm/components/FieldSelect/types.d.ts +26 -3
- package/dist/esm/components/FieldSelect/utils/customOption.d.ts +21 -0
- package/dist/esm/components/FieldSelect/utils/customOption.js +48 -0
- package/dist/esm/components/FieldSelect/utils/extract.d.ts +11 -0
- package/dist/esm/components/FieldSelect/utils/extract.js +34 -0
- package/dist/esm/components/FieldSelect/utils/filter.d.ts +3 -0
- package/dist/esm/components/FieldSelect/utils/filter.js +36 -0
- package/dist/esm/components/FieldSelect/utils/index.d.ts +6 -0
- package/dist/esm/components/FieldSelect/utils/index.js +6 -0
- package/dist/esm/components/FieldSelect/utils/items.d.ts +9 -0
- package/dist/esm/components/FieldSelect/utils/items.js +41 -0
- package/dist/esm/components/FieldSelect/utils/selection.d.ts +11 -0
- package/dist/esm/components/FieldSelect/utils/selection.js +14 -0
- package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +4 -0
- package/dist/esm/components/FieldSelect/utils/tagSize.js +2 -0
- package/dist/esm/components/FieldTime/FieldTime.js +2 -2
- package/dist/esm/segments/useSegmentedMask.d.ts +0 -2
- package/dist/esm/segments/useSegmentedMask.js +56 -146
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/components/FieldDate/FieldDate.tsx +3 -3
- package/src/components/FieldDate/mask.ts +3 -4
- package/src/components/FieldSelect/FieldSelect.tsx +151 -63
- package/src/components/FieldSelect/constants.ts +22 -0
- package/src/components/FieldSelect/hooks.ts +177 -0
- package/src/components/FieldSelect/types.ts +82 -48
- package/src/components/FieldSelect/utils/customOption.ts +77 -0
- package/src/components/FieldSelect/utils/extract.ts +49 -0
- package/src/components/FieldSelect/utils/filter.ts +51 -0
- package/src/components/FieldSelect/utils/index.ts +6 -0
- package/src/components/FieldSelect/utils/items.ts +58 -0
- package/src/components/FieldSelect/utils/selection.ts +25 -0
- package/src/components/FieldSelect/utils/tagSize.ts +5 -0
- package/src/components/FieldTime/FieldTime.tsx +2 -2
- package/src/segments/useSegmentedMask.ts +52 -172
- package/dist/cjs/components/FieldSelect/utils.d.ts +0 -19
- package/dist/cjs/components/FieldSelect/utils.js +0 -103
- package/dist/esm/components/FieldSelect/utils.d.ts +0 -19
- package/dist/esm/components/FieldSelect/utils.js +0 -92
- package/src/components/FieldSelect/utils.ts +0 -132
|
@@ -42,9 +42,8 @@ function buildSegments(mode, showSeconds) {
|
|
|
42
42
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
43
43
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
44
44
|
* фокус автоматически уезжает на следующий сегмент.
|
|
45
|
-
* 2.
|
|
46
|
-
*
|
|
47
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
45
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
46
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
48
47
|
*/
|
|
49
48
|
export function formatMask(input, mode, showSeconds = true) {
|
|
50
49
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -70,7 +69,7 @@ export function formatMask(input, mode, showSeconds = true) {
|
|
|
70
69
|
stream = stream.slice(seg.len);
|
|
71
70
|
if (raw.length === seg.len) {
|
|
72
71
|
const n = Number(raw);
|
|
73
|
-
out += n > seg.max ?
|
|
72
|
+
out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
|
|
74
73
|
}
|
|
75
74
|
else {
|
|
76
75
|
out += raw;
|
|
@@ -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 { useLayoutEffect } from '@cloud-ru/ds-utils';
|
|
6
|
+
import { isBrowser, 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,18 +14,22 @@ 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';
|
|
17
18
|
import styles from './styles.module.css';
|
|
18
|
-
import { extractAppearance, extractLabel, filterItems, findItem, flatten, isMultiple, TAG_SIZE_MAP, } from './utils.js';
|
|
19
|
+
import { extractAppearance, extractLabel, filterItems, findItem, flatten, getMissingSelectedItems, getSelectedIds, isMultiple, TAG_SIZE_MAP, } from './utils/index.js';
|
|
19
20
|
export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
20
21
|
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;
|
|
21
22
|
const multiple = isMultiple(props);
|
|
22
23
|
const chips = multiple ? (props.chips ?? true) : false;
|
|
23
24
|
const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
|
|
24
25
|
const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
|
|
26
|
+
const allowCustomOption = props.allowCustomOption === true;
|
|
27
|
+
const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
|
|
25
28
|
const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
|
|
26
29
|
const inputRef = useRef(null);
|
|
27
30
|
// Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
|
|
28
31
|
const inputElementRef = useRef(null);
|
|
32
|
+
const restoreInputFocusRef = useRef(false);
|
|
29
33
|
const clearButtonRef = useRef(null);
|
|
30
34
|
const copyButtonRef = useRef(null);
|
|
31
35
|
const contentRef = useRef(null);
|
|
@@ -67,16 +71,20 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
67
71
|
}, [multiple, props.value, multipleLocal]);
|
|
68
72
|
const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
|
|
69
73
|
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]);
|
|
70
78
|
// Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
|
|
71
79
|
const seenItems = useRef(new Map());
|
|
72
80
|
const resolveItem = useMemo(() => {
|
|
73
|
-
for (const item of flatten(allItems)) {
|
|
81
|
+
for (const item of flatten([...missingSelectedItems, ...allItems])) {
|
|
74
82
|
if (item.id !== undefined) {
|
|
75
83
|
seenItems.current.set(item.id, item);
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
|
-
return (id) => findItem(allItems, id) ?? seenItems.current.get(id);
|
|
79
|
-
}, [allItems]);
|
|
86
|
+
return (id) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
|
|
87
|
+
}, [allItems, missingSelectedItems]);
|
|
80
88
|
const selectedPairs = useMemo(() => {
|
|
81
89
|
if (!multiple || multipleValue.length === 0) {
|
|
82
90
|
return [];
|
|
@@ -131,13 +139,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
131
139
|
syncedLabelRef.current = selectedLabel;
|
|
132
140
|
setInputValue(selectedLabel);
|
|
133
141
|
}, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
|
|
134
|
-
// При autocomplete фильтрует сервер — items берутся как
|
|
142
|
+
// При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
|
|
135
143
|
const filteredItems = useMemo(() => {
|
|
136
144
|
if (!searchable || autocomplete || !typing) {
|
|
137
|
-
return
|
|
145
|
+
return itemsWithPlaceholders;
|
|
138
146
|
}
|
|
139
|
-
return filterItems(
|
|
140
|
-
}, [searchable, autocomplete, typing,
|
|
147
|
+
return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
|
|
148
|
+
}, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
|
|
141
149
|
const filteredPinTop = useMemo(() => {
|
|
142
150
|
if (!searchable || autocomplete || !typing || !pinTop) {
|
|
143
151
|
return pinTop;
|
|
@@ -150,22 +158,7 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
150
158
|
}
|
|
151
159
|
return filterItems(pinBottom, inputValue, enableFuzzySearch);
|
|
152
160
|
}, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
|
|
153
|
-
const
|
|
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) => {
|
|
161
|
+
const handleSingleChange = useCallback((next) => {
|
|
169
162
|
if (multiple) {
|
|
170
163
|
return;
|
|
171
164
|
}
|
|
@@ -174,11 +167,12 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
174
167
|
}
|
|
175
168
|
props.onChange?.(next);
|
|
176
169
|
setTyping(false);
|
|
177
|
-
};
|
|
178
|
-
const handleMultipleChange = (next) => {
|
|
170
|
+
}, [multiple, props]);
|
|
171
|
+
const handleMultipleChange = useCallback((next) => {
|
|
179
172
|
if (!multiple) {
|
|
180
173
|
return;
|
|
181
174
|
}
|
|
175
|
+
restoreInputFocusRef.current = isBrowser() && inputElementRef.current === document.activeElement;
|
|
182
176
|
if (props.value === undefined) {
|
|
183
177
|
setMultipleLocal(next);
|
|
184
178
|
}
|
|
@@ -189,8 +183,57 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
189
183
|
setInputValue('');
|
|
190
184
|
}
|
|
191
185
|
}
|
|
192
|
-
};
|
|
186
|
+
}, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
|
|
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
|
+
]);
|
|
193
235
|
const handleInputChange = (next) => {
|
|
236
|
+
enableCloseCommit();
|
|
194
237
|
setInputValue(next);
|
|
195
238
|
setTyping(true);
|
|
196
239
|
if (!open) {
|
|
@@ -289,19 +332,12 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
289
332
|
}
|
|
290
333
|
if (event.key === 'Escape' && open) {
|
|
291
334
|
event.preventDefault();
|
|
335
|
+
suppressCloseCommit();
|
|
292
336
|
handleOpenChange(false);
|
|
293
337
|
}
|
|
294
|
-
if (
|
|
295
|
-
// Введённый текст становится новым значением (создание опции «на лету»).
|
|
338
|
+
if (tryCommitFromKeyboard(event)) {
|
|
296
339
|
event.preventDefault();
|
|
297
|
-
|
|
298
|
-
if (!multipleValue.includes(inputValue)) {
|
|
299
|
-
handleMultipleChange([...multipleValue, inputValue]);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
handleSingleChange(inputValue);
|
|
304
|
-
}
|
|
340
|
+
event.stopPropagation();
|
|
305
341
|
return;
|
|
306
342
|
}
|
|
307
343
|
if (event.key === 'Enter' && !open) {
|
|
@@ -314,6 +350,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
314
350
|
removeChip(last);
|
|
315
351
|
}
|
|
316
352
|
};
|
|
353
|
+
const handleTriggerKeyDownCapture = (event) => {
|
|
354
|
+
if (event.key === 'Escape') {
|
|
355
|
+
suppressCloseCommit();
|
|
356
|
+
}
|
|
357
|
+
};
|
|
317
358
|
const handleTriggerMouseEnter = useCallback(() => {
|
|
318
359
|
if (!readOnly && !disabled) {
|
|
319
360
|
setHover(true);
|
|
@@ -338,14 +379,28 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
338
379
|
}
|
|
339
380
|
onFocusProp?.(event);
|
|
340
381
|
}, [searchable, onFocusProp]);
|
|
341
|
-
const handleInputBlur =
|
|
382
|
+
const handleInputBlur = (event) => {
|
|
342
383
|
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
|
+
}
|
|
343
391
|
onBlurProp?.(event);
|
|
344
|
-
}
|
|
392
|
+
};
|
|
345
393
|
const droplistSelection = multiple
|
|
346
|
-
? { mode: 'multiple', value: multipleValue, onChange:
|
|
347
|
-
: { mode: 'single', value: singleValue, onChange:
|
|
394
|
+
? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
|
|
395
|
+
: { mode: 'single', value: singleValue, onChange: handleListSingleChange };
|
|
348
396
|
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]);
|
|
349
404
|
// minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
|
|
350
405
|
// Замер до paint, иначе input мигает при вводе.
|
|
351
406
|
useLayoutEffect(() => {
|
|
@@ -358,11 +413,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
358
413
|
setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
|
|
359
414
|
}, [hasChips, inputValue, selectedPairs.length]);
|
|
360
415
|
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 }));
|
|
361
|
-
const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
|
|
416
|
+
const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField }, 'search-input')) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
|
|
362
417
|
const trigger = (
|
|
363
418
|
// Focusable-элемент — InputPrivate внутри, обёртка только собирает combobox.
|
|
364
419
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
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({
|
|
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, onKeyDownCapture: handleTriggerKeyDownCapture, onClick: handleTriggerClick, children: [_jsxs("div", { className: fieldStyles.backgroundWrapper, children: [_jsx("div", { className: fieldStyles.materialLayer, ...getAcrylicProps({
|
|
366
421
|
validationState: effectiveValidationState,
|
|
367
422
|
disabled,
|
|
368
423
|
readonly: readOnly,
|
|
@@ -371,5 +426,5 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
|
|
|
371
426
|
}), 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 && (
|
|
372
427
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
373
428
|
_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, {}) }) })] }) })] }));
|
|
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 }) }));
|
|
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, listRef: listRef, 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 }) }));
|
|
375
430
|
});
|
|
@@ -2,3 +2,14 @@ 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,3 +2,22 @@ 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
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ItemId } from '@cloud-ru/ds-list';
|
|
2
|
+
import { FocusEvent, KeyboardEvent, RefObject } from 'react';
|
|
3
|
+
import { FieldSelectAddCustomOptionTrigger } from './types.js';
|
|
4
|
+
/** Результат blur: фокус остался внутри поля, ввод зафиксирован, или коммитить было нечего. */
|
|
5
|
+
type CustomOptionBlurResult = 'inside' | 'committed' | 'skipped';
|
|
6
|
+
type UseCustomOptionParams = {
|
|
7
|
+
allowCustomOption: boolean;
|
|
8
|
+
addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
|
|
9
|
+
addOptionByEnter: boolean;
|
|
10
|
+
multiple: boolean;
|
|
11
|
+
inputValue: string;
|
|
12
|
+
typing: boolean;
|
|
13
|
+
multipleValue: ItemId[];
|
|
14
|
+
commitSingle(value: ItemId | undefined): void;
|
|
15
|
+
commitMultiple(value: ItemId[]): void;
|
|
16
|
+
resetSearch(): void;
|
|
17
|
+
inputElementRef: RefObject<HTMLInputElement | null>;
|
|
18
|
+
clearButtonRef: RefObject<HTMLButtonElement | null>;
|
|
19
|
+
copyButtonRef: RefObject<HTMLButtonElement | null>;
|
|
20
|
+
footerActiveElementsRefs?: RefObject<HTMLElement>[];
|
|
21
|
+
};
|
|
22
|
+
/** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
|
|
23
|
+
export declare function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, typing, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }: UseCustomOptionParams): {
|
|
24
|
+
listRef: RefObject<HTMLElement>;
|
|
25
|
+
suppressCloseCommit: () => void;
|
|
26
|
+
enableCloseCommit: () => void;
|
|
27
|
+
tryCommitOnClose: () => boolean;
|
|
28
|
+
tryCommitOnBlur: (event: FocusEvent<HTMLInputElement>) => CustomOptionBlurResult;
|
|
29
|
+
tryCommitFromKeyboard: (event: KeyboardEvent<HTMLInputElement>) => boolean;
|
|
30
|
+
handleListSingleChange: (next: ItemId | undefined) => void;
|
|
31
|
+
handleListMultipleChange: (next: ItemId[]) => void;
|
|
32
|
+
};
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isBrowser } from '@cloud-ru/ds-utils';
|
|
2
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
3
|
+
import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, } from './constants.js';
|
|
4
|
+
import { getCustomOptionTriggerByCode, isCustomOptionBlurInsideField, resolveAddCustomOptionTriggers, shouldCommitCustomOptionOnClose, shouldHandleCustomOptionTrigger, } from './utils/index.js';
|
|
5
|
+
/** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
|
|
6
|
+
export function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, typing, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }) {
|
|
7
|
+
const listRef = useRef(null);
|
|
8
|
+
const skipCloseCommitRef = useRef(false);
|
|
9
|
+
const skipBlurCommitRef = useRef(false);
|
|
10
|
+
const resolvedTriggers = useMemo(() => resolveAddCustomOptionTriggers({
|
|
11
|
+
allowCustomOption,
|
|
12
|
+
addCustomOptionTriggers,
|
|
13
|
+
addOptionByEnter,
|
|
14
|
+
defaultTriggers: multiple ? ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
|
|
15
|
+
}), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
|
|
16
|
+
const tryCommit = useCallback((trigger) => {
|
|
17
|
+
if (!typing || !shouldHandleCustomOptionTrigger(trigger, resolvedTriggers) || inputValue === '') {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (multiple) {
|
|
21
|
+
if (multipleValue.includes(inputValue)) {
|
|
22
|
+
resetSearch();
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
commitMultiple([...multipleValue, inputValue]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
commitSingle(inputValue);
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}, [typing, resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
|
|
33
|
+
const suppressCloseCommit = useCallback(() => {
|
|
34
|
+
skipCloseCommitRef.current = true;
|
|
35
|
+
skipBlurCommitRef.current = true;
|
|
36
|
+
}, []);
|
|
37
|
+
const enableCloseCommit = useCallback(() => {
|
|
38
|
+
skipCloseCommitRef.current = false;
|
|
39
|
+
skipBlurCommitRef.current = false;
|
|
40
|
+
}, []);
|
|
41
|
+
const tryCommitOnClose = useCallback(() => {
|
|
42
|
+
const skipped = skipCloseCommitRef.current;
|
|
43
|
+
const shouldCommit = shouldCommitCustomOptionOnClose({
|
|
44
|
+
skip: skipped,
|
|
45
|
+
inputElement: inputElementRef.current,
|
|
46
|
+
activeElement: isBrowser() ? document.activeElement : null,
|
|
47
|
+
});
|
|
48
|
+
if (skipped) {
|
|
49
|
+
queueMicrotask(() => {
|
|
50
|
+
skipCloseCommitRef.current = false;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return shouldCommit && tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur);
|
|
54
|
+
}, [inputElementRef, tryCommit]);
|
|
55
|
+
const tryCommitOnBlur = useCallback((event) => {
|
|
56
|
+
if (skipBlurCommitRef.current) {
|
|
57
|
+
skipBlurCommitRef.current = false;
|
|
58
|
+
return 'skipped';
|
|
59
|
+
}
|
|
60
|
+
if (isCustomOptionBlurInsideField(event.relatedTarget, [
|
|
61
|
+
listRef.current,
|
|
62
|
+
clearButtonRef.current,
|
|
63
|
+
copyButtonRef.current,
|
|
64
|
+
...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
|
|
65
|
+
])) {
|
|
66
|
+
return 'inside';
|
|
67
|
+
}
|
|
68
|
+
return tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
|
|
69
|
+
}, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
|
|
70
|
+
const tryCommitFromKeyboard = useCallback((event) => {
|
|
71
|
+
const trigger = getCustomOptionTriggerByCode(event.code, event.key);
|
|
72
|
+
return trigger !== undefined && tryCommit(trigger);
|
|
73
|
+
}, [tryCommit]);
|
|
74
|
+
const handleListSingleChange = useCallback((next) => {
|
|
75
|
+
skipCloseCommitRef.current = true;
|
|
76
|
+
commitSingle(next);
|
|
77
|
+
}, [commitSingle]);
|
|
78
|
+
const handleListMultipleChange = useCallback((next) => {
|
|
79
|
+
skipCloseCommitRef.current = true;
|
|
80
|
+
commitMultiple(next);
|
|
81
|
+
}, [commitMultiple]);
|
|
82
|
+
return {
|
|
83
|
+
listRef,
|
|
84
|
+
suppressCloseCommit,
|
|
85
|
+
enableCloseCommit,
|
|
86
|
+
tryCommitOnClose,
|
|
87
|
+
tryCommitOnBlur,
|
|
88
|
+
tryCommitFromKeyboard,
|
|
89
|
+
handleListSingleChange,
|
|
90
|
+
handleListMultipleChange,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -4,9 +4,30 @@ 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 { SELECTION_MODE } from './constants.js';
|
|
7
|
+
import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, 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
|
+
};
|
|
10
31
|
type WithTagAppearance<T> = T extends {
|
|
11
32
|
items: ItemProps[];
|
|
12
33
|
} ? Omit<T, 'items'> & {
|
|
@@ -98,6 +119,8 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
|
|
|
98
119
|
autocomplete?: boolean;
|
|
99
120
|
/**
|
|
100
121
|
* Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»).
|
|
122
|
+
*
|
|
123
|
+
* @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`.
|
|
101
124
|
* @default false
|
|
102
125
|
*/
|
|
103
126
|
addOptionByEnter?: boolean;
|
|
@@ -154,7 +177,7 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
|
|
|
154
177
|
/** Тестовый id корня */
|
|
155
178
|
'data-test-id'?: string;
|
|
156
179
|
};
|
|
157
|
-
export type FieldSelectSingleProps = CommonSelectProps & {
|
|
180
|
+
export type FieldSelectSingleProps = CommonSelectProps & CustomOptionProps<FieldSelectSingleAddCustomOptionTrigger> & {
|
|
158
181
|
/** Режим выбора. По умолчанию `'single'`. */
|
|
159
182
|
selection?: typeof SELECTION_MODE.Single;
|
|
160
183
|
/** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
|
|
@@ -169,7 +192,7 @@ export type FieldSelectSingleProps = CommonSelectProps & {
|
|
|
169
192
|
*/
|
|
170
193
|
closeDroplistOnItemClick?: boolean;
|
|
171
194
|
};
|
|
172
|
-
export type FieldSelectMultipleProps = CommonSelectProps & {
|
|
195
|
+
export type FieldSelectMultipleProps = CommonSelectProps & CustomOptionProps<FieldSelectMultipleAddCustomOptionTrigger> & {
|
|
173
196
|
/** Режим выбора */
|
|
174
197
|
selection: typeof SELECTION_MODE.Multiple;
|
|
175
198
|
/** Управляемые значения */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FieldSelectAddCustomOptionTrigger, FieldSelectMultipleAddCustomOptionTrigger } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
|
|
4
|
+
* Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
|
|
5
|
+
*/
|
|
6
|
+
export declare function getCustomOptionTriggerByCode(code: string, key?: string): FieldSelectMultipleAddCustomOptionTrigger | undefined;
|
|
7
|
+
export declare function shouldHandleCustomOptionTrigger(trigger: FieldSelectAddCustomOptionTrigger | undefined, availableTriggers: readonly FieldSelectAddCustomOptionTrigger[]): trigger is FieldSelectAddCustomOptionTrigger;
|
|
8
|
+
export declare function resolveAddCustomOptionTriggers(params: {
|
|
9
|
+
allowCustomOption: boolean;
|
|
10
|
+
addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
|
|
11
|
+
addOptionByEnter: boolean;
|
|
12
|
+
defaultTriggers: readonly FieldSelectAddCustomOptionTrigger[];
|
|
13
|
+
}): FieldSelectAddCustomOptionTrigger[];
|
|
14
|
+
/** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
|
|
15
|
+
export declare function isCustomOptionBlurInsideField(relatedTarget: EventTarget | null, nodes: (Node | null | undefined)[]): boolean;
|
|
16
|
+
/** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
|
|
17
|
+
export declare function shouldCommitCustomOptionOnClose(params: {
|
|
18
|
+
skip: boolean;
|
|
19
|
+
inputElement: Element | null;
|
|
20
|
+
activeElement: Element | null;
|
|
21
|
+
}): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ADD_CUSTOM_OPTION_TRIGGER } from '../constants.js';
|
|
2
|
+
/**
|
|
3
|
+
* Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
|
|
4
|
+
* Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
|
|
5
|
+
*/
|
|
6
|
+
export function getCustomOptionTriggerByCode(code, key) {
|
|
7
|
+
switch (code) {
|
|
8
|
+
case 'Enter':
|
|
9
|
+
return ADD_CUSTOM_OPTION_TRIGGER.Enter;
|
|
10
|
+
case 'Space':
|
|
11
|
+
return ADD_CUSTOM_OPTION_TRIGGER.Space;
|
|
12
|
+
case 'Comma':
|
|
13
|
+
return ADD_CUSTOM_OPTION_TRIGGER.Comma;
|
|
14
|
+
default:
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
switch (key) {
|
|
18
|
+
case 'Enter':
|
|
19
|
+
return ADD_CUSTOM_OPTION_TRIGGER.Enter;
|
|
20
|
+
case ' ':
|
|
21
|
+
return ADD_CUSTOM_OPTION_TRIGGER.Space;
|
|
22
|
+
case ',':
|
|
23
|
+
return ADD_CUSTOM_OPTION_TRIGGER.Comma;
|
|
24
|
+
default:
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function shouldHandleCustomOptionTrigger(trigger, availableTriggers) {
|
|
29
|
+
return trigger !== undefined && availableTriggers.includes(trigger);
|
|
30
|
+
}
|
|
31
|
+
export function resolveAddCustomOptionTriggers(params) {
|
|
32
|
+
if (params.allowCustomOption) {
|
|
33
|
+
const source = params.addCustomOptionTriggers !== undefined ? params.addCustomOptionTriggers : params.defaultTriggers;
|
|
34
|
+
return source.filter(trigger => params.defaultTriggers.includes(trigger));
|
|
35
|
+
}
|
|
36
|
+
if (params.addOptionByEnter) {
|
|
37
|
+
return [ADD_CUSTOM_OPTION_TRIGGER.Enter];
|
|
38
|
+
}
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
/** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
|
|
42
|
+
export function isCustomOptionBlurInsideField(relatedTarget, nodes) {
|
|
43
|
+
return relatedTarget instanceof Node && nodes.some(node => Boolean(node?.contains(relatedTarget)));
|
|
44
|
+
}
|
|
45
|
+
/** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
|
|
46
|
+
export function shouldCommitCustomOptionOnClose(params) {
|
|
47
|
+
return !params.skip && params.activeElement !== null && params.activeElement === params.inputElement;
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ItemId } from '@cloud-ru/ds-list';
|
|
2
|
+
import { Appearance as TagAppearance } from '@cloud-ru/ds-tag';
|
|
3
|
+
export type WithIdContent = {
|
|
4
|
+
id?: ItemId;
|
|
5
|
+
content?: unknown;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
appearance?: TagAppearance;
|
|
8
|
+
};
|
|
9
|
+
export declare function extractLabel(item: WithIdContent): string;
|
|
10
|
+
export declare function extractSearchText(item: WithIdContent): string;
|
|
11
|
+
export declare function extractAppearance(item?: WithIdContent): TagAppearance | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function extractLabel(item) {
|
|
2
|
+
const { content, id } = item;
|
|
3
|
+
if (!content) {
|
|
4
|
+
return String(id ?? '');
|
|
5
|
+
}
|
|
6
|
+
if (typeof content === 'string' || typeof content === 'number') {
|
|
7
|
+
return String(content);
|
|
8
|
+
}
|
|
9
|
+
if (typeof content === 'object' && content !== null && 'label' in content) {
|
|
10
|
+
return String(content.label);
|
|
11
|
+
}
|
|
12
|
+
return String(id ?? '');
|
|
13
|
+
}
|
|
14
|
+
// Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
|
|
15
|
+
// который матчил запрос по всем трём полям контента, а не только по лейблу.
|
|
16
|
+
export function extractSearchText(item) {
|
|
17
|
+
const parts = [extractLabel(item)];
|
|
18
|
+
const { content } = item;
|
|
19
|
+
if (content && typeof content === 'object') {
|
|
20
|
+
const c = content;
|
|
21
|
+
if (typeof c.caption === 'string') {
|
|
22
|
+
parts.push(c.caption);
|
|
23
|
+
}
|
|
24
|
+
if (typeof c.description === 'string') {
|
|
25
|
+
parts.push(c.description);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return parts.join(' ');
|
|
29
|
+
}
|
|
30
|
+
// Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
|
|
31
|
+
// Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
|
|
32
|
+
export function extractAppearance(item) {
|
|
33
|
+
return typeof item?.appearance === 'string' ? item.appearance : undefined;
|
|
34
|
+
}
|