@cloud-ru/ds-fields 2.2.3-preview-0c4119af.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.
Files changed (76) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
  3. package/dist/cjs/components/FieldDate/mask.d.ts +3 -2
  4. package/dist/cjs/components/FieldDate/mask.js +4 -3
  5. package/dist/cjs/components/FieldSelect/FieldSelect.js +41 -81
  6. package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
  7. package/dist/cjs/components/FieldSelect/constants.js +1 -20
  8. package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
  9. package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
  10. package/dist/cjs/components/FieldSelect/utils.js +103 -0
  11. package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
  12. package/dist/cjs/segments/useSegmentedMask.d.ts +4 -0
  13. package/dist/cjs/segments/useSegmentedMask.js +155 -56
  14. package/dist/esm/components/FieldDate/FieldDate.js +3 -3
  15. package/dist/esm/components/FieldDate/mask.d.ts +3 -2
  16. package/dist/esm/components/FieldDate/mask.js +4 -3
  17. package/dist/esm/components/FieldSelect/FieldSelect.js +42 -82
  18. package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
  19. package/dist/esm/components/FieldSelect/constants.js +0 -19
  20. package/dist/esm/components/FieldSelect/types.d.ts +3 -26
  21. package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
  22. package/dist/esm/components/FieldSelect/utils.js +92 -0
  23. package/dist/esm/components/FieldTime/FieldTime.js +2 -2
  24. package/dist/esm/segments/useSegmentedMask.d.ts +4 -0
  25. package/dist/esm/segments/useSegmentedMask.js +155 -56
  26. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  27. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  28. package/package.json +6 -6
  29. package/src/components/FieldDate/FieldDate.tsx +3 -3
  30. package/src/components/FieldDate/mask.ts +4 -3
  31. package/src/components/FieldSelect/FieldSelect.tsx +61 -129
  32. package/src/components/FieldSelect/constants.ts +0 -22
  33. package/src/components/FieldSelect/types.ts +48 -82
  34. package/src/components/FieldSelect/utils.ts +132 -0
  35. package/src/components/FieldTime/FieldTime.tsx +2 -2
  36. package/src/segments/useSegmentedMask.ts +181 -52
  37. package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -32
  38. package/dist/cjs/components/FieldSelect/hooks.js +0 -83
  39. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
  40. package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
  41. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
  42. package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
  43. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
  44. package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
  45. package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
  46. package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
  47. package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
  48. package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
  49. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
  50. package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
  51. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
  52. package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
  53. package/dist/esm/components/FieldSelect/hooks.d.ts +0 -32
  54. package/dist/esm/components/FieldSelect/hooks.js +0 -80
  55. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
  56. package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
  57. package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
  58. package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
  59. package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
  60. package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
  61. package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
  62. package/dist/esm/components/FieldSelect/utils/index.js +0 -6
  63. package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
  64. package/dist/esm/components/FieldSelect/utils/items.js +0 -41
  65. package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
  66. package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
  67. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
  68. package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
  69. package/src/components/FieldSelect/hooks.ts +0 -162
  70. package/src/components/FieldSelect/utils/customOption.ts +0 -77
  71. package/src/components/FieldSelect/utils/extract.ts +0 -49
  72. package/src/components/FieldSelect/utils/filter.ts +0 -51
  73. package/src/components/FieldSelect/utils/index.ts +0 -6
  74. package/src/components/FieldSelect/utils/items.ts +0 -58
  75. package/src/components/FieldSelect/utils/selection.ts +0 -25
  76. package/src/components/FieldSelect/utils/tagSize.ts +0 -5
@@ -1,11 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useSegmentedMask = useSegmentedMask;
4
+ const utils_1 = require("@cloud-ru/ds-utils");
4
5
  const react_1 = require("react");
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
+ ]);
5
17
  /**
6
18
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
7
19
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
8
20
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
21
+ *
22
+ * Навигация идёт через `handleKeyDown`, правка значения — через нативные `beforeinput`/`input`:
23
+ * `nativeInputRef` нужно передать в `ref` того же input'а.
9
24
  */
10
25
  function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
11
26
  const firstSlot = slots[0];
@@ -110,70 +125,64 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
110
125
  }
111
126
  selectSlot(nextSlot(slot));
