@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud-ru/ds-fields",
|
|
3
|
-
"version": "2.2.3-preview-
|
|
3
|
+
"version": "2.2.3-preview-a0540d7f.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-bottom-sheet": "1.1.1",
|
|
43
42
|
"@cloud-ru/ds-adaptive": "1.0.1",
|
|
43
|
+
"@cloud-ru/ds-bottom-sheet": "1.1.1",
|
|
44
|
+
"@cloud-ru/ds-button": "1.0.2",
|
|
45
|
+
"@cloud-ru/ds-calendar": "1.0.15-preview-a0540d7f.0",
|
|
44
46
|
"@cloud-ru/ds-color-picker": "1.0.6",
|
|
45
|
-
"@cloud-ru/ds-calendar": "1.0.15-preview-8025692f.0",
|
|
46
47
|
"@cloud-ru/ds-divider": "1.0.1",
|
|
47
48
|
"@cloud-ru/ds-dropdown": "1.0.8",
|
|
48
|
-
"@cloud-ru/ds-button": "1.0.2",
|
|
49
|
-
"@cloud-ru/ds-field-decorator": "1.0.5",
|
|
50
49
|
"@cloud-ru/ds-icons": "1.1.0",
|
|
51
|
-
"@cloud-ru/ds-
|
|
50
|
+
"@cloud-ru/ds-field-decorator": "1.0.5",
|
|
52
51
|
"@cloud-ru/ds-input-private": "1.0.4",
|
|
53
52
|
"@cloud-ru/ds-loader": "1.0.1",
|
|
53
|
+
"@cloud-ru/ds-list": "2.1.4-preview-a0540d7f.0",
|
|
54
54
|
"@cloud-ru/ds-materials": "1.0.1",
|
|
55
55
|
"@cloud-ru/ds-scroll": "1.0.2",
|
|
56
|
-
"@cloud-ru/ds-slider": "1.0.4",
|
|
57
56
|
"@cloud-ru/ds-skeleton": "1.0.1",
|
|
58
|
-
"@cloud-ru/ds-
|
|
57
|
+
"@cloud-ru/ds-slider": "1.0.4",
|
|
59
58
|
"@cloud-ru/ds-tag": "1.1.1",
|
|
59
|
+
"@cloud-ru/ds-tooltip": "1.0.4",
|
|
60
60
|
"@cloud-ru/ds-utils": "1.1.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -520,12 +520,12 @@ export const FieldDate = forwardRef<HTMLInputElement, FieldDateProps>(function F
|
|
|
520
520
|
</span>
|
|
521
521
|
) : (
|
|
522
522
|
<InputPrivate
|
|
523
|
-
ref={mergeRefs(ref, fromInputRef
|
|
523
|
+
ref={mergeRefs(ref, fromInputRef)}
|
|
524
524
|
className={fieldStyles.fieldInput}
|
|
525
525
|
value={singleInputValue}
|
|
526
526
|
placeholder={placeholderMask}
|
|
527
|
-
// Single/date-time ведёт сегментный движок (useSegmentedMask), нативный
|
|
528
|
-
// игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
527
|
+
// Single/date-time ведёт сегментный движок (keydown, useSegmentedMask), нативный
|
|
528
|
+
// onChange игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
529
529
|
onChange={() => {}}
|
|
530
530
|
onKeyDown={handleSingleKeyDown}
|
|
531
531
|
onClick={singleSeg.handleClick}
|
|
@@ -55,9 +55,8 @@ function buildSegments(mode: MaskMode, showSeconds: boolean): Segment[] {
|
|
|
55
55
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
56
56
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
57
57
|
* фокус автоматически уезжает на следующий сегмент.
|
|
58
|
-
* 2.
|
|
59
|
-
*
|
|
60
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
58
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
59
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
61
60
|
*/
|
|
62
61
|
export function formatMask(input: string, mode: MaskMode, showSeconds = true): string {
|
|
63
62
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -82,7 +81,7 @@ export function formatMask(input: string, mode: MaskMode, showSeconds = true): s
|
|
|
82
81
|
stream = stream.slice(seg.len);
|
|
83
82
|
if (raw.length === seg.len) {
|
|
84
83
|
const n = Number(raw);
|
|
85
|
-
out += n > seg.max ?
|
|
84
|
+
out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
|
|
86
85
|
} else {
|
|
87
86
|
out += raw;
|
|
88
87
|
}
|
|
@@ -2,7 +2,7 @@ import { ChevronDownSVG } from '@cloud-ru/ds-icons/interface/system';
|
|
|
2
2
|
import { InputPrivate, useButtonNavigation, useClearButton } from '@cloud-ru/ds-input-private';
|
|
3
3
|
import { Droplist, ItemId } from '@cloud-ru/ds-list';
|
|
4
4
|
import { Tag } from '@cloud-ru/ds-tag';
|
|
5
|
-
import { useLayoutEffect } from '@cloud-ru/ds-utils';
|
|
5
|
+
import { isBrowser, useLayoutEffect } from '@cloud-ru/ds-utils';
|
|
6
6
|
import cn from 'classnames';
|
|
7
7
|
import mergeRefs from 'merge-refs';
|
|
8
8
|
import {
|
|
@@ -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,12 +114,15 @@ 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
|
|
|
117
122
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
118
123
|
// Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
|
|
119
124
|
const inputElementRef = useRef<HTMLInputElement>(null);
|
|
125
|
+
const restoreInputFocusRef = useRef(false);
|
|
120
126
|
const clearButtonRef = useRef<HTMLButtonElement>(null);
|
|
121
127
|
const copyButtonRef = useRef<HTMLButtonElement>(null);
|
|
122
128
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
@@ -177,18 +183,31 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
177
183
|
|
|
178
184
|
const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
|
|
179
185
|
|
|
186
|
+
const selectedIds = useMemo(
|
|
187
|
+
() => getSelectedIds({ multiple, multipleValue, singleValue }),
|
|
188
|
+
[multiple, multipleValue, singleValue],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
|
|
192
|
+
const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
|
|
193
|
+
|
|
194
|
+
const itemsWithPlaceholders = useMemo(
|
|
195
|
+
() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items),
|
|
196
|
+
[missingSelectedItems, items],
|
|
197
|
+
);
|
|
198
|
+
|
|
180
199
|
// Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
|
|
181
200
|
const seenItems = useRef(new Map<ItemId, WithIdContent>());
|
|
182
201
|
|
|
183
202
|
const resolveItem = useMemo(() => {
|
|
184
|
-
for (const item of flatten(allItems)) {
|
|
203
|
+
for (const item of flatten([...missingSelectedItems, ...allItems])) {
|
|
185
204
|
if (item.id !== undefined) {
|
|
186
205
|
seenItems.current.set(item.id, item);
|
|
187
206
|
}
|
|
188
207
|
}
|
|
189
208
|
|
|
190
|
-
return (id: ItemId) => findItem(allItems, id) ?? seenItems.current.get(id);
|
|
191
|
-
}, [allItems]);
|
|
209
|
+
return (id: ItemId) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
|
|
210
|
+
}, [allItems, missingSelectedItems]);
|
|
192
211
|
|
|
193
212
|
const selectedPairs = useMemo(() => {
|
|
194
213
|
if (!multiple || multipleValue.length === 0) {
|
|
@@ -265,14 +284,14 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
265
284
|
setInputValue(selectedLabel);
|
|
266
285
|
}, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
|
|
267
286
|
|
|
268
|
-
// При autocomplete фильтрует сервер — items берутся как
|
|
287
|
+
// При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
|
|
269
288
|
const filteredItems = useMemo(() => {
|
|
270
289
|
if (!searchable || autocomplete || !typing) {
|
|
271
|
-
return
|
|
290
|
+
return itemsWithPlaceholders;
|
|
272
291
|
}
|
|
273
292
|
|
|
274
|
-
return filterItems(
|
|
275
|
-
}, [searchable, autocomplete, typing,
|
|
293
|
+
return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
|
|
294
|
+
}, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
|
|
276
295
|
|
|
277
296
|
const filteredPinTop = useMemo(() => {
|
|
278
297
|
if (!searchable || autocomplete || !typing || !pinTop) {
|
|
@@ -290,61 +309,115 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
290
309
|
return filterItems(pinBottom, inputValue, enableFuzzySearch);
|
|
291
310
|
}, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
|
|
292
311
|
|
|
293
|
-
const
|
|
294
|
-
(next:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (openProp === undefined) {
|
|
298
|
-
setOpenLocal(next);
|
|
312
|
+
const handleSingleChange = useCallback(
|
|
313
|
+
(next: ItemId | undefined) => {
|
|
314
|
+
if (multiple) {
|
|
315
|
+
return;
|
|
299
316
|
}
|
|
300
317
|
|
|
301
|
-
|
|
318
|
+
if (props.value === undefined) {
|
|
319
|
+
setSingleLocal(next);
|
|
320
|
+
}
|
|
302
321
|
|
|
303
|
-
|
|
322
|
+
props.onChange?.(next);
|
|
323
|
+
setTyping(false);
|
|
324
|
+
},
|
|
325
|
+
[multiple, props],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const handleMultipleChange = useCallback(
|
|
329
|
+
(next: ItemId[]) => {
|
|
330
|
+
if (!multiple) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
restoreInputFocusRef.current = isBrowser() && inputElementRef.current === document.activeElement;
|
|
335
|
+
|
|
336
|
+
if (props.value === undefined) {
|
|
337
|
+
setMultipleLocal(next);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
props.onChange?.(next);
|
|
341
|
+
|
|
342
|
+
if (searchable) {
|
|
304
343
|
setTyping(false);
|
|
305
344
|
|
|
306
345
|
if (resetSearchOnOptionSelection) {
|
|
307
|
-
setInputValue(
|
|
346
|
+
setInputValue('');
|
|
308
347
|
}
|
|
309
348
|
}
|
|
310
349
|
},
|
|
311
|
-
[
|
|
350
|
+
[multiple, props, searchable, resetSearchOnOptionSelection, setInputValue],
|
|
312
351
|
);
|
|
313
352
|
|
|
314
|
-
const
|
|
315
|
-
if (multiple) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (props.value === undefined) {
|
|
320
|
-
setSingleLocal(next);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
props.onChange?.(next);
|
|
353
|
+
const resetCustomOptionSearch = useCallback(() => {
|
|
324
354
|
setTyping(false);
|
|
325
|
-
};
|
|
326
355
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return;
|
|
356
|
+
if (resetSearchOnOptionSelection) {
|
|
357
|
+
setInputValue(multiple ? '' : selectedLabel);
|
|
330
358
|
}
|
|
359
|
+
}, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
|
|
331
360
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
361
|
+
const {
|
|
362
|
+
listRef,
|
|
363
|
+
suppressCloseCommit,
|
|
364
|
+
enableCloseCommit,
|
|
365
|
+
tryCommitOnClose,
|
|
366
|
+
tryCommitOnBlur,
|
|
367
|
+
tryCommitFromKeyboard,
|
|
368
|
+
handleListSingleChange,
|
|
369
|
+
handleListMultipleChange,
|
|
370
|
+
} = useCustomOption({
|
|
371
|
+
allowCustomOption,
|
|
372
|
+
addCustomOptionTriggers,
|
|
373
|
+
addOptionByEnter,
|
|
374
|
+
multiple,
|
|
375
|
+
inputValue,
|
|
376
|
+
typing,
|
|
377
|
+
multipleValue,
|
|
378
|
+
commitSingle: handleSingleChange,
|
|
379
|
+
commitMultiple: handleMultipleChange,
|
|
380
|
+
resetSearch: resetCustomOptionSearch,
|
|
381
|
+
inputElementRef,
|
|
382
|
+
clearButtonRef,
|
|
383
|
+
copyButtonRef,
|
|
384
|
+
footerActiveElementsRefs,
|
|
385
|
+
});
|
|
335
386
|
|
|
336
|
-
|
|
387
|
+
const handleOpenChange = useCallback(
|
|
388
|
+
(next: boolean) => {
|
|
389
|
+
// readonly/disabled поле открывать нельзя, закрывать — можно.
|
|
390
|
+
if (next && (disabled || readOnly)) return;
|
|
391
|
+
if (openProp === undefined) {
|
|
392
|
+
setOpenLocal(next);
|
|
393
|
+
}
|
|
337
394
|
|
|
338
|
-
|
|
339
|
-
|
|
395
|
+
onOpenChange?.(next);
|
|
396
|
+
|
|
397
|
+
if (!next) {
|
|
398
|
+
setTyping(false);
|
|
399
|
+
const committed = tryCommitOnClose();
|
|
340
400
|
|
|
341
|
-
|
|
342
|
-
|
|
401
|
+
// Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
|
|
402
|
+
if (resetSearchOnOptionSelection && !committed) {
|
|
403
|
+
setInputValue(selectedLabel);
|
|
404
|
+
}
|
|
343
405
|
}
|
|
344
|
-
}
|
|
345
|
-
|
|
406
|
+
},
|
|
407
|
+
[
|
|
408
|
+
openProp,
|
|
409
|
+
onOpenChange,
|
|
410
|
+
disabled,
|
|
411
|
+
readOnly,
|
|
412
|
+
resetSearchOnOptionSelection,
|
|
413
|
+
setInputValue,
|
|
414
|
+
selectedLabel,
|
|
415
|
+
tryCommitOnClose,
|
|
416
|
+
],
|
|
417
|
+
);
|
|
346
418
|
|
|
347
419
|
const handleInputChange = (next: string) => {
|
|
420
|
+
enableCloseCommit();
|
|
348
421
|
setInputValue(next);
|
|
349
422
|
setTyping(true);
|
|
350
423
|
|
|
@@ -471,21 +544,13 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
471
544
|
|
|
472
545
|
if (event.key === 'Escape' && open) {
|
|
473
546
|
event.preventDefault();
|
|
547
|
+
suppressCloseCommit();
|
|
474
548
|
handleOpenChange(false);
|
|
475
549
|
}
|
|
476
550
|
|
|
477
|
-
if (
|
|
478
|
-
// Введённый текст становится новым значением (создание опции «на лету»).
|
|
551
|
+
if (tryCommitFromKeyboard(event)) {
|
|
479
552
|
event.preventDefault();
|
|
480
|
-
|
|
481
|
-
if (multiple) {
|
|
482
|
-
if (!multipleValue.includes(inputValue)) {
|
|
483
|
-
handleMultipleChange([...multipleValue, inputValue]);
|
|
484
|
-
}
|
|
485
|
-
} else {
|
|
486
|
-
handleSingleChange(inputValue);
|
|
487
|
-
}
|
|
488
|
-
|
|
553
|
+
event.stopPropagation();
|
|
489
554
|
return;
|
|
490
555
|
}
|
|
491
556
|
|
|
@@ -501,6 +566,12 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
501
566
|
}
|
|
502
567
|
};
|
|
503
568
|
|
|
569
|
+
const handleTriggerKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
|
|
570
|
+
if (event.key === 'Escape') {
|
|
571
|
+
suppressCloseCommit();
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
504
575
|
const handleTriggerMouseEnter = useCallback(() => {
|
|
505
576
|
if (!readOnly && !disabled) {
|
|
506
577
|
setHover(true);
|
|
@@ -536,20 +607,35 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
536
607
|
[searchable, onFocusProp],
|
|
537
608
|
);
|
|
538
609
|
|
|
539
|
-
const handleInputBlur =
|
|
540
|
-
(
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
610
|
+
const handleInputBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
611
|
+
setFocusVisible(false);
|
|
612
|
+
const blurResult = tryCommitOnBlur(event);
|
|
613
|
+
|
|
614
|
+
// Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
|
|
615
|
+
// Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
|
|
616
|
+
if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
|
|
617
|
+
setTyping(false);
|
|
618
|
+
setInputValue(selectedLabel);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
onBlurProp?.(event);
|
|
622
|
+
};
|
|
546
623
|
|
|
547
624
|
const droplistSelection = multiple
|
|
548
|
-
? { mode: 'multiple' as const, value: multipleValue, onChange:
|
|
549
|
-
: { mode: 'single' as const, value: singleValue, onChange:
|
|
625
|
+
? { mode: 'multiple' as const, value: multipleValue, onChange: handleListMultipleChange }
|
|
626
|
+
: { mode: 'single' as const, value: singleValue, onChange: handleListSingleChange };
|
|
550
627
|
|
|
551
628
|
const hasChips = chips && selectedPairs.length > 0;
|
|
552
629
|
|
|
630
|
+
useLayoutEffect(() => {
|
|
631
|
+
if (!restoreInputFocusRef.current) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
restoreInputFocusRef.current = false;
|
|
636
|
+
inputElementRef.current?.focus();
|
|
637
|
+
}, [multipleValue.length]);
|
|
638
|
+
|
|
553
639
|
// minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
|
|
554
640
|
// Замер до paint, иначе input мигает при вводе.
|
|
555
641
|
useLayoutEffect(() => {
|
|
@@ -583,7 +669,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
583
669
|
);
|
|
584
670
|
|
|
585
671
|
const searchInput = hasChips ? (
|
|
586
|
-
<div className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
|
|
672
|
+
<div key='search-input' className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
|
|
587
673
|
{searchInputField}
|
|
588
674
|
</div>
|
|
589
675
|
) : (
|
|
@@ -605,6 +691,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
605
691
|
data-test-id={dataTestId}
|
|
606
692
|
onMouseEnter={handleTriggerMouseEnter}
|
|
607
693
|
onMouseLeave={handleTriggerMouseLeave}
|
|
694
|
+
onKeyDownCapture={handleTriggerKeyDownCapture}
|
|
608
695
|
onClick={handleTriggerClick}
|
|
609
696
|
>
|
|
610
697
|
<div className={fieldStyles.backgroundWrapper}>
|
|
@@ -698,6 +785,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
|
|
|
698
785
|
placement={placement}
|
|
699
786
|
widthStrategy={widthStrategy}
|
|
700
787
|
triggerElemRef={inputRef}
|
|
788
|
+
listRef={listRef}
|
|
701
789
|
size={size}
|
|
702
790
|
open={open}
|
|
703
791
|
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,177 @@
|
|
|
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
|
+
typing: boolean;
|
|
29
|
+
multipleValue: ItemId[];
|
|
30
|
+
commitSingle(value: ItemId | undefined): void;
|
|
31
|
+
commitMultiple(value: ItemId[]): void;
|
|
32
|
+
resetSearch(): void;
|
|
33
|
+
inputElementRef: RefObject<HTMLInputElement | null>;
|
|
34
|
+
clearButtonRef: RefObject<HTMLButtonElement | null>;
|
|
35
|
+
copyButtonRef: RefObject<HTMLButtonElement | null>;
|
|
36
|
+
footerActiveElementsRefs?: RefObject<HTMLElement>[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
|
|
40
|
+
export function useCustomOption({
|
|
41
|
+
allowCustomOption,
|
|
42
|
+
addCustomOptionTriggers,
|
|
43
|
+
addOptionByEnter,
|
|
44
|
+
multiple,
|
|
45
|
+
inputValue,
|
|
46
|
+
typing,
|
|
47
|
+
multipleValue,
|
|
48
|
+
commitSingle,
|
|
49
|
+
commitMultiple,
|
|
50
|
+
resetSearch,
|
|
51
|
+
inputElementRef,
|
|
52
|
+
clearButtonRef,
|
|
53
|
+
copyButtonRef,
|
|
54
|
+
footerActiveElementsRefs,
|
|
55
|
+
}: UseCustomOptionParams) {
|
|
56
|
+
const listRef = useRef<HTMLElement>(null);
|
|
57
|
+
const skipCloseCommitRef = useRef(false);
|
|
58
|
+
const skipBlurCommitRef = useRef(false);
|
|
59
|
+
|
|
60
|
+
const resolvedTriggers = useMemo(
|
|
61
|
+
() =>
|
|
62
|
+
resolveAddCustomOptionTriggers({
|
|
63
|
+
allowCustomOption,
|
|
64
|
+
addCustomOptionTriggers,
|
|
65
|
+
addOptionByEnter,
|
|
66
|
+
defaultTriggers: multiple ? ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
|
|
67
|
+
}),
|
|
68
|
+
[allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const tryCommit = useCallback(
|
|
72
|
+
(trigger: FieldSelectAddCustomOptionTrigger) => {
|
|
73
|
+
if (!typing || !shouldHandleCustomOptionTrigger(trigger, resolvedTriggers) || inputValue === '') {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (multiple) {
|
|
78
|
+
if (multipleValue.includes(inputValue)) {
|
|
79
|
+
resetSearch();
|
|
80
|
+
} else {
|
|
81
|
+
commitMultiple([...multipleValue, inputValue]);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
commitSingle(inputValue);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
},
|
|
89
|
+
[typing, resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const suppressCloseCommit = useCallback(() => {
|
|
93
|
+
skipCloseCommitRef.current = true;
|
|
94
|
+
skipBlurCommitRef.current = true;
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const enableCloseCommit = useCallback(() => {
|
|
98
|
+
skipCloseCommitRef.current = false;
|
|
99
|
+
skipBlurCommitRef.current = false;
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const tryCommitOnClose = useCallback(() => {
|
|
103
|
+
const skipped = skipCloseCommitRef.current;
|
|
104
|
+
const shouldCommit = shouldCommitCustomOptionOnClose({
|
|
105
|
+
skip: skipped,
|
|
106
|
+
inputElement: inputElementRef.current,
|
|
107
|
+
activeElement: isBrowser() ? document.activeElement : null,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (skipped) {
|
|
111
|
+
queueMicrotask(() => {
|
|
112
|
+
skipCloseCommitRef.current = false;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return shouldCommit && tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur);
|
|
117
|
+
}, [inputElementRef, tryCommit]);
|
|
118
|
+
|
|
119
|
+
const tryCommitOnBlur = useCallback(
|
|
120
|
+
(event: FocusEvent<HTMLInputElement>): CustomOptionBlurResult => {
|
|
121
|
+
if (skipBlurCommitRef.current) {
|
|
122
|
+
skipBlurCommitRef.current = false;
|
|
123
|
+
return 'skipped';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
isCustomOptionBlurInsideField(event.relatedTarget, [
|
|
128
|
+
listRef.current,
|
|
129
|
+
clearButtonRef.current,
|
|
130
|
+
copyButtonRef.current,
|
|
131
|
+
...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
|
|
132
|
+
])
|
|
133
|
+
) {
|
|
134
|
+
return 'inside';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
|
|
138
|
+
},
|
|
139
|
+
[clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const tryCommitFromKeyboard = useCallback(
|
|
143
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
144
|
+
const trigger = getCustomOptionTriggerByCode(event.code, event.key);
|
|
145
|
+
|
|
146
|
+
return trigger !== undefined && tryCommit(trigger);
|
|
147
|
+
},
|
|
148
|
+
[tryCommit],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const handleListSingleChange = useCallback(
|
|
152
|
+
(next: ItemId | undefined) => {
|
|
153
|
+
skipCloseCommitRef.current = true;
|
|
154
|
+
commitSingle(next);
|
|
155
|
+
},
|
|
156
|
+
[commitSingle],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const handleListMultipleChange = useCallback(
|
|
160
|
+
(next: ItemId[]) => {
|
|
161
|
+
skipCloseCommitRef.current = true;
|
|
162
|
+
commitMultiple(next);
|
|
163
|
+
},
|
|
164
|
+
[commitMultiple],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
listRef,
|
|
169
|
+
suppressCloseCommit,
|
|
170
|
+
enableCloseCommit,
|
|
171
|
+
tryCommitOnClose,
|
|
172
|
+
tryCommitOnBlur,
|
|
173
|
+
tryCommitFromKeyboard,
|
|
174
|
+
handleListSingleChange,
|
|
175
|
+
handleListMultipleChange,
|
|
176
|
+
};
|
|
177
|
+
}
|