@cloud-ru/ds-fields 2.2.3 → 2.2.4

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