112
127
  }, [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete]);
128
+ // Цифры, набранные в текущий сегмент. Храним отдельно от текста: по `01` в поле нельзя понять,
129
+ // набрали `1` или `0` + `1`, а от этого зависит, ждать ли вторую цифру.
130
+ const typedRef = (0, react_1.useRef)(null);
131
+ const resetTyped = (0, react_1.useCallback)(() => {
132
+ typedRef.current = null;
133
+ }, []);
113
134
  const typeDigit = (0, react_1.useCallback)((slot, key) => {
114
- const text = getSlotText(slot);
115
- const numberValue = Number(text) || 0;
116
- const digit = Number(key);
117
- const slotValue = parseInt(numberValue.toString() + key, 10) || 0;
118
- const valueLength = slotValue.toString().length;
119
135
  const maxLength = slot.max.toString().length;
120
- const isTheLastInput = /^0+$/.test(text) && maxLength === 2 && digit === 0;
121
- if (valueLength < maxLength) {
122
- if (slotValue || slotValue >= slot.min) {
123
- updateSlot(slot, slotValue);
124
- if (isTheLastInput)
125
- checkInputAndGoNext(slot);
126
- }
127
- if (slotValue * 10 > slot.max) {
128
- checkInputAndGoNext(slot);
129
- }
130
- }
131
- else if (valueLength > maxLength) {
132
- if (digit * 10 > slot.max) {
133
- updateSlot(slot, key);
134
- checkInputAndGoNext(slot);
135
- }
136
- else if (digit || digit >= slot.min) {
137
- updateSlot(slot, key);
138
- }
139
- }
140
- else if (slotValue <= slot.max) {
141
- updateSlot(slot, slotValue);
142
- checkInputAndGoNext(slot);
136
+ const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
137
+ const appended = prev + key;
138
+ // Цифра выводит сегмент за max — сегмент начинается с неё заново (как React Aria и MUI X): `13` в месяце → `03`.
139
+ const overflow = Number(appended) > slot.max;
140
+ const digits = overflow ? key : appended;
141
+ const value = Number(digits);
142
+ const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
143
+ // Значение ниже min (`0` в дне) не показываем, но помним цифру до следующей.
144
+ const accepted = value >= slot.min;
145
+ if (accepted)
146
+ updateSlot(slot, value);
147
+ if (!complete) {
148
+ typedRef.current = { key: slot.key, digits };
149
+ return;
143
150
  }
144
- else if (digit * 10 > slot.max) {
145
- updateSlot(slot, key);
151
+ typedRef.current = null;
152
+ if (accepted)
146
153
  checkInputAndGoNext(slot);
147
- }
148
- else if (digit || digit >= slot.min) {
149
- updateSlot(slot, key);
150
- }
151
- }, [checkInputAndGoNext, getSlotText, updateSlot]);
154
+ }, [checkInputAndGoNext, updateSlot]);
152
155
  const handleFocus = (0, react_1.useCallback)(() => {
153
156
  if (readonly || disabled)
154
157
  return;
158
+ resetTyped();
155
159
  ensureMask();
156
160
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
157
161
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
158
162
  selectSlot(firstSlot);
159
- }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
163
+ }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
160
164
  const handleClick = (0, react_1.useCallback)((event) => {
161
165
  if (readonly || disabled)
162
166
  return;
167
+ resetTyped();
163
168
  ensureMask();
164
169
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
165
- }, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
170
+ }, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
166
171
  const handleBlur = (0, react_1.useCallback)(() => {
172
+ resetTyped();
167
173
  const input = inputRef.current;
168
174
  if (input && input.value === mask) {
169
175
  input.value = '';
170
176
  setValue('');
171
177
  }
172
- }, [inputRef, mask, setValue]);
178
+ }, [inputRef, mask, resetTyped, setValue]);
173
179
  const handleKeyDown = (0, react_1.useCallback)((event) => {
174
180
  const input = inputRef.current;
175
181
  if (!input || readonly || disabled)
176
182
  return;
183
+ // Навигация и правка начинают набор сегмента заново. Цифры, `Unidentified` (Android) и модификаторы — нет.
184
+ if (RESET_TYPED_KEYS.has(event.key))
185
+ resetTyped();
177
186
  // Tab уводит фокус из поля — не перехватываем.
178
187
  if (event.key === 'Tab')
179
188
  return;
@@ -188,7 +197,13 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
188
197
  onArrowUp?.();
189
198
  return;
190
199
  }
200
+ // Сочетания (копирование, вставка, выделение) отдаём браузеру: правку поля перехватит beforeinput.
201
+ if (event.ctrlKey || event.metaKey || event.altKey)
202
+ return;
191
203
  onEdit?.();
204
+ // Цифры и `Unidentified` (экранные клавиатуры Android, IME) вводятся через beforeinput.
205
+ if (/^\d$/.test(event.key) || event.key === 'Unidentified')
206
+ return;
192
207
  if (event.key === 'Escape') {
193
208
  event.preventDefault();
194
209
  onEscape?.();
@@ -223,11 +238,6 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
223
238
  if (event.key === 'Enter') {
224
239
  tryToComplete();
225
240
  emit();
226
- return;
227
- }
228
- if (/^\d$/.test(event.key)) {
229
- typeDigit(slot, event.key);
230
- emit();
231
241
  }
232
242
  }, [
233
243
  clearSlot,
@@ -243,21 +253,14 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
243
253
  onEscape,
244
254
  prevSlot,
245
255
  readonly,
256
+ resetTyped,
246
257
  selectSlot,
247
258
  slotFromIndex,
248
259
  tryToComplete,
249
- typeDigit,
250
260
  ]);
