@cloud-ru/ds-fields 2.2.3-preview-a0540d7f.0 → 2.2.4

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 (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -3
  3. package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
  4. package/dist/cjs/components/FieldDate/mask.d.ts +3 -2
  5. package/dist/cjs/components/FieldDate/mask.js +4 -3
  6. package/dist/cjs/components/FieldSelect/FieldSelect.js +43 -98
  7. package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
  8. package/dist/cjs/components/FieldSelect/constants.js +1 -20
  9. package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
  10. package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
  11. package/dist/cjs/components/FieldSelect/utils.js +103 -0
  12. package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
  13. package/dist/cjs/segments/useSegmentedMask.d.ts +2 -0
  14. package/dist/cjs/segments/useSegmentedMask.js +145 -56
  15. package/dist/esm/components/FieldDate/FieldDate.js +3 -3
  16. package/dist/esm/components/FieldDate/mask.d.ts +3 -2
  17. package/dist/esm/components/FieldDate/mask.js +4 -3
  18. package/dist/esm/components/FieldSelect/FieldSelect.js +45 -100
  19. package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
  20. package/dist/esm/components/FieldSelect/constants.js +0 -19
  21. package/dist/esm/components/FieldSelect/types.d.ts +3 -26
  22. package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
  23. package/dist/esm/components/FieldSelect/utils.js +92 -0
  24. package/dist/esm/components/FieldTime/FieldTime.js +2 -2
  25. package/dist/esm/segments/useSegmentedMask.d.ts +2 -0
  26. package/dist/esm/segments/useSegmentedMask.js +145 -56
  27. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  28. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  29. package/package.json +20 -20
  30. package/src/components/FieldDate/FieldDate.tsx +3 -3
  31. package/src/components/FieldDate/mask.ts +4 -3
  32. package/src/components/FieldSelect/FieldSelect.tsx +63 -151
  33. package/src/components/FieldSelect/constants.ts +0 -22
  34. package/src/components/FieldSelect/types.ts +48 -82
  35. package/src/components/FieldSelect/utils.ts +132 -0
  36. package/src/components/FieldTime/FieldTime.tsx +2 -2
  37. package/src/segments/useSegmentedMask.ts +171 -52
  38. package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -33
  39. package/dist/cjs/components/FieldSelect/hooks.js +0 -95
  40. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
  41. package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
  42. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
  43. package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
  44. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
  45. package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
  46. package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
  47. package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
  48. package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
  49. package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
  50. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
  51. package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
  52. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
  53. package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
  54. package/dist/esm/components/FieldSelect/hooks.d.ts +0 -33
  55. package/dist/esm/components/FieldSelect/hooks.js +0 -92
  56. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
  57. package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
  58. package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
  59. package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
  60. package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
  61. package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
  62. package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
  63. package/dist/esm/components/FieldSelect/utils/index.js +0 -6
  64. package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
  65. package/dist/esm/components/FieldSelect/utils/items.js +0 -41
  66. package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
  67. package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
  68. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
  69. package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
  70. package/src/components/FieldSelect/hooks.ts +0 -177
  71. package/src/components/FieldSelect/utils/customOption.ts +0 -77
  72. package/src/components/FieldSelect/utils/extract.ts +0 -49
  73. package/src/components/FieldSelect/utils/filter.ts +0 -51
  74. package/src/components/FieldSelect/utils/index.ts +0 -6
  75. package/src/components/FieldSelect/utils/items.ts +0 -58
  76. package/src/components/FieldSelect/utils/selection.ts +0 -25
  77. package/src/components/FieldSelect/utils/tagSize.ts +0 -5
@@ -1,11 +1,24 @@
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
+ * `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
9
22
  */
10
23
  function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
11
24
  const firstSlot = slots[0];
@@ -110,70 +123,60 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
110
123
  }
111
124
  selectSlot(nextSlot(slot));
112
125
  }, [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete]);
126
+ // Цифры сегмента храним отдельно от текста: по `01` в поле не отличить `1` от `0` + `1`.
127
+ const typedRef = (0, react_1.useRef)(null);
128
+ const resetTyped = (0, react_1.useCallback)(() => {
129
+ typedRef.current = null;
130
+ }, []);
113
131
  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
132
  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);
133
+ const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
134
+ const appended = prev + key;
135
+ const overflow = Number(appended) > slot.max;
136
+ const digits = overflow ? key : appended;
137
+ const value = Number(digits);
138
+ const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
139
+ const accepted = value >= slot.min;
140
+ if (accepted)
141
+ updateSlot(slot, value);
142
+ if (!complete) {
143
+ typedRef.current = { key: slot.key, digits };
144
+ return;
143
145
  }
144
- else if (digit * 10 > slot.max) {
145
- updateSlot(slot, key);
146
+ typedRef.current = null;
147
+ if (accepted)
146
148
  checkInputAndGoNext(slot);
147
- }
148
- else if (digit || digit >= slot.min) {
149
- updateSlot(slot, key);
150
- }
151
- }, [checkInputAndGoNext, getSlotText, updateSlot]);
149
+ }, [checkInputAndGoNext, updateSlot]);
152
150
  const handleFocus = (0, react_1.useCallback)(() => {
153
151
  if (readonly || disabled)
154
152
  return;
153
+ resetTyped();
155
154
  ensureMask();
156
155
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
157
156
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
158
157
  selectSlot(firstSlot);
159
- }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
158
+ }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
160
159
  const handleClick = (0, react_1.useCallback)((event) => {
161
160
  if (readonly || disabled)
162
161
  return;
162
+ resetTyped();
163
163
  ensureMask();
164
164
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
165
- }, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
165
+ }, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
166
166
  const handleBlur = (0, react_1.useCallback)(() => {
167
+ resetTyped();
167
168
  const input = inputRef.current;
168
169
  if (input && input.value === mask) {
169
170
  input.value = '';
170
171
  setValue('');
171
172
  }
172
- }, [inputRef, mask, setValue]);
173
+ }, [inputRef, mask, resetTyped, setValue]);
173
174
  const handleKeyDown = (0, react_1.useCallback)((event) => {
174
175
  const input = inputRef.current;
175
176
  if (!input || readonly || disabled)
176
177
  return;
178
+ if (RESET_TYPED_KEYS.has(event.key))
179
+ resetTyped();
177
180
  // Tab уводит фокус из поля — не перехватываем.
178
181
  if (event.key === 'Tab')
179
182
  return;
@@ -188,7 +191,13 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
188
191
  onArrowUp?.();
189
192
  return;
190
193
  }
194
+ // Копирование и вставку не блокируем: правку поля перехватит beforeinput.
195
+ if (event.ctrlKey || event.metaKey || event.altKey)
196
+ return;
191
197
  onEdit?.();
198
+ // Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
199
+ if (/^\d$/.test(event.key) || event.key === 'Unidentified')
200
+ return;
192
201
  if (event.key === 'Escape') {
193
202
  event.preventDefault();
194
203
  onEscape?.();
@@ -223,11 +232,6 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
223
232
  if (event.key === 'Enter') {
224
233
  tryToComplete();
225
234
  emit();
226
- return;
227
- }
228
- if (/^\d$/.test(event.key)) {
229
- typeDigit(slot, event.key);
230
- emit();
231
235
  }
232
236
  }, [
233
237
  clearSlot,
@@ -243,21 +247,13 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
243
247
  onEscape,
244
248
  prevSlot,
245
249
  readonly,
250
+ resetTyped,
246
251
  selectSlot,
247
252
  slotFromIndex,
248
253
  tryToComplete,
249
- typeDigit,
250
254
  ]);
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;
255
+ const fillFromDigits = (0, react_1.useCallback)((digits) => {
256
+ resetTyped();
261
257
  ensureMask();
262
258
  let cursor = 0;
263
259
  let lastFilled;
@@ -276,7 +272,100 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
276
272
  else if (lastFilled) {
277
273
  selectSlot(nextSlot(lastFilled));
278
274
  }
275
+ }, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
276
+ const handlePaste = (0, react_1.useCallback)((event) => {
277
+ if (readonly || disabled)
278
+ return;
279
+ event.preventDefault();
280
+ const digits = event.clipboardData.getData('text').replace(/\D/g, '');
281
+ if (!digits)
282
+ return;
283
+ fillFromDigits(digits);
284
+ emit();
285
+ }, [disabled, emit, fillFromDigits, readonly]);
286
+ const applyEdit = (0, react_1.useCallback)((inputType, text) => {
287
+ const input = inputRef.current;
288
+ if (!input)
289
+ return;
290
+ onEdit?.();
291
+ if (inputType.startsWith('delete')) {
292
+ resetTyped();
293
+ clearSlot(slotFromIndex(input.selectionStart));
294
+ emit();
295
+ return;
296
+ }
297
+ const digits = text.replace(/\D/g, '');
298
+ if (!inputType.startsWith('insert') || !digits)
299
+ return;
300
+ // Автозаполнение, `fill` и drop присылают строку целиком.
301
+ if (text.length > 1) {
302
+ fillFromDigits(digits);
303
+ }
304
+ else {
305
+ typeDigit(slotFromIndex(input.selectionStart), digits);
306
+ }
307
+ emit();
308
+ }, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
309
+ // Композицию IME браузер не даёт отменить — откатываем её в input.
310
+ const pendingEditRef = (0, react_1.useRef)(null);
311
+ const handleBeforeInput = (0, react_1.useCallback)((event) => {
312
+ const input = inputRef.current;
313
+ if (!input || readonly || disabled)
314
+ return;
315
+ ensureMask();
316
+ if (!event.cancelable) {
317
+ pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
318
+ return;
319
+ }
320
+ event.preventDefault();
321
+ applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
322
+ }, [applyEdit, disabled, ensureMask, inputRef, readonly]);
323
+ const handleInput = (0, react_1.useCallback)((event) => {
324
+ const input = inputRef.current;
325
+ if (!input || readonly || disabled)
326
+ return;
327
+ const pending = pendingEditRef.current;
328
+ pendingEditRef.current = null;
329
+ if (pending) {
330
+ const inputType = event instanceof InputEvent ? event.inputType : '';
331
+ const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
332
+ input.value = pending.value;
333
+ setValue(pending.value);
334
+ input.setSelectionRange(pending.start, pending.end);
335
+ // Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
336
+ if (!inputType.includes('Composition'))
337
+ applyEdit(inputType, inserted);
338
+ return;
339
+ }
340
+ // Автозаполнение меняет значение без beforeinput.
341
+ const digits = input.value.replace(/\D/g, '');
342
+ input.value = mask;
343
+ setValue(mask);
344
+ fillFromDigits(digits);
279
345
  emit();
280
- }, [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot]);
281
- return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
346
+ }, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
347
+ // React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
348
+ const onNativeBeforeInput = (0, utils_1.useEventHandler)(handleBeforeInput);
349
+ const onNativeInput = (0, utils_1.useEventHandler)(handleInput);
350
+ const attachedInputRef = (0, react_1.useRef)(null);
351
+ const nativeInputRef = (0, react_1.useCallback)((node) => {
352
+ /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
353
+ attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
354
+ attachedInputRef.current?.removeEventListener('input', onNativeInput);
355
+ attachedInputRef.current = node;
356
+ node?.addEventListener('beforeinput', onNativeBeforeInput);
357
+ node?.addEventListener('input', onNativeInput);
358
+ /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
359
+ }, [onNativeBeforeInput, onNativeInput]);
360
+ return {
361
+ handleKeyDown,
362
+ handleClick,
363
+ handleFocus,
364
+ handleBlur,
365
+ handlePaste,
366
+ nativeInputRef,
367
+ selectSlot,
368
+ firstSlot,
369
+ lastSlot,
370
+ };
282
371
  }
@@ -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;
@@ -3,7 +3,7 @@ import { ChevronDownSVG } from '@cloud-ru/ds-icons/interface/system';
3
3
  import { InputPrivate, useButtonNavigation, useClearButton } from '@cloud-ru/ds-input-private';
4
4
  import { Droplist } from '@cloud-ru/ds-list';
5
5
  import { Tag } from '@cloud-ru/ds-tag';
6
- import { isBrowser, useLayoutEffect } from '@cloud-ru/ds-utils';
6
+ import { useLayoutEffect } from '@cloud-ru/ds-utils';
7
7
  import cn from 'classnames';
8
8
  import mergeRefs from 'merge-refs';
9
9
  import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
@@ -14,22 +14,18 @@ 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.
31
28
  const inputElementRef = useRef(null);
32
- const restoreInputFocusRef = useRef(false);
33
29
  const clearButtonRef = useRef(null);
34
30
  const copyButtonRef = useRef(null);
35
31
  const contentRef = useRef(null);
@@ -71,20 +67,16 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
71
67
  }, [multiple, props.value, multipleLocal]);
72
68
  const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
73
69
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
74
- const selectedIds = useMemo(() => getSelectedIds({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
75
- // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
76
- const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
77
- const itemsWithPlaceholders = useMemo(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
78
70
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
79
71
  const seenItems = useRef(new Map());
80
72
  const resolveItem = useMemo(() => {
81
- for (const item of flatten([...missingSelectedItems, ...allItems])) {
73
+ for (const item of flatten(allItems)) {
82
74
  if (item.id !== undefined) {
83
75
  seenItems.current.set(item.id, item);
84
76
  }
85
77
  }
86
- return (id) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
87
- }, [allItems, missingSelectedItems]);
78
+ return (id) => findItem(allItems, id) ?? seenItems.current.get(id);
79
+ }, [allItems]);
88
80
  const selectedPairs = useMemo(() => {
89
81
  if (!multiple || multipleValue.length === 0) {
90
82
  return [];
@@ -139,13 +131,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
139
131
  syncedLabelRef.current = selectedLabel;
140
132
  setInputValue(selectedLabel);
141
133
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
142
- // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
134
+ // При autocomplete фильтрует сервер — items берутся как есть.
143
135
  const filteredItems = useMemo(() => {
144
136
  if (!searchable || autocomplete || !typing) {
145
- return itemsWithPlaceholders;
137
+ return items;
146
138
  }
147
- return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
148
- }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
139
+ return filterItems(items, inputValue, enableFuzzySearch);
140
+ }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
149
141
  const filteredPinTop = useMemo(() => {
150
142
  if (!searchable || autocomplete || !typing || !pinTop) {
151
143
  return pinTop;
@@ -158,7 +150,22 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
158
150
  }
159
151
  return filterItems(pinBottom, inputValue, enableFuzzySearch);
160
152
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
161
- 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) => {
162
169
  if (multiple) {
163
170
  return;
164
171
  }
@@ -167,12 +174,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
167
174
  }
168
175
  props.onChange?.(next);
169
176
  setTyping(false);
170
- }, [multiple, props]);
171
- const handleMultipleChange = useCallback((next) => {
177
+ };
178
+ const handleMultipleChange = (next) => {
172
179
  if (!multiple) {
173
180
  return;
174
181
  }
175
- restoreInputFocusRef.current = isBrowser() && inputElementRef.current === document.activeElement;
176
182
  if (props.value === undefined) {
177
183
  setMultipleLocal(next);
178
184
  }
@@ -183,57 +189,8 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
183
189
  setInputValue('');
184
190
  }
185
191
  }
186
- }, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
187
- const resetCustomOptionSearch = useCallback(() => {
188
- setTyping(false);
189
- if (resetSearchOnOptionSelection) {
190
- setInputValue(multiple ? '' : selectedLabel);
191
- }
192
- }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
193
- const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = useCustomOption({
194
- allowCustomOption,
195
- addCustomOptionTriggers,
196
- addOptionByEnter,
197
- multiple,
198
- inputValue,
199
- typing,
200
- multipleValue,
201
- commitSingle: handleSingleChange,
202
- commitMultiple: handleMultipleChange,
203
- resetSearch: resetCustomOptionSearch,
204
- inputElementRef,
205
- clearButtonRef,
206
- copyButtonRef,
207
- footerActiveElementsRefs,
208
- });
209
- const handleOpenChange = useCallback((next) => {
210
- // readonly/disabled поле открывать нельзя, закрывать — можно.
211
- if (next && (disabled || readOnly))
212
- return;
213
- if (openProp === undefined) {
214
- setOpenLocal(next);
215
- }
216
- onOpenChange?.(next);
217
- if (!next) {
218
- setTyping(false);
219
- const committed = tryCommitOnClose();
220
- // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
221
- if (resetSearchOnOptionSelection && !committed) {
222
- setInputValue(selectedLabel);
223
- }
224
- }
225
- }, [
226
- openProp,
227
- onOpenChange,
228
- disabled,
229
- readOnly,
230
- resetSearchOnOptionSelection,
231
- setInputValue,
232
- selectedLabel,
233
- tryCommitOnClose,
234
- ]);
192
+ };
235
193
  const handleInputChange = (next) => {
236
- enableCloseCommit();
237
194
  setInputValue(next);
238
195
  setTyping(true);
239
196
  if (!open) {
@@ -332,12 +289,19 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
332
289
  }
333
290
  if (event.key === 'Escape' && open) {
334
291
  event.preventDefault();
335
- suppressCloseCommit();
336
292
  handleOpenChange(false);
337
293
  }
338
- if (tryCommitFromKeyboard(event)) {
294
+ if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
295
+ // Введённый текст становится новым значением (создание опции «на лету»).
339
296
  event.preventDefault();
340
- event.stopPropagation();
297
+ if (multiple) {
298
+ if (!multipleValue.includes(inputValue)) {
299
+ handleMultipleChange([...multipleValue, inputValue]);
300
+ }
301
+ }
302
+ else {
303
+ handleSingleChange(inputValue);
304
+ }
341
305
  return;
342
306
  }
343
307
  if (event.key === 'Enter' && !open) {
@@ -350,11 +314,6 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
350
314
  removeChip(last);
351
315
  }
352
316
  };
353
- const handleTriggerKeyDownCapture = (event) => {
354
- if (event.key === 'Escape') {
355
- suppressCloseCommit();
356
- }
357
- };
358
317
  const handleTriggerMouseEnter = useCallback(() => {
359
318
  if (!readOnly && !disabled) {
360
319
  setHover(true);
@@ -379,28 +338,14 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
379
338
  }
380
339
  onFocusProp?.(event);
381
340
  }, [searchable, onFocusProp]);
382
- const handleInputBlur = (event) => {
341
+ const handleInputBlur = useCallback((event) => {
383
342
  setFocusVisible(false);
384
- const blurResult = tryCommitOnBlur(event);
385
- // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
386
- // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
387
- if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
388
- setTyping(false);
389
- setInputValue(selectedLabel);
390
- }
391
343
  onBlurProp?.(event);
392
- };
344
+ }, [onBlurProp]);
393
345
  const droplistSelection = multiple
394
- ? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
395
- : { mode: 'single', value: singleValue, onChange: handleListSingleChange };
346
+ ? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
347
+ : { mode: 'single', value: singleValue, onChange: handleSingleChange };
396
348
  const hasChips = chips && selectedPairs.length > 0;
397
- useLayoutEffect(() => {
398
- if (!restoreInputFocusRef.current) {
399
- return;
400
- }
401
- restoreInputFocusRef.current = false;
402
- inputElementRef.current?.focus();
403
- }, [multipleValue.length]);
404
349
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
405
350
  // Замер до paint, иначе input мигает при вводе.
406
351
  useLayoutEffect(() => {
@@ -413,11 +358,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
413
358
  setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
414
359
  }, [hasChips, inputValue, selectedPairs.length]);
415
360
  const searchInputField = (_jsx(InputPrivate, { ref: mergeRefs(ref, inputRef, inputElementRef), className: cn(fieldStyles.fieldInput, !searchable && styles.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel || hasChips ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": TEST_IDS.fieldSelectInput }));
416
- const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField }, 'search-input')) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
361
+ const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
417
362
  const trigger = (
418
363
  // Focusable-элемент — InputPrivate внутри, обёртка только собирает combobox.
419
364
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
420
- _jsxs("div", { className: cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName), "data-size": size, "data-validation-state": effectiveValidationState, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-withbackground": background || undefined, "data-focusvisible": focusVisible || open || undefined, "data-hover": !readOnly && !disabled && hover ? true : undefined, "data-test-id": dataTestId, onMouseEnter: handleTriggerMouseEnter, onMouseLeave: handleTriggerMouseLeave, onKeyDownCapture: handleTriggerKeyDownCapture, onClick: handleTriggerClick, children: [_jsxs("div", { className: fieldStyles.backgroundWrapper, children: [_jsx("div", { className: fieldStyles.materialLayer, ...getAcrylicProps({
365
+ _jsxs("div", { className: cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName), "data-size": size, "data-validation-state": effectiveValidationState, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-withbackground": background || undefined, "data-focusvisible": focusVisible || open || undefined, "data-hover": !readOnly && !disabled && hover ? true : undefined, "data-test-id": dataTestId, onMouseEnter: handleTriggerMouseEnter, onMouseLeave: handleTriggerMouseLeave, onClick: handleTriggerClick, children: [_jsxs("div", { className: fieldStyles.backgroundWrapper, children: [_jsx("div", { className: fieldStyles.materialLayer, ...getAcrylicProps({
421
366
  validationState: effectiveValidationState,
422
367
  disabled,
423
368
  readonly: readOnly,
@@ -426,5 +371,5 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
426
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 && (
427
372
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
428
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, {}) }) })] }) })] }));
429
- 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 }) }));
430
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
- ];