@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
package/README.md
CHANGED
|
@@ -435,7 +435,9 @@ export function Select() {
|
|
|
435
435
|
|
|
436
436
|
| Prop | Type | Default | Description |
|
|
437
437
|
|------|------|---------|-------------|
|
|
438
|
-
| `
|
|
438
|
+
| `addCustomOptionTriggers` | `("enter" \| "blur")[] \| ("enter" \| "blur" \| "space" \| "comma")[]` | — | События, по которым произвольный ввод фиксируется как выбранная опция. <br/> Учитывается только при `allowCustomOption={true}`. <br/> Если не задан — используются все триггеры режима (`single`: enter, blur; `multiple`: enter, blur, space, comma). <br/> Триггеры, недоступные в текущем `selection`, игнорируются. |
|
|
439
|
+
| `addOptionByEnter` | `boolean` | `false` | Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»). <br/> @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`. |
|
|
440
|
+
| `allowCustomOption` | `boolean` | `false` | Разрешить фиксировать произвольный ввод из строки поиска как выбранную опцию <br/> (значение, которого нет в `items`). |
|
|
439
441
|
| `autoFocus` | `boolean` | — | Автофокус input при монтировании. На mobile выключается адаптивно (см. `layoutPresets`) |
|
|
440
442
|
| `autocomplete` | `boolean` | `false` | Не фильтровать список на клиенте — фильтрацию обеспечивает потребитель (серверный поиск). <br/> Введённый текст уходит в `search.onChange`, список берётся из `items` как есть. |
|
|
441
443
|
| `background` | `boolean` | `true` | Фон поля (acrylic) |
|
|
@@ -514,7 +516,7 @@ export function Select() {
|
|
|
514
516
|
| `mobile` | `any` | — | |
|
|
515
517
|
| `tablet` | `any` | — | |
|
|
516
518
|
|
|
517
|
-
- `FieldSelectItem` = `(
|
|
519
|
+
- `FieldSelectItem` = `({ 'data-test-id'?: string; } & AriaAttributes & { beforeContent?: ReactNode; afterContent?: ReactNode; content?: ItemContentProps | ReactNode; onClick?(e: MouseEvent<HTMLElement>): void; onMouseDown?(e: MouseEvent<HTMLElement>): void; onKeyDown?(e: KeyboardEvent<HTMLElement>): void; onFocus?(e: FocusEvent<HTMLElement>): void; onBlur?(e: FocusEvent<HTMLElement>): void; id?: ItemId; disabled?: boolean; hidden?: boolean; itemRef?: RefObject<HTMLElement>; className?: string; inactive?: boolean; switch?: boolean; showSwitchIcon?: boolean; itemWrapRender?(item: ReactNode): ReactNode; checked?: boolean; } & { appearance?: TagAppearance; }) | (Omit<GroupItem, "items"> & { appearance?: TagAppearance; items: WithTagAppearance<ItemProps>[]; }) | (Omit<GroupSelectItem, "items"> & { appearance?: TagAppearance; items: WithTagAppearance<ItemProps>[]; }) | (Omit<NextListItem, "items"> & { appearance?: TagAppearance; items: WithTagAppearance<ItemProps>[]; }) | (Omit<AccordionItem, "items"> & { appearance?: TagAppearance; items: WithTagAppearance<ItemProps>[]; })`
|
|
518
520
|
|
|
519
521
|
## FieldSlider
|
|
520
522
|
|
|
@@ -327,9 +327,9 @@ exports.FieldDate = (0, react_1.forwardRef)(function FieldDate(props, ref) {
|
|
|
327
327
|
(0, jsx_runtime_1.jsxs)("span", { className: styles_module_scss_2.default.rangeInputs, style: { '--field-date-mask-ch': placeholderMask.length }, children: [(0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ...inputCommon, ref: (0, merge_refs_1.default)(ref, fromInputRef), className: (0, classnames_1.default)(styles_module_scss_1.default.fieldInput, styles_module_scss_2.default.rangeInputFrom), value: rangeInputValue[0], placeholder: placeholderMask, onChange: handleRangeInput(0), id: id, name: name, autoFocus: resolvedAutoFocus, "aria-label": labelFrom, "data-test-id": constants_1.TEST_IDS.fieldDateInputFrom }), (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_2.default.rangeSeparator, "aria-hidden": true, children: "\u2013" }), (0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ...inputCommon, ref: toInputRef, className: styles_module_scss_1.default.fieldInput, value: rangeInputValue[1], placeholder: placeholderMask, onChange: handleRangeInput(1),
|
|
328
328
|
// «to» — последний инпут перед кнопками: на нём живёт roving-композит
|
|
329
329
|
// (ArrowRight в конце → clear/copy), keydown-цепочка с nav-handler'ом.
|
|
330
|
-
onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": constants_1.TEST_IDS.fieldDateInputTo })] })) : ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, fromInputRef
|
|
331
|
-
// Single/date-time ведёт сегментный движок (useSegmentedMask), нативный
|
|
332
|
-
// игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
330
|
+
onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": constants_1.TEST_IDS.fieldDateInputTo })] })) : ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, fromInputRef), className: styles_module_scss_1.default.fieldInput, value: singleInputValue, placeholder: placeholderMask,
|
|
331
|
+
// Single/date-time ведёт сегментный движок (keydown, useSegmentedMask), нативный
|
|
332
|
+
// onChange игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
333
333
|
onChange: () => { }, onKeyDown: handleSingleKeyDown, onClick: singleSeg.handleClick, onPaste: singleSeg.handlePaste, onFocus: handleSingleFocus, onBlur: handleSingleBlur, disabled: disabled, readonly: readOnly, inputMode: 'numeric', tabIndex: inputTabIndex, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, "data-test-id": constants_1.TEST_IDS.fieldDateInput })) }), postfixButtons && (
|
|
334
334
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
335
335
|
(0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, onMouseDown: shared_1.preventSlotMouseDown, onClick: shared_1.stopSlotClickPropagation, children: postfixButtons }))] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, "data-test-id": constants_1.TEST_IDS.fieldDateCalendar, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.CalendarSVG, {}) })] }) })] }));
|
|
@@ -10,9 +10,8 @@ export declare function getMaskString(mode: MaskMode, showSeconds?: boolean): st
|
|
|
10
10
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
11
11
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
12
12
|
* фокус автоматически уезжает на следующий сегмент.
|
|
13
|
-
* 2.
|
|
14
|
-
*
|
|
15
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
13
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
14
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
16
15
|
*/
|
|
17
16
|
export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
|
|
18
17
|
/** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */
|
|
@@ -48,9 +48,8 @@ function buildSegments(mode, showSeconds) {
|
|
|
48
48
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
49
49
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
50
50
|
* фокус автоматически уезжает на следующий сегмент.
|
|
51
|
-
* 2.
|
|
52
|
-
*
|
|
53
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
51
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
52
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
54
53
|
*/
|
|
55
54
|
function formatMask(input, mode, showSeconds = true) {
|
|
56
55
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -76,7 +75,7 @@ function formatMask(input, mode, showSeconds = true) {
|
|
|
76
75
|
stream = stream.slice(seg.len);
|
|
77
76
|
if (raw.length === seg.len) {
|
|
78
77
|
const n = Number(raw);
|
|
79
|
-
out += n > seg.max ?
|
|
78
|
+
out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
|
|
80
79
|
}
|
|
81
80
|
else {
|
|
82
81
|
out += raw;
|
|
@@ -20,6 +20,7 @@ const constants_1 = require("../../constants");
|
|
|
20
20
|
const hooks_1 = require("../../hooks");
|
|
21
21
|
const shared_1 = require("../shared");
|
|
22
22
|
const styles_module_scss_1 = __importDefault(require('../shared/styles.module.css'));
|
|
23
|
+
const hooks_2 = require("./hooks");
|
|
23
24
|
const styles_module_scss_2 = __importDefault(require('./styles.module.css'));
|
|
24
25
|
const utils_2 = require("./utils");
|
|
25
26
|
exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
@@ -28,10 +29,13 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
28
29
|
const chips = multiple ? (props.chips ?? true) : false;
|
|
29
30
|
const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
|
|
30
31
|
const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
|
|
32
|
+
const allowCustomOption = props.allowCustomOption === true;
|
|
33
|
+
const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
|
|
31
34
|
const resolvedAutoFocus = (0, hooks_1.useAdaptiveAutoFocus)(autoFocus, layoutPresets);
|
|
32
35
|
const inputRef = (0, react_1.useRef)(null);
|
|
33
36
|
// Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
|
|
34
37
|
const inputElementRef = (0, react_1.useRef)(null);
|
|
38
|
+
const restoreInputFocusRef = (0, react_1.useRef)(false);
|
|
35
39
|
const clearButtonRef = (0, react_1.useRef)(null);
|
|
36
40
|
const copyButtonRef = (0, react_1.useRef)(null);
|
|
37
41
|
const contentRef = (0, react_1.useRef)(null);
|
|
@@ -73,16 +77,20 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
73
77
|
}, [multiple, props.value, multipleLocal]);
|
|
74
78
|
const effectiveValidationState = (0, react_1.useMemo)(() => (error ? field_decorator_1.VALIDATION_STATE.Error : validationState), [error, validationState]);
|
|
75
79
|
const allItems = (0, react_1.useMemo)(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
|
|
80
|
+
const selectedIds = (0, react_1.useMemo)(() => (0, utils_2.getSelectedIds)({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
|
|
81
|
+
// Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
|
|
82
|
+
const missingSelectedItems = (0, react_1.useMemo)(() => (0, utils_2.getMissingSelectedItems)(selectedIds, allItems), [selectedIds, allItems]);
|
|
83
|
+
const itemsWithPlaceholders = (0, react_1.useMemo)(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
|
|
76
84
|
// Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
|
|
77
85
|
const seenItems = (0, react_1.useRef)(new Map());
|
|
78
86
|
const resolveItem = (0, react_1.useMemo)(() => {
|
|
79
|
-
for (const item of (0, utils_2.flatten)(allItems)) {
|
|
87
|
+
for (const item of (0, utils_2.flatten)([...missingSelectedItems, ...allItems])) {
|
|
80
88
|
if (item.id !== undefined) {
|
|
81
89
|
seenItems.current.set(item.id, item);
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
|
-
return (id) => (0, utils_2.findItem)(allItems, id) ?? seenItems.current.get(id);
|
|
85
|
-
}, [allItems]);
|
|
92
|
+
return (id) => (0, utils_2.findItem)(allItems, id) ?? (0, utils_2.findItem)(missingSelectedItems, id) ?? seenItems.current.get(id);
|
|
93
|
+
}, [allItems, missingSelectedItems]);
|
|
86
94
|
const selectedPairs = (0, react_1.useMemo)(() => {
|
|
87
95
|
if (!multiple || multipleValue.length === 0) {
|
|
88
96
|
return [];
|
|
@@ -137,13 +145,13 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
137
145
|
syncedLabelRef.current = selectedLabel;
|
|
138
146
|
setInputValue(selectedLabel);
|
|
139
147
|
}, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
|
|
140
|
-
// При autocomplete фильтрует сервер — items берутся как
|
|
148
|
+
// При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
|
|
141
149
|
const filteredItems = (0, react_1.useMemo)(() => {
|
|
142
150
|
if (!searchable || autocomplete || !typing) {
|
|
143
|
-
return
|
|
151
|
+
return itemsWithPlaceholders;
|
|
144
152
|
}
|
|
145
|
-
return (0, utils_2.filterItems)(
|
|
146
|
-
}, [searchable, autocomplete, typing,
|
|
153
|
+
return (0, utils_2.filterItems)(itemsWithPlaceholders, inputValue, enableFuzzySearch);
|
|
154
|
+
}, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
|
|
147
155
|
const filteredPinTop = (0, react_1.useMemo)(() => {
|
|
148
156
|
if (!searchable || autocomplete || !typing || !pinTop) {
|
|
149
157
|
return pinTop;
|
|
@@ -156,22 +164,7 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
156
164
|
}
|
|
157
165
|
return (0, utils_2.filterItems)(pinBottom, inputValue, enableFuzzySearch);
|
|
158
166
|
}, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
|
|
159
|
-
const
|
|
160
|
-
// readonly/disabled поле открывать нельзя, закрывать — можно.
|
|
161
|
-
if (next && (disabled || readOnly))
|
|
162
|
-
return;
|
|
163
|
-
if (openProp === undefined) {
|
|
164
|
-
setOpenLocal(next);
|
|
165
|
-
}
|
|
166
|
-
onOpenChange?.(next);
|
|
167
|
-
if (!next) {
|
|
168
|
-
setTyping(false);
|
|
169
|
-
if (resetSearchOnOptionSelection) {
|
|
170
|
-
setInputValue(selectedLabel);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}, [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel]);
|
|
174
|
-
const handleSingleChange = (next) => {
|
|
167
|
+
const handleSingleChange = (0, react_1.useCallback)((next) => {
|
|
175
168
|
if (multiple) {
|
|
176
169
|
return;
|
|
177
170
|
}
|
|
@@ -180,11 +173,12 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
180
173
|
}
|
|
181
174
|
props.onChange?.(next);
|
|
182
175
|
setTyping(false);
|
|
183
|
-
};
|
|
184
|
-
const handleMultipleChange = (next) => {
|
|
176
|
+
}, [multiple, props]);
|
|
177
|
+
const handleMultipleChange = (0, react_1.useCallback)((next) => {
|
|
185
178
|
if (!multiple) {
|
|
186
179
|
return;
|
|
187
180
|
}
|
|
181
|
+
restoreInputFocusRef.current = (0, utils_1.isBrowser)() && inputElementRef.current === document.activeElement;
|
|
188
182
|
if (props.value === undefined) {
|
|
189
183
|
setMultipleLocal(next);
|
|
190
184
|
}
|
|
@@ -195,8 +189,57 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
195
189
|
setInputValue('');
|
|
196
190
|
}
|
|
197
191
|
}
|
|
198
|
-
};
|
|
192
|
+
}, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
|
|
193
|
+
const resetCustomOptionSearch = (0, react_1.useCallback)(() => {
|
|
194
|
+
setTyping(false);
|
|
195
|
+
if (resetSearchOnOptionSelection) {
|
|
196
|
+
setInputValue(multiple ? '' : selectedLabel);
|
|
197
|
+
}
|
|
198
|
+
}, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
|
|
199
|
+
const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = (0, hooks_2.useCustomOption)({
|
|
200
|
+
allowCustomOption,
|
|
201
|
+
addCustomOptionTriggers,
|
|
202
|
+
addOptionByEnter,
|
|
203
|
+
multiple,
|
|
204
|
+
inputValue,
|
|
205
|
+
typing,
|
|
206
|
+
multipleValue,
|
|
207
|
+
commitSingle: handleSingleChange,
|
|
208
|
+
commitMultiple: handleMultipleChange,
|
|
209
|
+
resetSearch: resetCustomOptionSearch,
|
|
210
|
+
inputElementRef,
|
|
211
|
+
clearButtonRef,
|
|
212
|
+
copyButtonRef,
|
|
213
|
+
footerActiveElementsRefs,
|
|
214
|
+
});
|
|
215
|
+
const handleOpenChange = (0, react_1.useCallback)((next) => {
|
|
216
|
+
// readonly/disabled поле открывать нельзя, закрывать — можно.
|
|
217
|
+
if (next && (disabled || readOnly))
|
|
218
|
+
return;
|
|
219
|
+
if (openProp === undefined) {
|
|
220
|
+
setOpenLocal(next);
|
|
221
|
+
}
|
|
222
|
+
onOpenChange?.(next);
|
|
223
|
+
if (!next) {
|
|
224
|
+
setTyping(false);
|
|
225
|
+
const committed = tryCommitOnClose();
|
|
226
|
+
// Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
|
|
227
|
+
if (resetSearchOnOptionSelection && !committed) {
|
|
228
|
+
setInputValue(selectedLabel);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}, [
|
|
232
|
+
openProp,
|
|
233
|
+
onOpenChange,
|
|
234
|
+
disabled,
|
|
235
|
+
readOnly,
|
|
236
|
+
resetSearchOnOptionSelection,
|
|
237
|
+
setInputValue,
|
|
238
|
+
selectedLabel,
|
|
239
|
+
tryCommitOnClose,
|
|
240
|
+
]);
|
|
199
241
|
const handleInputChange = (next) => {
|
|
242
|
+
enableCloseCommit();
|
|
200
243
|
setInputValue(next);
|
|
201
244
|
setTyping(true);
|
|
202
245
|
if (!open) {
|
|
@@ -295,19 +338,12 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
295
338
|
}
|
|
296
339
|
if (event.key === 'Escape' && open) {
|
|
297
340
|
event.preventDefault();
|
|
341
|
+
suppressCloseCommit();
|
|
298
342
|
handleOpenChange(false);
|
|
299
343
|
}
|
|
300
|
-
if (
|
|
301
|
-
// Введённый текст становится новым значением (создание опции «на лету»).
|
|
344
|
+
if (tryCommitFromKeyboard(event)) {
|
|
302
345
|
event.preventDefault();
|
|
303
|
-
|
|
304
|
-
if (!multipleValue.includes(inputValue)) {
|
|
305
|
-
handleMultipleChange([...multipleValue, inputValue]);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
handleSingleChange(inputValue);
|
|
310
|
-
}
|
|
346
|
+
event.stopPropagation();
|
|
311
347
|
return;
|
|
312
348
|
}
|
|
313
349
|
if (event.key === 'Enter' && !open) {
|
|
@@ -320,6 +356,11 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
320
356
|
removeChip(last);
|
|
321
357
|
}
|
|
322
358
|
};
|
|
359
|
+
const handleTriggerKeyDownCapture = (event) => {
|
|
360
|
+
if (event.key === 'Escape') {
|
|
361
|
+
suppressCloseCommit();
|
|
362
|
+
}
|
|
363
|
+
};
|
|
323
364
|
const handleTriggerMouseEnter = (0, react_1.useCallback)(() => {
|
|
324
365
|
if (!readOnly && !disabled) {
|
|
325
366
|
setHover(true);
|
|
@@ -344,14 +385,28 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
344
385
|
}
|
|
345
386
|
onFocusProp?.(event);
|
|
346
387
|
}, [searchable, onFocusProp]);
|
|
347
|
-
const handleInputBlur = (
|
|
388
|
+
const handleInputBlur = (event) => {
|
|
348
389
|
setFocusVisible(false);
|
|
390
|
+
const blurResult = tryCommitOnBlur(event);
|
|
391
|
+
// Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
|
|
392
|
+
// Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
|
|
393
|
+
if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
|
|
394
|
+
setTyping(false);
|
|
395
|
+
setInputValue(selectedLabel);
|
|
396
|
+
}
|
|
349
397
|
onBlurProp?.(event);
|
|
350
|
-
}
|
|
398
|
+
};
|
|
351
399
|
const droplistSelection = multiple
|
|
352
|
-
? { mode: 'multiple', value: multipleValue, onChange:
|
|
353
|
-
: { mode: 'single', value: singleValue, onChange:
|
|
400
|
+
? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
|
|
401
|
+
: { mode: 'single', value: singleValue, onChange: handleListSingleChange };
|
|
354
402
|
const hasChips = chips && selectedPairs.length > 0;
|
|
403
|
+
(0, utils_1.useLayoutEffect)(() => {
|
|
404
|
+
if (!restoreInputFocusRef.current) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
restoreInputFocusRef.current = false;
|
|
408
|
+
inputElementRef.current?.focus();
|
|
409
|
+
}, [multipleValue.length]);
|
|
355
410
|
// minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
|
|
356
411
|
// Замер до paint, иначе input мигает при вводе.
|
|
357
412
|
(0, utils_1.useLayoutEffect)(() => {
|
|
@@ -364,11 +419,11 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
364
419
|
setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
|
|
365
420
|
}, [hasChips, inputValue, selectedPairs.length]);
|
|
366
421
|
const searchInputField = ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, inputRef, inputElementRef), className: (0, classnames_1.default)(styles_module_scss_1.default.fieldInput, !searchable && styles_module_scss_2.default.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": constants_1.TEST_IDS.fieldSelectInput }));
|
|
367
|
-
const searchInput = hasChips ? ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_2.default.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputArea, children: searchInputField }));
|
|
422
|
+
const searchInput = hasChips ? ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_2.default.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField }, 'search-input')) : ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputArea, children: searchInputField }));
|
|
368
423
|
const trigger = (
|
|
369
424
|
// Focusable-элемент — InputPrivate внутри, обёртка только собирает combobox.
|
|
370
425
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
371
|
-
(0, jsx_runtime_1.jsxs)("div", { className: (0, classnames_1.default)(styles_module_scss_1.default.fieldWrapper, styles_module_scss_2.default.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: [(0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.backgroundWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.materialLayer, ...(0, shared_1.getAcrylicProps)({
|
|
426
|
+
(0, jsx_runtime_1.jsxs)("div", { className: (0, classnames_1.default)(styles_module_scss_1.default.fieldWrapper, styles_module_scss_2.default.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: [(0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.backgroundWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.materialLayer, ...(0, shared_1.getAcrylicProps)({
|
|
372
427
|
validationState: effectiveValidationState,
|
|
373
428
|
disabled,
|
|
374
429
|
readonly: readOnly,
|
|
@@ -377,5 +432,5 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
|
|
|
377
432
|
}), children: (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.acrylicBg, "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.borderStateLayer, "data-state": 'borderOnBackground' }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.focusLayer })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.fieldContainer, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.contentWrapper, children: [iconBefore && (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, children: iconBefore }), (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.inputLine, children: [prefix && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.prefix, children: prefix }), hasChips ? ((0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_2.default.chipsRow, ref: contentRef, "data-test-id": constants_1.TEST_IDS.fieldSelectChips, children: [selectedPairs.map(pair => ((0, jsx_runtime_1.jsx)(tag_1.Tag, { label: formatPair(pair), size: utils_2.TAG_SIZE_MAP[size], appearance: pair.appearance ?? 'neutral', onDelete: disabled || readOnly || pair.disabled ? undefined : handleRemoveChip(pair.id) }, String(pair.id)))), searchInput, (0, jsx_runtime_1.jsx)("span", { ref: inputPlugRef, className: styles_module_scss_2.default.inputPlug, "aria-hidden": true, children: inputValue })] })) : (searchInput), postfixButtons && (
|
|
378
433
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
379
434
|
(0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, onMouseDown: shared_1.preventSlotMouseDown, onClick: shared_1.stopSlotClickPropagation, children: postfixButtons })), postfix && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfix, children: postfix })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, children: (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_2.default.chevron, "data-open": open || undefined, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.ChevronDownSVG, {}) }) })] }) })] }));
|
|
380
|
-
return ((0, jsx_runtime_1.jsx)(field_decorator_1.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: (0, jsx_runtime_1.jsx)(list_1.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 }) }));
|
|
435
|
+
return ((0, jsx_runtime_1.jsx)(field_decorator_1.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: (0, jsx_runtime_1.jsx)(list_1.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 }) }));
|
|
381
436
|
});
|
|
@@ -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"];
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SELECTION_MODE = void 0;
|
|
3
|
+
exports.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = exports.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = exports.ADD_CUSTOM_OPTION_TRIGGER = exports.SELECTION_MODE = void 0;
|
|
4
4
|
exports.SELECTION_MODE = {
|
|
5
5
|
Single: 'single',
|
|
6
6
|
Multiple: 'multiple',
|
|
7
7
|
};
|
|
8
|
+
/** События, по которым произвольный ввод фиксируется как выбранная опция. */
|
|
9
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER = {
|
|
10
|
+
Enter: 'enter',
|
|
11
|
+
Blur: 'blur',
|
|
12
|
+
Space: 'space',
|
|
13
|
+
Comma: 'comma',
|
|
14
|
+
};
|
|
15
|
+
/** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
|
|
16
|
+
exports.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
|
|
17
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER.Enter,
|
|
18
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER.Blur,
|
|
19
|
+
];
|
|
20
|
+
/** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
|
|
21
|
+
exports.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
|
|
22
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER.Enter,
|
|
23
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER.Blur,
|
|
24
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER.Space,
|
|
25
|
+
exports.ADD_CUSTOM_OPTION_TRIGGER.Comma,
|
|
26
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ItemId } from '@cloud-ru/ds-list';
|
|
2
|
+
import { FocusEvent, KeyboardEvent, RefObject } from 'react';
|
|
3
|
+
import { FieldSelectAddCustomOptionTrigger } from './types';
|
|
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,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useCustomOption = useCustomOption;
|
|
4
|
+
const utils_1 = require("@cloud-ru/ds-utils");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const constants_1 = require("./constants");
|
|
7
|
+
const utils_2 = require("./utils");
|
|
8
|
+
/** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
|
|
9
|
+
function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, typing, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }) {
|
|
10
|
+
const listRef = (0, react_1.useRef)(null);
|
|
11
|
+
const skipCloseCommitRef = (0, react_1.useRef)(false);
|
|
12
|
+
const skipBlurCommitRef = (0, react_1.useRef)(false);
|
|
13
|
+
const resolvedTriggers = (0, react_1.useMemo)(() => (0, utils_2.resolveAddCustomOptionTriggers)({
|
|
14
|
+
allowCustomOption,
|
|
15
|
+
addCustomOptionTriggers,
|
|
16
|
+
addOptionByEnter,
|
|
17
|
+
defaultTriggers: multiple ? constants_1.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : constants_1.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
|
|
18
|
+
}), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
|
|
19
|
+
const tryCommit = (0, react_1.useCallback)((trigger) => {
|
|
20
|
+
if (!typing || !(0, utils_2.shouldHandleCustomOptionTrigger)(trigger, resolvedTriggers) || inputValue === '') {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (multiple) {
|
|
24
|
+
if (multipleValue.includes(inputValue)) {
|
|
25
|
+
resetSearch();
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
commitMultiple([...multipleValue, inputValue]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
commitSingle(inputValue);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}, [typing, resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
|
|
36
|
+
const suppressCloseCommit = (0, react_1.useCallback)(() => {
|
|
37
|
+
skipCloseCommitRef.current = true;
|
|
38
|
+
skipBlurCommitRef.current = true;
|
|
39
|
+
}, []);
|
|
40
|
+
const enableCloseCommit = (0, react_1.useCallback)(() => {
|
|
41
|
+
skipCloseCommitRef.current = false;
|
|
42
|
+
skipBlurCommitRef.current = false;
|
|
43
|
+
}, []);
|
|
44
|
+
const tryCommitOnClose = (0, react_1.useCallback)(() => {
|
|
45
|
+
const skipped = skipCloseCommitRef.current;
|
|
46
|
+
const shouldCommit = (0, utils_2.shouldCommitCustomOptionOnClose)({
|
|
47
|
+
skip: skipped,
|
|
48
|
+
inputElement: inputElementRef.current,
|
|
49
|
+
activeElement: (0, utils_1.isBrowser)() ? document.activeElement : null,
|
|
50
|
+
});
|
|
51
|
+
if (skipped) {
|
|
52
|
+
queueMicrotask(() => {
|
|
53
|
+
skipCloseCommitRef.current = false;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return shouldCommit && tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur);
|
|
57
|
+
}, [inputElementRef, tryCommit]);
|
|
58
|
+
const tryCommitOnBlur = (0, react_1.useCallback)((event) => {
|
|
59
|
+
if (skipBlurCommitRef.current) {
|
|
60
|
+
skipBlurCommitRef.current = false;
|
|
61
|
+
return 'skipped';
|
|
62
|
+
}
|
|
63
|
+
if ((0, utils_2.isCustomOptionBlurInsideField)(event.relatedTarget, [
|
|
64
|
+
listRef.current,
|
|
65
|
+
clearButtonRef.current,
|
|
66
|
+
copyButtonRef.current,
|
|
67
|
+
...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
|
|
68
|
+
])) {
|
|
69
|
+
return 'inside';
|
|
70
|
+
}
|
|
71
|
+
return tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
|
|
72
|
+
}, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
|
|
73
|
+
const tryCommitFromKeyboard = (0, react_1.useCallback)((event) => {
|
|
74
|
+
const trigger = (0, utils_2.getCustomOptionTriggerByCode)(event.code, event.key);
|
|
75
|
+
return trigger !== undefined && tryCommit(trigger);
|
|
76
|
+
}, [tryCommit]);
|
|
77
|
+
const handleListSingleChange = (0, react_1.useCallback)((next) => {
|
|
78
|
+
skipCloseCommitRef.current = true;
|
|
79
|
+
commitSingle(next);
|
|
80
|
+
}, [commitSingle]);
|
|
81
|
+
const handleListMultipleChange = (0, react_1.useCallback)((next) => {
|
|
82
|
+
skipCloseCommitRef.current = true;
|
|
83
|
+
commitMultiple(next);
|
|
84
|
+
}, [commitMultiple]);
|
|
85
|
+
return {
|
|
86
|
+
listRef,
|
|
87
|
+
suppressCloseCommit,
|
|
88
|
+
enableCloseCommit,
|
|
89
|
+
tryCommitOnClose,
|
|
90
|
+
tryCommitOnBlur,
|
|
91
|
+
tryCommitFromKeyboard,
|
|
92
|
+
handleListSingleChange,
|
|
93
|
+
handleListMultipleChange,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -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';
|
|
7
|
-
import { SELECTION_MODE } from './constants';
|
|
7
|
+
import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, SELECTION_MODE } from './constants';
|
|
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';
|
|
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;
|