251
- // Вставка (Ctrl+V) полной строки даты/времени: вытаскиваем цифры и заполняем сегменты
252
- // слева направо. Нативный onChange у поля игнорируется (движок controlled), поэтому без
253
- // этого обработчика вставка не работала бы вовсе.
254
- const handlePaste = (0, react_1.useCallback)((event) => {
255
- if (readonly || disabled)
256
- return;
257
- event.preventDefault();
258
- const digits = event.clipboardData.getData('text').replace(/\D/g, '');
259
- if (!digits)
260
- return;
261
+ // Раскладывает цифры по сегментам слева направо полными кусками — для вставки и автозаполнения.
262
+ const fillFromDigits = (0, react_1.useCallback)((digits) => {
263
+ resetTyped();
261
264
  ensureMask();
262
265
  let cursor = 0;
263
266
  let lastFilled;
@@ -276,7 +279,103 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
276
279
  else if (lastFilled) {
277
280
  selectSlot(nextSlot(lastFilled));
278
281
  }
282
+ }, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
283
+ // Вставка (Ctrl+V) полной строки даты/времени. Нативный onChange у поля игнорируется (движок
284
+ // controlled), поэтому без этого обработчика вставка не работала бы вовсе.
285
+ const handlePaste = (0, react_1.useCallback)((event) => {
286
+ if (readonly || disabled)
287
+ return;
288
+ event.preventDefault();
289
+ const digits = event.clipboardData.getData('text').replace(/\D/g, '');
290
+ if (!digits)
291
+ return;
292
+ fillFromDigits(digits);
293
+ emit();
294
+ }, [disabled, emit, fillFromDigits, readonly]);
295
+ const applyEdit = (0, react_1.useCallback)((inputType, text) => {
296
+ const input = inputRef.current;
297
+ if (!input)
298
+ return;
299
+ onEdit?.();
300
+ if (inputType.startsWith('delete')) {
301
+ resetTyped();
302
+ clearSlot(slotFromIndex(input.selectionStart));
303
+ emit();
304
+ return;
305
+ }
306
+ const digits = text.replace(/\D/g, '');
307
+ if (!inputType.startsWith('insert') || !digits)
308
+ return;
309
+ // Строка целиком (автозаполнение, `fill`, drop) раскладывается с начала маски, одиночная цифра — в текущий сегмент.
310
+ if (text.length > 1) {
311
+ fillFromDigits(digits);
312
+ }
313
+ else {
314
+ typeDigit(slotFromIndex(input.selectionStart), digits);
315
+ }
316
+ emit();
317
+ }, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
318
+ // Значение и выделение до правки, которую браузер не дал отменить (композиция IME).
319
+ const pendingEditRef = (0, react_1.useRef)(null);
320
+ const handleBeforeInput = (0, react_1.useCallback)((event) => {
321
+ const input = inputRef.current;
322
+ if (!input || readonly || disabled)
323
+ return;
324
+ ensureMask();
325
+ if (!event.cancelable) {
326
+ pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
327
+ return;
328
+ }
329
+ event.preventDefault();
330
+ applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
331
+ }, [applyEdit, disabled, ensureMask, inputRef, readonly]);
332
+ const handleInput = (0, react_1.useCallback)((event) => {
333
+ const input = inputRef.current;
334
+ if (!input || readonly || disabled)
335
+ return;
336
+ const pending = pendingEditRef.current;
337
+ pendingEditRef.current = null;
338
+ if (pending) {
339
+ // Браузер уже изменил значение: вырезаем вставленное, возвращаем прежнее и применяем как обычный ввод.
340
+ const inputType = event instanceof InputEvent ? event.inputType : '';
341
+ const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
342
+ input.value = pending.value;
343
+ setValue(pending.value);
344
+ input.setSelectionRange(pending.start, pending.end);
345
+ // Цифру из композиции IME применит её фиксация (отменяемый insertText), как в React Aria.
346
+ if (!inputType.includes('Composition'))
347
+ applyEdit(inputType, inserted);
348
+ return;
349
+ }
350
+ // Значение сменили без beforeinput (автозаполнение браузера).
351
+ const digits = input.value.replace(/\D/g, '');
352
+ input.value = mask;
353
+ setValue(mask);
354
+ fillFromDigits(digits);
279
355
  emit();
280
- }, [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot]);
281
- return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
356
+ }, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
357
+ // React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
358
+ const onNativeBeforeInput = (0, utils_1.useEventHandler)(handleBeforeInput);
359
+ const onNativeInput = (0, utils_1.useEventHandler)(handleInput);
360
+ const attachedInputRef = (0, react_1.useRef)(null);
361
+ const nativeInputRef = (0, react_1.useCallback)((node) => {
362
+ /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
363
+ attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
364
+ attachedInputRef.current?.removeEventListener('input', onNativeInput);
365
+ attachedInputRef.current = node;
366
+ node?.addEventListener('beforeinput', onNativeBeforeInput);
367
+ node?.addEventListener('input', onNativeInput);
368
+ /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
369
+ }, [onNativeBeforeInput, onNativeInput]);
370
+ return {
371
+ handleKeyDown,
372
+ handleClick,
373
+ handleFocus,
374
+ handleBlur,
375
+ handlePaste,
376
+ nativeInputRef,
377
+ selectSlot,
378
+ firstSlot,
379
+ lastSlot,
380
+ };
282
381
  }
@@ -321,9 +321,9 @@ export const FieldDate = forwardRef(function FieldDate(props, ref) {
321
321
  _jsxs("span", { className: styles.rangeInputs, style: { '--field-date-mask-ch': placeholderMask.length }, children: [_jsx(InputPrivate, { ...inputCommon, ref: mergeRefs(ref, fromInputRef), className: cn(fieldStyles.fieldInput, styles.rangeInputFrom), value: rangeInputValue[0], placeholder: placeholderMask, onChange: handleRangeInput(0), id: id, name: name, autoFocus: resolvedAutoFocus, "aria-label": labelFrom, "data-test-id": TEST_IDS.fieldDateInputFrom }), _jsx("span", { className: styles.rangeSeparator, "aria-hidden": true, children: "\u2013" }), _jsx(InputPrivate, { ...inputCommon, ref: toInputRef, className: fieldStyles.fieldInput, value: rangeInputValue[1], placeholder: placeholderMask, onChange: handleRangeInput(1),
322
322
  // «to» — последний инпут перед кнопками: на нём живёт roving-композит
323
323
  // (ArrowRight в конце → clear/copy), keydown-цепочка с nav-handler'ом.
324
- onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": TEST_IDS.fieldDateInputTo })] })) : (_jsx(InputPrivate, { ref: mergeRefs(ref, fromInputRef), className: fieldStyles.fieldInput, value: singleInputValue, placeholder: placeholderMask,
325
- // Single/date-time ведёт сегментный движок (keydown, useSegmentedMask), нативный
326
- // onChange игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
324
+ onKeyDown: handleToInputKeyDown, tabIndex: inputTabIndex, "aria-label": labelTo, "data-test-id": TEST_IDS.fieldDateInputTo })] })) : (_jsx(InputPrivate, { ref: mergeRefs(ref, fromInputRef, singleSeg.nativeInputRef), className: fieldStyles.fieldInput, value: singleInputValue, placeholder: placeholderMask,
325
+ // Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
326
+ // игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
327
327
  onChange: () => { }, onKeyDown: handleSingleKeyDown, onClick: singleSeg.handleClick, onPaste: singleSeg.handlePaste, onFocus: handleSingleFocus, onBlur: handleSingleBlur, disabled: disabled, readonly: readOnly, inputMode: 'numeric', tabIndex: inputTabIndex, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, "data-test-id": TEST_IDS.fieldDateInput })) }), postfixButtons && (
328
328
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
329
329
  _jsx("span", { className: fieldStyles.postfixButtonsSlot, onMouseDown: preventSlotMouseDown, onClick: stopSlotClickPropagation, children: postfixButtons }))] }), _jsx("div", { className: fieldStyles.iconSlot, "data-test-id": TEST_IDS.fieldDateCalendar, "aria-hidden": true, children: _jsx(CalendarSVG, {}) })] }) })] }));
