@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-ru/ds-fields",
3
- "version": "2.2.3-preview-9414fc09.0",
3
+ "version": "2.2.3-preview-7425cb27.0",
4
4
  "description": "Поля ввода с единой обёрткой label/caption/hint/error и общими осями size/validationState — текст, пароль, число, ползунок, дата, время, выбор, цвет.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -42,13 +42,13 @@
42
42
  "@cloud-ru/ds-adaptive": "1.0.1",
43
43
  "@cloud-ru/ds-bottom-sheet": "1.1.1",
44
44
  "@cloud-ru/ds-button": "1.0.2",
45
- "@cloud-ru/ds-calendar": "1.0.15-preview-9414fc09.0",
45
+ "@cloud-ru/ds-calendar": "1.0.14",
46
46
  "@cloud-ru/ds-color-picker": "1.0.6",
47
47
  "@cloud-ru/ds-divider": "1.0.1",
48
- "@cloud-ru/ds-dropdown": "1.0.8",
49
48
  "@cloud-ru/ds-field-decorator": "1.0.5",
50
49
  "@cloud-ru/ds-icons": "1.1.0",
51
50
  "@cloud-ru/ds-list": "2.1.3",
51
+ "@cloud-ru/ds-dropdown": "1.0.8",
52
52
  "@cloud-ru/ds-input-private": "1.0.4",
53
53
  "@cloud-ru/ds-loader": "1.0.1",
54
54
  "@cloud-ru/ds-materials": "1.0.1",
