@cloud-ru/ds-fields 2.2.3-preview-9414fc09.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/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/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/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 +3 -3
- package/src/components/FieldDate/FieldDate.tsx +3 -3
- package/src/components/FieldDate/mask.ts +4 -3
- package/src/components/FieldTime/FieldTime.tsx +2 -2
- package/src/segments/useSegmentedMask.ts +181 -52
|
@@ -327,9 +327,9 @@ exports.FieldDate = (0, react_1.forwardRef)(function FieldDate(props, ref) {
|
|
|
327
327
|
(0, jsx_runtime_1.jsxs)("span", { className: styles_module_scss_2.default.rangeInputs, style: { '--field-date-mask-ch': placeholderMask.length }, children: [(0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ...inputCommon, ref: (0, merge_refs_1.default)(ref, fromInputRef), className: (0, classnames_1.default)(styles_module_scss_1.default.fieldInput, styles_module_scss_2.default.rangeInputFrom), value: rangeInputValue[0], placeholder: placeholderMask, onChange: handleRangeInput(0), id: id, name: name, autoFocus: resolvedAutoFocus, "aria-label": labelFrom, "data-test-id": constants_1.TEST_IDS.fieldDateInputFrom }), (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_2.default.rangeSeparator, "aria-hidden": true, children: "\u2013" }), (0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ...inputCommon, ref: toInputRef, className: styles_module_scss_1.default.fieldInput, value: rangeInputValue[1], placeholder: placeholderMask, onChange: handleRangeInput(1),
|
|
328
328
|
// «to» — последний инпут перед кнопками: на нём живёт roving-композит
|
|
329
329
|
// (ArrowRight в конце → clear/copy), keydown-цепочка с nav-handler'ом.
|
|
330
|
-
onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": constants_1.TEST_IDS.fieldDateInputTo })] })) : ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, fromInputRef), className: styles_module_scss_1.default.fieldInput, value: singleInputValue, placeholder: placeholderMask,
|
|
331
|
-
// Single/date-time ведёт сегментный движок (
|
|
332
|
-
//
|
|
330
|
+
onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": constants_1.TEST_IDS.fieldDateInputTo })] })) : ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, fromInputRef, singleSeg.nativeInputRef), className: styles_module_scss_1.default.fieldInput, value: singleInputValue, placeholder: placeholderMask,
|
|
331
|
+
// Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
|
|
332
|
+
// игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
333
333
|
onChange: () => { }, onKeyDown: handleSingleKeyDown, onClick: singleSeg.handleClick, onPaste: singleSeg.handlePaste, onFocus: handleSingleFocus, onBlur: handleSingleBlur, disabled: disabled, readonly: readOnly, inputMode: 'numeric', tabIndex: inputTabIndex, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, "data-test-id": constants_1.TEST_IDS.fieldDateInput })) }), postfixButtons && (
|
|
334
334
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
335
335
|
(0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, onMouseDown: shared_1.preventSlotMouseDown, onClick: shared_1.stopSlotClickPropagation, children: postfixButtons }))] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, "data-test-id": constants_1.TEST_IDS.fieldDateCalendar, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.CalendarSVG, {}) })] }) })] }));
|
|
@@ -10,8 +10,9 @@ export declare function getMaskString(mode: MaskMode, showSeconds?: boolean): st
|
|
|
10
10
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
11
11
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
12
12
|
* фокус автоматически уезжает на следующий сегмент.
|
|
13
|
-
* 2.
|
|
14
|
-
*
|
|
13
|
+
* 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
14
|
+
* сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
|
|
15
|
+
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
15
16
|
*/
|
|
16
17
|
export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
|
|
17
18
|
/** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */
|
|
@@ -48,8 +48,9 @@ function buildSegments(mode, showSeconds) {
|
|
|
48
48
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
49
49
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
50
50
|
* фокус автоматически уезжает на следующий сегмент.
|
|
51
|
-
* 2.
|
|
52
|
-
*
|
|
51
|
+
* 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
52
|
+
* сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
|
|
53
|
+
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
53
54
|
*/
|
|
54
55
|
function formatMask(input, mode, showSeconds = true) {
|
|
55
56
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -75,7 +76,7 @@ function formatMask(input, mode, showSeconds = true) {
|
|
|
75
76
|
stream = stream.slice(seg.len);
|
|
76
77
|
if (raw.length === seg.len) {
|
|
77
78
|
const n = Number(raw);
|
|
78
|
-
out += n > seg.max ?
|
|
79
|
+
out += n > seg.max ? raw.slice(-1).padStart(seg.len, '0') : raw;
|
|
79
80
|
}
|
|
80
81
|
else {
|
|
81
82
|
out += raw;
|
|
@@ -137,7 +137,7 @@ exports.FieldTime = (0, react_1.forwardRef)(function FieldTime({ label = '', lab
|
|
|
137
137
|
// clear/copy недостижимыми со стрелок — движок всегда делает preventDefault на ArrowRight.
|
|
138
138
|
onInputKeyDown(event);
|
|
139
139
|
}, [onInputKeyDown, timeSeg]);
|
|
140
|
-
// value ведёт сегментный движок (
|
|
140
|
+
// value ведёт сегментный движок (useSegmentedMask); нативный onChange input'а игнорируем.
|
|
141
141
|
const handleInputChange = (0, react_1.useCallback)(() => undefined, []);
|
|
142
142
|
const handleInputFocus = (0, react_1.useCallback)((event) => {
|
|
143
143
|
setFocusVisible(event.target.matches(':focus-visible'));
|
|
@@ -155,7 +155,7 @@ exports.FieldTime = (0, react_1.forwardRef)(function FieldTime({ label = '', lab
|
|
|
155
155
|
readonly: readOnly,
|
|
156
156
|
hover,
|
|
157
157
|
focusVisible: focusVisible || open,
|
|
158
|
-
}), children: (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.acrylicBg, "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.borderStateLayer, "data-state": 'borderOnBackground' }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.focusLayer })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.fieldContainer, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.contentWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputLine, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.inputArea, children: [(0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, localRef), className: styles_module_scss_1.default.fieldInput, value: inputValue, onChange: handleInputChange, disabled: disabled, readonly: readOnly, placeholder: placeholderMask, inputMode: input_private_1.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, ...(0, utils_1.extractSupportProps)(rest), "data-test-id": constants_1.TEST_IDS.fieldTimeInput }), postfixButtons && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, children: postfixButtons })] }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, "data-test-id": constants_1.TEST_IDS.fieldTimeIcon, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.WatchSVG, {}) })] }) })] }));
|
|
158
|
+
}), children: (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.acrylicBg, "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.borderStateLayer, "data-state": 'borderOnBackground' }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.focusLayer })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.fieldContainer, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.contentWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputLine, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.inputArea, children: [(0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, localRef, timeSeg.nativeInputRef), className: styles_module_scss_1.default.fieldInput, value: inputValue, onChange: handleInputChange, disabled: disabled, readonly: readOnly, placeholder: placeholderMask, inputMode: input_private_1.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, ...(0, utils_1.extractSupportProps)(rest), "data-test-id": constants_1.TEST_IDS.fieldTimeInput }), postfixButtons && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, children: postfixButtons })] }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, "data-test-id": constants_1.TEST_IDS.fieldTimeIcon, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.WatchSVG, {}) })] }) })] }));
|
|
159
159
|
return ((0, jsx_runtime_1.jsx)(field_decorator_1.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: (0, jsx_runtime_1.jsx)(calendar_1.TimePickerDropdown
|
|
160
160
|
// Явный data-test-id на дропдауне → детерминированный id портального контента:
|
|
161
161
|
// `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,11 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.useSegmentedMask = useSegmentedMask;
|
|
4
|
+
const utils_1 = require("@cloud-ru/ds-utils");
|
|
4
5
|
const react_1 = require("react");
|
|
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
|
+
]);
|
|
5
17
|
/**
|
|
6
18
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
7
19
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
8
20
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
21
|
+
*
|
|
22
|
+
* Навигация идёт через `handleKeyDown`, правка значения — через нативные `beforeinput`/`input`:
|
|
23
|
+
* `nativeInputRef` нужно передать в `ref` того же input'а.
|
|
9
24
|
*/
|
|
10
25
|
function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
|
|
11
26
|
const firstSlot = slots[0];
|
|
@@ -110,70 +125,64 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
110
125
|
}
|
|
111
126
|
selectSlot(nextSlot(slot));
|
|
112
127
|
}, [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete]);
|
|
128
|
+
// Цифры, набранные в текущий сегмент. Храним отдельно от текста: по `01` в поле нельзя понять,
|
|
129
|
+
// набрали `1` или `0` + `1`, а от этого зависит, ждать ли вторую цифру.
|
|
130
|
+
const typedRef = (0, react_1.useRef)(null);
|
|
131
|
+
const resetTyped = (0, react_1.useCallback)(() => {
|
|
132
|
+
typedRef.current = null;
|
|
133
|
+
}, []);
|
|
113
134
|
const typeDigit = (0, react_1.useCallback)((slot, key) => {
|
|
114
|
-
const text = getSlotText(slot);
|
|
115
|
-
const numberValue = Number(text) || 0;
|
|
116
|
-
const digit = Number(key);
|
|
117
|
-
const slotValue = parseInt(numberValue.toString() + key, 10) || 0;
|
|
118
|
-
const valueLength = slotValue.toString().length;
|
|
119
135
|
const maxLength = slot.max.toString().length;
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
checkInputAndGoNext(slot);
|
|
135
|
-
}
|
|
136
|
-
else if (digit || digit >= slot.min) {
|
|
137
|
-
updateSlot(slot, key);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
else if (slotValue <= slot.max) {
|
|
141
|
-
updateSlot(slot, slotValue);
|
|
142
|
-
checkInputAndGoNext(slot);
|
|
136
|
+
const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
|
|
137
|
+
const appended = prev + key;
|
|
138
|
+
// Цифра выводит сегмент за max — сегмент начинается с неё заново (как React Aria и MUI X): `13` в месяце → `03`.
|
|
139
|
+
const overflow = Number(appended) > slot.max;
|
|
140
|
+
const digits = overflow ? key : appended;
|
|
141
|
+
const value = Number(digits);
|
|
142
|
+
const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
|
|
143
|
+
// Значение ниже min (`0` в дне) не показываем, но помним цифру до следующей.
|
|
144
|
+
const accepted = value >= slot.min;
|
|
145
|
+
if (accepted)
|
|
146
|
+
updateSlot(slot, value);
|
|
147
|
+
if (!complete) {
|
|
148
|
+
typedRef.current = { key: slot.key, digits };
|
|
149
|
+
return;
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
typedRef.current = null;
|
|
152
|
+
if (accepted)
|
|
146
153
|
checkInputAndGoNext(slot);
|
|
147
|
-
|
|
148
|
-
else if (digit || digit >= slot.min) {
|
|
149
|
-
updateSlot(slot, key);
|
|
150
|
-
}
|
|
151
|
-
}, [checkInputAndGoNext, getSlotText, updateSlot]);
|
|
154
|
+
}, [checkInputAndGoNext, updateSlot]);
|
|
152
155
|
const handleFocus = (0, react_1.useCallback)(() => {
|
|
153
156
|
if (readonly || disabled)
|
|
154
157
|
return;
|
|
158
|
+
resetTyped();
|
|
155
159
|
ensureMask();
|
|
156
160
|
// На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
|
|
157
161
|
// отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
|
|
158
162
|
selectSlot(firstSlot);
|
|
159
|
-
}, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
|
|
163
|
+
}, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
|
|
160
164
|
const handleClick = (0, react_1.useCallback)((event) => {
|
|
161
165
|
if (readonly || disabled)
|
|
162
166
|
return;
|
|
167
|
+
resetTyped();
|
|
163
168
|
ensureMask();
|
|
164
169
|
selectSlot(slotFromIndex(event.currentTarget.selectionStart));
|
|
165
|
-
}, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
|
|
170
|
+
}, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
|
|
166
171
|
const handleBlur = (0, react_1.useCallback)(() => {
|
|
172
|
+
resetTyped();
|
|
167
173
|
const input = inputRef.current;
|
|
168
174
|
if (input && input.value === mask) {
|
|
169
175
|
input.value = '';
|
|
170
176
|
setValue('');
|
|
171
177
|
}
|
|
172
|
-
}, [inputRef, mask, setValue]);
|
|
178
|
+
}, [inputRef, mask, resetTyped, setValue]);
|
|
173
179
|
const handleKeyDown = (0, react_1.useCallback)((event) => {
|
|
174
180
|
const input = inputRef.current;
|
|
175
181
|
if (!input || readonly || disabled)
|
|
176
182
|
return;
|
|
183
|
+
// Навигация и правка начинают набор сегмента заново. Цифры, `Unidentified` (Android) и модификаторы — нет.
|
|
184
|
+
if (RESET_TYPED_KEYS.has(event.key))
|
|
185
|
+
resetTyped();
|
|
177
186
|
// Tab уводит фокус из поля — не перехватываем.
|
|
178
187
|
if (event.key === 'Tab')
|
|
179
188
|
return;
|
|
@@ -188,7 +197,13 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
188
197
|
onArrowUp?.();
|
|
189
198
|
return;
|
|
190
199
|
}
|
|
200
|
+
// Сочетания (копирование, вставка, выделение) отдаём браузеру: правку поля перехватит beforeinput.
|
|
201
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
202
|
+
return;
|
|
191
203
|
onEdit?.();
|
|
204
|
+
// Цифры и `Unidentified` (экранные клавиатуры Android, IME) вводятся через beforeinput.
|
|
205
|
+
if (/^\d$/.test(event.key) || event.key === 'Unidentified')
|
|
206
|
+
return;
|
|
192
207
|
if (event.key === 'Escape') {
|
|
193
208
|
event.preventDefault();
|
|
194
209
|
onEscape?.();
|
|
@@ -223,11 +238,6 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
223
238
|
if (event.key === 'Enter') {
|
|
224
239
|
tryToComplete();
|
|
225
240
|
emit();
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (/^\d$/.test(event.key)) {
|
|
229
|
-
typeDigit(slot, event.key);
|
|
230
|
-
emit();
|
|
231
241
|
}
|
|
232
242
|
}, [
|
|
233
243
|
clearSlot,
|
|
@@ -243,21 +253,14 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
243
253
|
onEscape,
|
|
244
254
|
prevSlot,
|
|
245
255
|
readonly,
|
|
256
|
+
resetTyped,
|
|
246
257
|
selectSlot,
|
|
247
258
|
slotFromIndex,
|
|
248
259
|
tryToComplete,
|
|
249
|
-
typeDigit,
|
|
250
260
|
]);
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const handlePaste = (0, react_1.useCallback)((event) => {
|
|
255
|
-
if (readonly || disabled)
|
|
256
|
-
return;
|
|
257
|
-
event.preventDefault();
|
|
258
|
-
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
259
|
-
if (!digits)
|
|
260
|
-
return;
|
|
261
|
+
// Раскладывает цифры по сегментам слева направо полными кусками — для вставки и автозаполнения.
|
|
262
|
+
const fillFromDigits = (0, react_1.useCallback)((digits) => {
|
|
263
|
+
resetTyped();
|
|
261
264
|
ensureMask();
|
|
262
265
|
let cursor = 0;
|
|
263
266
|
let lastFilled;
|
|
@@ -276,7 +279,103 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
276
279
|
else if (lastFilled) {
|
|
277
280
|
selectSlot(nextSlot(lastFilled));
|
|
278
281
|
}
|
|
282
|
+
}, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
|
|
283
|
+
// Вставка (Ctrl+V) полной строки даты/времени. Нативный onChange у поля игнорируется (движок
|
|
284
|
+
// controlled), поэтому без этого обработчика вставка не работала бы вовсе.
|
|
285
|
+
const handlePaste = (0, react_1.useCallback)((event) => {
|
|
286
|
+
if (readonly || disabled)
|
|
287
|
+
return;
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
290
|
+
if (!digits)
|
|
291
|
+
return;
|
|
292
|
+
fillFromDigits(digits);
|
|
293
|
+
emit();
|
|
294
|
+
}, [disabled, emit, fillFromDigits, readonly]);
|
|
295
|
+
const applyEdit = (0, react_1.useCallback)((inputType, text) => {
|
|
296
|
+
const input = inputRef.current;
|
|
297
|
+
if (!input)
|
|
298
|
+
return;
|
|
299
|
+
onEdit?.();
|
|
300
|
+
if (inputType.startsWith('delete')) {
|
|
301
|
+
resetTyped();
|
|
302
|
+
clearSlot(slotFromIndex(input.selectionStart));
|
|
303
|
+
emit();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const digits = text.replace(/\D/g, '');
|
|
307
|
+
if (!inputType.startsWith('insert') || !digits)
|
|
308
|
+
return;
|
|
309
|
+
// Строка целиком (автозаполнение, `fill`, drop) раскладывается с начала маски, одиночная цифра — в текущий сегмент.
|
|
310
|
+
if (text.length > 1) {
|
|
311
|
+
fillFromDigits(digits);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
typeDigit(slotFromIndex(input.selectionStart), digits);
|
|
315
|
+
}
|
|
316
|
+
emit();
|
|
317
|
+
}, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
|
|
318
|
+
// Значение и выделение до правки, которую браузер не дал отменить (композиция IME).
|
|
319
|
+
const pendingEditRef = (0, react_1.useRef)(null);
|
|
320
|
+
const handleBeforeInput = (0, react_1.useCallback)((event) => {
|
|
321
|
+
const input = inputRef.current;
|
|
322
|
+
if (!input || readonly || disabled)
|
|
323
|
+
return;
|
|
324
|
+
ensureMask();
|
|
325
|
+
if (!event.cancelable) {
|
|
326
|
+
pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
|
|
331
|
+
}, [applyEdit, disabled, ensureMask, inputRef, readonly]);
|
|
332
|
+
const handleInput = (0, react_1.useCallback)((event) => {
|
|
333
|
+
const input = inputRef.current;
|
|
334
|
+
if (!input || readonly || disabled)
|
|
335
|
+
return;
|
|
336
|
+
const pending = pendingEditRef.current;
|
|
337
|
+
pendingEditRef.current = null;
|
|
338
|
+
if (pending) {
|
|
339
|
+
// Браузер уже изменил значение: вырезаем вставленное, возвращаем прежнее и применяем как обычный ввод.
|
|
340
|
+
const inputType = event instanceof InputEvent ? event.inputType : '';
|
|
341
|
+
const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
|
|
342
|
+
input.value = pending.value;
|
|
343
|
+
setValue(pending.value);
|
|
344
|
+
input.setSelectionRange(pending.start, pending.end);
|
|
345
|
+
// Цифру из композиции IME применит её фиксация (отменяемый insertText), как в React Aria.
|
|
346
|
+
if (!inputType.includes('Composition'))
|
|
347
|
+
applyEdit(inputType, inserted);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Значение сменили без beforeinput (автозаполнение браузера).
|
|
351
|
+
const digits = input.value.replace(/\D/g, '');
|
|
352
|
+
input.value = mask;
|
|
353
|
+
setValue(mask);
|
|
354
|
+
fillFromDigits(digits);
|
|
279
355
|
emit();
|
|
280
|
-
}, [disabled, emit,
|
|
281
|
-
|
|
356
|
+
}, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
|
|
357
|
+
// React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
|
|
358
|
+
const onNativeBeforeInput = (0, utils_1.useEventHandler)(handleBeforeInput);
|
|
359
|
+
const onNativeInput = (0, utils_1.useEventHandler)(handleInput);
|
|
360
|
+
const attachedInputRef = (0, react_1.useRef)(null);
|
|
361
|
+
const nativeInputRef = (0, react_1.useCallback)((node) => {
|
|
362
|
+
/* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
|
|
363
|
+
attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
|
|
364
|
+
attachedInputRef.current?.removeEventListener('input', onNativeInput);
|
|
365
|
+
attachedInputRef.current = node;
|
|
366
|
+
node?.addEventListener('beforeinput', onNativeBeforeInput);
|
|
367
|
+
node?.addEventListener('input', onNativeInput);
|
|
368
|
+
/* eslint-enable @cloud-ru/ssr-safe-react/domApi */
|
|
369
|
+
}, [onNativeBeforeInput, onNativeInput]);
|
|
370
|
+
return {
|
|
371
|
+
handleKeyDown,
|
|
372
|
+
handleClick,
|
|
373
|
+
handleFocus,
|
|
374
|
+
handleBlur,
|
|
375
|
+
handlePaste,
|
|
376
|
+
nativeInputRef,
|
|
377
|
+
selectSlot,
|
|
378
|
+
firstSlot,
|
|
379
|
+
lastSlot,
|
|
380
|
+
};
|
|
282
381
|
}
|
|
@@ -321,9 +321,9 @@ export const FieldDate = forwardRef(function FieldDate(props, ref) {
|
|
|
321
321
|
_jsxs("span", { className: styles.rangeInputs, style: { '--field-date-mask-ch': placeholderMask.length }, children: [_jsx(InputPrivate, { ...inputCommon, ref: mergeRefs(ref, fromInputRef), className: cn(fieldStyles.fieldInput, styles.rangeInputFrom), value: rangeInputValue[0], placeholder: placeholderMask, onChange: handleRangeInput(0), id: id, name: name, autoFocus: resolvedAutoFocus, "aria-label": labelFrom, "data-test-id": TEST_IDS.fieldDateInputFrom }), _jsx("span", { className: styles.rangeSeparator, "aria-hidden": true, children: "\u2013" }), _jsx(InputPrivate, { ...inputCommon, ref: toInputRef, className: fieldStyles.fieldInput, value: rangeInputValue[1], placeholder: placeholderMask, onChange: handleRangeInput(1),
|
|
322
322
|
// «to» — последний инпут перед кнопками: на нём живёт roving-композит
|
|
323
323
|
// (ArrowRight в конце → clear/copy), keydown-цепочка с nav-handler'ом.
|
|
324
|
-
onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": TEST_IDS.fieldDateInputTo })] })) : (_jsx(InputPrivate, { ref: mergeRefs(ref, fromInputRef), className: fieldStyles.fieldInput, value: singleInputValue, placeholder: placeholderMask,
|
|
325
|
-
// Single/date-time ведёт сегментный движок (
|
|
326
|
-
//
|
|
324
|
+
onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": TEST_IDS.fieldDateInputTo })] })) : (_jsx(InputPrivate, { ref: mergeRefs(ref, fromInputRef, singleSeg.nativeInputRef), className: fieldStyles.fieldInput, value: singleInputValue, placeholder: placeholderMask,
|
|
325
|
+
// Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
|
|
326
|
+
// игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
327
327
|
onChange: () => { }, onKeyDown: handleSingleKeyDown, onClick: singleSeg.handleClick, onPaste: singleSeg.handlePaste, onFocus: handleSingleFocus, onBlur: handleSingleBlur, disabled: disabled, readonly: readOnly, inputMode: 'numeric', tabIndex: inputTabIndex, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, "data-test-id": TEST_IDS.fieldDateInput })) }), postfixButtons && (
|
|
328
328
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
329
329
|
_jsx("span", { className: fieldStyles.postfixButtonsSlot, onMouseDown: preventSlotMouseDown, onClick: stopSlotClickPropagation, children: postfixButtons }))] }), _jsx("div", { className: fieldStyles.iconSlot, "data-test-id": TEST_IDS.fieldDateCalendar, "aria-hidden": true, children: _jsx(CalendarSVG, {}) })] }) })] }));
|
|
@@ -10,8 +10,9 @@ export declare function getMaskString(mode: MaskMode, showSeconds?: boolean): st
|
|
|
10
10
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
11
11
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
12
12
|
* фокус автоматически уезжает на следующий сегмент.
|
|
13
|
-
* 2.
|
|
14
|
-
*
|
|
13
|
+
* 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
14
|
+
* сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
|
|
15
|
+
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
15
16
|
*/
|
|
16
17
|
export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
|
|
17
18
|
/** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */
|
|
@@ -42,8 +42,9 @@ function buildSegments(mode, showSeconds) {
|
|
|
42
42
|
* «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
|
|
43
43
|
* как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
|
|
44
44
|
* фокус автоматически уезжает на следующий сегмент.
|
|
45
|
-
* 2.
|
|
46
|
-
*
|
|
45
|
+
* 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
46
|
+
* сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
|
|
47
|
+
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
47
48
|
*/
|
|
48
49
|
export function formatMask(input, mode, showSeconds = true) {
|
|
49
50
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -69,7 +70,7 @@ export function formatMask(input, mode, showSeconds = true) {
|
|
|
69
70
|
stream = stream.slice(seg.len);
|
|
70
71
|
if (raw.length === seg.len) {
|
|
71
72
|
const n = Number(raw);
|
|
72
|
-
out += n > seg.max ?
|
|
73
|
+
out += n > seg.max ? raw.slice(-1).padStart(seg.len, '0') : raw;
|
|
73
74
|
}
|
|
74
75
|
else {
|
|
75
76
|
out += raw;
|
|
@@ -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,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;
|