@@ -10,8 +10,9 @@ export declare function getMaskString(mode: MaskMode, showSeconds?: boolean): st
10
10
  * «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
11
11
  * как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
12
12
  * фокус автоматически уезжает на следующий сегмент.
13
- * 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
14
- * значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
13
+ * 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
14
+ * сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
15
+ * Пример: `39` для day → `09`, `13` для month → `03`.
15
16
  */
16
17
  export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
17
18
  /** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */
@@ -42,8 +42,9 @@ function buildSegments(mode, showSeconds) {
42
42
  * «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
43
43
  * как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
44
44
  * фокус автоматически уезжает на следующий сегмент.
45
- * 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
46
- * значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
45
+ * 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
46
+ * сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
47
+ * Пример: `39` для day → `09`, `13` для month → `03`.
47
48
  */
48
49
  export function formatMask(input, mode, showSeconds = true) {
49
50
  const maxLen = totalLen(mode, showSeconds);
@@ -69,7 +70,7 @@ export function formatMask(input, mode, showSeconds = true) {
69
70
  stream = stream.slice(seg.len);
70
71
  if (raw.length === seg.len) {
71
72
  const n = Number(raw);
72
- out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
73
+ out += n > seg.max ? raw.slice(-1).padStart(seg.len, '0') : raw;
73
74
  }
74
75
  else {
75
76
  out += raw;
@@ -14,17 +14,14 @@ import { TEST_IDS } from '../../constants.js';
14
14
  import { useAdaptiveAutoFocus } from '../../hooks/index.js';
15
15
  import { copyTextToClipboard, getAcrylicProps, preventSlotMouseDown, stopSlotClickPropagation, toInputSize, useCopyButton, } from '../shared/index.js';
16
16
  import fieldStyles from '../shared/styles.module.css';
17
- import { useCustomOption } from './hooks.js';
18
17
  import styles from './styles.module.css';
19
- import { extractAppearance, extractLabel, filterItems, findItem, flatten, getMissingSelectedItems, getSelectedIds, isMultiple, TAG_SIZE_MAP, } from './utils/index.js';
18
+ import { extractAppearance, extractLabel, filterItems, findItem, flatten, isMultiple, TAG_SIZE_MAP, } from './utils.js';
20
19
  export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
21
20
  const { label = '', labelTooltip, caption, hint, error, validationState = VALIDATION_STATE.Default, showHintIcon, length, required, size = SIZE.M, className, fieldClassName, items, pinTop, pinBottom, placeholder = '', iconBefore, prefix, postfix, disabled, readonly: readOnly, background = true, open: openProp, onOpenChange, placement, widthStrategy = 'eq', searchable = true, search, autocomplete = false, addOptionByEnter = false, resetSearchOnOptionSelection = true, showClearButton = true, showCopyButton = true, onCopyButtonClick, id, name, autoFocus, layoutPresets, onFocus: onFocusProp, onBlur: onBlurProp, enableFuzzySearch = true, selectedOptionFormatter, footer, footerActiveElementsRefs, loading, noDataState, noResultsState, errorDataState, dataError, dataFiltered, virtualized, scrollToSelectedItem, limitedScrollHeight, untouchableScrollbars, closeOnPopstate, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId = TEST_IDS.fieldSelect, } = props;
22
21
  const multiple = isMultiple(props);
23
22
  const chips = multiple ? (props.chips ?? true) : false;
24
23
  const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
25
24
  const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
26
- const allowCustomOption = props.allowCustomOption === true;
27
- const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
28
25
  const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
29
26
  const inputRef = useRef(null);
30
27
  // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
@@ -70,20 +67,16 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
70
67
  }, [multiple, props.value, multipleLocal]);
71
68
  const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
72
69
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
73
- const selectedIds = useMemo(() => getSelectedIds({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
74
- // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
75
- const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
76
- const itemsWithPlaceholders = useMemo(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
77
70
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
78
71
  const seenItems = useRef(new Map());
79
72
  const resolveItem = useMemo(() => {
80
- for (const item of flatten([...missingSelectedItems, ...allItems])) {
73
+ for (const item of flatten(allItems)) {
81
74
  if (item.id !== undefined) {
82
75
  seenItems.current.set(item.id, item);
83
76
  }
84
77
  }
85
- return (id) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
86
- }, [allItems, missingSelectedItems]);
78
+ return (id) => findItem(allItems, id) ?? seenItems.current.get(id);
79
+ }, [allItems]);
87
80
  const selectedPairs = useMemo(() => {
88
81
  if (!multiple || multipleValue.length === 0) {
89
82
  return [];
@@ -138,13 +131,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
138
131
  syncedLabelRef.current = selectedLabel;
139
132
  setInputValue(selectedLabel);
140
133
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
141
- // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
134
+ // При autocomplete фильтрует сервер — items берутся как есть.
142
135
  const filteredItems = useMemo(() => {
143
136
  if (!searchable || autocomplete || !typing) {
144
- return itemsWithPlaceholders;
137
+ return items;
145
138
  }
146
- return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
147
- }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
139
+ return filterItems(items, inputValue, enableFuzzySearch);
140
+ }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
148
141
  const filteredPinTop = useMemo(() => {
149
142
  if (!searchable || autocomplete || !typing || !pinTop) {
150
143
  return pinTop;
@@ -157,7 +150,22 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
157
150
  }
158
151
  return filterItems(pinBottom, inputValue, enableFuzzySearch);
159
152
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
160
- const handleSingleChange = useCallback((next) => {
153
+ const handleOpenChange = useCallback((next) => {
154
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
155
+ if (next && (disabled || readOnly))
156
+ return;
157
+ if (openProp === undefined) {
158
+ setOpenLocal(next);
159
+ }
160
+ onOpenChange?.(next);
161
+ if (!next) {
162
+ setTyping(false);
163
+ if (resetSearchOnOptionSelection) {
164
+ setInputValue(selectedLabel);
165
+ }
166
+ }
167
+ }, [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel]);
168
+ const handleSingleChange = (next) => {
161
169
  if (multiple) {
162
170
  return;
163
171
  }
@@ -166,8 +174,8 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
166
174
  }
167
175
  props.onChange?.(next);
168
176
  setTyping(false);
169
- }, [multiple, props]);
170
- const handleMultipleChange = useCallback((next) => {
177
+ };
178
+ const handleMultipleChange = (next) => {
171
179
  if (!multiple) {
172
180
  return;
173
181
  }
@@ -181,56 +189,8 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
181
189
  setInputValue('');
182
190
  }
183
191
  }
184
- }, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
185
- const resetCustomOptionSearch = useCallback(() => {
186
- setTyping(false);
187
- if (resetSearchOnOptionSelection) {
188
- setInputValue(multiple ? '' : selectedLabel);
189
- }
190
- }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
191
- const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = useCustomOption({
192
- allowCustomOption,
193
- addCustomOptionTriggers,
194
- addOptionByEnter,
195
- multiple,
196
- inputValue,
197
- multipleValue,
198
- commitSingle: handleSingleChange,
199
- commitMultiple: handleMultipleChange,
200
- resetSearch: resetCustomOptionSearch,
201
- inputElementRef,
202
- clearButtonRef,
203
- copyButtonRef,
204
- footerActiveElementsRefs,
205
- });
206
- const handleOpenChange = useCallback((next) => {
207
- // readonly/disabled поле открывать нельзя, закрывать — можно.
208
- if (next && (disabled || readOnly))
209
- return;
210
- if (openProp === undefined) {
211
- setOpenLocal(next);
212
- }
213
- onOpenChange?.(next);
214
- if (!next) {
215
- setTyping(false);
216
- const committed = tryCommitOnClose();
217
- // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
218
- if (resetSearchOnOptionSelection && !committed) {
219
- setInputValue(selectedLabel);
220
- }
221
- }
222
- }, [
223
- openProp,
224
- onOpenChange,
225
- disabled,
226
- readOnly,
227
- resetSearchOnOptionSelection,
228
- setInputValue,
229
- selectedLabel,
230
- tryCommitOnClose,
231
- ]);
192
+ };
232
193
  const handleInputChange = (next) => {
233
- enableCloseCommit();
234
194
  setInputValue(next);
235
195
  setTyping(true);
236
196
  if (!open) {
@@ -329,12 +289,19 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
329
289
  }
330
290
  if (event.key === 'Escape' && open) {
331
291
  event.preventDefault();
332
- suppressCloseCommit();
333
292
  handleOpenChange(false);
334
293
  }
335
- if (tryCommitFromKeyboard(event)) {
294
+ if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
295
+ // Введённый текст становится новым значением (создание опции «на лету»).
336
296
  event.preventDefault();
337
- event.stopPropagation();
297
+ if (multiple) {
298
+ if (!multipleValue.includes(inputValue)) {
299
+ handleMultipleChange([...multipleValue, inputValue]);
300
+ }
301
+ }
302
+ else {
303
+ handleSingleChange(inputValue);
304
+ }
338
305
  return;
339
306
  }
340
307
  if (event.key === 'Enter' && !open) {
@@ -371,20 +338,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
371
338
  }
372
339
  onFocusProp?.(event);
373
340
  }, [searchable, onFocusProp]);
374
- const handleInputBlur = (event) => {
341
+ const handleInputBlur = useCallback((event) => {
375
342
  setFocusVisible(false);
376
- const blurResult = tryCommitOnBlur(event);
377
- // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
378
- // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
379
- if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
380
- setTyping(false);
381
- setInputValue(selectedLabel);
382
- }
383
343
  onBlurProp?.(event);
384
- };
344
+ }, [onBlurProp]);
385
345
  const droplistSelection = multiple
386
- ? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
387
- : { mode: 'single', value: singleValue, onChange: handleListSingleChange };
346
+ ? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
347
+ : { mode: 'single', value: singleValue, onChange: handleSingleChange };
388
348
  const hasChips = chips && selectedPairs.length > 0;
389
349
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
390
350
  // Замер до paint, иначе input мигает при вводе.
@@ -411,5 +371,5 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
411
371
  }), children: _jsx("div", { className: fieldStyles.acrylicBg, "aria-hidden": true }) }), _jsx("div", { className: fieldStyles.borderStateLayer, "data-state": 'borderOnBackground' }), _jsx("div", { className: fieldStyles.focusLayer })] }), _jsx("div", { className: fieldStyles.fieldContainer, children: _jsxs("div", { className: fieldStyles.contentWrapper, children: [iconBefore && _jsx("div", { className: fieldStyles.iconSlot, children: iconBefore }), _jsxs("div", { className: fieldStyles.inputLine, children: [prefix && _jsx("span", { className: fieldStyles.prefix, children: prefix }), hasChips ? (_jsxs("div", { className: styles.chipsRow, ref: contentRef, "data-test-id": TEST_IDS.fieldSelectChips, children: [selectedPairs.map(pair => (_jsx(Tag, { label: formatPair(pair), size: TAG_SIZE_MAP[size], appearance: pair.appearance ?? 'neutral', onDelete: disabled || readOnly || pair.disabled ? undefined : handleRemoveChip(pair.id) }, String(pair.id)))), searchInput, _jsx("span", { ref: inputPlugRef, className: styles.inputPlug, "aria-hidden": true, children: inputValue })] })) : (searchInput), postfixButtons && (
412
372
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
413
373
  _jsx("span", { className: fieldStyles.postfixButtonsSlot, onMouseDown: preventSlotMouseDown, onClick: stopSlotClickPropagation, children: postfixButtons })), postfix && _jsx("span", { className: fieldStyles.postfix, children: postfix })] }), _jsx("div", { className: fieldStyles.iconSlot, children: _jsx("span", { className: styles.chevron, "data-open": open || undefined, "aria-hidden": true, children: _jsx(ChevronDownSVG, {}) }) })] }) })] }));
