@cloud-ru/ds-fields 2.2.3-preview-0c4119af.0 → 2.2.3-preview-a42433ce.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 +2 -0
  13. package/dist/cjs/segments/useSegmentedMask.js +146 -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 +2 -0
  25. package/dist/esm/segments/useSegmentedMask.js +146 -56
  26. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  27. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  28. package/package.json +8 -8
  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 +172 -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
@@ -0,0 +1,132 @@
1
+ import { Size } from '@cloud-ru/ds-field-decorator';
2
+ import { ItemId } from '@cloud-ru/ds-list';
3
+ import { Appearance as TagAppearance, Size as TagSize } from '@cloud-ru/ds-tag';
4
+
5
+ import { SELECTION_MODE } from './constants';
6
+ import { FieldSelectItem, FieldSelectMultipleProps, FieldSelectProps } from './types';
7
+
8
+ export const TAG_SIZE_MAP: Record<Size, TagSize> = { s: 'xs', m: 'xs', l: 's' };
9
+
10
+ export type WithIdContent = { id?: ItemId; content?: unknown; disabled?: boolean; appearance?: TagAppearance };
11
+
12
+ export function extractLabel(item: WithIdContent): string {
13
+ const { content, id } = item;
14
+
15
+ if (!content) {
16
+ return String(id ?? '');
17
+ }
18
+
19
+ if (typeof content === 'string' || typeof content === 'number') {
20
+ return String(content);
21
+ }
22
+
23
+ if (typeof content === 'object' && content !== null && 'label' in content) {
24
+ return String((content as { label: unknown }).label);
25
+ }
26
+
27
+ return String(id ?? '');
28
+ }
29
+
30
+ // Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
31
+ // который матчил запрос по всем трём полям контента, а не только по лейблу.
32
+ export function extractSearchText(item: WithIdContent): string {
33
+ const parts = [extractLabel(item)];
34
+ const { content } = item;
35
+
36
+ if (content && typeof content === 'object') {
37
+ const c = content as { caption?: unknown; description?: unknown };
38
+
39
+ if (typeof c.caption === 'string') {
40
+ parts.push(c.caption);
41
+ }
42
+
43
+ if (typeof c.description === 'string') {
44
+ parts.push(c.description);
45
+ }
46
+ }
47
+
48
+ return parts.join(' ');
49
+ }
50
+
51
+ // Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
52
+ // Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
53
+ export function extractAppearance(item?: WithIdContent): TagAppearance | undefined {
54
+ return typeof item?.appearance === 'string' ? item.appearance : undefined;
55
+ }
56
+
57
+ export function flatten(items: FieldSelectItem[]): WithIdContent[] {
58
+ const out: WithIdContent[] = [];
59
+
60
+ for (const item of items) {
61
+ out.push(item as WithIdContent);
62
+
63
+ if ('items' in item && Array.isArray(item.items)) {
64
+ out.push(...flatten(item.items as FieldSelectItem[]));
65
+ }
66
+ }
67
+
68
+ return out;
69
+ }
70
+
71
+ export function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined {
72
+ for (const item of flatten(items)) {
73
+ if (item.id === id) {
74
+ return item;
75
+ }
76
+ }
77
+
78
+ return undefined;
79
+ }
80
+
81
+ // Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
82
+ // Пример: query=`lge` matches `Large` (L → r → arg → e).
83
+ export function isFuzzyMatch(haystack: string, needle: string): boolean {
84
+ if (!needle) {
85
+ return true;
86
+ }
87
+
88
+ let i = 0;
89
+
90
+ for (const ch of haystack) {
91
+ if (ch === needle[i]) {
92
+ i += 1;
93
+
94
+ if (i === needle.length) {
95
+ return true;
96
+ }
97
+ }
98
+ }
99
+
100
+ return false;
101
+ }
102
+
103
+ export function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[] {
104
+ if (!query) {
105
+ return items;
106
+ }
107
+
108
+ const q = query.toLowerCase();
109
+
110
+ const matches = (item: FieldSelectItem): boolean => {
111
+ const text = extractSearchText(item as WithIdContent).toLowerCase();
112
+
113
+ return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
114
+ };
115
+
116
+ const walk = (list: FieldSelectItem[]): FieldSelectItem[] =>
117
+ list.flatMap(item => {
118
+ if ('items' in item && Array.isArray(item.items)) {
119
+ const filteredChildren = walk(item.items as FieldSelectItem[]);
120
+
121
+ return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren } as FieldSelectItem] : [];
122
+ }
123
+
124
+ return matches(item) ? [item] : [];
125
+ });
126
+
127
+ return walk(items);
128
+ }
129
+
130
+ export function isMultiple(props: FieldSelectProps): props is FieldSelectMultipleProps {
131
+ return props.selection === SELECTION_MODE.Multiple;
132
+ }
@@ -267,7 +267,7 @@ export const FieldTime = forwardRef<HTMLInputElement, FieldTimeProps>(function F
267
267
  [onInputKeyDown, timeSeg],
268
268
  );
