@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.
Files changed (76) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
  3. package/dist/cjs/components/FieldDate/mask.d.ts +3 -2
  4. package/dist/cjs/components/FieldDate/mask.js +4 -3
  5. package/dist/cjs/components/FieldSelect/FieldSelect.js +41 -81
  6. package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
  7. package/dist/cjs/components/FieldSelect/constants.js +1 -20
  8. package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
  9. package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
  10. package/dist/cjs/components/FieldSelect/utils.js +103 -0
  11. package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
  12. package/dist/cjs/segments/useSegmentedMask.d.ts +4 -0
  13. package/dist/cjs/segments/useSegmentedMask.js +155 -56
  14. package/dist/esm/components/FieldDate/FieldDate.js +3 -3
  15. package/dist/esm/components/FieldDate/mask.d.ts +3 -2
  16. package/dist/esm/components/FieldDate/mask.js +4 -3
  17. package/dist/esm/components/FieldSelect/FieldSelect.js +42 -82
  18. package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
  19. package/dist/esm/components/FieldSelect/constants.js +0 -19
  20. package/dist/esm/components/FieldSelect/types.d.ts +3 -26
  21. package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
  22. package/dist/esm/components/FieldSelect/utils.js +92 -0
  23. package/dist/esm/components/FieldTime/FieldTime.js +2 -2
  24. package/dist/esm/segments/useSegmentedMask.d.ts +4 -0
  25. package/dist/esm/segments/useSegmentedMask.js +155 -56
  26. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  27. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  28. package/package.json +6 -6
  29. package/src/components/FieldDate/FieldDate.tsx +3 -3
  30. package/src/components/FieldDate/mask.ts +4 -3
  31. package/src/components/FieldSelect/FieldSelect.tsx +61 -129
  32. package/src/components/FieldSelect/constants.ts +0 -22
  33. package/src/components/FieldSelect/types.ts +48 -82
  34. package/src/components/FieldSelect/utils.ts +132 -0
  35. package/src/components/FieldTime/FieldTime.tsx +2 -2
  36. package/src/segments/useSegmentedMask.ts +181 -52
  37. package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -32
  38. package/dist/cjs/components/FieldSelect/hooks.js +0 -83
  39. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
  40. package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
  41. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
  42. package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
  43. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
  44. package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
  45. package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
  46. package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
  47. package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
  48. package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
  49. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
  50. package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
  51. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
  52. package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
  53. package/dist/esm/components/FieldSelect/hooks.d.ts +0 -32
  54. package/dist/esm/components/FieldSelect/hooks.js +0 -80
  55. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
  56. package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
  57. package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
  58. package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
  59. package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
  60. package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
  61. package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
  62. package/dist/esm/components/FieldSelect/utils/index.js +0 -6
  63. package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
  64. package/dist/esm/components/FieldSelect/utils/items.js +0 -41
  65. package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
  66. package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
  67. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
  68. package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
  69. package/src/components/FieldSelect/hooks.ts +0 -162
  70. package/src/components/FieldSelect/utils/customOption.ts +0 -77
  71. package/src/components/FieldSelect/utils/extract.ts +0 -49
  72. package/src/components/FieldSelect/utils/filter.ts +0 -51
  73. package/src/components/FieldSelect/utils/index.ts +0 -6
  74. package/src/components/FieldSelect/utils/items.ts +0 -58
  75. package/src/components/FieldSelect/utils/selection.ts +0 -25
  76. 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 { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, SELECTION_MODE } from './constants.js';
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 & CustomOptionProps<FieldSelectSingleAddCustomOptionTrigger> & {
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 & CustomOptionProps<FieldSelectMultipleAddCustomOptionTrigger> & {
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 ведёт сегментный движок (keydown); нативный onChange input'а игнорируем.
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,9 @@ type UseSegmentedMaskParams = {
29
29
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
30
30
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
31
31
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
32
+ *
33
+ * Навигация идёт через `handleKeyDown`, правка значения — через нативные `beforeinput`/`input`:
34
+ * `nativeInputRef` нужно передать в `ref` того же input'а.
32
35
  */
33
36
  export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }: UseSegmentedMaskParams): {
34
37
  handleKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
@@ -36,6 +39,7 @@ export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disa
36
39
  handleFocus: () => void;
37
40
  handleBlur: () => void;
38
41
  handlePaste: (event: ClipboardEvent<HTMLInputElement>) => void;
42
+ nativeInputRef: (node: HTMLInputElement | null) => void;
39
43
  selectSlot: (slot: SlotMeta) => void;
40
44
  firstSlot: SlotMeta;
41
45
  lastSlot: SlotMeta;
@@ -1,8 +1,23 @@
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
+ *
19
+ * Навигация идёт через `handleKeyDown`, правка значения — через нативные `beforeinput`/`input`:
20
+ * `nativeInputRef` нужно передать в `ref` того же input'а.
6
21
  */
7
22
  export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
8
23
  const firstSlot = slots[0];
@@ -107,70 +122,64 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
107
122
  }
108
123
  selectSlot(nextSlot(slot));
109
124
  }, [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete]);