414
- return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, size: size, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, children: _jsx(Droplist, { items: filteredItems, label: label, pinTop: filteredPinTop, pinBottom: filteredPinBottom, trigger: 'click', placement: placement, widthStrategy: widthStrategy, triggerElemRef: inputRef, listRef: listRef, size: size, open: open, onOpenChange: handleOpenChange, selection: droplistSelection, closeDroplistOnItemClick: closeDroplistOnItemClick, footer: footer, footerActiveElementsRefs: footerActiveElementsRefs, loading: loading, noDataState: noDataState, noResultsState: noResultsState, errorDataState: errorDataState, dataError: dataError, dataFiltered: dataFiltered ?? (searchable && typing && inputValue !== ''), virtualized: virtualized, scroll: true, scrollToSelectedItem: scrollToSelectedItem, limitedScrollHeight: limitedScrollHeight, untouchableScrollbars: untouchableScrollbars, closeOnPopstate: closeOnPopstate, children: trigger }) }));
374
+ return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, size: size, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, children: _jsx(Droplist, { items: filteredItems, label: label, pinTop: filteredPinTop, pinBottom: filteredPinBottom, trigger: 'click', placement: placement, widthStrategy: widthStrategy, triggerElemRef: inputRef, size: size, open: open, onOpenChange: handleOpenChange, selection: droplistSelection, closeDroplistOnItemClick: closeDroplistOnItemClick, footer: footer, footerActiveElementsRefs: footerActiveElementsRefs, loading: loading, noDataState: noDataState, noResultsState: noResultsState, errorDataState: errorDataState, dataError: dataError, dataFiltered: dataFiltered ?? (searchable && typing && inputValue !== ''), virtualized: virtualized, scroll: true, scrollToSelectedItem: scrollToSelectedItem, limitedScrollHeight: limitedScrollHeight, untouchableScrollbars: untouchableScrollbars, closeOnPopstate: closeOnPopstate, children: trigger }) }));
415
375
  });
