@cloud-ru/ds-fields 2.2.3 → 2.2.5

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",
3
+ "version": "2.2.5",
4
4
  "description": "Поля ввода с единой обёрткой label/caption/hint/error и общими осями size/validationState — текст, пароль, число, ползунок, дата, время, выбор, цвет.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,23 +40,23 @@
40
40
  "merge-refs": "^2.0.0",
41
41
  "react-textarea-autosize": "^8.5.9",
42
42
  "@cloud-ru/ds-adaptive": "^1.0.1",
43
+ "@cloud-ru/ds-calendar": "^1.0.17",
44
+ "@cloud-ru/ds-bottom-sheet": "^1.1.2",
43
45
  "@cloud-ru/ds-button": "^1.0.2",
44
- "@cloud-ru/ds-bottom-sheet": "^1.1.1",
45
- "@cloud-ru/ds-color-picker": "^1.0.6",
46
+ "@cloud-ru/ds-color-picker": "^1.0.7",
46
47
  "@cloud-ru/ds-divider": "^1.0.1",
47
- "@cloud-ru/ds-calendar": "^1.0.15",
48
- "@cloud-ru/ds-dropdown": "^1.0.8",
49
- "@cloud-ru/ds-field-decorator": "^1.0.5",
50
- "@cloud-ru/ds-icons": "^1.1.0",
51
- "@cloud-ru/ds-input-private": "^1.0.4",
52
- "@cloud-ru/ds-list": "^2.1.3",
53
- "@cloud-ru/ds-materials": "^1.0.1",
48
+ "@cloud-ru/ds-dropdown": "^1.0.9",
49
+ "@cloud-ru/ds-input-private": "^1.0.5",
50
+ "@cloud-ru/ds-field-decorator": "^1.0.6",
51
+ "@cloud-ru/ds-list": "^2.1.5",
54
52
  "@cloud-ru/ds-loader": "^1.0.1",
53
+ "@cloud-ru/ds-materials": "^1.0.1",
54
+ "@cloud-ru/ds-icons": "^1.2.0",
55
55
  "@cloud-ru/ds-scroll": "^1.0.2",
56
56
  "@cloud-ru/ds-skeleton": "^1.0.1",
57
- "@cloud-ru/ds-slider": "^1.0.4",
58
- "@cloud-ru/ds-tag": "^1.1.1",
59
- "@cloud-ru/ds-utils": "^1.1.0",
60
- "@cloud-ru/ds-tooltip": "^1.0.4"
57
+ "@cloud-ru/ds-slider": "^1.0.5",
58
+ "@cloud-ru/ds-tag": "^1.1.2",
59
+ "@cloud-ru/ds-tooltip": "^1.0.5",
60
+ "@cloud-ru/ds-utils": "^1.1.0"
61
61
  }
62
62
  }
