@cloud-ru/ds-fields 2.2.3 → 2.2.5
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/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 +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/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 +14 -14
- 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 +171 -52
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## 2.2.5 (2026-09-18)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @ds/fields
|
|
9
|
+
|
|
10
|
+
## 2.2.4 (2026-09-14)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **FF-9049:** handle date/time segment input via beforeinput for mobile keyboards, IME and autofill ([2bdf60d](https://github.com/cloud-ru-tech/snack-v2/commit/2bdf60da37d46a3be1e164874aa96fab26a08e08))
|
|
15
|
+
- **FF-9049:** keep leading zeros in segmented date/time input, restart overflowing segment from last digit ([d7652ac](https://github.com/cloud-ru-tech/snack-v2/commit/d7652ac0bded8a0c5a4785ecb62b1e2d7d2720f8))
|
|
16
|
+
|
|
6
17
|
## 2.2.3 (2026-09-14)
|
|
7
18
|
|
|
8
19
|
**Note:** Version bump only for package @ds/fields
|
|
@@ -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,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,11 +1,24 @@
|
|
|
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
|
+
* `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
|
|
9
22
|
*/
|
|
10
23
|
function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
|
|
11
24
|
const firstSlot = slots[0];
|
|
@@ -110,70 +123,60 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
110
123
|
}
|
|
111
124
|
selectSlot(nextSlot(slot));
|
|
112
125
|
}, [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete]);
|
|
126
|
+
// Цифры сегмента храним отдельно от текста: по `01` в поле не отличить `1` от `0` + `1`.
|
|
127
|
+
const typedRef = (0, react_1.useRef)(null);
|
|
128
|
+
const resetTyped = (0, react_1.useCallback)(() => {
|
|
129
|
+
typedRef.current = null;
|
|
130
|
+
}, []);
|
|
113
131
|
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
132
|
const maxLength = slot.max.toString().length;
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (digit * 10 > slot.max) {
|
|
133
|
-
updateSlot(slot, key);
|
|
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);
|
|
133
|
+
const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
|
|
134
|
+
const appended = prev + key;
|
|
135
|
+
const overflow = Number(appended) > slot.max;
|
|
136
|
+
const digits = overflow ? key : appended;
|
|
137
|
+
const value = Number(digits);
|
|
138
|
+
const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
|
|
139
|
+
const accepted = value >= slot.min;
|
|
140
|
+
if (accepted)
|
|
141
|
+
updateSlot(slot, value);
|
|
142
|
+
if (!complete) {
|
|
143
|
+
typedRef.current = { key: slot.key, digits };
|
|
144
|
+
return;
|
|
143
145
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
typedRef.current = null;
|
|
147
|
+
if (accepted)
|
|
146
148
|
checkInputAndGoNext(slot);
|
|
147
|
-
|
|
148
|
-
else if (digit || digit >= slot.min) {
|
|
149
|
-
updateSlot(slot, key);
|
|
150
|
-
}
|
|
151
|
-
}, [checkInputAndGoNext, getSlotText, updateSlot]);
|
|
149
|
+
}, [checkInputAndGoNext, updateSlot]);
|
|
152
150
|
const handleFocus = (0, react_1.useCallback)(() => {
|
|
153
151
|
if (readonly || disabled)
|
|
154
152
|
return;
|
|
153
|
+
resetTyped();
|
|
155
154
|
ensureMask();
|
|
156
155
|
// На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
|
|
157
156
|
// отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
|
|
158
157
|
selectSlot(firstSlot);
|
|
159
|
-
}, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
|
|
158
|
+
}, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
|
|
160
159
|
const handleClick = (0, react_1.useCallback)((event) => {
|
|
161
160
|
if (readonly || disabled)
|
|
162
161
|
return;
|
|
162
|
+
resetTyped();
|
|
163
163
|
ensureMask();
|
|
164
164
|
selectSlot(slotFromIndex(event.currentTarget.selectionStart));
|
|
165
|
-
}, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
|
|
165
|
+
}, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
|
|
166
166
|
const handleBlur = (0, react_1.useCallback)(() => {
|
|
167
|
+
resetTyped();
|
|
167
168
|
const input = inputRef.current;
|
|
168
169
|
if (input && input.value === mask) {
|
|
169
170
|
input.value = '';
|
|
170
171
|
setValue('');
|
|
171
172
|
}
|
|
172
|
-
}, [inputRef, mask, setValue]);
|
|
173
|
+
}, [inputRef, mask, resetTyped, setValue]);
|
|
173
174
|
const handleKeyDown = (0, react_1.useCallback)((event) => {
|
|
174
175
|
const input = inputRef.current;
|
|
175
176
|
if (!input || readonly || disabled)
|
|
176
177
|
return;
|
|
178
|
+
if (RESET_TYPED_KEYS.has(event.key))
|
|
179
|
+
resetTyped();
|
|
177
180
|
// Tab уводит фокус из поля — не перехватываем.
|
|
178
181
|
if (event.key === 'Tab')
|
|
179
182
|
return;
|
|
@@ -188,7 +191,13 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
188
191
|
onArrowUp?.();
|
|
189
192
|
return;
|
|
190
193
|
}
|
|
194
|
+
// Копирование и вставку не блокируем: правку поля перехватит beforeinput.
|
|
195
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
196
|
+
return;
|
|
191
197
|
onEdit?.();
|
|
198
|
+
// Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
|
|
199
|
+
if (/^\d$/.test(event.key) || event.key === 'Unidentified')
|
|
200
|
+
return;
|
|
192
201
|
if (event.key === 'Escape') {
|
|
193
202
|
event.preventDefault();
|
|
194
203
|
onEscape?.();
|
|
@@ -223,11 +232,6 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
223
232
|
if (event.key === 'Enter') {
|
|
224
233
|
tryToComplete();
|
|
225
234
|
emit();
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (/^\d$/.test(event.key)) {
|
|
229
|
-
typeDigit(slot, event.key);
|
|
230
|
-
emit();
|
|
231
235
|
}
|
|
232
236
|
}, [
|
|
233
237
|
clearSlot,
|
|
@@ -243,21 +247,13 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
243
247
|
onEscape,
|
|
244
248
|
prevSlot,
|
|
245
249
|
readonly,
|
|
250
|
+
resetTyped,
|
|
246
251
|
selectSlot,
|
|
247
252
|
slotFromIndex,
|
|
248
253
|
tryToComplete,
|
|
249
|
-
typeDigit,
|
|
250
254
|
]);
|
|
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;
|
|
255
|
+
const fillFromDigits = (0, react_1.useCallback)((digits) => {
|
|
256
|
+
resetTyped();
|
|
261
257
|
ensureMask();
|
|
262
258
|
let cursor = 0;
|
|
263
259
|
let lastFilled;
|
|
@@ -276,7 +272,100 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
276
272
|
else if (lastFilled) {
|
|
277
273
|
selectSlot(nextSlot(lastFilled));
|
|
278
274
|
}
|
|
275
|
+
}, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
|
|
276
|
+
const handlePaste = (0, react_1.useCallback)((event) => {
|
|
277
|
+
if (readonly || disabled)
|
|
278
|
+
return;
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
281
|
+
if (!digits)
|
|
282
|
+
return;
|
|
283
|
+
fillFromDigits(digits);
|
|
284
|
+
emit();
|
|
285
|
+
}, [disabled, emit, fillFromDigits, readonly]);
|
|
286
|
+
const applyEdit = (0, react_1.useCallback)((inputType, text) => {
|
|
287
|
+
const input = inputRef.current;
|
|
288
|
+
if (!input)
|
|
289
|
+
return;
|
|
290
|
+
onEdit?.();
|
|
291
|
+
if (inputType.startsWith('delete')) {
|
|
292
|
+
resetTyped();
|
|
293
|
+
clearSlot(slotFromIndex(input.selectionStart));
|
|
294
|
+
emit();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const digits = text.replace(/\D/g, '');
|
|
298
|
+
if (!inputType.startsWith('insert') || !digits)
|
|
299
|
+
return;
|
|
300
|
+
// Автозаполнение, `fill` и drop присылают строку целиком.
|
|
301
|
+
if (text.length > 1) {
|
|
302
|
+
fillFromDigits(digits);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
typeDigit(slotFromIndex(input.selectionStart), digits);
|
|
306
|
+
}
|
|
307
|
+
emit();
|
|
308
|
+
}, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
|
|
309
|
+
// Композицию IME браузер не даёт отменить — откатываем её в input.
|
|
310
|
+
const pendingEditRef = (0, react_1.useRef)(null);
|
|
311
|
+
const handleBeforeInput = (0, react_1.useCallback)((event) => {
|
|
312
|
+
const input = inputRef.current;
|
|
313
|
+
if (!input || readonly || disabled)
|
|
314
|
+
return;
|
|
315
|
+
ensureMask();
|
|
316
|
+
if (!event.cancelable) {
|
|
317
|
+
pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
|
|
322
|
+
}, [applyEdit, disabled, ensureMask, inputRef, readonly]);
|
|
323
|
+
const handleInput = (0, react_1.useCallback)((event) => {
|
|
324
|
+
const input = inputRef.current;
|
|
325
|
+
if (!input || readonly || disabled)
|
|
326
|
+
return;
|
|
327
|
+
const pending = pendingEditRef.current;
|
|
328
|
+
pendingEditRef.current = null;
|
|
329
|
+
if (pending) {
|
|
330
|
+
const inputType = event instanceof InputEvent ? event.inputType : '';
|
|
331
|
+
const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
|
|
332
|
+
input.value = pending.value;
|
|
333
|
+
setValue(pending.value);
|
|
334
|
+
input.setSelectionRange(pending.start, pending.end);
|
|
335
|
+
// Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
|
|
336
|
+
if (!inputType.includes('Composition'))
|
|
337
|
+
applyEdit(inputType, inserted);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Автозаполнение меняет значение без beforeinput.
|
|
341
|
+
const digits = input.value.replace(/\D/g, '');
|
|
342
|
+
input.value = mask;
|
|
343
|
+
setValue(mask);
|
|
344
|
+
fillFromDigits(digits);
|
|
279
345
|
emit();
|
|
280
|
-
}, [disabled, emit,
|
|
281
|
-
|
|
346
|
+
}, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
|
|
347
|
+
// React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
|
|
348
|
+
const onNativeBeforeInput = (0, utils_1.useEventHandler)(handleBeforeInput);
|
|
349
|
+
const onNativeInput = (0, utils_1.useEventHandler)(handleInput);
|
|
350
|
+
const attachedInputRef = (0, react_1.useRef)(null);
|
|
351
|
+
const nativeInputRef = (0, react_1.useCallback)((node) => {
|
|
352
|
+
/* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
|
|
353
|
+
attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
|
|
354
|
+
attachedInputRef.current?.removeEventListener('input', onNativeInput);
|
|
355
|
+
attachedInputRef.current = node;
|
|
356
|
+
node?.addEventListener('beforeinput', onNativeBeforeInput);
|
|
357
|
+
node?.addEventListener('input', onNativeInput);
|
|
358
|
+
/* eslint-enable @cloud-ru/ssr-safe-react/domApi */
|
|
359
|
+
}, [onNativeBeforeInput, onNativeInput]);
|
|
360
|
+
return {
|
|
361
|
+
handleKeyDown,
|
|
362
|
+
handleClick,
|
|
363
|
+
handleFocus,
|
|
364
|
+
handleBlur,
|
|
365
|
+
handlePaste,
|
|
366
|
+
nativeInputRef,
|
|
367
|
+
selectSlot,
|
|
368
|
+
firstSlot,
|
|
369
|
+
lastSlot,
|
|
370
|
+
};
|
|
282
371
|
}
|
|
@@ -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,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;
|