@cloud-ru/ds-fields 2.2.3-preview-0c4119af.0 → 2.2.3-preview-7425cb27.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 +1 -3
- package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
- package/dist/cjs/components/FieldDate/mask.d.ts +3 -2
- package/dist/cjs/components/FieldDate/mask.js +4 -3
- package/dist/cjs/components/FieldSelect/FieldSelect.js +41 -81
- package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
- package/dist/cjs/components/FieldSelect/constants.js +1 -20
- package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
- package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
- package/dist/cjs/components/FieldSelect/utils.js +103 -0
- package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
- package/dist/cjs/segments/useSegmentedMask.d.ts +4 -0
- package/dist/cjs/segments/useSegmentedMask.js +155 -56
- package/dist/esm/components/FieldDate/FieldDate.js +3 -3
- package/dist/esm/components/FieldDate/mask.d.ts +3 -2
- package/dist/esm/components/FieldDate/mask.js +4 -3
- package/dist/esm/components/FieldSelect/FieldSelect.js +42 -82
- package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
- package/dist/esm/components/FieldSelect/constants.js +0 -19
- package/dist/esm/components/FieldSelect/types.d.ts +3 -26
- package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
- package/dist/esm/components/FieldSelect/utils.js +92 -0
- package/dist/esm/components/FieldTime/FieldTime.js +2 -2
- package/dist/esm/segments/useSegmentedMask.d.ts +4 -0
- package/dist/esm/segments/useSegmentedMask.js +155 -56
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/components/FieldDate/FieldDate.tsx +3 -3
- package/src/components/FieldDate/mask.ts +4 -3
- package/src/components/FieldSelect/FieldSelect.tsx +61 -129
- package/src/components/FieldSelect/constants.ts +0 -22
- package/src/components/FieldSelect/types.ts +48 -82
- package/src/components/FieldSelect/utils.ts +132 -0
- package/src/components/FieldTime/FieldTime.tsx +2 -2
- package/src/segments/useSegmentedMask.ts +181 -52
- package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -32
- package/dist/cjs/components/FieldSelect/hooks.js +0 -83
- package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
- package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
- package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
- package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
- package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
- package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
- package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
- package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
- package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
- package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
- package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
- package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
- package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
- package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
- package/dist/esm/components/FieldSelect/hooks.d.ts +0 -32
- package/dist/esm/components/FieldSelect/hooks.js +0 -80
- package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
- package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
- package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
- package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
- package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
- package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
- package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
- package/dist/esm/components/FieldSelect/utils/index.js +0 -6
- package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
- package/dist/esm/components/FieldSelect/utils/items.js +0 -41
- package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
- package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
- package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
- package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
- package/src/components/FieldSelect/hooks.ts +0 -162
- package/src/components/FieldSelect/utils/customOption.ts +0 -77
- package/src/components/FieldSelect/utils/extract.ts +0 -49
- package/src/components/FieldSelect/utils/filter.ts +0 -51
- package/src/components/FieldSelect/utils/index.ts +0 -6
- package/src/components/FieldSelect/utils/items.ts +0 -58
- package/src/components/FieldSelect/utils/selection.ts +0 -25
- package/src/components/FieldSelect/utils/tagSize.ts +0 -5
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Size } from '@cloud-ru/ds-field-decorator';
|
|
2
|
+
import { ItemId } from '@cloud-ru/ds-list';
|
|
3
|
+
import { Appearance as TagAppearance, Size as TagSize } from '@cloud-ru/ds-tag';
|
|
4
|
+
|
|
5
|
+
import { SELECTION_MODE } from './constants';
|
|
6
|
+
import { FieldSelectItem, FieldSelectMultipleProps, FieldSelectProps } from './types';
|
|
7
|
+
|
|
8
|
+
export const TAG_SIZE_MAP: Record<Size, TagSize> = { s: 'xs', m: 'xs', l: 's' };
|
|
9
|
+
|
|
10
|
+
export type WithIdContent = { id?: ItemId; content?: unknown; disabled?: boolean; appearance?: TagAppearance };
|
|
11
|
+
|
|
12
|
+
export function extractLabel(item: WithIdContent): string {
|
|
13
|
+
const { content, id } = item;
|
|
14
|
+
|
|
15
|
+
if (!content) {
|
|
16
|
+
return String(id ?? '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof content === 'string' || typeof content === 'number') {
|
|
20
|
+
return String(content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof content === 'object' && content !== null && 'label' in content) {
|
|
24
|
+
return String((content as { label: unknown }).label);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return String(id ?? '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
|
|
31
|
+
// который матчил запрос по всем трём полям контента, а не только по лейблу.
|
|
32
|
+
export function extractSearchText(item: WithIdContent): string {
|
|
33
|
+
const parts = [extractLabel(item)];
|
|
34
|
+
const { content } = item;
|
|
35
|
+
|
|
36
|
+
if (content && typeof content === 'object') {
|
|
37
|
+
const c = content as { caption?: unknown; description?: unknown };
|
|
38
|
+
|
|
39
|
+
if (typeof c.caption === 'string') {
|
|
40
|
+
parts.push(c.caption);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof c.description === 'string') {
|
|
44
|
+
parts.push(c.description);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return parts.join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
|
|
52
|
+
// Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
|
|
53
|
+
export function extractAppearance(item?: WithIdContent): TagAppearance | undefined {
|
|
54
|
+
return typeof item?.appearance === 'string' ? item.appearance : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function flatten(items: FieldSelectItem[]): WithIdContent[] {
|
|
58
|
+
const out: WithIdContent[] = [];
|
|
59
|
+
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
out.push(item as WithIdContent);
|
|
62
|
+
|
|
63
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
64
|
+
out.push(...flatten(item.items as FieldSelectItem[]));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined {
|
|
72
|
+
for (const item of flatten(items)) {
|
|
73
|
+
if (item.id === id) {
|
|
74
|
+
return item;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
|
|
82
|
+
// Пример: query=`lge` matches `Large` (L → r → arg → e).
|
|
83
|
+
export function isFuzzyMatch(haystack: string, needle: string): boolean {
|
|
84
|
+
if (!needle) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let i = 0;
|
|
89
|
+
|
|
90
|
+
for (const ch of haystack) {
|
|
91
|
+
if (ch === needle[i]) {
|
|
92
|
+
i += 1;
|
|
93
|
+
|
|
94
|
+
if (i === needle.length) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[] {
|
|
104
|
+
if (!query) {
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const q = query.toLowerCase();
|
|
109
|
+
|
|
110
|
+
const matches = (item: FieldSelectItem): boolean => {
|
|
111
|
+
const text = extractSearchText(item as WithIdContent).toLowerCase();
|
|
112
|
+
|
|
113
|
+
return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const walk = (list: FieldSelectItem[]): FieldSelectItem[] =>
|
|
117
|
+
list.flatMap(item => {
|
|
118
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
119
|
+
const filteredChildren = walk(item.items as FieldSelectItem[]);
|
|
120
|
+
|
|
121
|
+
return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren } as FieldSelectItem] : [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return matches(item) ? [item] : [];
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return walk(items);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isMultiple(props: FieldSelectProps): props is FieldSelectMultipleProps {
|
|
131
|
+
return props.selection === SELECTION_MODE.Multiple;
|
|
132
|
+
}
|
|
@@ -267,7 +267,7 @@ export const FieldTime = forwardRef<HTMLInputElement, FieldTimeProps>(function F
|
|
|
267
267
|
[onInputKeyDown, timeSeg],
|
|
268
268
|
);
|
|
269
269
|
|
|
270
|
-
// value ведёт сегментный движок (
|
|
270
|
+
// value ведёт сегментный движок (useSegmentedMask); нативный onChange input'а игнорируем.
|
|
271
271
|
const handleInputChange = useCallback(() => undefined, []);
|
|
272
272
|
|
|
273
273
|
const handleInputFocus = useCallback(
|
|
@@ -323,7 +323,7 @@ export const FieldTime = forwardRef<HTMLInputElement, FieldTimeProps>(function F
|
|
|
323
323
|
<div className={fieldStyles.inputLine}>
|
|
324
324
|
<div className={fieldStyles.inputArea}>
|
|
325
325
|
<InputPrivate
|
|
326
|
-
ref={mergeRefs(ref, localRef)}
|
|
326
|
+
ref={mergeRefs(ref, localRef, timeSeg.nativeInputRef)}
|
|
327
327
|
className={fieldStyles.fieldInput}
|
|
328
328
|
value={inputValue}
|
|
329
329
|
onChange={handleInputChange}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import { useEventHandler } from '@cloud-ru/ds-utils';
|
|
1
2
|
import { ClipboardEvent, KeyboardEvent, MouseEvent, RefObject, useCallback, useRef } from 'react';
|
|
2
3
|
|
|
3
|
-
import { SlotMeta } from './segments';
|
|
4
|
+
import { SegmentKey, SlotMeta } from './segments';
|
|
5
|
+
|
|
6
|
+
const RESET_TYPED_KEYS = new Set([
|
|
7
|
+
'ArrowLeft',
|
|
8
|
+
'ArrowRight',
|
|
9
|
+
'ArrowUp',
|
|
10
|
+
'ArrowDown',
|
|
11
|
+
'Backspace',
|
|
12
|
+
'Delete',
|
|
13
|
+
'Enter',
|
|
14
|
+
'Tab',
|
|
15
|
+
'Escape',
|
|
16
|
+
]);
|
|
4
17
|
|
|
5
18
|
type UseSegmentedMaskParams = {
|
|
6
19
|
inputRef: RefObject<HTMLInputElement | null>;
|
|
@@ -32,6 +45,9 @@ type UseSegmentedMaskParams = {
|
|
|
32
45
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
33
46
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
34
47
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
48
|
+
*
|
|
49
|
+
* Навигация идёт через `handleKeyDown`, правка значения — через нативные `beforeinput`/`input`:
|
|
50
|
+
* `nativeInputRef` нужно передать в `ref` того же input'а.
|
|
35
51
|
*/
|
|
36
52
|
export function useSegmentedMask({
|
|
37
53
|
inputRef,
|
|
@@ -174,74 +190,75 @@ export function useSegmentedMask({
|
|
|
174
190
|
[clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete],
|
|
175
191
|
);
|
|
176
192
|
|
|
193
|
+
// Цифры, набранные в текущий сегмент. Храним отдельно от текста: по `01` в поле нельзя понять,
|
|
194
|
+
// набрали `1` или `0` + `1`, а от этого зависит, ждать ли вторую цифру.
|
|
195
|
+
const typedRef = useRef<{ key: SegmentKey; digits: string } | null>(null);
|
|
196
|
+
const resetTyped = useCallback(() => {
|
|
197
|
+
typedRef.current = null;
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
177
200
|
const typeDigit = useCallback(
|
|
178
201
|
(slot: SlotMeta, key: string) => {
|
|
179
|
-
const text = getSlotText(slot);
|
|
180
|
-
const numberValue = Number(text) || 0;
|
|
181
|
-
const digit = Number(key);
|
|
182
|
-
const slotValue = parseInt(numberValue.toString() + key, 10) || 0;
|
|
183
|
-
const valueLength = slotValue.toString().length;
|
|
184
202
|
const maxLength = slot.max.toString().length;
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
updateSlot(slot, key);
|
|
201
|
-
}
|
|
202
|
-
} else if (slotValue <= slot.max) {
|
|
203
|
-
updateSlot(slot, slotValue);
|
|
204
|
-
checkInputAndGoNext(slot);
|
|
205
|
-
} else if (digit * 10 > slot.max) {
|
|
206
|
-
updateSlot(slot, key);
|
|
207
|
-
checkInputAndGoNext(slot);
|
|
208
|
-
} else if (digit || digit >= slot.min) {
|
|
209
|
-
updateSlot(slot, key);
|
|
203
|
+
const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
|
|
204
|
+
const appended = prev + key;
|
|
205
|
+
// Цифра выводит сегмент за max — сегмент начинается с неё заново (как React Aria и MUI X): `13` в месяце → `03`.
|
|
206
|
+
const overflow = Number(appended) > slot.max;
|
|
207
|
+
const digits = overflow ? key : appended;
|
|
208
|
+
const value = Number(digits);
|
|
209
|
+
const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
|
|
210
|
+
// Значение ниже min (`0` в дне) не показываем, но помним цифру до следующей.
|
|
211
|
+
const accepted = value >= slot.min;
|
|
212
|
+
|
|
213
|
+
if (accepted) updateSlot(slot, value);
|
|
214
|
+
|
|
215
|
+
if (!complete) {
|
|
216
|
+
typedRef.current = { key: slot.key, digits };
|
|
217
|
+
return;
|
|
210
218
|
}
|
|
219
|
+
|
|
220
|
+
typedRef.current = null;
|
|
221
|
+
if (accepted) checkInputAndGoNext(slot);
|
|
211
222
|
},
|
|
212
|
-
[checkInputAndGoNext,
|
|
223
|
+
[checkInputAndGoNext, updateSlot],
|
|
213
224
|
);
|
|
214
225
|
|
|
215
226
|
const handleFocus = useCallback(() => {
|
|
216
227
|
if (readonly || disabled) return;
|
|
228
|
+
resetTyped();
|
|
217
229
|
ensureMask();
|
|
218
230
|
// На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
|
|
219
231
|
// отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
|
|
220
232
|
selectSlot(firstSlot);
|
|
221
|
-
}, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
|
|
233
|
+
}, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
|
|
222
234
|
|
|
223
235
|
const handleClick = useCallback(
|
|
224
236
|
(event: MouseEvent<HTMLInputElement>) => {
|
|
225
237
|
if (readonly || disabled) return;
|
|
238
|
+
resetTyped();
|
|
226
239
|
ensureMask();
|
|
227
240
|
selectSlot(slotFromIndex(event.currentTarget.selectionStart));
|
|
228
241
|
},
|
|
229
|
-
[disabled, ensureMask, readonly, selectSlot, slotFromIndex],
|
|
242
|
+
[disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex],
|
|
230
243
|
);
|
|
231
244
|
|
|
232
245
|
const handleBlur = useCallback(() => {
|
|
246
|
+
resetTyped();
|
|
233
247
|
const input = inputRef.current;
|
|
234
248
|
if (input && input.value === mask) {
|
|
235
249
|
input.value = '';
|
|
236
250
|
setValue('');
|
|
237
251
|
}
|
|
238
|
-
}, [inputRef, mask, setValue]);
|
|
252
|
+
}, [inputRef, mask, resetTyped, setValue]);
|
|
239
253
|
|
|
240
254
|
const handleKeyDown = useCallback(
|
|
241
255
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
242
256
|
const input = inputRef.current;
|
|
243
257
|
if (!input || readonly || disabled) return;
|
|
244
258
|
|
|
259
|
+
// Навигация и правка начинают набор сегмента заново. Цифры, `Unidentified` (Android) и модификаторы — нет.
|
|
260
|
+
if (RESET_TYPED_KEYS.has(event.key)) resetTyped();
|
|
261
|
+
|
|
245
262
|
// Tab уводит фокус из поля — не перехватываем.
|
|
246
263
|
if (event.key === 'Tab') return;
|
|
247
264
|
|
|
@@ -258,8 +275,14 @@ export function useSegmentedMask({
|
|
|
258
275
|
return;
|
|
259
276
|
}
|
|
260
277
|
|
|
278
|
+
// Сочетания (копирование, вставка, выделение) отдаём браузеру: правку поля перехватит beforeinput.
|
|
279
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
280
|
+
|
|
261
281
|
onEdit?.();
|
|
262
282
|
|
|
283
|
+
// Цифры и `Unidentified` (экранные клавиатуры Android, IME) вводятся через beforeinput.
|
|
284
|
+
if (/^\d$/.test(event.key) || event.key === 'Unidentified') return;
|
|
285
|
+
|
|
263
286
|
if (event.key === 'Escape') {
|
|
264
287
|
event.preventDefault();
|
|
265
288
|
onEscape?.();
|
|
@@ -297,11 +320,6 @@ export function useSegmentedMask({
|
|
|
297
320
|
if (event.key === 'Enter') {
|
|
298
321
|
tryToComplete();
|
|
299
322
|
emit();
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
if (/^\d$/.test(event.key)) {
|
|
303
|
-
typeDigit(slot, event.key);
|
|
304
|
-
emit();
|
|
305
323
|
}
|
|
306
324
|
},
|
|
307
325
|
[
|
|
@@ -318,22 +336,17 @@ export function useSegmentedMask({
|
|
|
318
336
|
onEscape,
|
|
319
337
|
prevSlot,
|
|
320
338
|
readonly,
|
|
339
|
+
resetTyped,
|
|
321
340
|
selectSlot,
|
|
322
341
|
slotFromIndex,
|
|
323
342
|
tryToComplete,
|
|
324
|
-
typeDigit,
|
|
325
343
|
],
|
|
326
344
|
);
|
|
327
345
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
(event: ClipboardEvent<HTMLInputElement>) => {
|
|
333
|
-
if (readonly || disabled) return;
|
|
334
|
-
event.preventDefault();
|
|
335
|
-
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
336
|
-
if (!digits) return;
|
|
346
|
+
// Раскладывает цифры по сегментам слева направо полными кусками — для вставки и автозаполнения.
|
|
347
|
+
const fillFromDigits = useCallback(
|
|
348
|
+
(digits: string) => {
|
|
349
|
+
resetTyped();
|
|
337
350
|
ensureMask();
|
|
338
351
|
|
|
339
352
|
let cursor = 0;
|
|
@@ -352,10 +365,126 @@ export function useSegmentedMask({
|
|
|
352
365
|
} else if (lastFilled) {
|
|
353
366
|
selectSlot(nextSlot(lastFilled));
|
|
354
367
|
}
|
|
368
|
+
},
|
|
369
|
+
[ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot],
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// Вставка (Ctrl+V) полной строки даты/времени. Нативный onChange у поля игнорируется (движок
|
|
373
|
+
// controlled), поэтому без этого обработчика вставка не работала бы вовсе.
|
|
374
|
+
const handlePaste = useCallback(
|
|
375
|
+
(event: ClipboardEvent<HTMLInputElement>) => {
|
|
376
|
+
if (readonly || disabled) return;
|
|
377
|
+
event.preventDefault();
|
|
378
|
+
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
379
|
+
if (!digits) return;
|
|
380
|
+
fillFromDigits(digits);
|
|
381
|
+
emit();
|
|
382
|
+
},
|
|
383
|
+
[disabled, emit, fillFromDigits, readonly],
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const applyEdit = useCallback(
|
|
387
|
+
(inputType: string, text: string) => {
|
|
388
|
+
const input = inputRef.current;
|
|
389
|
+
if (!input) return;
|
|
390
|
+
onEdit?.();
|
|
391
|
+
|
|
392
|
+
if (inputType.startsWith('delete')) {
|
|
393
|
+
resetTyped();
|
|
394
|
+
clearSlot(slotFromIndex(input.selectionStart));
|
|
395
|
+
emit();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const digits = text.replace(/\D/g, '');
|
|
400
|
+
if (!inputType.startsWith('insert') || !digits) return;
|
|
401
|
+
|
|
402
|
+
// Строка целиком (автозаполнение, `fill`, drop) раскладывается с начала маски, одиночная цифра — в текущий сегмент.
|
|
403
|
+
if (text.length > 1) {
|
|
404
|
+
fillFromDigits(digits);
|
|
405
|
+
} else {
|
|
406
|
+
typeDigit(slotFromIndex(input.selectionStart), digits);
|
|
407
|
+
}
|
|
355
408
|
emit();
|
|
356
409
|
},
|
|
357
|
-
[
|
|
410
|
+
[clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit],
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Значение и выделение до правки, которую браузер не дал отменить (композиция IME).
|
|
414
|
+
const pendingEditRef = useRef<{ value: string; start: number; end: number } | null>(null);
|
|
415
|
+
|
|
416
|
+
const handleBeforeInput = useCallback(
|
|
417
|
+
(event: InputEvent) => {
|
|
418
|
+
const input = inputRef.current;
|
|
419
|
+
if (!input || readonly || disabled) return;
|
|
420
|
+
ensureMask();
|
|
421
|
+
|
|
422
|
+
if (!event.cancelable) {
|
|
423
|
+
pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
|
|
429
|
+
},
|
|
430
|
+
[applyEdit, disabled, ensureMask, inputRef, readonly],
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const handleInput = useCallback(
|
|
434
|
+
(event: Event) => {
|
|
435
|
+
const input = inputRef.current;
|
|
436
|
+
if (!input || readonly || disabled) return;
|
|
437
|
+
const pending = pendingEditRef.current;
|
|
438
|
+
pendingEditRef.current = null;
|
|
439
|
+
|
|
440
|
+
if (pending) {
|
|
441
|
+
// Браузер уже изменил значение: вырезаем вставленное, возвращаем прежнее и применяем как обычный ввод.
|
|
442
|
+
const inputType = event instanceof InputEvent ? event.inputType : '';
|
|
443
|
+
const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
|
|
444
|
+
input.value = pending.value;
|
|
445
|
+
setValue(pending.value);
|
|
446
|
+
input.setSelectionRange(pending.start, pending.end);
|
|
447
|
+
// Цифру из композиции IME применит её фиксация (отменяемый insertText), как в React Aria.
|
|
448
|
+
if (!inputType.includes('Composition')) applyEdit(inputType, inserted);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Значение сменили без beforeinput (автозаполнение браузера).
|
|
453
|
+
const digits = input.value.replace(/\D/g, '');
|
|
454
|
+
input.value = mask;
|
|
455
|
+
setValue(mask);
|
|
456
|
+
fillFromDigits(digits);
|
|
457
|
+
emit();
|
|
458
|
+
},
|
|
459
|
+
[applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue],
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
|
|
463
|
+
const onNativeBeforeInput = useEventHandler(handleBeforeInput);
|
|
464
|
+
const onNativeInput = useEventHandler(handleInput);
|
|
465
|
+
const attachedInputRef = useRef<HTMLInputElement | null>(null);
|
|
466
|
+
const nativeInputRef = useCallback(
|
|
467
|
+
(node: HTMLInputElement | null) => {
|
|
468
|
+
/* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
|
|
469
|
+
attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
|
|
470
|
+
attachedInputRef.current?.removeEventListener('input', onNativeInput);
|
|
471
|
+
attachedInputRef.current = node;
|
|
472
|
+
node?.addEventListener('beforeinput', onNativeBeforeInput);
|
|
473
|
+
node?.addEventListener('input', onNativeInput);
|
|
474
|
+
/* eslint-enable @cloud-ru/ssr-safe-react/domApi */
|
|
475
|
+
},
|
|
476
|
+
[onNativeBeforeInput, onNativeInput],
|
|
358
477
|
);
|
|
359
478
|
|
|
360
|
-
return {
|
|
479
|
+
return {
|
|
480
|
+
handleKeyDown,
|
|
481
|
+
handleClick,
|
|
482
|
+
handleFocus,
|
|
483
|
+
handleBlur,
|
|
484
|
+
handlePaste,
|
|
485
|
+
nativeInputRef,
|
|
486
|
+
selectSlot,
|
|
487
|
+
firstSlot,
|
|
488
|
+
lastSlot,
|
|
489
|
+
};
|
|
361
490
|
}
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
multipleValue: ItemId[];
|
|
13
|
-
commitSingle(value: ItemId | undefined): void;
|
|
14
|
-
commitMultiple(value: ItemId[]): void;
|
|
15
|
-
resetSearch(): void;
|
|
16
|
-
inputElementRef: RefObject<HTMLInputElement | null>;
|
|
17
|
-
clearButtonRef: RefObject<HTMLButtonElement | null>;
|
|
18
|
-
copyButtonRef: RefObject<HTMLButtonElement | null>;
|
|
19
|
-
footerActiveElementsRefs?: RefObject<HTMLElement>[];
|
|
20
|
-
};
|
|
21
|
-
/** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
|
|
22
|
-
export declare function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }: UseCustomOptionParams): {
|
|
23
|
-
listRef: RefObject<HTMLElement>;
|
|
24
|
-
suppressCloseCommit: () => void;
|
|
25
|
-
enableCloseCommit: () => void;
|
|
26
|
-
tryCommitOnClose: () => boolean;
|
|
27
|
-
tryCommitOnBlur: (event: FocusEvent<HTMLInputElement>) => CustomOptionBlurResult;
|
|
28
|
-
tryCommitFromKeyboard: (event: KeyboardEvent<HTMLInputElement>) => boolean;
|
|
29
|
-
handleListSingleChange: (next: ItemId | undefined) => void;
|
|
30
|
-
handleListMultipleChange: (next: ItemId[]) => void;
|
|
31
|
-
};
|
|
32
|
-
export {};
|
|
@@ -1,83 +0,0 @@
|
|
|
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, 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 resolvedTriggers = (0, react_1.useMemo)(() => (0, utils_2.resolveAddCustomOptionTriggers)({
|
|
13
|
-
allowCustomOption,
|
|
14
|
-
addCustomOptionTriggers,
|
|
15
|
-
addOptionByEnter,
|
|
16
|
-
defaultTriggers: multiple ? constants_1.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : constants_1.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
|
|
17
|
-
}), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
|
|
18
|
-
const tryCommit = (0, react_1.useCallback)((trigger) => {
|
|
19
|
-
if (!(0, utils_2.shouldHandleCustomOptionTrigger)(trigger, resolvedTriggers) || inputValue === '') {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
if (multiple) {
|
|
23
|
-
if (multipleValue.includes(inputValue)) {
|
|
24
|
-
resetSearch();
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
27
|
-
commitMultiple([...multipleValue, inputValue]);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
commitSingle(inputValue);
|
|
32
|
-
}
|
|
33
|
-
return true;
|
|
34
|
-
}, [resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
|
|
35
|
-
const suppressCloseCommit = (0, react_1.useCallback)(() => {
|
|
36
|
-
skipCloseCommitRef.current = true;
|
|
37
|
-
}, []);
|
|
38
|
-
const enableCloseCommit = (0, react_1.useCallback)(() => {
|
|
39
|
-
skipCloseCommitRef.current = false;
|
|
40
|
-
}, []);
|
|
41
|
-
const tryCommitOnClose = (0, react_1.useCallback)(() => {
|
|
42
|
-
const shouldCommit = (0, utils_2.shouldCommitCustomOptionOnClose)({
|
|
43
|
-
skip: skipCloseCommitRef.current,
|
|
44
|
-
inputElement: inputElementRef.current,
|
|
45
|
-
activeElement: (0, utils_1.isBrowser)() ? document.activeElement : null,
|
|
46
|
-
});
|
|
47
|
-
skipCloseCommitRef.current = false;
|
|
48
|
-
return shouldCommit && tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur);
|
|
49
|
-
}, [inputElementRef, tryCommit]);
|
|
50
|
-
const tryCommitOnBlur = (0, react_1.useCallback)((event) => {
|
|
51
|
-
if ((0, utils_2.isCustomOptionBlurInsideField)(event.relatedTarget, [
|
|
52
|
-
listRef.current,
|
|
53
|
-
clearButtonRef.current,
|
|
54
|
-
copyButtonRef.current,
|
|
55
|
-
...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
|
|
56
|
-
])) {
|
|
57
|
-
return 'inside';
|
|
58
|
-
}
|
|
59
|
-
return tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
|
|
60
|
-
}, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
|
|
61
|
-
const tryCommitFromKeyboard = (0, react_1.useCallback)((event) => {
|
|
62
|
-
const trigger = (0, utils_2.getCustomOptionTriggerByCode)(event.code, event.key);
|
|
63
|
-
return trigger !== undefined && tryCommit(trigger);
|
|
64
|
-
}, [tryCommit]);
|
|
65
|
-
const handleListSingleChange = (0, react_1.useCallback)((next) => {
|
|
66
|
-
skipCloseCommitRef.current = true;
|
|
67
|
-
commitSingle(next);
|
|
68
|
-
}, [commitSingle]);
|
|
69
|
-
const handleListMultipleChange = (0, react_1.useCallback)((next) => {
|
|
70
|
-
skipCloseCommitRef.current = true;
|
|
71
|
-
commitMultiple(next);
|
|
72
|
-
}, [commitMultiple]);
|
|
73
|
-
return {
|
|
74
|
-
listRef,
|
|
75
|
-
suppressCloseCommit,
|
|
76
|
-
enableCloseCommit,
|
|
77
|
-
tryCommitOnClose,
|
|
78
|
-
tryCommitOnBlur,
|
|
79
|
-
tryCommitFromKeyboard,
|
|
80
|
-
handleListSingleChange,
|
|
81
|
-
handleListMultipleChange,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
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;
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getCustomOptionTriggerByCode = getCustomOptionTriggerByCode;
|
|
4
|
-
exports.shouldHandleCustomOptionTrigger = shouldHandleCustomOptionTrigger;
|
|
5
|
-
exports.resolveAddCustomOptionTriggers = resolveAddCustomOptionTriggers;
|
|
6
|
-
exports.isCustomOptionBlurInsideField = isCustomOptionBlurInsideField;
|
|
7
|
-
exports.shouldCommitCustomOptionOnClose = shouldCommitCustomOptionOnClose;
|
|
8
|
-
const constants_1 = require("../constants");
|
|
9
|
-
/**
|
|
10
|
-
* Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
|
|
11
|
-
* Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
|
|
12
|
-
*/
|
|
13
|
-
function getCustomOptionTriggerByCode(code, key) {
|
|
14
|
-
switch (code) {
|
|
15
|
-
case 'Enter':
|
|
16
|
-
return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter;
|
|
17
|
-
case 'Space':
|
|
18
|
-
return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Space;
|
|
19
|
-
case 'Comma':
|
|
20
|
-
return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Comma;
|
|
21
|
-
default:
|
|
22
|
-
break;
|
|
23
|
-
}
|
|
24
|
-
switch (key) {
|
|
25
|
-
case 'Enter':
|
|
26
|
-
return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter;
|
|
27
|
-
case ' ':
|
|
28
|
-
return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Space;
|
|
29
|
-
case ',':
|
|
30
|
-
return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Comma;
|
|
31
|
-
default:
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function shouldHandleCustomOptionTrigger(trigger, availableTriggers) {
|
|
36
|
-
return trigger !== undefined && availableTriggers.includes(trigger);
|
|
37
|
-
}
|
|
38
|
-
function resolveAddCustomOptionTriggers(params) {
|
|
39
|
-
if (params.allowCustomOption) {
|
|
40
|
-
const source = params.addCustomOptionTriggers !== undefined ? params.addCustomOptionTriggers : params.defaultTriggers;
|
|
41
|
-
return source.filter(trigger => params.defaultTriggers.includes(trigger));
|
|
42
|
-
}
|
|
43
|
-
if (params.addOptionByEnter) {
|
|
44
|
-
return [constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter];
|
|
45
|
-
}
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
48
|
-
/** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
|
|
49
|
-
function isCustomOptionBlurInsideField(relatedTarget, nodes) {
|
|
50
|
-
return relatedTarget instanceof Node && nodes.some(node => Boolean(node?.contains(relatedTarget)));
|
|
51
|
-
}
|
|
52
|
-
/** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
|
|
53
|
-
function shouldCommitCustomOptionOnClose(params) {
|
|
54
|
-
return !params.skip && params.activeElement !== null && params.activeElement === params.inputElement;
|
|
55
|
-
}
|