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