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