@cloud-ru/ds-fields 2.2.3-preview-a0540d7f.0 → 2.2.4
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/CHANGELOG.md +11 -0
- 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 +43 -98
- 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 +145 -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 +45 -100
- 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 +145 -56
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +20 -20
- package/src/components/FieldDate/FieldDate.tsx +3 -3
- package/src/components/FieldDate/mask.ts +4 -3
- package/src/components/FieldSelect/FieldSelect.tsx +63 -151
- 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 +171 -52
- package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -33
- package/dist/cjs/components/FieldSelect/hooks.js +0 -95
- 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 -33
- package/dist/esm/components/FieldSelect/hooks.js +0 -92
- 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 -177
- 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
|
@@ -4,30 +4,9 @@ import { Appearance as TagAppearance } from '@cloud-ru/ds-tag';
|
|
|
4
4
|
import { ValueOf } from '@cloud-ru/ds-utils';
|
|
5
5
|
import { FocusEvent, KeyboardEvent, ReactNode } from 'react';
|
|
6
6
|
import { FieldLayoutPresets } from '../../hooks/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { SELECTION_MODE } from './constants.js';
|
|
8
8
|
/** Режим выбора FieldSelect: одно значение или несколько. */
|
|
9
9
|
export type Selection = ValueOf<typeof SELECTION_MODE>;
|
|
10
|
-
/** Событие, по которому произвольный ввод фиксируется как выбранная опция. */
|
|
11
|
-
export type FieldSelectAddCustomOptionTrigger = ValueOf<typeof ADD_CUSTOM_OPTION_TRIGGER>;
|
|
12
|
-
/** Триггеры кастомной опции в режиме `single`. */
|
|
13
|
-
export type FieldSelectSingleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_SINGLE)[number];
|
|
14
|
-
/** Триггеры кастомной опции в режиме `multiple`. */
|
|
15
|
-
export type FieldSelectMultipleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE)[number];
|
|
16
|
-
type CustomOptionProps<TTrigger extends FieldSelectAddCustomOptionTrigger> = {
|
|
17
|
-
/**
|
|
18
|
-
* Разрешить фиксировать произвольный ввод из строки поиска как выбранную опцию
|
|
19
|
-
* (значение, которого нет в `items`).
|
|
20
|
-
* @default false
|
|
21
|
-
*/
|
|
22
|
-
allowCustomOption?: boolean;
|
|
23
|
-
/**
|
|
24
|
-
* События, по которым произвольный ввод фиксируется как выбранная опция.
|
|
25
|
-
* Учитывается только при `allowCustomOption={true}`.
|
|
26
|
-
* Если не задан — используются все триггеры режима (`single`: enter, blur; `multiple`: enter, blur, space, comma).
|
|
27
|
-
* Триггеры, недоступные в текущем `selection`, игнорируются.
|
|
28
|
-
*/
|
|
29
|
-
addCustomOptionTriggers?: TTrigger[];
|
|
30
|
-
};
|
|
31
10
|
type WithTagAppearance<T> = T extends {
|
|
32
11
|
items: ItemProps[];
|
|
33
12
|
} ? Omit<T, 'items'> & {
|
|
@@ -119,8 +98,6 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
|
|
|
119
98
|
autocomplete?: boolean;
|
|
120
99
|
/**
|
|
121
100
|
* Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»).
|
|
122
|
-
*
|
|
123
|
-
* @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`.
|
|
124
101
|
* @default false
|
|
125
102
|
*/
|
|
126
103
|
addOptionByEnter?: boolean;
|
|
@@ -177,7 +154,7 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
|
|
|
177
154
|
/** Тестовый id корня */
|
|
178
155
|
'data-test-id'?: string;
|
|
179
156
|
};
|
|
180
|
-
export type FieldSelectSingleProps = CommonSelectProps &
|
|
157
|
+
export type FieldSelectSingleProps = CommonSelectProps & {
|
|
181
158
|
/** Режим выбора. По умолчанию `'single'`. */
|
|
182
159
|
selection?: typeof SELECTION_MODE.Single;
|
|
183
160
|
/** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
|
|
@@ -192,7 +169,7 @@ export type FieldSelectSingleProps = CommonSelectProps & CustomOptionProps<Field
|
|
|
192
169
|
*/
|
|
193
170
|
closeDroplistOnItemClick?: boolean;
|
|
194
171
|
};
|
|
195
|
-
export type FieldSelectMultipleProps = CommonSelectProps &
|
|
172
|
+
export type FieldSelectMultipleProps = CommonSelectProps & {
|
|
196
173
|
/** Режим выбора */
|
|
197
174
|
selection: typeof SELECTION_MODE.Multiple;
|
|
198
175
|
/** Управляемые значения */
|
|
@@ -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,60 @@ 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
|
-
if (digit * 10 > slot.max) {
|
|
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
|
+
const overflow = Number(appended) > slot.max;
|
|
133
|
+
const digits = overflow ? key : appended;
|
|
134
|
+
const value = Number(digits);
|
|
135
|
+
const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
|
|
136
|
+
const accepted = value >= slot.min;
|
|
137
|
+
if (accepted)
|
|
138
|
+
updateSlot(slot, value);
|
|
139
|
+
if (!complete) {
|
|
140
|
+
typedRef.current = { key: slot.key, digits };
|
|
141
|
+
return;
|
|
140
142
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
typedRef.current = null;
|
|
144
|
+
if (accepted)
|
|
143
145
|
checkInputAndGoNext(slot);
|
|
144
|
-
|
|
145
|
-
else if (digit || digit >= slot.min) {
|
|
146
|
-
updateSlot(slot, key);
|
|
147
|
-
}
|
|
148
|
-
}, [checkInputAndGoNext, getSlotText, updateSlot]);
|
|
146
|
+
}, [checkInputAndGoNext, updateSlot]);
|
|
149
147
|
const handleFocus = useCallback(() => {
|
|
150
148
|
if (readonly || disabled)
|
|
151
149
|
return;
|
|
150
|
+
resetTyped();
|
|
152
151
|
ensureMask();
|
|
153
152
|
// На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
|
|
154
153
|
// отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
|
|
155
154
|
selectSlot(firstSlot);
|
|
156
|
-
}, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
|
|
155
|
+
}, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
|
|
157
156
|
const handleClick = useCallback((event) => {
|
|
158
157
|
if (readonly || disabled)
|
|
159
158
|
return;
|
|
159
|
+
resetTyped();
|
|
160
160
|
ensureMask();
|
|
161
161
|
selectSlot(slotFromIndex(event.currentTarget.selectionStart));
|
|
162
|
-
}, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
|
|
162
|
+
}, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
|
|
163
163
|
const handleBlur = useCallback(() => {
|
|
164
|
+
resetTyped();
|
|
164
165
|
const input = inputRef.current;
|
|
165
166
|
if (input && input.value === mask) {
|
|
166
167
|
input.value = '';
|
|
167
168
|
setValue('');
|
|
168
169
|
}
|
|
169
|
-
}, [inputRef, mask, setValue]);
|
|
170
|
+
}, [inputRef, mask, resetTyped, setValue]);
|
|
170
171
|
const handleKeyDown = useCallback((event) => {
|
|
171
172
|
const input = inputRef.current;
|
|
172
173
|
if (!input || readonly || disabled)
|
|
173
174
|
return;
|
|
175
|
+
if (RESET_TYPED_KEYS.has(event.key))
|
|
176
|
+
resetTyped();
|
|
174
177
|
// Tab уводит фокус из поля — не перехватываем.
|
|
175
178
|
if (event.key === 'Tab')
|
|
176
179
|
return;
|
|
@@ -185,7 +188,13 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
185
188
|
onArrowUp?.();
|
|
186
189
|
return;
|
|
187
190
|
}
|
|
191
|
+
// Копирование и вставку не блокируем: правку поля перехватит beforeinput.
|
|
192
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
193
|
+
return;
|
|
188
194
|
onEdit?.();
|
|
195
|
+
// Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
|
|
196
|
+
if (/^\d$/.test(event.key) || event.key === 'Unidentified')
|
|
197
|
+
return;
|
|
189
198
|
if (event.key === 'Escape') {
|
|
190
199
|
event.preventDefault();
|
|
191
200
|
onEscape?.();
|
|
@@ -220,11 +229,6 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
220
229
|
if (event.key === 'Enter') {
|
|
221
230
|
tryToComplete();
|
|
222
231
|
emit();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
if (/^\d$/.test(event.key)) {
|
|
226
|
-
typeDigit(slot, event.key);
|
|
227
|
-
emit();
|
|
228
232
|
}
|
|
229
233
|
}, [
|
|
230
234
|
clearSlot,
|
|
@@ -240,21 +244,13 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
240
244
|
onEscape,
|
|
241
245
|
prevSlot,
|
|
242
246
|
readonly,
|
|
247
|
+
resetTyped,
|
|
243
248
|
selectSlot,
|
|
244
249
|
slotFromIndex,
|
|
245
250
|
tryToComplete,
|
|
246
|
-
typeDigit,
|
|
247
251
|
]);
|
|
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;
|
|
252
|
+
const fillFromDigits = useCallback((digits) => {
|
|
253
|
+
resetTyped();
|
|
258
254
|
ensureMask();
|
|
259
255
|
let cursor = 0;
|
|
260
256
|
let lastFilled;
|
|
@@ -273,7 +269,100 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
|
|
|
273
269
|
else if (lastFilled) {
|
|
274
270
|
selectSlot(nextSlot(lastFilled));
|
|
275
271
|
}
|
|
272
|
+
}, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
|
|
273
|
+
const handlePaste = useCallback((event) => {
|
|
274
|
+
if (readonly || disabled)
|
|
275
|
+
return;
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
278
|
+
if (!digits)
|
|
279
|
+
return;
|
|
280
|
+
fillFromDigits(digits);
|
|
281
|
+
emit();
|
|
282
|
+
}, [disabled, emit, fillFromDigits, readonly]);
|
|
283
|
+
const applyEdit = useCallback((inputType, text) => {
|
|
284
|
+
const input = inputRef.current;
|
|
285
|
+
if (!input)
|
|
286
|
+
return;
|
|
287
|
+
onEdit?.();
|
|
288
|
+
if (inputType.startsWith('delete')) {
|
|
289
|
+
resetTyped();
|
|
290
|
+
clearSlot(slotFromIndex(input.selectionStart));
|
|
291
|
+
emit();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const digits = text.replace(/\D/g, '');
|
|
295
|
+
if (!inputType.startsWith('insert') || !digits)
|
|
296
|
+
return;
|
|
297
|
+
// Автозаполнение, `fill` и drop присылают строку целиком.
|
|
298
|
+
if (text.length > 1) {
|
|
299
|
+
fillFromDigits(digits);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
typeDigit(slotFromIndex(input.selectionStart), digits);
|
|
303
|
+
}
|
|
304
|
+
emit();
|
|
305
|
+
}, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
|
|
306
|
+
// Композицию IME браузер не даёт отменить — откатываем её в input.
|
|
307
|
+
const pendingEditRef = useRef(null);
|
|
308
|
+
const handleBeforeInput = useCallback((event) => {
|
|
309
|
+
const input = inputRef.current;
|
|
310
|
+
if (!input || readonly || disabled)
|
|
311
|
+
return;
|
|
312
|
+
ensureMask();
|
|
313
|
+
if (!event.cancelable) {
|
|
314
|
+
pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
|
|
319
|
+
}, [applyEdit, disabled, ensureMask, inputRef, readonly]);
|
|
320
|
+
const handleInput = useCallback((event) => {
|
|
321
|
+
const input = inputRef.current;
|
|
322
|
+
if (!input || readonly || disabled)
|
|
323
|
+
return;
|
|
324
|
+
const pending = pendingEditRef.current;
|
|
325
|
+
pendingEditRef.current = null;
|
|
326
|
+
if (pending) {
|
|
327
|
+
const inputType = event instanceof InputEvent ? event.inputType : '';
|
|
328
|
+
const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
|
|
329
|
+
input.value = pending.value;
|
|
330
|
+
setValue(pending.value);
|
|
331
|
+
input.setSelectionRange(pending.start, pending.end);
|
|
332
|
+
// Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
|
|
333
|
+
if (!inputType.includes('Composition'))
|
|
334
|
+
applyEdit(inputType, inserted);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Автозаполнение меняет значение без beforeinput.
|
|
338
|
+
const digits = input.value.replace(/\D/g, '');
|
|
339
|
+
input.value = mask;
|
|
340
|
+
setValue(mask);
|
|
341
|
+
fillFromDigits(digits);
|
|
276
342
|
emit();
|
|
277
|
-
}, [disabled, emit,
|
|
278
|
-
|
|
343
|
+
}, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
|
|
344
|
+
// React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
|
|
345
|
+
const onNativeBeforeInput = useEventHandler(handleBeforeInput);
|
|
346
|
+
const onNativeInput = useEventHandler(handleInput);
|
|
347
|
+
const attachedInputRef = useRef(null);
|
|
348
|
+
const nativeInputRef = useCallback((node) => {
|
|
349
|
+
/* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
|
|
350
|
+
attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
|
|
351
|
+
attachedInputRef.current?.removeEventListener('input', onNativeInput);
|
|
352
|
+
attachedInputRef.current = node;
|
|
353
|
+
node?.addEventListener('beforeinput', onNativeBeforeInput);
|
|
354
|
+
node?.addEventListener('input', onNativeInput);
|
|
355
|
+
/* eslint-enable @cloud-ru/ssr-safe-react/domApi */
|
|
356
|
+
}, [onNativeBeforeInput, onNativeInput]);
|
|
357
|
+
return {
|
|
358
|
+
handleKeyDown,
|
|
359
|
+
handleClick,
|
|
360
|
+
handleFocus,
|
|
361
|
+
handleBlur,
|
|
362
|
+
handlePaste,
|
|
363
|
+
nativeInputRef,
|
|
364
|
+
selectSlot,
|
|
365
|
+
firstSlot,
|
|
366
|
+
lastSlot,
|
|
367
|
+
};
|
|
279
368
|
}
|