@@ -520,12 +520,12 @@ export const FieldDate = forwardRef<HTMLInputElement, FieldDateProps>(function F
520
520
  </span>
521
521
  ) : (
522
522
  <InputPrivate
523
- ref={mergeRefs(ref, fromInputRef)}
523
+ ref={mergeRefs(ref, fromInputRef, singleSeg.nativeInputRef)}
524
524
  className={fieldStyles.fieldInput}
525
525
  value={singleInputValue}
526
526
  placeholder={placeholderMask}
527
- // Single/date-time ведёт сегментный движок (keydown, useSegmentedMask), нативный
528
- // onChange игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
527
+ // Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
528
+ // игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
529
529
  onChange={() => {}}
530
530
  onKeyDown={handleSingleKeyDown}
531
531
  onClick={singleSeg.handleClick}
@@ -55,8 +55,9 @@ function buildSegments(mode: MaskMode, showSeconds: boolean): Segment[] {
55
55
  * «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
56
56
  * как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
57
57
  * фокус автоматически уезжает на следующий сегмент.
58
- * 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
59
- * значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
58
+ * 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
59
+ * сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
60
+ * Пример: `39` для day → `09`, `13` для month → `03`.
60
61
  */
61
62
  export function formatMask(input: string, mode: MaskMode, showSeconds = true): string {
62
63
  const maxLen = totalLen(mode, showSeconds);
@@ -81,7 +82,7 @@ export function formatMask(input: string, mode: MaskMode, showSeconds = true): s
81
82
  stream = stream.slice(seg.len);
82
83
  if (raw.length === seg.len) {
83
84
  const n = Number(raw);
84
- out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
85
+ out += n > seg.max ? raw.slice(-1).padStart(seg.len, '0') : raw;
85
86
  } else {
86
87
  out += raw;
87
88
  }
@@ -267,7 +267,7 @@ export const FieldTime = forwardRef<HTMLInputElement, FieldTimeProps>(function F
267
267
  [onInputKeyDown, timeSeg],
268
268
  );
269
269
 
270
- // value ведёт сегментный движок (keydown); нативный onChange input'а игнорируем.
270
+ // value ведёт сегментный движок (useSegmentedMask); нативный onChange input'а игнорируем.
271
271
  const handleInputChange = useCallback(() => undefined, []);
272
272
 
273
273
  const handleInputFocus = useCallback(
@@ -323,7 +323,7 @@ export const FieldTime = forwardRef<HTMLInputElement, FieldTimeProps>(function F
323
323
  <div className={fieldStyles.inputLine}>
324
324
  <div className={fieldStyles.inputArea}>
325
325
  <InputPrivate
326
- ref={mergeRefs(ref, localRef)}
326
+ ref={mergeRefs(ref, localRef, timeSeg.nativeInputRef)}
327
327
  className={fieldStyles.fieldInput}
328
328
  value={inputValue}
329
329
  onChange={handleInputChange}
@@ -1,6 +1,19 @@
1
+ import { useEventHandler } from '@cloud-ru/ds-utils';
1
2
  import { ClipboardEvent, KeyboardEvent, MouseEvent, RefObject, useCallback, useRef } from 'react';
2
3
 
3
- import { SlotMeta } from './segments';
4
+ import { SegmentKey, SlotMeta } from './segments';
5
+
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
+ ]);
4
17
 
5
18
  type UseSegmentedMaskParams = {
6
19
  inputRef: RefObject<HTMLInputElement | null>;
@@ -32,6 +45,9 @@ type UseSegmentedMaskParams = {
32
45
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
33
46
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
34
47
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
48
+ *
49
+ * Навигация идёт через `handleKeyDown`, правка значения — через нативные `beforeinput`/`input`:
50
+ * `nativeInputRef` нужно передать в `ref` того же input'а.
35
51
  */
36
52
  export function useSegmentedMask({
37
53
  inputRef,
@@ -174,74 +190,75 @@ export function useSegmentedMask({
174
190
  [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete],
175
191
  );
176
192
 
193
+ // Цифры, набранные в текущий сегмент. Храним отдельно от текста: по `01` в поле нельзя понять,
194
+ // набрали `1` или `0` + `1`, а от этого зависит, ждать ли вторую цифру.
195
+ const typedRef = useRef<{ key: SegmentKey; digits: string } | null>(null);
196
+ const resetTyped = useCallback(() => {
197
+ typedRef.current = null;
198
+ }, []);
199
+
177
200
  const typeDigit = useCallback(
178
201
  (slot: SlotMeta, key: string) => {
179
- const text = getSlotText(slot);
180
- const numberValue = Number(text) || 0;
181
- const digit = Number(key);
182
- const slotValue = parseInt(numberValue.toString() + key, 10) || 0;
183
- const valueLength = slotValue.toString().length;
184
202
  const maxLength = slot.max.toString().length;
185
- const isTheLastInput = /^0+$/.test(text) && maxLength === 2 && digit === 0;
186
-
187
- if (valueLength < maxLength) {
188
- if (slotValue || slotValue >= slot.min) {
189
- updateSlot(slot, slotValue);
190
- if (isTheLastInput) checkInputAndGoNext(slot);
191
- }
192
- if (slotValue * 10 > slot.max) {
193
- checkInputAndGoNext(slot);
194
- }
195
- } else if (valueLength > maxLength) {
196
- if (digit * 10 > slot.max) {
197
- updateSlot(slot, key);
198
- checkInputAndGoNext(slot);
199
- } else if (digit || digit >= slot.min) {
200
- updateSlot(slot, key);
201
- }
202
- } else if (slotValue <= slot.max) {
203
- updateSlot(slot, slotValue);
204
- checkInputAndGoNext(slot);
205
- } else if (digit * 10 > slot.max) {
206
- updateSlot(slot, key);
207
- checkInputAndGoNext(slot);
208
- } else if (digit || digit >= slot.min) {
209
- updateSlot(slot, key);
203
+ const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
204
+ const appended = prev + key;
205
+ // Цифра выводит сегмент за max — сегмент начинается с неё заново (как React Aria и MUI X): `13` в месяце → `03`.
206
+ const overflow = Number(appended) > slot.max;
207
+ const digits = overflow ? key : appended;
208
+ const value = Number(digits);
209
+ const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
210
+ // Значение ниже min (`0` в дне) не показываем, но помним цифру до следующей.
211
+ const accepted = value >= slot.min;
212
+
213
+ if (accepted) updateSlot(slot, value);
214
+
215
+ if (!complete) {
216
+ typedRef.current = { key: slot.key, digits };
217
+ return;
210
218
  }
219
+
220
+ typedRef.current = null;
221
+ if (accepted) checkInputAndGoNext(slot);
211
222
  },
212
- [checkInputAndGoNext, getSlotText, updateSlot],
223
+ [checkInputAndGoNext, updateSlot],
213
224
  );
214
225
 
215
226
  const handleFocus = useCallback(() => {
216
227
  if (readonly || disabled) return;
228
+ resetTyped();
217
229
  ensureMask();
218
230
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
219
231
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
220
232
  selectSlot(firstSlot);
221
- }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
233
+ }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
222
234
 
223
235
  const handleClick = useCallback(
224
236
  (event: MouseEvent<HTMLInputElement>) => {
225
237
  if (readonly || disabled) return;
238
+ resetTyped();
226
239
  ensureMask();
227
240
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
228
241
  },
229
- [disabled, ensureMask, readonly, selectSlot, slotFromIndex],
242
+ [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex],
230
243
  );
231
244
 
232
245
  const handleBlur = useCallback(() => {
246
+ resetTyped();
233
247
  const input = inputRef.current;
234
248
  if (input && input.value === mask) {
235
249
  input.value = '';
236
250
  setValue('');
237
251
  }
238
- }, [inputRef, mask, setValue]);
252
+ }, [inputRef, mask, resetTyped, setValue]);
239
253
 
240
254
  const handleKeyDown = useCallback(
241
255
  (event: KeyboardEvent<HTMLInputElement>) => {
242
256
  const input = inputRef.current;
243
257
  if (!input || readonly || disabled) return;
244
258
 
259
+ // Навигация и правка начинают набор сегмента заново. Цифры, `Unidentified` (Android) и модификаторы — нет.
260
+ if (RESET_TYPED_KEYS.has(event.key)) resetTyped();
261
+
245
262
  // Tab уводит фокус из поля — не перехватываем.
246
263
  if (event.key === 'Tab') return;
247
264
 
@@ -258,8 +275,14 @@ export function useSegmentedMask({
258
275
  return;
259
276
  }
260
277
 
278
+ // Сочетания (копирование, вставка, выделение) отдаём браузеру: правку поля перехватит beforeinput.
279
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
280
+
261
281
  onEdit?.();
262
282
 
283
+ // Цифры и `Unidentified` (экранные клавиатуры Android, IME) вводятся через beforeinput.
284
+ if (/^\d$/.test(event.key) || event.key === 'Unidentified') return;
285
+
263
286
  if (event.key === 'Escape') {
264
287
  event.preventDefault();
265
288
  onEscape?.();
@@ -297,11 +320,6 @@ export function useSegmentedMask({
297
320
  if (event.key === 'Enter') {
298
321
  tryToComplete();
299
322
  emit();
300
- return;
301
- }
302
- if (/^\d$/.test(event.key)) {
303
- typeDigit(slot, event.key);
304
- emit();
305
323
  }
306
324
  },
307
325
  [
@@ -318,22 +336,17 @@ export function useSegmentedMask({
318
336
  onEscape,
319
337
  prevSlot,
320
338
  readonly,
339
+ resetTyped,
321
340
  selectSlot,
322
341
  slotFromIndex,
323
342
  tryToComplete,
324
- typeDigit,
325
343
  ],
326
344
  );
327
345
 
328
- // Вставка (Ctrl+V) полной строки даты/времени: вытаскиваем цифры и заполняем сегменты
329
- // слева направо. Нативный onChange у поля игнорируется (движок controlled), поэтому без
330
- // этого обработчика вставка не работала бы вовсе.
331
- const handlePaste = useCallback(
332
- (event: ClipboardEvent<HTMLInputElement>) => {
333
- if (readonly || disabled) return;
334
- event.preventDefault();
335
- const digits = event.clipboardData.getData('text').replace(/\D/g, '');
336
- if (!digits) return;
346
+ // Раскладывает цифры по сегментам слева направо полными кусками — для вставки и автозаполнения.
347
+ const fillFromDigits = useCallback(
348
+ (digits: string) => {
349
+ resetTyped();
337
350
  ensureMask();
338
351
 
339
352
  let cursor = 0;
@@ -352,10 +365,126 @@ export function useSegmentedMask({
352
365
  } else if (lastFilled) {
353
366
  selectSlot(nextSlot(lastFilled));
354
367
  }
368
+ },
369
+ [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot],
370
+ );
371
+
372
+ // Вставка (Ctrl+V) полной строки даты/времени. Нативный onChange у поля игнорируется (движок
373
+ // controlled), поэтому без этого обработчика вставка не работала бы вовсе.
374
+ const handlePaste = useCallback(
375
+ (event: ClipboardEvent<HTMLInputElement>) => {
376
+ if (readonly || disabled) return;
377
+ event.preventDefault();
378
+ const digits = event.clipboardData.getData('text').replace(/\D/g, '');
379
+ if (!digits) return;
380
+ fillFromDigits(digits);
381
+ emit();
382
+ },
383
+ [disabled, emit, fillFromDigits, readonly],
384
+ );
385
+
386
+ const applyEdit = useCallback(
387
+ (inputType: string, text: string) => {
388
+ const input = inputRef.current;
389
+ if (!input) return;
390
+ onEdit?.();
391
+
392
+ if (inputType.startsWith('delete')) {
393
+ resetTyped();
394
+ clearSlot(slotFromIndex(input.selectionStart));
395
+ emit();
396
+ return;
397
+ }
398
+
399
+ const digits = text.replace(/\D/g, '');
400
+ if (!inputType.startsWith('insert') || !digits) return;
401
+
402
+ // Строка целиком (автозаполнение, `fill`, drop) раскладывается с начала маски, одиночная цифра — в текущий сегмент.
403
+ if (text.length > 1) {
404
+ fillFromDigits(digits);
405
+ } else {
406
+ typeDigit(slotFromIndex(input.selectionStart), digits);
407
+ }
355
408
  emit();
356
409
  },
357
- [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot],
410
+ [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit],
411
+ );
412
+
413
+ // Значение и выделение до правки, которую браузер не дал отменить (композиция IME).
414
+ const pendingEditRef = useRef<{ value: string; start: number; end: number } | null>(null);
415
+
416
+ const handleBeforeInput = useCallback(
417
+ (event: InputEvent) => {
418
+ const input = inputRef.current;
419
+ if (!input || readonly || disabled) return;
420
+ ensureMask();
421
+
422
+ if (!event.cancelable) {
423
+ pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
424
+ return;
425
+ }
426
+
427
+ event.preventDefault();
428
+ applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
429
+ },
430
+ [applyEdit, disabled, ensureMask, inputRef, readonly],
431
+ );
432
+
433
+ const handleInput = useCallback(
434
+ (event: Event) => {
435
+ const input = inputRef.current;
436
+ if (!input || readonly || disabled) return;
437
+ const pending = pendingEditRef.current;
438
+ pendingEditRef.current = null;
439
+
440
+ if (pending) {
441
+ // Браузер уже изменил значение: вырезаем вставленное, возвращаем прежнее и применяем как обычный ввод.
442
+ const inputType = event instanceof InputEvent ? event.inputType : '';
443
+ const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
444
+ input.value = pending.value;
445
+ setValue(pending.value);
446
+ input.setSelectionRange(pending.start, pending.end);
447
+ // Цифру из композиции IME применит её фиксация (отменяемый insertText), как в React Aria.
448
+ if (!inputType.includes('Composition')) applyEdit(inputType, inserted);
449
+ return;
450
+ }
451
+
452
+ // Значение сменили без beforeinput (автозаполнение браузера).
453
+ const digits = input.value.replace(/\D/g, '');
454
+ input.value = mask;
455
+ setValue(mask);
456
+ fillFromDigits(digits);
457
+ emit();
458
+ },
459
+ [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue],
460
+ );
461
+
462
+ // React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
463
+ const onNativeBeforeInput = useEventHandler(handleBeforeInput);
464
+ const onNativeInput = useEventHandler(handleInput);
465
+ const attachedInputRef = useRef<HTMLInputElement | null>(null);
466
+ const nativeInputRef = useCallback(
467
+ (node: HTMLInputElement | null) => {
468
+ /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
469
+ attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
470
+ attachedInputRef.current?.removeEventListener('input', onNativeInput);
471
+ attachedInputRef.current = node;
472
+ node?.addEventListener('beforeinput', onNativeBeforeInput);
473
+ node?.addEventListener('input', onNativeInput);
474
+ /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
475
+ },
476
+ [onNativeBeforeInput, onNativeInput],
358
477
  );
359
478
 
360
- return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
479
+ return {
480
+ handleKeyDown,
481
+ handleClick,
482
+ handleFocus,
483
+ handleBlur,
484
+ handlePaste,
485
+ nativeInputRef,
486
+ selectSlot,
487
+ firstSlot,
488
+ lastSlot,
489
+ };
361
490
  }