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