@@ -2,14 +2,3 @@ export declare const SELECTION_MODE: {
2
2
  readonly Single: "single";
3
3
  readonly Multiple: "multiple";
4
4
  };
5
- /** События, по которым произвольный ввод фиксируется как выбранная опция. */
6
- export declare const ADD_CUSTOM_OPTION_TRIGGER: {
7
- readonly Enter: "enter";
8
- readonly Blur: "blur";
9
- readonly Space: "space";
10
- readonly Comma: "comma";
11
- };
12
- /** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
13
- export declare const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE: readonly ["enter", "blur"];
14
- /** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
15
- export declare const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE: readonly ["enter", "blur", "space", "comma"];
@@ -2,22 +2,3 @@ export const SELECTION_MODE = {
2
2
  Single: 'single',
3
3
  Multiple: 'multiple',
4
4
  };
5
- /** События, по которым произвольный ввод фиксируется как выбранная опция. */
6
- export const ADD_CUSTOM_OPTION_TRIGGER = {
7
- Enter: 'enter',
8
- Blur: 'blur',
9
- Space: 'space',
10
- Comma: 'comma',
11
- };
12
- /** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
13
- export const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
14
- ADD_CUSTOM_OPTION_TRIGGER.Enter,
15
- ADD_CUSTOM_OPTION_TRIGGER.Blur,
16
- ];
17
- /** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
18
- export const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
19
- ADD_CUSTOM_OPTION_TRIGGER.Enter,
20
- ADD_CUSTOM_OPTION_TRIGGER.Blur,
21
- ADD_CUSTOM_OPTION_TRIGGER.Space,
22
- ADD_CUSTOM_OPTION_TRIGGER.Comma,
23
- ];