125
+ // Цифры, набранные в текущий сегмент. Храним отдельно от текста: по `01` в поле нельзя понять,
126
+ // набрали `1` или `0` + `1`, а от этого зависит, ждать ли вторую цифру.
127
+ const typedRef = useRef(null);
128
+ const resetTyped = useCallback(() => {
129
+ typedRef.current = null;
130
+ }, []);
110
131
  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
132
  const maxLength = slot.max.toString().length;
117
- const isTheLastInput = /^0+$/.test(text) && maxLength === 2 && digit === 0;
118
- if (valueLength < maxLength) {
119
- if (slotValue || slotValue >= slot.min) {
120
- updateSlot(slot, slotValue);
121
- if (isTheLastInput)
122
- checkInputAndGoNext(slot);
123
- }
124
- if (slotValue * 10 > slot.max) {
125
- checkInputAndGoNext(slot);
126
- }
127
- }
128
- else if (valueLength > maxLength) {
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);
133
+ const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
134
+ const appended = prev + key;
135
+ // Цифра выводит сегмент за max — сегмент начинается с неё заново (как React Aria и MUI X): `13` в месяце → `03`.
136
+ const overflow = Number(appended) > slot.max;
137
+ const digits = overflow ? key : appended;
138
+ const value = Number(digits);
139
+ const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
140
+ // Значение ниже min (`0` в дне) не показываем, но помним цифру до следующей.
141
+ const accepted = value >= slot.min;
142
+ if (accepted)
143
+ updateSlot(slot, value);
144
+ if (!complete) {
145
+ typedRef.current = { key: slot.key, digits };
146
+ return;
140
147
  }
141
- else if (digit * 10 > slot.max) {
142
- updateSlot(slot, key);
148
+ typedRef.current = null;
149
+ if (accepted)
143
150
  checkInputAndGoNext(slot);
144
- }
145
- else if (digit || digit >= slot.min) {
146
- updateSlot(slot, key);
147
- }
148
- }, [checkInputAndGoNext, getSlotText, updateSlot]);
151
+ }, [checkInputAndGoNext, updateSlot]);
149
152
  const handleFocus = useCallback(() => {
150
153
  if (readonly || disabled)
151
154
  return;
155
+ resetTyped();
152
156
  ensureMask();
153
157
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
154
158
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
155
159
  selectSlot(firstSlot);
156
- }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
160
+ }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
157
161
  const handleClick = useCallback((event) => {
158
162
  if (readonly || disabled)
159
163
  return;
164
+ resetTyped();
160
165
  ensureMask();
161
166
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
162
- }, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
167
+ }, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
163
168
  const handleBlur = useCallback(() => {
169
+ resetTyped();
164
170
  const input = inputRef.current;
165
171
  if (input && input.value === mask) {
166
172
  input.value = '';
167
173
  setValue('');
168
174
  }
169
- }, [inputRef, mask, setValue]);
175
+ }, [inputRef, mask, resetTyped, setValue]);
170
176
  const handleKeyDown = useCallback((event) => {
171
177
  const input = inputRef.current;
172
178
  if (!input || readonly || disabled)
173
179
  return;
180
+ // Навигация и правка начинают набор сегмента заново. Цифры, `Unidentified` (Android) и модификаторы — нет.
181
+ if (RESET_TYPED_KEYS.has(event.key))
182
+ resetTyped();
174
183
  // Tab уводит фокус из поля — не перехватываем.
175
184
  if (event.key === 'Tab')
176
185
  return;
@@ -185,7 +194,13 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
185
194
  onArrowUp?.();
186
195
  return;
187
196
  }
197
+ // Сочетания (копирование, вставка, выделение) отдаём браузеру: правку поля перехватит beforeinput.
198
+ if (event.ctrlKey || event.metaKey || event.altKey)
199
+ return;
188
200
  onEdit?.();
201
+ // Цифры и `Unidentified` (экранные клавиатуры Android, IME) вводятся через beforeinput.
202
+ if (/^\d$/.test(event.key) || event.key === 'Unidentified')
203
+ return;
189
204
  if (event.key === 'Escape') {
190
205
  event.preventDefault();
191
206
  onEscape?.();
@@ -220,11 +235,6 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
220
235
  if (event.key === 'Enter') {
221
236
  tryToComplete();
222
237
  emit();
223
- return;
224
- }
225
- if (/^\d$/.test(event.key)) {
226
- typeDigit(slot, event.key);
227
- emit();
228
238
  }
229
239
  }, [
230
240
  clearSlot,
@@ -240,21 +250,14 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
240
250
  onEscape,
241
251
  prevSlot,
242
252
  readonly,
253
+ resetTyped,
243
254
  selectSlot,
244
255
  slotFromIndex,
245
256
  tryToComplete,
246
- typeDigit,
247
257
  ]);