@@ -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,7 @@ type UseSegmentedMaskParams = {
32
45
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
33
46
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
34
47
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
48
+ * `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
35
49
  */
36
50
  export function useSegmentedMask({
37
51
  inputRef,
@@ -174,74 +188,71 @@ export function useSegmentedMask({
174
188
  [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete],
175
189
  );
176
190
 
191
+ // Цифры сегмента храним отдельно от текста: по `01` в поле не отличить `1` от `0` + `1`.
192
+ const typedRef = useRef<{ key: SegmentKey; digits: string } | null>(null);
193
+ const resetTyped = useCallback(() => {
194
+ typedRef.current = null;
195
+ }, []);
196
+
177
197
  const typeDigit = useCallback(
178
198
  (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
199
  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);
200
+ const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
201
+ const appended = prev + key;
202
+ const overflow = Number(appended) > slot.max;
203
+ const digits = overflow ? key : appended;
204
+ const value = Number(digits);
205
+ const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
206
+ const accepted = value >= slot.min;
207
+
208
+ if (accepted) updateSlot(slot, value);
209
+
210
+ if (!complete) {
211
+ typedRef.current = { key: slot.key, digits };
212
+ return;
210
213
  }
214
+
215
+ typedRef.current = null;
216
+ if (accepted) checkInputAndGoNext(slot);
211
217
  },
212
- [checkInputAndGoNext, getSlotText, updateSlot],
218
+ [checkInputAndGoNext, updateSlot],
213
219
  );
214
220
 
215
221
  const handleFocus = useCallback(() => {
216
222
  if (readonly || disabled) return;
223
+ resetTyped();
217
224
  ensureMask();
218
225
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
219
226
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
220
227
  selectSlot(firstSlot);
221
- }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
228
+ }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
222
229
 
223
230
  const handleClick = useCallback(
224
231
  (event: MouseEvent<HTMLInputElement>) => {
225
232
  if (readonly || disabled) return;
233
+ resetTyped();
226
234
  ensureMask();
227
235
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
228
236
  },
229
- [disabled, ensureMask, readonly, selectSlot, slotFromIndex],
237
+ [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex],
230
238
  );
231
239
 
232
240
  const handleBlur = useCallback(() => {
241
+ resetTyped();
233
242
  const input = inputRef.current;
234
243
  if (input && input.value === mask) {
235
244
  input.value = '';
236
245
  setValue('');
237
246
  }
238
- }, [inputRef, mask, setValue]);
247
+ }, [inputRef, mask, resetTyped, setValue]);
239
248
 
240
249
  const handleKeyDown = useCallback(
241
250
  (event: KeyboardEvent<HTMLInputElement>) => {
242
251
  const input = inputRef.current;
243
252
  if (!input || readonly || disabled) return;
244
253
 
254
+ if (RESET_TYPED_KEYS.has(event.key)) resetTyped();
255
+
245
256
  // Tab уводит фокус из поля — не перехватываем.
246
257
  if (event.key === 'Tab') return;
247
258
 
@@ -258,8 +269,14 @@ export function useSegmentedMask({
258
269
  return;
259
270
  }
260
271
 
272
+ // Копирование и вставку не блокируем: правку поля перехватит beforeinput.
273
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
274
+
261
275
  onEdit?.();
262
276
 
277
+ // Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
278
+ if (/^\d$/.test(event.key) || event.key === 'Unidentified') return;
279
+
263
280
  if (event.key === 'Escape') {
264
281
  event.preventDefault();
265
282
  onEscape?.();
@@ -297,11 +314,6 @@ export function useSegmentedMask({
297
314
  if (event.key === 'Enter') {
298
315
  tryToComplete();
299
316
  emit();
300
- return;
301
- }
302
- if (/^\d$/.test(event.key)) {
303
- typeDigit(slot, event.key);
304
- emit();
305
317
  }
306
318
  },
307
319
  [
@@ -318,22 +330,16 @@ export function useSegmentedMask({
318
330
  onEscape,
319
331
  prevSlot,
320
332
  readonly,
333
+ resetTyped,
321
334
  selectSlot,
322
335
  slotFromIndex,
323
336
  tryToComplete,
324
- typeDigit,
325
337
  ],
326
338
  );
327
339
 
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;
340
+ const fillFromDigits = useCallback(
341
+ (digits: string) => {
342
+ resetTyped();
337
343
  ensureMask();
338
344
 
339
345
  let cursor = 0;
@@ -352,10 +358,123 @@ export function useSegmentedMask({
352
358
  } else if (lastFilled) {
353
359
  selectSlot(nextSlot(lastFilled));
354
360
  }
361
+ },
362
+ [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot],
363
+ );
364
+
365
+ const handlePaste = useCallback(
366
+ (event: ClipboardEvent<HTMLInputElement>) => {
367
+ if (readonly || disabled) return;
368
+ event.preventDefault();
369
+ const digits = event.clipboardData.getData('text').replace(/\D/g, '');
370
+ if (!digits) return;
371
+ fillFromDigits(digits);
372
+ emit();
373
+ },
374
+ [disabled, emit, fillFromDigits, readonly],
375
+ );
376
+
377
+ const applyEdit = useCallback(
378
+ (inputType: string, text: string) => {
379
+ const input = inputRef.current;
380
+ if (!input) return;
381
+ onEdit?.();
382
+
383
+ if (inputType.startsWith('delete')) {
384
+ resetTyped();
385
+ clearSlot(slotFromIndex(input.selectionStart));
386
+ emit();
387
+ return;
388
+ }
389
+
390
+ const digits = text.replace(/\D/g, '');
391
+ if (!inputType.startsWith('insert') || !digits) return;
392
+
393
+ // Автозаполнение, `fill` и drop присылают строку целиком.
394
+ if (text.length > 1) {
395
+ fillFromDigits(digits);
396
+ } else {
397
+ typeDigit(slotFromIndex(input.selectionStart), digits);
398
+ }
355
399
  emit();
356
400
  },
357
- [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot],
401
+ [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit],
402
+ );
403
+
404
+ // Композицию IME браузер не даёт отменить — откатываем её в input.
405
+ const pendingEditRef = useRef<{ value: string; start: number; end: number } | null>(null);
406
+
407
+ const handleBeforeInput = useCallback(
408
+ (event: InputEvent) => {
409
+ const input = inputRef.current;
410
+ if (!input || readonly || disabled) return;
411
+ ensureMask();
412
+
413
+ if (!event.cancelable) {
414
+ pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
415
+ return;
416
+ }
417
+
418
+ event.preventDefault();
419
+ applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
420
+ },
421
+ [applyEdit, disabled, ensureMask, inputRef, readonly],
422
+ );
423
+
424
+ const handleInput = useCallback(
425
+ (event: Event) => {
426
+ const input = inputRef.current;
427
+ if (!input || readonly || disabled) return;
428
+ const pending = pendingEditRef.current;
429
+ pendingEditRef.current = null;
430
+
431
+ if (pending) {
432
+ const inputType = event instanceof InputEvent ? event.inputType : '';
433
+ const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
434
+ input.value = pending.value;
435
+ setValue(pending.value);
436
+ input.setSelectionRange(pending.start, pending.end);
437
+ // Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
438
+ if (!inputType.includes('Composition')) applyEdit(inputType, inserted);
439
+ return;
440
+ }
441
+
442
+ // Автозаполнение меняет значение без beforeinput.
443
+ const digits = input.value.replace(/\D/g, '');
444
+ input.value = mask;
445
+ setValue(mask);
446
+ fillFromDigits(digits);
447
+ emit();
448
+ },
449
+ [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue],
450
+ );
451
+
452
+ // React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
453
+ const onNativeBeforeInput = useEventHandler(handleBeforeInput);
454
+ const onNativeInput = useEventHandler(handleInput);
455
+ const attachedInputRef = useRef<HTMLInputElement | null>(null);
456
+ const nativeInputRef = useCallback(
457
+ (node: HTMLInputElement | null) => {
458
+ /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
459
+ attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
460
+ attachedInputRef.current?.removeEventListener('input', onNativeInput);
461
+ attachedInputRef.current = node;
462
+ node?.addEventListener('beforeinput', onNativeBeforeInput);
463
+ node?.addEventListener('input', onNativeInput);
464
+ /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
465
+ },
466
+ [onNativeBeforeInput, onNativeInput],
358
467
  );
359
468
 
360
- return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
469
+ return {
470
+ handleKeyDown,
471
+ handleClick,
472
+ handleFocus,
473
+ handleBlur,
474
+ handlePaste,
475
+ nativeInputRef,
476
+ selectSlot,
477
+ firstSlot,
478
+ lastSlot,
479
+ };
361
480
  }