@cloud-ru/ds-fields 2.2.2 → 2.2.3-preview-0c4119af.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 +3 -1
- package/dist/cjs/components/FieldSelect/FieldSelect.js +81 -41
- 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 +32 -0
- package/dist/cjs/components/FieldSelect/hooks.js +83 -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/esm/components/FieldSelect/FieldSelect.js +82 -42
- 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 +32 -0
- package/dist/esm/components/FieldSelect/hooks.js +80 -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/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +20 -20
- package/src/components/FieldSelect/FieldSelect.tsx +129 -61
- package/src/components/FieldSelect/constants.ts +22 -0
- package/src/components/FieldSelect/hooks.ts +162 -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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud-ru/ds-fields",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3-preview-0c4119af.0",
|
|
4
4
|
"description": "Поля ввода с единой обёрткой label/caption/hint/error и общими осями size/validationState — текст, пароль, число, ползунок, дата, время, выбор, цвет.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -39,24 +39,24 @@
|
|
|
39
39
|
"classnames": "^2.5.1",
|
|
40
40
|
"merge-refs": "^2.0.0",
|
|
41
41
|
"react-textarea-autosize": "^8.5.9",
|
|
42
|
-
"@cloud-ru/ds-adaptive": "
|
|
43
|
-
"@cloud-ru/ds-button": "
|
|
44
|
-
"@cloud-ru/ds-bottom-sheet": "
|
|
45
|
-
"@cloud-ru/ds-calendar": "
|
|
46
|
-
"@cloud-ru/ds-color-picker": "
|
|
47
|
-
"@cloud-ru/ds-
|
|
48
|
-
"@cloud-ru/ds-
|
|
49
|
-
"@cloud-ru/ds-
|
|
50
|
-
"@cloud-ru/ds-
|
|
51
|
-
"@cloud-ru/ds-
|
|
52
|
-
"@cloud-ru/ds-
|
|
53
|
-
"@cloud-ru/ds-
|
|
54
|
-
"@cloud-ru/ds-
|
|
55
|
-
"@cloud-ru/ds-
|
|
56
|
-
"@cloud-ru/ds-
|
|
57
|
-
"@cloud-ru/ds-slider": "
|
|
58
|
-
"@cloud-ru/ds-
|
|
59
|
-
"@cloud-ru/ds-
|
|
60
|
-
"@cloud-ru/ds-utils": "
|
|
42
|
+
"@cloud-ru/ds-adaptive": "1.0.1",
|
|
43
|
+
"@cloud-ru/ds-button": "1.0.2",
|
|
44
|
+
"@cloud-ru/ds-bottom-sheet": "1.1.1",
|
|
45
|
+
"@cloud-ru/ds-calendar": "1.0.15-preview-0c4119af.0",
|
|
46
|
+
"@cloud-ru/ds-color-picker": "1.0.6",
|
|
47
|
+
"@cloud-ru/ds-divider": "1.0.1",
|
|
48
|
+
"@cloud-ru/ds-field-decorator": "1.0.5",
|
|
49
|
+
"@cloud-ru/ds-icons": "1.1.0",
|
|
50
|
+
"@cloud-ru/ds-dropdown": "1.0.8",
|
|
51
|
+
"@cloud-ru/ds-list": "2.1.4-preview-0c4119af.0",
|
|
52
|
+
"@cloud-ru/ds-input-private": "1.0.4",
|
|
53
|
+
"@cloud-ru/ds-loader": "1.0.1",
|
|
54
|
+
"@cloud-ru/ds-materials": "1.0.1",
|
|
55
|
+
"@cloud-ru/ds-scroll": "1.0.2",
|
|
56
|
+
"@cloud-ru/ds-tooltip": "1.0.4",
|
|
57
|
+
"@cloud-ru/ds-slider": "1.0.4",
|
|
58
|
+
"@cloud-ru/ds-skeleton": "1.0.1",
|
|
59
|
+
"@cloud-ru/ds-tag": "1.1.1",
|
|
60
|
+
"@cloud-ru/ds-utils": "1.1.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
useCopyButton,
|
|
35
35
|
} from '../shared';
|
|
36
36
|
import fieldStyles from '../shared/styles.module.scss';
|
|
37
|
+
import { useCustomOption } from './hooks';
|
|
37
38
|
import styles from './styles.module.scss';
|
|
38
39
|
import { FieldSelectProps } from './types';
|
|
39
40
|
import {
|
|
@@ -42,6 +43,8 @@ import {
|
|
|
42
43
|
filterItems,
|
|
43
44
|
findItem,
|
|
44
45
|
flatten,
|
|
46
|
+
getMissingSelectedItems,
|
|
47
|
+
getSelectedIds,
|
|
45
48
|
isMultiple,
|
|
46
49
|
TAG_SIZE_MAP,
|
|
47
50
|
WithIdContent,
|
|
@@ -111,6 +114,8 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
111
114
|
const chips = multiple ? (props.chips ?? true) : false;
|
|
112
115
|
const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
|
|
113
116
|
const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
|
|
117
|
+
const allowCustomOption = props.allowCustomOption === true;
|
|
118
|
+
const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
|
|
114
119
|
|
|
115
120
|
const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
|
|
116
121
|
|
|
@@ -177,18 +182,31 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
177
182
|
|
|
178
183
|
const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
|
|
179
184
|
|
|
185
|
+
const selectedIds = useMemo(
|
|
186
|
+
() => getSelectedIds({ multiple, multipleValue, singleValue }),
|
|
187
|
+
[multiple, multipleValue, singleValue],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
|
|
191
|
+
const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
|
|
192
|
+
|
|
193
|
+
const itemsWithPlaceholders = useMemo(
|
|
194
|
+
() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items),
|
|
195
|
+
[missingSelectedItems, items],
|
|
196
|
+
);
|
|
197
|
+
|
|
180
198
|
// Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
|
|
181
199
|
const seenItems = useRef(new Map<ItemId, WithIdContent>());
|
|
182
200
|
|
|
183
201
|
const resolveItem = useMemo(() => {
|
|
184
|
-
for (const item of flatten(allItems)) {
|
|
202
|
+
for (const item of flatten([...missingSelectedItems, ...allItems])) {
|
|
185
203
|
if (item.id !== undefined) {
|
|
186
204
|
seenItems.current.set(item.id, item);
|
|
187
205
|
}
|
|
188
206
|
}
|
|
189
207
|
|
|
190
|
-
return (id: ItemId) => findItem(allItems, id) ?? seenItems.current.get(id);
|
|
191
|
-
}, [allItems]);
|
|
208
|
+
return (id: ItemId) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
|
|
209
|
+
}, [allItems, missingSelectedItems]);
|
|
192
210
|
|
|
193
211
|
const selectedPairs = useMemo(() => {
|
|
194
212
|
if (!multiple || multipleValue.length === 0) {
|
|
@@ -265,14 +283,14 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
265
283
|
setInputValue(selectedLabel);
|
|
266
284
|
}, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
|
|
267
285
|
|
|
268
|
-
// При autocomplete фильтрует сервер — items берутся как
|
|
286
|
+
// При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
|
|
269
287
|
const filteredItems = useMemo(() => {
|
|
270
288
|
if (!searchable || autocomplete || !typing) {
|
|
271
|
-
return
|
|
289
|
+
return itemsWithPlaceholders;
|
|
272
290
|
}
|
|
273
291
|
|
|
274
|
-
return filterItems(
|
|
275
|
-
}, [searchable, autocomplete, typing,
|
|
292
|
+
return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
|
|
293
|
+
}, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
|
|
276
294
|
|
|
277
295
|
const filteredPinTop = useMemo(() => {
|
|
278
296
|
if (!searchable || autocomplete || !typing || !pinTop) {
|
|
@@ -290,61 +308,112 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
290
308
|
return filterItems(pinBottom, inputValue, enableFuzzySearch);
|
|
291
309
|
}, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
|
|
292
310
|
|
|
293
|
-
const
|
|
294
|
-
(next:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (openProp === undefined) {
|
|
298
|
-
setOpenLocal(next);
|
|
311
|
+
const handleSingleChange = useCallback(
|
|
312
|
+
(next: ItemId | undefined) => {
|
|
313
|
+
if (multiple) {
|
|
314
|
+
return;
|
|
299
315
|
}
|
|
300
316
|
|
|
301
|
-
|
|
317
|
+
if (props.value === undefined) {
|
|
318
|
+
setSingleLocal(next);
|
|
319
|
+
}
|
|
302
320
|
|
|
303
|
-
|
|
321
|
+
props.onChange?.(next);
|
|
322
|
+
setTyping(false);
|
|
323
|
+
},
|
|
324
|
+
[multiple, props],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const handleMultipleChange = useCallback(
|
|
328
|
+
(next: ItemId[]) => {
|
|
329
|
+
if (!multiple) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (props.value === undefined) {
|
|
334
|
+
setMultipleLocal(next);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
props.onChange?.(next);
|
|
338
|
+
|
|
339
|
+
if (searchable) {
|
|
304
340
|
setTyping(false);
|
|
305
341
|
|
|
306
342
|
if (resetSearchOnOptionSelection) {
|
|
307
|
-
setInputValue(
|
|
343
|
+
setInputValue('');
|
|
308
344
|
}
|
|
309
345
|
}
|
|
310
346
|
},
|
|
311
|
-
[
|
|
347
|
+
[multiple, props, searchable, resetSearchOnOptionSelection, setInputValue],
|
|
312
348
|
);
|
|
313
349
|
|
|
314
|
-
const
|
|
315
|
-
if (multiple) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (props.value === undefined) {
|
|
320
|
-
setSingleLocal(next);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
props.onChange?.(next);
|
|
350
|
+
const resetCustomOptionSearch = useCallback(() => {
|
|
324
351
|
setTyping(false);
|
|
325
|
-
};
|
|
326
352
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return;
|
|
353
|
+
if (resetSearchOnOptionSelection) {
|
|
354
|
+
setInputValue(multiple ? '' : selectedLabel);
|
|
330
355
|
}
|
|
356
|
+
}, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
|
|
331
357
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
358
|
+
const {
|
|
359
|
+
listRef,
|
|
360
|
+
suppressCloseCommit,
|
|
361
|
+
enableCloseCommit,
|
|
362
|
+
tryCommitOnClose,
|
|
363
|
+
tryCommitOnBlur,
|
|
364
|
+
tryCommitFromKeyboard,
|
|
365
|
+
handleListSingleChange,
|
|
366
|
+
handleListMultipleChange,
|
|
367
|
+
} = useCustomOption({
|
|
368
|
+
allowCustomOption,
|
|
369
|
+
addCustomOptionTriggers,
|
|
370
|
+
addOptionByEnter,
|
|
371
|
+
multiple,
|
|
372
|
+
inputValue,
|
|
373
|
+
multipleValue,
|
|
374
|
+
commitSingle: handleSingleChange,
|
|
375
|
+
commitMultiple: handleMultipleChange,
|
|
376
|
+
resetSearch: resetCustomOptionSearch,
|
|
377
|
+
inputElementRef,
|
|
378
|
+
clearButtonRef,
|
|
379
|
+
copyButtonRef,
|
|
380
|
+
footerActiveElementsRefs,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const handleOpenChange = useCallback(
|
|
384
|
+
(next: boolean) => {
|
|
385
|
+
// readonly/disabled поле открывать нельзя, закрывать — можно.
|
|
386
|
+
if (next && (disabled || readOnly)) return;
|
|
387
|
+
if (openProp === undefined) {
|
|
388
|
+
setOpenLocal(next);
|
|
389
|
+
}
|
|
335
390
|
|
|
336
|
-
|
|
391
|
+
onOpenChange?.(next);
|
|
337
392
|
|
|
338
|
-
|
|
339
|
-
|
|
393
|
+
if (!next) {
|
|
394
|
+
setTyping(false);
|
|
395
|
+
const committed = tryCommitOnClose();
|
|
340
396
|
|
|
341
|
-
|
|
342
|
-
|
|
397
|
+
// Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
|
|
398
|
+
if (resetSearchOnOptionSelection && !committed) {
|
|
399
|
+
setInputValue(selectedLabel);
|
|
400
|
+
}
|
|
343
401
|
}
|
|
344
|
-
}
|
|
345
|
-
|
|
402
|
+
},
|
|
403
|
+
[
|
|
404
|
+
openProp,
|
|
405
|
+
onOpenChange,
|
|
406
|
+
disabled,
|
|
407
|
+
readOnly,
|
|
408
|
+
resetSearchOnOptionSelection,
|
|
409
|
+
setInputValue,
|
|
410
|
+
selectedLabel,
|
|
411
|
+
tryCommitOnClose,
|
|
412
|
+
],
|
|
413
|
+
);
|
|
346
414
|
|
|
347
415
|
const handleInputChange = (next: string) => {
|
|
416
|
+
enableCloseCommit();
|
|
348
417
|
setInputValue(next);
|
|
349
418
|
setTyping(true);
|
|
350
419
|
|
|
@@ -471,21 +540,13 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
471
540
|
|
|
472
541
|
if (event.key === 'Escape' && open) {
|
|
473
542
|
event.preventDefault();
|
|
543
|
+
suppressCloseCommit();
|
|
474
544
|
handleOpenChange(false);
|
|
475
545
|
}
|
|
476
546
|
|
|
477
|
-
if (
|
|
478
|
-
// Введённый текст становится новым значением (создание опции «на лету»).
|
|
547
|
+
if (tryCommitFromKeyboard(event)) {
|
|
479
548
|
event.preventDefault();
|
|
480
|
-
|
|
481
|
-
if (multiple) {
|
|
482
|
-
if (!multipleValue.includes(inputValue)) {
|
|
483
|
-
handleMultipleChange([...multipleValue, inputValue]);
|
|
484
|
-
}
|
|
485
|
-
} else {
|
|
486
|
-
handleSingleChange(inputValue);
|
|
487
|
-
}
|
|
488
|
-
|
|
549
|
+
event.stopPropagation();
|
|
489
550
|
return;
|
|
490
551
|
}
|
|
491
552
|
|
|
@@ -536,17 +597,23 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
536
597
|
[searchable, onFocusProp],
|
|
537
598
|
);
|
|
538
599
|
|
|
539
|
-
const handleInputBlur =
|
|
540
|
-
(
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
600
|
+
const handleInputBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
601
|
+
setFocusVisible(false);
|
|
602
|
+
const blurResult = tryCommitOnBlur(event);
|
|
603
|
+
|
|
604
|
+
// Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
|
|
605
|
+
// Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
|
|
606
|
+
if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
|
|
607
|
+
setTyping(false);
|
|
608
|
+
setInputValue(selectedLabel);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
onBlurProp?.(event);
|
|
612
|
+
};
|
|
546
613
|
|
|
547
614
|
const droplistSelection = multiple
|
|
548
|
-
? { mode: 'multiple' as const, value: multipleValue, onChange:
|
|
549
|
-
: { mode: 'single' as const, value: singleValue, onChange:
|
|
615
|
+
? { mode: 'multiple' as const, value: multipleValue, onChange: handleListMultipleChange }
|
|
616
|
+
: { mode: 'single' as const, value: singleValue, onChange: handleListSingleChange };
|
|
550
617
|
|
|
551
618
|
const hasChips = chips && selectedPairs.length > 0;
|
|
552
619
|
|
|
@@ -698,6 +765,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
698
765
|
placement={placement}
|
|
699
766
|
widthStrategy={widthStrategy}
|
|
700
767
|
triggerElemRef={inputRef}
|
|
768
|
+
listRef={listRef}
|
|
701
769
|
size={size}
|
|
702
770
|
open={open}
|
|
703
771
|
onOpenChange={handleOpenChange}
|
|
@@ -2,3 +2,25 @@ export const SELECTION_MODE = {
|
|
|
2
2
|
Single: 'single',
|
|
3
3
|
Multiple: 'multiple',
|
|
4
4
|
} as const;
|
|
5
|
+
|
|
6
|
+
/** События, по которым произвольный ввод фиксируется как выбранная опция. */
|
|
7
|
+
export const ADD_CUSTOM_OPTION_TRIGGER = {
|
|
8
|
+
Enter: 'enter',
|
|
9
|
+
Blur: 'blur',
|
|
10
|
+
Space: 'space',
|
|
11
|
+
Comma: 'comma',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
|
|
15
|
+
export const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
|
|
16
|
+
ADD_CUSTOM_OPTION_TRIGGER.Enter,
|
|
17
|
+
ADD_CUSTOM_OPTION_TRIGGER.Blur,
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
|
|
21
|
+
export const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
|
|
22
|
+
ADD_CUSTOM_OPTION_TRIGGER.Enter,
|
|
23
|
+
ADD_CUSTOM_OPTION_TRIGGER.Blur,
|
|
24
|
+
ADD_CUSTOM_OPTION_TRIGGER.Space,
|
|
25
|
+
ADD_CUSTOM_OPTION_TRIGGER.Comma,
|
|
26
|
+
] as const;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ItemId } from '@cloud-ru/ds-list';
|
|
2
|
+
import { isBrowser } from '@cloud-ru/ds-utils';
|
|
3
|
+
import { FocusEvent, KeyboardEvent, RefObject, useCallback, useMemo, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ADD_CUSTOM_OPTION_TRIGGER,
|
|
7
|
+
ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE,
|
|
8
|
+
ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
|
|
9
|
+
} from './constants';
|
|
10
|
+
import { FieldSelectAddCustomOptionTrigger } from './types';
|
|
11
|
+
import {
|
|
12
|
+
getCustomOptionTriggerByCode,
|
|
13
|
+
isCustomOptionBlurInsideField,
|
|
14
|
+
resolveAddCustomOptionTriggers,
|
|
15
|
+
shouldCommitCustomOptionOnClose,
|
|
16
|
+
shouldHandleCustomOptionTrigger,
|
|
17
|
+
} from './utils';
|
|
18
|
+
|
|
19
|
+
/** Результат blur: фокус остался внутри поля, ввод зафиксирован, или коммитить было нечего. */
|
|
20
|
+
type CustomOptionBlurResult = 'inside' | 'committed' | 'skipped';
|
|
21
|
+
|
|
22
|
+
type UseCustomOptionParams = {
|
|
23
|
+
allowCustomOption: boolean;
|
|
24
|
+
addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
|
|
25
|
+
addOptionByEnter: boolean;
|
|
26
|
+
multiple: boolean;
|
|
27
|
+
inputValue: string;
|
|
28
|
+
multipleValue: ItemId[];
|
|
29
|
+
commitSingle(value: ItemId | undefined): void;
|
|
30
|
+
commitMultiple(value: ItemId[]): void;
|
|
31
|
+
resetSearch(): void;
|
|
32
|
+
inputElementRef: RefObject<HTMLInputElement | null>;
|
|
33
|
+
clearButtonRef: RefObject<HTMLButtonElement | null>;
|
|
34
|
+
copyButtonRef: RefObject<HTMLButtonElement | null>;
|
|
35
|
+
footerActiveElementsRefs?: RefObject<HTMLElement>[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
|
|
39
|
+
export function useCustomOption({
|
|
40
|
+
allowCustomOption,
|
|
41
|
+
addCustomOptionTriggers,
|
|
42
|
+
addOptionByEnter,
|
|
43
|
+
multiple,
|
|
44
|
+
inputValue,
|
|
45
|
+
multipleValue,
|
|
46
|
+
commitSingle,
|
|
47
|
+
commitMultiple,
|
|
48
|
+
resetSearch,
|
|
49
|
+
inputElementRef,
|
|
50
|
+
clearButtonRef,
|
|
51
|
+
copyButtonRef,
|
|
52
|
+
footerActiveElementsRefs,
|
|
53
|
+
}: UseCustomOptionParams) {
|
|
54
|
+
const listRef = useRef<HTMLElement>(null);
|
|
55
|
+
const skipCloseCommitRef = useRef(false);
|
|
56
|
+
|
|
57
|
+
const resolvedTriggers = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
resolveAddCustomOptionTriggers({
|
|
60
|
+
allowCustomOption,
|
|
61
|
+
addCustomOptionTriggers,
|
|
62
|
+
addOptionByEnter,
|
|
63
|
+
defaultTriggers: multiple ? ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
|
|
64
|
+
}),
|
|
65
|
+
[allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const tryCommit = useCallback(
|
|
69
|
+
(trigger: FieldSelectAddCustomOptionTrigger) => {
|
|
70
|
+
if (!shouldHandleCustomOptionTrigger(trigger, resolvedTriggers) || inputValue === '') {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (multiple) {
|
|
75
|
+
if (multipleValue.includes(inputValue)) {
|
|
76
|
+
resetSearch();
|
|
77
|
+
} else {
|
|
78
|
+
commitMultiple([...multipleValue, inputValue]);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
commitSingle(inputValue);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
[resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const suppressCloseCommit = useCallback(() => {
|
|
90
|
+
skipCloseCommitRef.current = true;
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const enableCloseCommit = useCallback(() => {
|
|
94
|
+
skipCloseCommitRef.current = false;
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const tryCommitOnClose = useCallback(() => {
|
|
98
|
+
const shouldCommit = shouldCommitCustomOptionOnClose({
|
|
99
|
+
skip: skipCloseCommitRef.current,
|
|
100
|
+
inputElement: inputElementRef.current,
|
|
101
|
+
activeElement: isBrowser() ? document.activeElement : null,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
skipCloseCommitRef.current = false;
|
|
105
|
+
|
|
106
|
+
return shouldCommit && tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur);
|
|
107
|
+
}, [inputElementRef, tryCommit]);
|
|
108
|
+
|
|
109
|
+
const tryCommitOnBlur = useCallback(
|
|
110
|
+
(event: FocusEvent<HTMLInputElement>): CustomOptionBlurResult => {
|
|
111
|
+
if (
|
|
112
|
+
isCustomOptionBlurInsideField(event.relatedTarget, [
|
|
113
|
+
listRef.current,
|
|
114
|
+
clearButtonRef.current,
|
|
115
|
+
copyButtonRef.current,
|
|
116
|
+
...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
|
|
117
|
+
])
|
|
118
|
+
) {
|
|
119
|
+
return 'inside';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
|
|
123
|
+
},
|
|
124
|
+
[clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const tryCommitFromKeyboard = useCallback(
|
|
128
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
129
|
+
const trigger = getCustomOptionTriggerByCode(event.code, event.key);
|
|
130
|
+
|
|
131
|
+
return trigger !== undefined && tryCommit(trigger);
|
|
132
|
+
},
|
|
133
|
+
[tryCommit],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const handleListSingleChange = useCallback(
|
|
137
|
+
(next: ItemId | undefined) => {
|
|
138
|
+
skipCloseCommitRef.current = true;
|
|
139
|
+
commitSingle(next);
|
|
140
|
+
},
|
|
141
|
+
[commitSingle],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const handleListMultipleChange = useCallback(
|
|
145
|
+
(next: ItemId[]) => {
|
|
146
|
+
skipCloseCommitRef.current = true;
|
|
147
|
+
commitMultiple(next);
|
|
148
|
+
},
|
|
149
|
+
[commitMultiple],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
listRef,
|
|
154
|
+
suppressCloseCommit,
|
|
155
|
+
enableCloseCommit,
|
|
156
|
+
tryCommitOnClose,
|
|
157
|
+
tryCommitOnBlur,
|
|
158
|
+
tryCommitFromKeyboard,
|
|
159
|
+
handleListSingleChange,
|
|
160
|
+
handleListMultipleChange,
|
|
161
|
+
};
|
|
162
|
+
}
|