@cloud-ru/ds-fields 2.2.3-preview-a42433ce.0 → 2.2.3-preview-f436e22b.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 +2 -3
- package/dist/cjs/components/FieldDate/mask.js +3 -4
- package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
- package/dist/cjs/segments/useSegmentedMask.d.ts +0 -2
- package/dist/cjs/segments/useSegmentedMask.js +56 -146
- package/dist/esm/components/FieldDate/FieldDate.js +3 -3
- package/dist/esm/components/FieldDate/mask.d.ts +2 -3
- package/dist/esm/components/FieldDate/mask.js +3 -4
- package/dist/esm/components/FieldTime/FieldTime.js +2 -2
- package/dist/esm/segments/useSegmentedMask.d.ts +0 -2
- package/dist/esm/segments/useSegmentedMask.js +56 -146
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/components/FieldDate/FieldDate.tsx +3 -3
- package/src/components/FieldDate/mask.ts +3 -4
- package/src/components/FieldTime/FieldTime.tsx +2 -2
- package/src/segments/useSegmentedMask.ts +52 -172
|
@@ -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
|
|
331
|
-
// Single/date-time ведёт сегментный движок (useSegmentedMask), нативный
|
|
332
|
-
// игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
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 ведёт сегментный движок (keydown, useSegmentedMask), нативный
|
|
332
|
+
// onChange игнорируется. 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,9 +10,8 @@ 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
|
-
*
|
|
15
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
13
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
14
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
16
15
|
*/
|
|
17
16
|
export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
|
|
18
17
|
/** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */
|
|
@@ -48,9 +48,8 @@ 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
|
-
*
|
|
53
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
51
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
52
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
54
53
|
*/
|
|
55
54
|
function formatMask(input, mode, showSeconds = true) {
|
|
56
55
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -76,7 +75,7 @@ function formatMask(input, mode, showSeconds = true) {
|
|
|
76
75
|
stream = stream.slice(seg.len);
|
|
77
76
|
if (raw.length === seg.len) {
|
|
78
77
|
const n = Number(raw);
|
|
79
|
-
out += n > seg.max ?
|
|
78
|
+
out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
|
|
80
79
|
}
|
|
81
80
|
else {
|
|
82
81
|
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 ведёт сегментный движок (keydown); нативный 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
|
|
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, {}) })] }) })] }));
|
|
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,7 +29,6 @@ type UseSegmentedMaskParams = {
|
|
|
29
29
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
30
30
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
31
31
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
32
|
-
* `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
|
|
33
32
|
*/
|
|
34
33
|
export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }: UseSegmentedMaskParams): {
|
|
35
34
|
handleKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
@@ -37,7 +36,6 @@ export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disa
|
|
|
37
36
|
handleFocus: () => void;
|
|
38
37
|
handleBlur: () => void;
|
|
39
38
|
handlePaste: (event: ClipboardEvent<HTMLInputElement>) => void;
|
|
40
|
-
nativeInputRef: (node: HTMLInputElement | null) => void;
|
|
41
39
|
selectSlot: (slot: SlotMeta) => void;
|
|
42
40
|
firstSlot: SlotMeta;
|
|
43
41
|
lastSlot: SlotMeta;
|
|
@@ -1,24 +1,11 @@
|
|
|
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");
|
|
5
4
|
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
|
-
]);
|
|
17
5
|
/**
|
|
18
6
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
19
7
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
20
8
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
21
|
-
* `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
|
|
22
9
|
*/
|
|
23
10
|
function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
|
|
24
11
|
const firstSlot = slots[0];
|
|
@@ -123,61 +110,70 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
123
110
|
}
|
|
124
111
|
selectSlot(nextSlot(slot));
|
|
125
112
|
}, [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
|
-
}, []);
|
|
131
113
|
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;
|
|
132
119
|
const maxLength = slot.max.toString().length;
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
120
|
+
const isTheLastInput = /^0+$/.test(text) && maxLength === 2 && digit === 0;
|
|
121
|
+
if (valueLength < maxLength) {
|
|
122
|
+
if (slotValue || slotValue >= slot.min) {
|
|
123
|
+
updateSlot(slot, slotValue);
|
|
124
|
+
if (isTheLastInput)
|
|
125
|
+
checkInputAndGoNext(slot);
|
|
126
|
+
}
|
|
127
|
+
if (slotValue * 10 > slot.max) {
|
|
128
|
+
checkInputAndGoNext(slot);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (valueLength > maxLength) {
|
|
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);
|
|
146
143
|
}
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
else if (digit * 10 > slot.max) {
|
|
145
|
+
updateSlot(slot, key);
|
|
149
146
|
checkInputAndGoNext(slot);
|
|
150
|
-
|
|
147
|
+
}
|
|
148
|
+
else if (digit || digit >= slot.min) {
|
|
149
|
+
updateSlot(slot, key);
|
|
150
|
+
}
|
|
151
|
+
}, [checkInputAndGoNext, getSlotText, updateSlot]);
|
|
151
152
|
const handleFocus = (0, react_1.useCallback)(() => {
|
|
152
153
|
if (readonly || disabled)
|
|
153
154
|
return;
|
|
154
|
-
resetTyped();
|
|
155
155
|
ensureMask();
|
|
156
156
|
// На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
|
|
157
157
|
// отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
|
|
158
158
|
selectSlot(firstSlot);
|
|
159
|
-
}, [disabled, ensureMask, firstSlot, readonly,
|
|
159
|
+
}, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
|
|
160
160
|
const handleClick = (0, react_1.useCallback)((event) => {
|
|
161
161
|
if (readonly || disabled)
|
|
162
162
|
return;
|
|
163
|
-
resetTyped();
|
|
164
163
|
ensureMask();
|
|
165
164
|
selectSlot(slotFromIndex(event.currentTarget.selectionStart));
|
|
166
|
-
}, [disabled, ensureMask, readonly,
|
|
165
|
+
}, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
|
|
167
166
|
const handleBlur = (0, react_1.useCallback)(() => {
|
|
168
|
-
resetTyped();
|
|
169
167
|
const input = inputRef.current;
|
|
170
168
|
if (input && input.value === mask) {
|
|
171
169
|
input.value = '';
|
|
172
170
|
setValue('');
|
|
173
171
|
}
|
|
174
|
-
}, [inputRef, mask,
|
|
172
|
+
}, [inputRef, mask, setValue]);
|
|
175
173
|
const handleKeyDown = (0, react_1.useCallback)((event) => {
|
|
176
174
|
const input = inputRef.current;
|
|
177
175
|
if (!input || readonly || disabled)
|
|
178
176
|
return;
|
|
179
|
-
if (RESET_TYPED_KEYS.has(event.key))
|
|
180
|
-
resetTyped();
|
|
181
177
|
// Tab уводит фокус из поля — не перехватываем.
|
|
182
178
|
if (event.key === 'Tab')
|
|
183
179
|
return;
|
|
@@ -192,13 +188,7 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
192
188
|
onArrowUp?.();
|
|
193
189
|
return;
|
|
194
190
|
}
|
|
195
|
-
// Копирование и вставку не блокируем: правку поля перехватит beforeinput.
|
|
196
|
-
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
197
|
-
return;
|
|
198
191
|
onEdit?.();
|
|
199
|
-
// Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
|
|
200
|
-
if (/^\d$/.test(event.key) || event.key === 'Unidentified')
|
|
201
|
-
return;
|
|
202
192
|
if (event.key === 'Escape') {
|
|
203
193
|
event.preventDefault();
|
|
204
194
|
onEscape?.();
|
|
@@ -233,6 +223,11 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
233
223
|
if (event.key === 'Enter') {
|
|
234
224
|
tryToComplete();
|
|
235
225
|
emit();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (/^\d$/.test(event.key)) {
|
|
229
|
+
typeDigit(slot, event.key);
|
|
230
|
+
emit();
|
|
236
231
|
}
|
|
237
232
|
}, [
|
|
238
233
|
clearSlot,
|
|
@@ -248,13 +243,21 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
248
243
|
onEscape,
|
|
249
244
|
prevSlot,
|
|
250
245
|
readonly,
|
|
251
|
-
resetTyped,
|
|
252
246
|
selectSlot,
|
|
253
247
|
slotFromIndex,
|
|
254
248
|
tryToComplete,
|
|
249
|
+
typeDigit,
|
|
255
250
|
]);
|
|
256
|
-
|
|
257
|
-
|
|
251
|
+
// Вставка (Ctrl+V) полной строки даты/времени: вытаскиваем цифры и заполняем сегменты
|
|
252
|
+
// слева направо. Нативный onChange у поля игнорируется (движок controlled), поэтому без
|
|
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;
|
|
258
261
|
ensureMask();
|
|
259
262
|
let cursor = 0;
|
|
260
263
|
let lastFilled;
|
|
@@ -273,100 +276,7 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
|
|
|
273
276
|
else if (lastFilled) {
|
|
274
277
|
selectSlot(nextSlot(lastFilled));
|
|
275
278
|
}
|
|
276
|
-
}, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
|
|
277
|
-
const handlePaste = (0, react_1.useCallback)((event) => {
|
|
278
|
-
if (readonly || disabled)
|
|
279
|
-
return;
|
|
280
|
-
event.preventDefault();
|
|
281
|
-
const digits = event.clipboardData.getData('text').replace(/\D/g, '');
|
|
282
|
-
if (!digits)
|
|
283
|
-
return;
|
|
284
|
-
fillFromDigits(digits);
|
|
285
|
-
emit();
|
|
286
|
-
}, [disabled, emit, fillFromDigits, readonly]);
|
|
287
|
-
const applyEdit = (0, react_1.useCallback)((inputType, text) => {
|
|
288
|
-
const input = inputRef.current;
|
|
289
|
-
if (!input)
|
|
290
|
-
return;
|
|
291
|
-
onEdit?.();
|
|
292
|
-
if (inputType.startsWith('delete')) {
|
|
293
|
-
resetTyped();
|
|
294
|
-
clearSlot(slotFromIndex(input.selectionStart));
|
|
295
|
-
emit();
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
const digits = text.replace(/\D/g, '');
|
|
299
|
-
if (!inputType.startsWith('insert') || !digits)
|
|
300
|
-
return;
|
|
301
|
-
// Автозаполнение, `fill` и drop присылают строку целиком.
|
|
302
|
-
if (text.length > 1) {
|
|
303
|
-
fillFromDigits(digits);
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
typeDigit(slotFromIndex(input.selectionStart), digits);
|
|
307
|
-
}
|
|
308
|
-
emit();
|
|
309
|
-
}, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
|
|
310
|
-
// Композицию IME браузер не даёт отменить — откатываем её в input.
|
|
311
|
-
const pendingEditRef = (0, react_1.useRef)(null);
|
|
312
|
-
const handleBeforeInput = (0, react_1.useCallback)((event) => {
|
|
313
|
-
const input = inputRef.current;
|
|
314
|
-
if (!input || readonly || disabled)
|
|
315
|
-
return;
|
|
316
|
-
ensureMask();
|
|
317
|
-
if (!event.cancelable) {
|
|
318
|
-
pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
event.preventDefault();
|
|
322
|
-
applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
|
|
323
|
-
}, [applyEdit, disabled, ensureMask, inputRef, readonly]);
|
|
324
|
-
const handleInput = (0, react_1.useCallback)((event) => {
|
|
325
|
-
const input = inputRef.current;
|
|
326
|
-
if (!input || readonly || disabled)
|
|
327
|
-
return;
|
|
328
|
-
const pending = pendingEditRef.current;
|
|
329
|
-
pendingEditRef.current = null;
|
|
330
|
-
if (pending) {
|
|
331
|
-
const inputType = event instanceof InputEvent ? event.inputType : '';
|
|
332
|
-
const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
|
|
333
|
-
input.value = pending.value;
|
|
334
|
-
setValue(pending.value);
|
|
335
|
-
input.setSelectionRange(pending.start, pending.end);
|
|
336
|
-
// Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
|
|
337
|
-
if (!inputType.includes('Composition'))
|
|
338
|
-
applyEdit(inputType, inserted);
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// Автозаполнение меняет значение без beforeinput.
|
|
342
|
-
const digits = input.value.replace(/\D/g, '');
|
|
343
|
-
input.value = mask;
|
|
344
|
-
setValue(mask);
|
|
345
|
-
fillFromDigits(digits);
|
|
346
279
|
emit();
|
|
347
|
-
}, [
|
|
348
|
-
|
|
349
|
-
const onNativeBeforeInput = (0, utils_1.useEventHandler)(handleBeforeInput);
|
|
350
|
-
const onNativeInput = (0, utils_1.useEventHandler)(handleInput);
|
|
351
|
-
const attachedInputRef = (0, react_1.useRef)(null);
|
|
352
|
-
const nativeInputRef = (0, react_1.useCallback)((node) => {
|
|
353
|
-
/* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
|
|
354
|
-
attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
|
|
355
|
-
attachedInputRef.current?.removeEventListener('input', onNativeInput);
|
|
356
|
-
attachedInputRef.current = node;
|
|
357
|
-
node?.addEventListener('beforeinput', onNativeBeforeInput);
|
|
358
|
-
node?.addEventListener('input', onNativeInput);
|
|
359
|
-
/* eslint-enable @cloud-ru/ssr-safe-react/domApi */
|
|
360
|
-
}, [onNativeBeforeInput, onNativeInput]);
|
|
361
|
-
return {
|
|
362
|
-
handleKeyDown,
|
|
363
|
-
handleClick,
|
|
364
|
-
handleFocus,
|
|
365
|
-
handleBlur,
|
|
366
|
-
handlePaste,
|
|
367
|
-
nativeInputRef,
|
|
368
|
-
selectSlot,
|
|
369
|
-
firstSlot,
|
|
370
|
-
lastSlot,
|
|
371
|
-
};
|
|
280
|
+
}, [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot]);
|
|
281
|
+
return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
|
|
372
282
|
}
|
|
@@ -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
|
|
325
|
-
// Single/date-time ведёт сегментный движок (useSegmentedMask), нативный
|
|
326
|
-
// игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
|
|
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 ведёт сегментный движок (keydown, useSegmentedMask), нативный
|
|
326
|
+
// onChange игнорируется. 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,9 +10,8 @@ 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
|
-
*
|
|
15
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
13
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
14
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
16
15
|
*/
|
|
17
16
|
export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
|
|
18
17
|
/** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */
|
|
@@ -42,9 +42,8 @@ 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
|
-
*
|
|
47
|
-
* Пример: `39` для day → `09`, `13` для month → `03`.
|
|
45
|
+
* 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
|
|
46
|
+
* значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
|
|
48
47
|
*/
|
|
49
48
|
export function formatMask(input, mode, showSeconds = true) {
|
|
50
49
|
const maxLen = totalLen(mode, showSeconds);
|
|
@@ -70,7 +69,7 @@ export function formatMask(input, mode, showSeconds = true) {
|
|
|
70
69
|
stream = stream.slice(seg.len);
|
|
71
70
|
if (raw.length === seg.len) {
|
|
72
71
|
const n = Number(raw);
|
|
73
|
-
out += n > seg.max ?
|
|
72
|
+
out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
|
|
74
73
|
}
|
|
75
74
|
else {
|
|
76
75
|
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 ведёт сегментный движок (keydown); нативный 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
|
|
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, {}) })] }) })] }));
|
|
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,7 +29,6 @@ type UseSegmentedMaskParams = {
|
|
|
29
29
|
* Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
|
|
30
30
|
* сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
|
|
31
31
|
* стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
|
|
32
|
-
* `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
|
|
33
32
|
*/
|
|
34
33
|
export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }: UseSegmentedMaskParams): {
|
|
35
34
|
handleKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
@@ -37,7 +36,6 @@ export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disa
|
|
|
37
36
|
handleFocus: () => void;
|
|
38
37
|
handleBlur: () => void;
|
|
39
38
|
handlePaste: (event: ClipboardEvent<HTMLInputElement>) => void;
|
|
40
|
-
nativeInputRef: (node: HTMLInputElement | null) => void;
|
|
41
39
|
selectSlot: (slot: SlotMeta) => void;
|
|
42
40
|
firstSlot: SlotMeta;
|
|
43
41
|
lastSlot: SlotMeta;
|