269
269
 
270
- // value ведёт сегментный движок (keydown); нативный onChange input'а игнорируем.
270
+ // value ведёт сегментный движок (useSegmentedMask); нативный onChange input'а игнорируем.
271
271
  const handleInputChange = useCallback(() => undefined, []);
272
272
 
273
273
  const handleInputFocus = useCallback(
@@ -323,7 +323,7 @@ export const FieldTime = forwardRef<HTMLInputElement, FieldTimeProps>(function F
323
323
  <div className={fieldStyles.inputLine}>
324
324
  <div className={fieldStyles.inputArea}>
325
325
  <InputPrivate
326
- ref={mergeRefs(ref, localRef)}
326
+ ref={mergeRefs(ref, localRef, timeSeg.nativeInputRef)}
327
327
  className={fieldStyles.fieldInput}
328
328
  value={inputValue}
329
329
  onChange={handleInputChange}
@@ -1,6 +1,19 @@
1
+ import { useEventHandler } from '@cloud-ru/ds-utils';
1
2
  import { ClipboardEvent, KeyboardEvent, MouseEvent, RefObject, useCallback, useRef } from 'react';
2
3
 
3
- import { SlotMeta } from './segments';
4
+ import { SegmentKey, SlotMeta } from './segments';
5
+
6
+ const RESET_TYPED_KEYS = new Set([
7
+ 'ArrowLeft',
8
+ 'ArrowRight',
9
+ 'ArrowUp',
10
+ 'ArrowDown',
11
+ 'Backspace',
12
+ 'Delete',
13
+ 'Enter',
14
+ 'Tab',
15
+ 'Escape',
16
+ ]);
4
17
 
5
18
  type UseSegmentedMaskParams = {
6
19
  inputRef: RefObject<HTMLInputElement | null>;
@@ -32,6 +45,7 @@ type UseSegmentedMaskParams = {
32
45
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
33
46
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
34
47
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
48
+ * `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
35
49
  */
36
50
  export function useSegmentedMask({
37
51
  inputRef,
@@ -174,74 +188,72 @@ export function useSegmentedMask({
174
188
  [clearSlot, isValidDate, lastSlot, nextSlot, selectSlot, slots, tryToComplete],
175
189
  );
176
190
 
191
+ // Цифры сегмента храним отдельно от текста: по `01` в поле не отличить `1` от `0` + `1`.
192
+ const typedRef = useRef<{ key: SegmentKey; digits: string } | null>(null);
193
+ const resetTyped = useCallback(() => {
194
+ typedRef.current = null;
195
+ }, []);
196
+
177
197
  const typeDigit = useCallback(
178
198
  (slot: SlotMeta, key: string) => {
179
- const text = getSlotText(slot);
180
- const numberValue = Number(text) || 0;
181
- const digit = Number(key);
182
- const slotValue = parseInt(numberValue.toString() + key, 10) || 0;
183
- const valueLength = slotValue.toString().length;
184
199
  const maxLength = slot.max.toString().length;
185
- const isTheLastInput = /^0+$/.test(text) && maxLength === 2 && digit === 0;
186
-
187
- if (valueLength < maxLength) {
188
- if (slotValue || slotValue >= slot.min) {
189
- updateSlot(slot, slotValue);
190
- if (isTheLastInput) checkInputAndGoNext(slot);
191
- }
192
- if (slotValue * 10 > slot.max) {
193
- checkInputAndGoNext(slot);
194
- }
195
- } else if (valueLength > maxLength) {
196
- if (digit * 10 > slot.max) {
197
- updateSlot(slot, key);
198
- checkInputAndGoNext(slot);
199
- } else if (digit || digit >= slot.min) {
200
- updateSlot(slot, key);
201
- }
202
- } else if (slotValue <= slot.max) {
203
- updateSlot(slot, slotValue);
204
- checkInputAndGoNext(slot);
205
- } else if (digit * 10 > slot.max) {
206
- updateSlot(slot, key);
207
- checkInputAndGoNext(slot);
208
- } else if (digit || digit >= slot.min) {
209
- updateSlot(slot, key);
200
+ const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
201
+ const appended = prev + key;
202
+ // Как в 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;
210
214
  }
215
+
216
+ typedRef.current = null;
217
+ if (accepted) checkInputAndGoNext(slot);
211
218
  },
212
- [checkInputAndGoNext, getSlotText, updateSlot],
219
+ [checkInputAndGoNext, updateSlot],
213
220
  );
214
221
 
215
222
  const handleFocus = useCallback(() => {
216
223
  if (readonly || disabled) return;
224
+ resetTyped();
217
225
  ensureMask();
218
226
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
219
227
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
220
228
  selectSlot(firstSlot);
221
- }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
229
+ }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
222
230
 
223
231
  const handleClick = useCallback(
224
232
  (event: MouseEvent<HTMLInputElement>) => {
225
233
  if (readonly || disabled) return;
234
+ resetTyped();
226
235
  ensureMask();
227
236
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
228
237
  },
229
- [disabled, ensureMask, readonly, selectSlot, slotFromIndex],
238
+ [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex],
230
239
  );
231
240
 
232
241
  const handleBlur = useCallback(() => {
242
+ resetTyped();
233
243
  const input = inputRef.current;
234
244
  if (input && input.value === mask) {
235
245
  input.value = '';
236
246
  setValue('');
237
247
  }
238
- }, [inputRef, mask, setValue]);
248
+ }, [inputRef, mask, resetTyped, setValue]);
239
249
 
240
250
  const handleKeyDown = useCallback(
241
251
  (event: KeyboardEvent<HTMLInputElement>) => {
242
252
  const input = inputRef.current;
243
253
  if (!input || readonly || disabled) return;
244
254
 
255
+ if (RESET_TYPED_KEYS.has(event.key)) resetTyped();
256
+
245
257
  // Tab уводит фокус из поля — не перехватываем.
246
258
  if (event.key === 'Tab') return;
247
259
 
@@ -258,8 +270,14 @@ export function useSegmentedMask({
258
270
  return;
259
271
  }
260
272
 
273
+ // Копирование и вставку не блокируем: правку поля перехватит beforeinput.
274
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
275
+
261
276
  onEdit?.();
262
277
 
278
+ // Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
279
+ if (/^\d$/.test(event.key) || event.key === 'Unidentified') return;
280
+
263
281
  if (event.key === 'Escape') {
264
282
  event.preventDefault();
265
283
  onEscape?.();
@@ -297,11 +315,6 @@ export function useSegmentedMask({
297
315
  if (event.key === 'Enter') {
298
316
  tryToComplete();
299
317
  emit();
300
- return;
301
- }
302
- if (/^\d$/.test(event.key)) {
303
- typeDigit(slot, event.key);
304
- emit();
305
318
  }
306
319
  },
307
320
  [
@@ -318,22 +331,16 @@ export function useSegmentedMask({
318
331
  onEscape,
319
332
  prevSlot,
320
333
  readonly,
334
+ resetTyped,
321
335
  selectSlot,
322
336
  slotFromIndex,
323
337
  tryToComplete,
324
- typeDigit,
325
338
  ],
326
339
  );
327
340
 
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;
341
+ const fillFromDigits = useCallback(
342
+ (digits: string) => {
343
+ resetTyped();
337
344
  ensureMask();
338
345
 
339
346
  let cursor = 0;
@@ -352,10 +359,123 @@ export function useSegmentedMask({
352
359
  } else if (lastFilled) {
353
360
  selectSlot(nextSlot(lastFilled));
354
361
  }
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
+ }
355
400
  emit();
356
401
  },
357
- [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot],
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],
358
468
  );
359
469
 
360
- return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
470
+ return {
471
+ handleKeyDown,
472
+ handleClick,
473
+ handleFocus,
474
+ handleBlur,
475
+ handlePaste,
476
+ nativeInputRef,
477
+ selectSlot,
478
+ firstSlot,
479
+ lastSlot,
480
+ };
361
481
  }
@@ -1,32 +0,0 @@
1
- import { ItemId } from '@cloud-ru/ds-list';
2
- import { FocusEvent, KeyboardEvent, RefObject } from 'react';
3
- import { FieldSelectAddCustomOptionTrigger } from './types';
4
- /** Результат blur: фокус остался внутри поля, ввод зафиксирован, или коммитить было нечего. */
5
- type CustomOptionBlurResult = 'inside' | 'committed' | 'skipped';
6
- type UseCustomOptionParams = {
7
- allowCustomOption: boolean;
8
- addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
9
- addOptionByEnter: boolean;
10
- multiple: boolean;
11
- inputValue: string;
12
- multipleValue: ItemId[];
13
- commitSingle(value: ItemId | undefined): void;
14
- commitMultiple(value: ItemId[]): void;
15
- resetSearch(): void;
16
- inputElementRef: RefObject<HTMLInputElement | null>;
17
- clearButtonRef: RefObject<HTMLButtonElement | null>;
18
- copyButtonRef: RefObject<HTMLButtonElement | null>;
19
- footerActiveElementsRefs?: RefObject<HTMLElement>[];
20
- };
21
- /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
22
- export declare function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }: UseCustomOptionParams): {
23
- listRef: RefObject<HTMLElement>;
24
- suppressCloseCommit: () => void;
25
- enableCloseCommit: () => void;
26
- tryCommitOnClose: () => boolean;
27
- tryCommitOnBlur: (event: FocusEvent<HTMLInputElement>) => CustomOptionBlurResult;
28
- tryCommitFromKeyboard: (event: KeyboardEvent<HTMLInputElement>) => boolean;
29
- handleListSingleChange: (next: ItemId | undefined) => void;
30
- handleListMultipleChange: (next: ItemId[]) => void;
31
- };
32
- export {};
@@ -1,83 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.useCustomOption = useCustomOption;
4
- const utils_1 = require("@cloud-ru/ds-utils");
5
- const react_1 = require("react");
6
- const constants_1 = require("./constants");
7
- const utils_2 = require("./utils");
8
- /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
9
- function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }) {
10
- const listRef = (0, react_1.useRef)(null);
11
- const skipCloseCommitRef = (0, react_1.useRef)(false);
12
- const resolvedTriggers = (0, react_1.useMemo)(() => (0, utils_2.resolveAddCustomOptionTriggers)({
13
- allowCustomOption,
14
- addCustomOptionTriggers,
15
- addOptionByEnter,
16
- defaultTriggers: multiple ? constants_1.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : constants_1.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
17
- }), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
18
- const tryCommit = (0, react_1.useCallback)((trigger) => {
19
- if (!(0, utils_2.shouldHandleCustomOptionTrigger)(trigger, resolvedTriggers) || inputValue === '') {
20
- return false;
21
- }
22
- if (multiple) {
23
- if (multipleValue.includes(inputValue)) {
24
- resetSearch();
25
- }
26
- else {
27
- commitMultiple([...multipleValue, inputValue]);
28
- }
29
- }
30
- else {
31
- commitSingle(inputValue);
32
- }
33
- return true;
34
- }, [resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
35
- const suppressCloseCommit = (0, react_1.useCallback)(() => {
36
- skipCloseCommitRef.current = true;
37
- }, []);
38
- const enableCloseCommit = (0, react_1.useCallback)(() => {
39
- skipCloseCommitRef.current = false;
40
- }, []);
41
- const tryCommitOnClose = (0, react_1.useCallback)(() => {
42
- const shouldCommit = (0, utils_2.shouldCommitCustomOptionOnClose)({
43
- skip: skipCloseCommitRef.current,
44
- inputElement: inputElementRef.current,
45
- activeElement: (0, utils_1.isBrowser)() ? document.activeElement : null,
46
- });
47
- skipCloseCommitRef.current = false;
48
- return shouldCommit && tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur);
49
- }, [inputElementRef, tryCommit]);
50
- const tryCommitOnBlur = (0, react_1.useCallback)((event) => {
51
- if ((0, utils_2.isCustomOptionBlurInsideField)(event.relatedTarget, [
52
- listRef.current,
53
- clearButtonRef.current,
54
- copyButtonRef.current,
55
- ...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
56
- ])) {
57
- return 'inside';
58
- }
59
- return tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
60
- }, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
61
- const tryCommitFromKeyboard = (0, react_1.useCallback)((event) => {
62
- const trigger = (0, utils_2.getCustomOptionTriggerByCode)(event.code, event.key);
63
- return trigger !== undefined && tryCommit(trigger);
64
- }, [tryCommit]);
65
- const handleListSingleChange = (0, react_1.useCallback)((next) => {
66
- skipCloseCommitRef.current = true;
67
- commitSingle(next);
68
- }, [commitSingle]);
69
- const handleListMultipleChange = (0, react_1.useCallback)((next) => {
70
- skipCloseCommitRef.current = true;
71
- commitMultiple(next);
72
- }, [commitMultiple]);
73
- return {
74
- listRef,
75
- suppressCloseCommit,
76
- enableCloseCommit,
77
- tryCommitOnClose,
78
- tryCommitOnBlur,
79
- tryCommitFromKeyboard,
80
- handleListSingleChange,
81
- handleListMultipleChange,
82
- };
83
- }
@@ -1,21 +0,0 @@
1
- import { FieldSelectAddCustomOptionTrigger, FieldSelectMultipleAddCustomOptionTrigger } from '../types';
2
- /**
3
- * Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
4
- * Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
5
- */
6
- export declare function getCustomOptionTriggerByCode(code: string, key?: string): FieldSelectMultipleAddCustomOptionTrigger | undefined;
7
- export declare function shouldHandleCustomOptionTrigger(trigger: FieldSelectAddCustomOptionTrigger | undefined, availableTriggers: readonly FieldSelectAddCustomOptionTrigger[]): trigger is FieldSelectAddCustomOptionTrigger;
8
- export declare function resolveAddCustomOptionTriggers(params: {
9
- allowCustomOption: boolean;
10
- addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
11
- addOptionByEnter: boolean;
12
- defaultTriggers: readonly FieldSelectAddCustomOptionTrigger[];
13
- }): FieldSelectAddCustomOptionTrigger[];
14
- /** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
15
- export declare function isCustomOptionBlurInsideField(relatedTarget: EventTarget | null, nodes: (Node | null | undefined)[]): boolean;
16
- /** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
17
- export declare function shouldCommitCustomOptionOnClose(params: {
18
- skip: boolean;
19
- inputElement: Element | null;
20
- activeElement: Element | null;
21
- }): boolean;
@@ -1,55 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getCustomOptionTriggerByCode = getCustomOptionTriggerByCode;
4
- exports.shouldHandleCustomOptionTrigger = shouldHandleCustomOptionTrigger;
5
- exports.resolveAddCustomOptionTriggers = resolveAddCustomOptionTriggers;
6
- exports.isCustomOptionBlurInsideField = isCustomOptionBlurInsideField;
7
- exports.shouldCommitCustomOptionOnClose = shouldCommitCustomOptionOnClose;
8
- const constants_1 = require("../constants");
9
- /**
10
- * Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
11
- * Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
12
- */
13
- function getCustomOptionTriggerByCode(code, key) {
14
- switch (code) {
15
- case 'Enter':
16
- return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter;
17
- case 'Space':
18
- return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Space;
19
- case 'Comma':
20
- return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Comma;
21
- default:
22
- break;
23
- }
24
- switch (key) {
25
- case 'Enter':
26
- return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter;
27
- case ' ':
28
- return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Space;
29
- case ',':
30
- return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Comma;
31
- default:
32
- return undefined;
33
- }
34
- }
35
- function shouldHandleCustomOptionTrigger(trigger, availableTriggers) {
36
- return trigger !== undefined && availableTriggers.includes(trigger);
37
- }
38
- function resolveAddCustomOptionTriggers(params) {
39
- if (params.allowCustomOption) {
40
- const source = params.addCustomOptionTriggers !== undefined ? params.addCustomOptionTriggers : params.defaultTriggers;
41
- return source.filter(trigger => params.defaultTriggers.includes(trigger));
42
- }
43
- if (params.addOptionByEnter) {
44
- return [constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter];
45
- }
46
- return [];
47
- }
48
- /** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
49
- function isCustomOptionBlurInsideField(relatedTarget, nodes) {
50
- return relatedTarget instanceof Node && nodes.some(node => Boolean(node?.contains(relatedTarget)));
51
- }
52
- /** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
53
- function shouldCommitCustomOptionOnClose(params) {
54
- return !params.skip && params.activeElement !== null && params.activeElement === params.inputElement;
55
- }
@@ -1,11 +0,0 @@
1
- import { ItemId } from '@cloud-ru/ds-list';
2
- import { Appearance as TagAppearance } from '@cloud-ru/ds-tag';
3
- export type WithIdContent = {
4
- id?: ItemId;
5
- content?: unknown;
6
- disabled?: boolean;
7
- appearance?: TagAppearance;
8
- };
9
- export declare function extractLabel(item: WithIdContent): string;
10
- export declare function extractSearchText(item: WithIdContent): string;
11
- export declare function extractAppearance(item?: WithIdContent): TagAppearance | undefined;