@cloud-ru/ds-fields 2.2.3-preview-0c4119af.0 → 2.2.3-preview-a42433ce.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 +2 -0
- package/dist/cjs/segments/useSegmentedMask.js +146 -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 +2 -0
- package/dist/esm/segments/useSegmentedMask.js +146 -56
- 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 +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 +172 -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,19 @@
|
|
|
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
|
+
import { FieldSelectItem, FieldSelectMultipleProps, FieldSelectProps } from './types.js';
|
|
5
|
+
export declare const TAG_SIZE_MAP: Record<Size, TagSize>;
|
|
6
|
+
export type WithIdContent = {
|
|
7
|
+
id?: ItemId;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
appearance?: TagAppearance;
|
|
11
|
+
};
|
|
12
|
+
export declare function extractLabel(item: WithIdContent): string;
|
|
13
|
+
export declare function extractSearchText(item: WithIdContent): string;
|
|
14
|
+
export declare function extractAppearance(item?: WithIdContent): TagAppearance | undefined;
|
|
15
|
+
export declare function flatten(items: FieldSelectItem[]): WithIdContent[];
|
|
16
|
+
export declare function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined;
|
|
17
|
+
export declare function isFuzzyMatch(haystack: string, needle: string): boolean;
|
|
18
|
+
export declare function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[];
|
|
19
|
+
export declare function isMultiple(props: FieldSelectProps): props is FieldSelectMultipleProps;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SELECTION_MODE } from './constants.js';
|
|
2
|
+
export const TAG_SIZE_MAP = { s: 'xs', m: 'xs', l: 's' };
|
|
3
|
+
export function extractLabel(item) {
|
|
4
|
+
const { content, id } = item;
|
|
5
|
+
if (!content) {
|
|
6
|
+
return String(id ?? '');
|
|
7
|
+
}
|
|
8
|
+
if (typeof content === 'string' || typeof content === 'number') {
|
|
9
|
+
return String(content);
|
|
10
|
+
}
|
|
11
|
+
if (typeof content === 'object' && content !== null && 'label' in content) {
|
|
12
|
+
return String(content.label);
|
|
13
|
+
}
|
|
14
|
+
return String(id ?? '');
|
|
15
|
+
}
|
|
16
|
+
// Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
|
|
17
|
+
// который матчил запрос по всем трём полям контента, а не только по лейблу.
|
|
18
|
+
export function extractSearchText(item) {
|
|
19
|
+
const parts = [extractLabel(item)];
|
|
20
|
+
const { content } = item;
|
|
21
|
+
if (content && typeof content === 'object') {
|
|
22
|
+
const c = content;
|
|
23
|
+
if (typeof c.caption === 'string') {
|
|
24
|
+
parts.push(c.caption);
|
|
25
|
+
}
|
|
26
|
+
if (typeof c.description === 'string') {
|
|
27
|
+
parts.push(c.description);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return parts.join(' ');
|
|
31
|
+
}
|
|
32
|
+
// Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
|
|
33
|
+
// Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
|
|
34
|
+
export function extractAppearance(item) {
|
|
35
|
+
return typeof item?.appearance === 'string' ? item.appearance : undefined;
|
|
36
|
+
}
|
|
37
|
+
export function flatten(items) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const item of items) {
|
|
40
|
+
out.push(item);
|
|
41
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
42
|
+
out.push(...flatten(item.items));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function findItem(items, id) {
|
|
48
|
+
for (const item of flatten(items)) {
|
|
49
|
+
if (item.id === id) {
|
|
50
|
+
return item;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
// Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
|
|
56
|
+
// Пример: query=`lge` matches `Large` (L → r → arg → e).
|
|
57
|
+
export function isFuzzyMatch(haystack, needle) {
|
|
58
|
+
if (!needle) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
let i = 0;
|
|
62
|
+
for (const ch of haystack) {
|
|
63
|
+
if (ch === needle[i]) {
|
|
64
|
+
i += 1;
|
|
65
|
+
if (i === needle.length) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
export function filterItems(items, query, fuzzy) {
|
|
73
|
+
if (!query) {
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
const q = query.toLowerCase();
|
|
77
|
+
const matches = (item) => {
|
|
78
|
+
const text = extractSearchText(item).toLowerCase();
|
|
79
|
+
return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
|
|
80
|
+
};
|
|
81
|
+
const walk = (list) => list.flatMap(item => {
|
|
82
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
83
|
+
const filteredChildren = walk(item.items);
|
|
84
|
+
return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren }] : [];
|
|
85
|
+
}
|
|
86
|
+
return matches(item) ? [item] : [];
|
|
87
|
+
});
|
|
88
|
+
return walk(items);
|
|
89
|
+
}
|
|
90
|
+
export function isMultiple(props) {
|
|
91
|
+
return props.selection === SELECTION_MODE.Multiple;
|
|
92
|
+
}
|
|
@@ -131,7 +131,7 @@ export const FieldTime = forwardRef(function FieldTime({ label = '', labelToolti
|
|
|
131
131
|
// clear/copy недостижимыми со стрелок — движок всегда делает preventDefault на ArrowRight.
|
|
132
132
|
onInputKeyDown(event);
|
|
133
133
|
}, [onInputKeyDown, timeSeg]);
|
|
134
|
-
// value ведёт сегментный движок (
|
|
134
|
+
// value ведёт сегментный движок (useSegmentedMask); нативный onChange input'а игнорируем.
|
|
135
135
|
const handleInputChange = useCallback(() => undefined, []);
|
|
136
136
|
const handleInputFocus = useCallback((event) => {
|
|
137
137
|
setFocusVisible(event.target.matches(':focus-visible'));
|
|
@@ -149,7 +149,7 @@ export const FieldTime = forwardRef(function FieldTime({ label = '', labelToolti
|
|
|
149
149
|
readonly: readOnly,
|
|
150
150
|
hover,
|
|
151
151
|
focusVisible: focusVisible || open,
|
|
152
|
-
}), children: _jsx("div", { className: fieldStyles.acrylicBg, "aria-hidden": true }) }), _jsx("div", { className: fieldStyles.borderStateLayer, "data-state": 'borderOnBackground' }), _jsx("div", { className: fieldStyles.focusLayer })] }), _jsx("div", { className: fieldStyles.fieldContainer, children: _jsxs("div", { className: fieldStyles.contentWrapper, children: [_jsx("div", { className: fieldStyles.inputLine, children: _jsxs("div", { className: fieldStyles.inputArea, children: [_jsx(InputPrivate, { ref: mergeRefs(ref, localRef), className: fieldStyles.fieldInput, value: inputValue, onChange: handleInputChange, disabled: disabled, readonly: readOnly, placeholder: placeholderMask, inputMode: INPUT_MODE.Numeric, tabIndex: inputTabIndex, onClick: timeSeg.handleClick, onPaste: timeSeg.handlePaste, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, ...extractSupportProps(rest), "data-test-id": TEST_IDS.fieldTimeInput }), postfixButtons && _jsx("span", { className: fieldStyles.postfixButtonsSlot, children: postfixButtons })] }) }), _jsx("div", { className: fieldStyles.iconSlot, "data-test-id": TEST_IDS.fieldTimeIcon, "aria-hidden": true, children: _jsx(WatchSVG, {}) })] }) })] }));
|
|
152
|
+
}), children: _jsx("div", { className: fieldStyles.acrylicBg, "aria-hidden": true }) }), _jsx("div", { className: fieldStyles.borderStateLayer, "data-state": 'borderOnBackground' }), _jsx("div", { className: fieldStyles.focusLayer })] }), _jsx("div", { className: fieldStyles.fieldContainer, children: _jsxs("div", { className: fieldStyles.contentWrapper, children: [_jsx("div", { className: fieldStyles.inputLine, children: _jsxs("div", { className: fieldStyles.inputArea, children: [_jsx(InputPrivate, { ref: mergeRefs(ref, localRef, timeSeg.nativeInputRef), className: fieldStyles.fieldInput, value: inputValue, onChange: handleInputChange, disabled: disabled, readonly: readOnly, placeholder: placeholderMask, inputMode: INPUT_MODE.Numeric, tabIndex: inputTabIndex, onClick: timeSeg.handleClick, onPaste: timeSeg.handlePaste, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, ...extractSupportProps(rest), "data-test-id": TEST_IDS.fieldTimeInput }), postfixButtons && _jsx("span", { className: fieldStyles.postfixButtonsSlot, children: postfixButtons })] }) }), _jsx("div", { className: fieldStyles.iconSlot, "data-test-id": TEST_IDS.fieldTimeIcon, "aria-hidden": true, children: _jsx(WatchSVG, {}) })] }) })] }));
|
|
153
153
|
return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, size: size, children: _jsx(TimePickerDropdown
|
|
154
154
|
// Явный data-test-id на дропдауне → детерминированный id портального контента:
|
|
155
155
|
// `getTestIdBuilder` строит `content-${id}`, так контент адресуем для open-picker-снимка
|
|
@@ -29,6 +29,7 @@ type UseSegmentedMaskParams = {
|
|
|
29
29
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
30
30
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
31
31
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
32
|
+
* `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
|
|
32
33
|
*/
|
|
33
34
|
export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }: UseSegmentedMaskParams): {
|
|
34
35
|
handleKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
@@ -36,6 +37,7 @@ export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disa
|
|
|
36
37
|
handleFocus: () => void;
|
|
37
38
|
handleBlur: () => void;
|
|
38
39
|
handlePaste: (event: ClipboardEvent<HTMLInputElement>) => void;
|
|
40
|
+
nativeInputRef: (node: HTMLInputElement | null) => void;
|
|
39
41
|
selectSlot: (slot: SlotMeta) => void;
|
|
40
42
|
firstSlot: SlotMeta;
|
|
41
43
|
lastSlot: SlotMeta;
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { useEventHandler } from '@cloud-ru/ds-utils';
|
|
1
2
|
import { useCallback, useRef } from 'react';
|
|
3
|
+
const RESET_TYPED_KEYS = new Set([
|
|
4
|
+
'ArrowLeft',
|
|
5
|
+
'ArrowRight',
|
|
6
|
+
'ArrowUp',
|
|
7
|
+
'ArrowDown',
|
|
8
|
+
'Backspace',
|
|
9
|
+
'Delete',
|
|
10
|
+
'Enter',
|
|
11
|
+
'Tab',
|
|
12
|
+
'Escape',
|
|
13
|
+
]);
|
|
2
14
|
/**
|
|
3
15
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
4
16
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
5
17
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
18
|
+
* `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
|
|
6
19
|
*/
|
|
7
20
|
export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
|
|
8
21
|
const firstSlot = slots[0];
|
|
@@ -107,70 +120,61 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
107
120
|
}
|
|
108
121
|
selectSlot(nextSlot(slot));
|
|
109
122
|
}, [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete]);
|
|
123
|
+
// Цифры сегмента храним отдельно от текста: по `01` в поле не отличить `1` от `0` + `1`.
|
|
124
|
+
const typedRef = useRef(null);
|
|
125
|
+
const resetTyped = useCallback(() => {
|
|
126
|
+
typedRef.current = null;
|
|
127
|
+
}, []);
|
|
110
128
|
const typeDigit = useCallback((slot, key) => {
|
|
111
|
-
const text = getSlotText(slot);
|
|
112
|
-
const numberValue = Number(text) || 0;
|
|
113
|
-
const digit = Number(key);
|
|
114
|
-
const slotValue = parseInt(numberValue.toString() + key, 10) || 0;
|
|
115
|
-
const valueLength = slotValue.toString().length;
|
|
116
129
|
const maxLength = slot.max.toString().length;
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
updateSlot(slot, key);
|
|
131
|
-
checkInputAndGoNext(slot);
|
|
132
|
-
}
|
|
133
|
-
else if (digit || digit >= slot.min) {
|
|
134
|
-
updateSlot(slot, key);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
else if (slotValue <= slot.max) {
|
|
138
|
-
updateSlot(slot, slotValue);
|
|
139
|
-
checkInputAndGoNext(slot);
|
|
130
|
+
const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
|
|
131
|
+
const appended = prev + key;
|
|
132
|
+
// Как в React Aria и MUI X: `13` в месяце → `03`.
|
|
133
|
+
const overflow = Number(appended) > slot.max;
|
|
134
|
+
const digits = overflow ? key : appended;
|
|
135
|
+
const value = Number(digits);
|
|
136
|
+
const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
|
|
137
|
+
const accepted = value >= slot.min;
|
|
138
|
+
if (accepted)
|
|
139
|
+
updateSlot(slot, value);
|
|
140
|
+
if (!complete) {
|
|
141
|
+
typedRef.current = { key: slot.key, digits };
|
|
142
|
+
return;
|
|
140
143
|
}
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
typedRef.current = null;
|
|
145
|
+
if (accepted)
|
|
143
146
|
checkInputAndGoNext(slot);
|
|
144
|
-
|
|
145
|
-
else if (digit || digit >= slot.min) {
|
|
146
|
-
updateSlot(slot, key);
|
|
147
|
-
}
|
|
148
|
-
}, [checkInputAndGoNext, getSlotText, updateSlot]);
|
|
147
|
+
}, [checkInputAndGoNext, updateSlot]);
|
|
149
148
|
const handleFocus = useCallback(() => {
|
|
150
149
|
if (readonly || disabled)
|
|
151
150
|
return;
|
|
151
|
+
resetTyped();
|
|
152
152
|
ensureMask();
|
|
153
153
|
// На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
|
|
154
154
|
// отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
|
|
155
155
|
selectSlot(firstSlot);
|
|
156
|
-
}, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
|
|
156
|
+
}, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
|
|
157
157
|
const handleClick = useCallback((event) => {
|
|
158
158
|
if (readonly || disabled)
|
|
159
159
|
return;
|
|
160
|
+
resetTyped();
|
|
160
161
|
ensureMask();
|
|
161
162
|
selectSlot(slotFromIndex(event.currentTarget.selectionStart));
|
|
162
|
-
}, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
|
|
163
|
+
}, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
|
|
163
164
|
const handleBlur = useCallback(() => {
|
|
165
|
+
resetTyped();
|
|
164
166
|
const input = inputRef.current;
|
|
165
167
|
if (input && input.value === mask) {
|
|
166
168
|
input.value = '';
|
|
167
169
|
setValue('');
|
|
168
170
|
}
|
|
169
|
-
}, [inputRef, mask, setValue]);
|
|
171
|
+
}, [inputRef, mask, resetTyped, setValue]);
|
|
170
172
|
const handleKeyDown = useCallback((event) => {
|
|
171
173
|
const input = inputRef.current;
|
|
172
174
|
if (!input || readonly || disabled)
|
|
173
175
|
return;
|
|
176
|
+
if (RESET_TYPED_KEYS.has(event.key))
|
|
177
|
+
resetTyped();
|
|
174
178
|
// Tab уводит фокус из поля — не перехватываем.
|
|
175
179
|
if (event.key === 'Tab')
|
|
176
180
|
return;
|
|
@@ -185,7 +189,13 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
185
189
|
onArrowUp?.();
|
|
186
190
|
return;
|
|
187
191
|
}
|
|
192
|
+
// Копирование и вставку не блокируем: правку поля перехватит beforeinput.
|
|
193
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
194
|
+
return;
|
|
188
195
|
onEdit?.();
|
|
196
|
+
// Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
|
|
197
|
+
if (/^\d$/.test(event.key) || event.key === 'Unidentified')
|
|
198
|
+
return;
|
|
189
199
|
if (event.key === 'Escape') {
|
|
190
200
|
event.preventDefault();
|
|
191
201
|
onEscape?.();
|
|
@@ -220,11 +230,6 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
220
230
|
if (event.key === 'Enter') {
|
|
221
231
|
tryToComplete();
|
|
222
232
|
emit();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
if (/^\d$/.test(event.key)) {
|
|
226
|
-
typeDigit(slot, event.key);
|
|
227
|
-
emit();
|
|
228
233
|
}
|
|
229
234
|
}, [
|
|
230
235
|
clearSlot,
|
|
@@ -240,21 +245,13 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
240
245
|
onEscape,
|
|
241
246
|
prevSlot,
|
|
242
247
|
readonly,
|
|
248
|
+
resetTyped,
|
|
243
249
|
selectSlot,
|
|
244
250
|
slotFromIndex,
|
|
245
251
|
tryToComplete,
|
|
246
|
-
typeDigit,
|
|
247
252
|
]);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// этого обработчика вставка не работала бы вовсе.
|
|
251
|
-
const handlePaste = useCallback((event) => {
|
|
252
|
-
if (readonly || disabled)
|
|
253
|
-
return;
|
|
254
|
-
event.preventDefault();
|
|
255
|
-
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
256
|
-
if (!digits)
|
|
257
|
-
return;
|
|
253
|
+
const fillFromDigits = useCallback((digits) => {
|
|
254
|
+
resetTyped();
|
|
258
255
|
ensureMask();
|
|
259
256
|
let cursor = 0;
|
|
260
257
|
let lastFilled;
|
|
@@ -273,7 +270,100 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
273
270
|
else if (lastFilled) {
|
|
274
271
|
selectSlot(nextSlot(lastFilled));
|
|
275
272
|
}
|
|
273
|
+
}, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
|
|
274
|
+
const handlePaste = useCallback((event) => {
|
|
275
|
+
if (readonly || disabled)
|
|
276
|
+
return;
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
279
|
+
if (!digits)
|
|
280
|
+
return;
|
|
281
|
+
fillFromDigits(digits);
|
|
282
|
+
emit();
|
|
283
|
+
}, [disabled, emit, fillFromDigits, readonly]);
|
|
284
|
+
const applyEdit = useCallback((inputType, text) => {
|
|
285
|
+
const input = inputRef.current;
|
|
286
|
+
if (!input)
|
|
287
|
+
return;
|
|
288
|
+
onEdit?.();
|
|
289
|
+
if (inputType.startsWith('delete')) {
|
|
290
|
+
resetTyped();
|
|
291
|
+
clearSlot(slotFromIndex(input.selectionStart));
|
|
292
|
+
emit();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const digits = text.replace(/\D/g, '');
|
|
296
|
+
if (!inputType.startsWith('insert') || !digits)
|
|
297
|
+
return;
|
|
298
|
+
// Автозаполнение, `fill` и drop присылают строку целиком.
|
|
299
|
+
if (text.length > 1) {
|
|
300
|
+
fillFromDigits(digits);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
typeDigit(slotFromIndex(input.selectionStart), digits);
|
|
304
|
+
}
|
|
305
|
+
emit();
|
|
306
|
+
}, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
|
|
307
|
+
// Композицию IME браузер не даёт отменить — откатываем её в input.
|
|
308
|
+
const pendingEditRef = useRef(null);
|
|
309
|
+
const handleBeforeInput = useCallback((event) => {
|
|
310
|
+
const input = inputRef.current;
|
|
311
|
+
if (!input || readonly || disabled)
|
|
312
|
+
return;
|
|
313
|
+
ensureMask();
|
|
314
|
+
if (!event.cancelable) {
|
|
315
|
+
pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
|
|
320
|
+
}, [applyEdit, disabled, ensureMask, inputRef, readonly]);
|
|
321
|
+
const handleInput = useCallback((event) => {
|
|
322
|
+
const input = inputRef.current;
|
|
323
|
+
if (!input || readonly || disabled)
|
|
324
|
+
return;
|
|
325
|
+
const pending = pendingEditRef.current;
|
|
326
|
+
pendingEditRef.current = null;
|
|
327
|
+
if (pending) {
|
|
328
|
+
const inputType = event instanceof InputEvent ? event.inputType : '';
|
|
329
|
+
const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
|
|
330
|
+
input.value = pending.value;
|
|
331
|
+
setValue(pending.value);
|
|
332
|
+
input.setSelectionRange(pending.start, pending.end);
|
|
333
|
+
// Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
|
|
334
|
+
if (!inputType.includes('Composition'))
|
|
335
|
+
applyEdit(inputType, inserted);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// Автозаполнение меняет значение без beforeinput.
|
|
339
|
+
const digits = input.value.replace(/\D/g, '');
|
|
340
|
+
input.value = mask;
|
|
341
|
+
setValue(mask);
|
|
342
|
+
fillFromDigits(digits);
|
|
276
343
|
emit();
|
|
277
|
-
}, [disabled, emit,
|
|
278
|
-
|
|
344
|
+
}, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
|
|
345
|
+
// React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
|
|
346
|
+
const onNativeBeforeInput = useEventHandler(handleBeforeInput);
|
|
347
|
+
const onNativeInput = useEventHandler(handleInput);
|
|
348
|
+
const attachedInputRef = useRef(null);
|
|
349
|
+
const nativeInputRef = useCallback((node) => {
|
|
350
|
+
/* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
|
|
351
|
+
attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
|
|
352
|
+
attachedInputRef.current?.removeEventListener('input', onNativeInput);
|
|
353
|
+
attachedInputRef.current = node;
|
|
354
|
+
node?.addEventListener('beforeinput', onNativeBeforeInput);
|
|
355
|
+
node?.addEventListener('input', onNativeInput);
|
|
356
|
+
/* eslint-enable @cloud-ru/ssr-safe-react/domApi */
|
|
357
|
+
}, [onNativeBeforeInput, onNativeInput]);
|
|
358
|
+
return {
|
|
359
|
+
handleKeyDown,
|
|
360
|
+
handleClick,
|
|
361
|
+
handleFocus,
|
|
362
|
+
handleBlur,
|
|
363
|
+
handlePaste,
|
|
364
|
+
nativeInputRef,
|
|
365
|
+
selectSlot,
|
|
366
|
+
firstSlot,
|
|
367
|
+
lastSlot,
|
|
368
|
+
};
|
|
279
369
|
}
|