248
- // Вставка (Ctrl+V) полной строки даты/времени: вытаскиваем цифры и заполняем сегменты
249
- // слева направо. Нативный onChange у поля игнорируется (движок controlled), поэтому без
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;
258
+ // Раскладывает цифры по сегментам слева направо полными кусками — для вставки и автозаполнения.
259
+ const fillFromDigits = useCallback((digits) => {
260
+ resetTyped();
258
261
  ensureMask();
259
262
  let cursor = 0;
260
263
  let lastFilled;
@@ -273,7 +276,103 @@ export function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, se
273
276
  else if (lastFilled) {
274
277
  selectSlot(nextSlot(lastFilled));
275
278
  }
279
+ }, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
280
+ // Вставка (Ctrl+V) полной строки даты/времени. Нативный onChange у поля игнорируется (движок
281
+ // controlled), поэтому без этого обработчика вставка не работала бы вовсе.
282
+ const handlePaste = useCallback((event) => {
283
+ if (readonly || disabled)
284
+ return;
285
+ event.preventDefault();
286
+ const digits = event.clipboardData.getData('text').replace(/\D/g, '');
287
+ if (!digits)
288
+ return;
289
+ fillFromDigits(digits);
290
+ emit();
291
+ }, [disabled, emit, fillFromDigits, readonly]);
292
+ const applyEdit = useCallback((inputType, text) => {
293
+ const input = inputRef.current;
294
+ if (!input)
295
+ return;
296
+ onEdit?.();
297
+ if (inputType.startsWith('delete')) {
298
+ resetTyped();
299
+ clearSlot(slotFromIndex(input.selectionStart));
300
+ emit();
301
+ return;
302
+ }
303
+ const digits = text.replace(/\D/g, '');
304
+ if (!inputType.startsWith('insert') || !digits)
305
+ return;
306
+ // Строка целиком (автозаполнение, `fill`, drop) раскладывается с начала маски, одиночная цифра — в текущий сегмент.
307
+ if (text.length > 1) {
308
+ fillFromDigits(digits);
309
+ }
310
+ else {
311
+ typeDigit(slotFromIndex(input.selectionStart), digits);
312
+ }
313
+ emit();
314
+ }, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
315
+ // Значение и выделение до правки, которую браузер не дал отменить (композиция IME).
316
+ const pendingEditRef = useRef(null);
317
+ const handleBeforeInput = useCallback((event) => {
318
+ const input = inputRef.current;
319
+ if (!input || readonly || disabled)
320
+ return;
321
+ ensureMask();
322
+ if (!event.cancelable) {
323
+ pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
324
+ return;
325
+ }
326
+ event.preventDefault();
327
+ applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
328
+ }, [applyEdit, disabled, ensureMask, inputRef, readonly]);
329
+ const handleInput = useCallback((event) => {
330
+ const input = inputRef.current;
331
+ if (!input || readonly || disabled)
332
+ return;
333
+ const pending = pendingEditRef.current;
334
+ pendingEditRef.current = null;
335
+ if (pending) {
336
+ // Браузер уже изменил значение: вырезаем вставленное, возвращаем прежнее и применяем как обычный ввод.
337
+ const inputType = event instanceof InputEvent ? event.inputType : '';
338
+ const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
339
+ input.value = pending.value;
340
+ setValue(pending.value);
341
+ input.setSelectionRange(pending.start, pending.end);
342
+ // Цифру из композиции IME применит её фиксация (отменяемый insertText), как в React Aria.
343
+ if (!inputType.includes('Composition'))
344
+ applyEdit(inputType, inserted);
345
+ return;
346
+ }
347
+ // Значение сменили без beforeinput (автозаполнение браузера).
348
+ const digits = input.value.replace(/\D/g, '');
349
+ input.value = mask;
350
+ setValue(mask);
351
+ fillFromDigits(digits);
276
352
  emit();
277
- }, [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot]);
278
- return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
353
+ }, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
354
+ // React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
355
+ const onNativeBeforeInput = useEventHandler(handleBeforeInput);
356
+ const onNativeInput = useEventHandler(handleInput);
357
+ const attachedInputRef = useRef(null);
358
+ const nativeInputRef = useCallback((node) => {
359
+ /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
360
+ attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
361
+ attachedInputRef.current?.removeEventListener('input', onNativeInput);
362
+ attachedInputRef.current = node;
363
+ node?.addEventListener('beforeinput', onNativeBeforeInput);
364
+ node?.addEventListener('input', onNativeInput);
365
+ /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
366
+ }, [onNativeBeforeInput, onNativeInput]);
367
+ return {
368
+ handleKeyDown,
369
+ handleClick,
370
+ handleFocus,
371
+ handleBlur,
372
+ handlePaste,
373
+ nativeInputRef,
374
+ selectSlot,
375
+ firstSlot,
376
+ lastSlot,
377
+ };
279
378
  }