@cloud-ru/ds-fields 2.2.3-preview-8025692f.0 → 2.2.3-preview-a0540d7f.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 +4 -2
  2. package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
  3. package/dist/cjs/components/FieldDate/mask.d.ts +2 -3
  4. package/dist/cjs/components/FieldDate/mask.js +3 -4
  5. package/dist/cjs/components/FieldSelect/FieldSelect.js +98 -43
  6. package/dist/cjs/components/FieldSelect/constants.d.ts +11 -0
  7. package/dist/cjs/components/FieldSelect/constants.js +20 -1
  8. package/dist/cjs/components/FieldSelect/hooks.d.ts +33 -0
  9. package/dist/cjs/components/FieldSelect/hooks.js +95 -0
  10. package/dist/cjs/components/FieldSelect/types.d.ts +26 -3
  11. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +21 -0
  12. package/dist/cjs/components/FieldSelect/utils/customOption.js +55 -0
  13. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +11 -0
  14. package/dist/cjs/components/FieldSelect/utils/extract.js +39 -0
  15. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +3 -0
  16. package/dist/cjs/components/FieldSelect/utils/filter.js +40 -0
  17. package/dist/cjs/components/FieldSelect/utils/index.d.ts +6 -0
  18. package/dist/cjs/components/FieldSelect/utils/index.js +22 -0
  19. package/dist/cjs/components/FieldSelect/utils/items.d.ts +9 -0
  20. package/dist/cjs/components/FieldSelect/utils/items.js +47 -0
  21. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +11 -0
  22. package/dist/cjs/components/FieldSelect/utils/selection.js +18 -0
  23. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +4 -0
  24. package/dist/cjs/components/FieldSelect/utils/tagSize.js +5 -0
  25. package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
  26. package/dist/cjs/segments/useSegmentedMask.d.ts +0 -2
  27. package/dist/cjs/segments/useSegmentedMask.js +56 -146
  28. package/dist/esm/components/FieldDate/FieldDate.js +3 -3
  29. package/dist/esm/components/FieldDate/mask.d.ts +2 -3
  30. package/dist/esm/components/FieldDate/mask.js +3 -4
  31. package/dist/esm/components/FieldSelect/FieldSelect.js +100 -45
  32. package/dist/esm/components/FieldSelect/constants.d.ts +11 -0
  33. package/dist/esm/components/FieldSelect/constants.js +19 -0
  34. package/dist/esm/components/FieldSelect/hooks.d.ts +33 -0
  35. package/dist/esm/components/FieldSelect/hooks.js +92 -0
  36. package/dist/esm/components/FieldSelect/types.d.ts +26 -3
  37. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +21 -0
  38. package/dist/esm/components/FieldSelect/utils/customOption.js +48 -0
  39. package/dist/esm/components/FieldSelect/utils/extract.d.ts +11 -0
  40. package/dist/esm/components/FieldSelect/utils/extract.js +34 -0
  41. package/dist/esm/components/FieldSelect/utils/filter.d.ts +3 -0
  42. package/dist/esm/components/FieldSelect/utils/filter.js +36 -0
  43. package/dist/esm/components/FieldSelect/utils/index.d.ts +6 -0
  44. package/dist/esm/components/FieldSelect/utils/index.js +6 -0
  45. package/dist/esm/components/FieldSelect/utils/items.d.ts +9 -0
  46. package/dist/esm/components/FieldSelect/utils/items.js +41 -0
  47. package/dist/esm/components/FieldSelect/utils/selection.d.ts +11 -0
  48. package/dist/esm/components/FieldSelect/utils/selection.js +14 -0
  49. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +4 -0
  50. package/dist/esm/components/FieldSelect/utils/tagSize.js +2 -0
  51. package/dist/esm/components/FieldTime/FieldTime.js +2 -2
  52. package/dist/esm/segments/useSegmentedMask.d.ts +0 -2
  53. package/dist/esm/segments/useSegmentedMask.js +56 -146
  54. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  55. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  56. package/package.json +8 -8
  57. package/src/components/FieldDate/FieldDate.tsx +3 -3
  58. package/src/components/FieldDate/mask.ts +3 -4
  59. package/src/components/FieldSelect/FieldSelect.tsx +151 -63
  60. package/src/components/FieldSelect/constants.ts +22 -0
  61. package/src/components/FieldSelect/hooks.ts +177 -0
  62. package/src/components/FieldSelect/types.ts +82 -48
  63. package/src/components/FieldSelect/utils/customOption.ts +77 -0
  64. package/src/components/FieldSelect/utils/extract.ts +49 -0
  65. package/src/components/FieldSelect/utils/filter.ts +51 -0
  66. package/src/components/FieldSelect/utils/index.ts +6 -0
  67. package/src/components/FieldSelect/utils/items.ts +58 -0
  68. package/src/components/FieldSelect/utils/selection.ts +25 -0
  69. package/src/components/FieldSelect/utils/tagSize.ts +5 -0
  70. package/src/components/FieldTime/FieldTime.tsx +2 -2
  71. package/src/segments/useSegmentedMask.ts +52 -172
  72. package/dist/cjs/components/FieldSelect/utils.d.ts +0 -19
  73. package/dist/cjs/components/FieldSelect/utils.js +0 -103
  74. package/dist/esm/components/FieldSelect/utils.d.ts +0 -19
  75. package/dist/esm/components/FieldSelect/utils.js +0 -92
  76. package/src/components/FieldSelect/utils.ts +0 -132
@@ -0,0 +1,55 @@
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
+ }
@@ -0,0 +1,11 @@
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;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractLabel = extractLabel;
4
+ exports.extractSearchText = extractSearchText;
5
+ exports.extractAppearance = extractAppearance;
6
+ function extractLabel(item) {
7
+ const { content, id } = item;
8
+ if (!content) {
9
+ return String(id ?? '');
10
+ }
11
+ if (typeof content === 'string' || typeof content === 'number') {
12
+ return String(content);
13
+ }
14
+ if (typeof content === 'object' && content !== null && 'label' in content) {
15
+ return String(content.label);
16
+ }
17
+ return String(id ?? '');
18
+ }
19
+ // Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
20
+ // который матчил запрос по всем трём полям контента, а не только по лейблу.
21
+ function extractSearchText(item) {
22
+ const parts = [extractLabel(item)];
23
+ const { content } = item;
24
+ if (content && typeof content === 'object') {
25
+ const c = content;
26
+ if (typeof c.caption === 'string') {
27
+ parts.push(c.caption);
28
+ }
29
+ if (typeof c.description === 'string') {
30
+ parts.push(c.description);
31
+ }
32
+ }
33
+ return parts.join(' ');
34
+ }
35
+ // Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
36
+ // Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
37
+ function extractAppearance(item) {
38
+ return typeof item?.appearance === 'string' ? item.appearance : undefined;
39
+ }
@@ -0,0 +1,3 @@
1
+ import { FieldSelectItem } from '../types';
2
+ export declare function isFuzzyMatch(haystack: string, needle: string): boolean;
3
+ export declare function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[];
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isFuzzyMatch = isFuzzyMatch;
4
+ exports.filterItems = filterItems;
5
+ const extract_1 = require("./extract");
6
+ // Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
7
+ // Пример: query=`lge` matches `Large` (L → r → arg → e).
8
+ function isFuzzyMatch(haystack, needle) {
9
+ if (!needle) {
10
+ return true;
11
+ }
12
+ let i = 0;
13
+ for (const ch of haystack) {
14
+ if (ch === needle[i]) {
15
+ i += 1;
16
+ if (i === needle.length) {
17
+ return true;
18
+ }
19
+ }
20
+ }
21
+ return false;
22
+ }
23
+ function filterItems(items, query, fuzzy) {
24
+ if (!query) {
25
+ return items;
26
+ }
27
+ const q = query.toLowerCase();
28
+ const matches = (item) => {
29
+ const text = (0, extract_1.extractSearchText)(item).toLowerCase();
30
+ return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
31
+ };
32
+ const walk = (list) => list.flatMap(item => {
33
+ if ('items' in item && Array.isArray(item.items)) {
34
+ const filteredChildren = walk(item.items);
35
+ return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren }] : [];
36
+ }
37
+ return matches(item) ? [item] : [];
38
+ });
39
+ return walk(items);
40
+ }
@@ -0,0 +1,6 @@
1
+ export * from './customOption';
2
+ export * from './extract';
3
+ export * from './filter';
4
+ export * from './items';
5
+ export * from './selection';
6
+ export * from './tagSize';
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./customOption"), exports);
18
+ __exportStar(require("./extract"), exports);
19
+ __exportStar(require("./filter"), exports);
20
+ __exportStar(require("./items"), exports);
21
+ __exportStar(require("./selection"), exports);
22
+ __exportStar(require("./tagSize"), exports);
@@ -0,0 +1,9 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { FieldSelectItem } from '../types';
3
+ import { WithIdContent } from './extract';
4
+ export declare function flatten(items: FieldSelectItem[]): WithIdContent[];
5
+ export declare function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined;
6
+ /** Айтем для выбранного значения, которого нет в `items` (кастомная опция, ленивая подгрузка). */
7
+ export declare function createPlaceholderItem(id: ItemId): FieldSelectItem;
8
+ /** Выбранные id, которых нет в дереве айтемов — в дроплист их нужно добавить отдельно. */
9
+ export declare function getMissingSelectedItems(selectedIds: ItemId[], allItems: FieldSelectItem[]): FieldSelectItem[];
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flatten = flatten;
4
+ exports.findItem = findItem;
5
+ exports.createPlaceholderItem = createPlaceholderItem;
6
+ exports.getMissingSelectedItems = getMissingSelectedItems;
7
+ function flatten(items) {
8
+ const out = [];
9
+ for (const item of items) {
10
+ out.push(item);
11
+ if ('items' in item && Array.isArray(item.items)) {
12
+ out.push(...flatten(item.items));
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+ function findItem(items, id) {
18
+ for (const item of flatten(items)) {
19
+ if (item.id === id) {
20
+ return item;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+ /** Айтем для выбранного значения, которого нет в `items` (кастомная опция, ленивая подгрузка). */
26
+ function createPlaceholderItem(id) {
27
+ return { id, content: { label: String(id) } };
28
+ }
29
+ /** Выбранные id, которых нет в дереве айтемов — в дроплист их нужно добавить отдельно. */
30
+ function getMissingSelectedItems(selectedIds, allItems) {
31
+ if (selectedIds.length === 0) {
32
+ return [];
33
+ }
34
+ const existingIds = new Set();
35
+ for (const item of flatten(allItems)) {
36
+ if (item.id !== undefined) {
37
+ existingIds.add(item.id);
38
+ }
39
+ }
40
+ const missing = [];
41
+ for (const id of selectedIds) {
42
+ if (id !== '' && !existingIds.has(id)) {
43
+ missing.push(createPlaceholderItem(id));
44
+ }
45
+ }
46
+ return missing;
47
+ }
@@ -0,0 +1,11 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { FieldSelectMultipleProps, Selection } from '../types';
3
+ export declare function isMultiple(props: {
4
+ selection?: Selection;
5
+ }): props is FieldSelectMultipleProps;
6
+ /** Выбранные id в нормализованном виде: массив и для `single`, и для `multiple`. */
7
+ export declare function getSelectedIds(params: {
8
+ multiple: boolean;
9
+ multipleValue: ItemId[];
10
+ singleValue: ItemId | undefined;
11
+ }): ItemId[];
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMultiple = isMultiple;
4
+ exports.getSelectedIds = getSelectedIds;
5
+ const constants_1 = require("../constants");
6
+ function isMultiple(props) {
7
+ return props.selection === constants_1.SELECTION_MODE.Multiple;
8
+ }
9
+ /** Выбранные id в нормализованном виде: массив и для `single`, и для `multiple`. */
10
+ function getSelectedIds(params) {
11
+ if (params.multiple) {
12
+ return params.multipleValue;
13
+ }
14
+ if (params.singleValue === undefined) {
15
+ return [];
16
+ }
17
+ return [params.singleValue];
18
+ }
@@ -0,0 +1,4 @@
1
+ import { Size } from '@cloud-ru/ds-field-decorator';
2
+ import { Size as TagSize } from '@cloud-ru/ds-tag';
3
+ /** Соответствие размера поля размеру чипа выбранного значения (`selection='multiple'`). */
4
+ export declare const TAG_SIZE_MAP: Record<Size, TagSize>;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TAG_SIZE_MAP = void 0;
4
+ /** Соответствие размера поля размеру чипа выбранного значения (`selection='multiple'`). */
5
+ exports.TAG_SIZE_MAP = { s: 'xs', m: 'xs', l: 's' };
@@ -137,7 +137,7 @@ exports.FieldTime = (0, react_1.forwardRef)(function FieldTime({ label = '', lab
137
137
  // clear/copy недостижимыми со стрелок — движок всегда делает preventDefault на ArrowRight.
138
138
  onInputKeyDown(event);
139
139
  }, [onInputKeyDown, timeSeg]);
140
- // value ведёт сегментный движок (useSegmentedMask); нативный onChange input'а игнорируем.
140
+ // value ведёт сегментный движок (keydown); нативный onChange input'а игнорируем.
141
141
  const handleInputChange = (0, react_1.useCallback)(() => undefined, []);
142
142
  const handleInputFocus = (0, react_1.useCallback)((event) => {
143
143
  setFocusVisible(event.target.matches(':focus-visible'));
@@ -155,7 +155,7 @@ exports.FieldTime = (0, react_1.forwardRef)(function FieldTime({ label = '', lab
155
155
  readonly: readOnly,
156
156
  hover,
157
157
  focusVisible: focusVisible || open,
158
- }), children: (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.acrylicBg, "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.borderStateLayer, "data-state": 'borderOnBackground' }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.focusLayer })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.fieldContainer, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.contentWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputLine, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.inputArea, children: [(0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, localRef, timeSeg.nativeInputRef), className: styles_module_scss_1.default.fieldInput, value: inputValue, onChange: handleInputChange, disabled: disabled, readonly: readOnly, placeholder: placeholderMask, inputMode: input_private_1.INPUT_MODE.Numeric, tabIndex: inputTabIndex, onClick: timeSeg.handleClick, onPaste: timeSeg.handlePaste, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, ...(0, utils_1.extractSupportProps)(rest), "data-test-id": constants_1.TEST_IDS.fieldTimeInput }), postfixButtons && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, children: postfixButtons })] }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, "data-test-id": constants_1.TEST_IDS.fieldTimeIcon, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.WatchSVG, {}) })] }) })] }));
158
+ }), children: (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.acrylicBg, "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.borderStateLayer, "data-state": 'borderOnBackground' }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.focusLayer })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.fieldContainer, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.contentWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputLine, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.inputArea, children: [(0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, localRef), className: styles_module_scss_1.default.fieldInput, value: inputValue, onChange: handleInputChange, disabled: disabled, readonly: readOnly, placeholder: placeholderMask, inputMode: input_private_1.INPUT_MODE.Numeric, tabIndex: inputTabIndex, onClick: timeSeg.handleClick, onPaste: timeSeg.handlePaste, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, id: id, name: name, autoFocus: resolvedAutoFocus, "aria-haspopup": 'dialog', "aria-expanded": open, ...(0, utils_1.extractSupportProps)(rest), "data-test-id": constants_1.TEST_IDS.fieldTimeInput }), postfixButtons && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, children: postfixButtons })] }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, "data-test-id": constants_1.TEST_IDS.fieldTimeIcon, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.WatchSVG, {}) })] }) })] }));
159
159
  return ((0, jsx_runtime_1.jsx)(field_decorator_1.FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, size: size, children: (0, jsx_runtime_1.jsx)(calendar_1.TimePickerDropdown
160
160
  // Явный data-test-id на дропдауне → детерминированный id портального контента:
161
161
  // `getTestIdBuilder` строит `content-${id}`, так контент адресуем для open-picker-снимка
@@ -29,7 +29,6 @@ type UseSegmentedMaskParams = {
29
29
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
30
30
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
31
31
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
32
- * `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
33
32
  */
34
33
  export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }: UseSegmentedMaskParams): {
35
34
  handleKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
@@ -37,7 +36,6 @@ export declare function useSegmentedMask({ inputRef, mask, slots, readonly, disa
37
36
  handleFocus: () => void;
38
37
  handleBlur: () => void;
39
38
  handlePaste: (event: ClipboardEvent<HTMLInputElement>) => void;
40
- nativeInputRef: (node: HTMLInputElement | null) => void;
41
39
  selectSlot: (slot: SlotMeta) => void;
42
40
  firstSlot: SlotMeta;
43
41
  lastSlot: SlotMeta;
@@ -1,24 +1,11 @@
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");
5
4
  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
- ]);
17
5
  /**
18
6
  * Сегментный ввод даты/времени (порт `useDateField` из @snack-uikit/fields). Каретка ходит по
19
7
  * сегментам (`setSelectionRange` подсвечивает текущий), цифры заполняют сегмент с авто-переходом,
20
8
  * стрелки двигают по сегментам, Backspace очищает сегмент до плейсхолдера.
21
- * `nativeInputRef` передаётся в `ref` того же input'а: значение правится через нативные `beforeinput`/`input`.
22
9
  */
23
10
  function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue, onMaskedChange, onArrowDown, onArrowUp, onEscape, onEdit, }) {
24
11
  const firstSlot = slots[0];
@@ -123,61 +110,70 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
123
110
  }
124
111
  selectSlot(nextSlot(slot));
125
112
  }, [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
- }, []);
131
113
  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;
132
119
  const maxLength = slot.max.toString().length;
133
- const prev = typedRef.current?.key === slot.key ? typedRef.current.digits : '';
134
- const appended = prev + key;
135
- // Как в React Aria и MUI X: `13` в месяце → `03`.
136
- const overflow = Number(appended) > slot.max;
137
- const digits = overflow ? key : appended;
138
- const value = Number(digits);
139
- const complete = overflow || digits.length >= maxLength || value * 10 > slot.max;
140
- const accepted = value >= slot.min;
141
- if (accepted)
142
- updateSlot(slot, value);
143
- if (!complete) {
144
- typedRef.current = { key: slot.key, digits };
145
- return;
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);
146
143
  }
147
- typedRef.current = null;
148
- if (accepted)
144
+ else if (digit * 10 > slot.max) {
145
+ updateSlot(slot, key);
149
146
  checkInputAndGoNext(slot);
150
- }, [checkInputAndGoNext, updateSlot]);
147
+ }
148
+ else if (digit || digit >= slot.min) {
149
+ updateSlot(slot, key);
150
+ }
151
+ }, [checkInputAndGoNext, getSlotText, updateSlot]);
151
152
  const handleFocus = (0, react_1.useCallback)(() => {
152
153
  if (readonly || disabled)
153
154
  return;
154
- resetTyped();
155
155
  ensureMask();
156
156
  // На фокус всегда выбираем ПЕРВЫЙ сегмент (паритет с легаси). При клике мышью `handleClick`
157
157
  // отработает следом и перевыберет сегмент под кареткой; при Tab остаётся первый.
158
158
  selectSlot(firstSlot);
159
- }, [disabled, ensureMask, firstSlot, readonly, resetTyped, selectSlot]);
159
+ }, [disabled, ensureMask, firstSlot, readonly, selectSlot]);
160
160
  const handleClick = (0, react_1.useCallback)((event) => {
161
161
  if (readonly || disabled)
162
162
  return;
163
- resetTyped();
164
163
  ensureMask();
165
164
  selectSlot(slotFromIndex(event.currentTarget.selectionStart));
166
- }, [disabled, ensureMask, readonly, resetTyped, selectSlot, slotFromIndex]);
165
+ }, [disabled, ensureMask, readonly, selectSlot, slotFromIndex]);
167
166
  const handleBlur = (0, react_1.useCallback)(() => {
168
- resetTyped();
169
167
  const input = inputRef.current;
170
168
  if (input && input.value === mask) {
171
169
  input.value = '';
172
170
  setValue('');
173
171
  }
174
- }, [inputRef, mask, resetTyped, setValue]);
172
+ }, [inputRef, mask, setValue]);
175
173
  const handleKeyDown = (0, react_1.useCallback)((event) => {
176
174
  const input = inputRef.current;
177
175
  if (!input || readonly || disabled)
178
176
  return;
179
- if (RESET_TYPED_KEYS.has(event.key))
180
- resetTyped();
181
177
  // Tab уводит фокус из поля — не перехватываем.
182
178
  if (event.key === 'Tab')
183
179
  return;
@@ -192,13 +188,7 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
192
188
  onArrowUp?.();
193
189
  return;
194
190
  }
195
- // Копирование и вставку не блокируем: правку поля перехватит beforeinput.
196
- if (event.ctrlKey || event.metaKey || event.altKey)
197
- return;
198
191
  onEdit?.();
199
- // Экранные клавиатуры присылают `Unidentified` вместо цифры, её данные есть только в beforeinput.
200
- if (/^\d$/.test(event.key) || event.key === 'Unidentified')
201
- return;
202
192
  if (event.key === 'Escape') {
203
193
  event.preventDefault();
204
194
  onEscape?.();
@@ -233,6 +223,11 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
233
223
  if (event.key === 'Enter') {
234
224
  tryToComplete();
235
225
  emit();
226
+ return;
227
+ }
228
+ if (/^\d$/.test(event.key)) {
229
+ typeDigit(slot, event.key);
230
+ emit();
236
231
  }
237
232
  }, [
238
233
  clearSlot,
@@ -248,13 +243,21 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
248
243
  onEscape,
249
244
  prevSlot,
250
245
  readonly,
251
- resetTyped,
252
246
  selectSlot,
253
247
  slotFromIndex,
254
248
  tryToComplete,
249
+ typeDigit,
255
250
  ]);
256
- const fillFromDigits = (0, react_1.useCallback)((digits) => {
257
- resetTyped();
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;
258
261
  ensureMask();
259
262
  let cursor = 0;
260
263
  let lastFilled;
@@ -273,100 +276,7 @@ function useSegmentedMask({ inputRef, mask, slots, readonly, disabled, setValue,
273
276
  else if (lastFilled) {
274
277
  selectSlot(nextSlot(lastFilled));
275
278
  }
276
- }, [ensureMask, lastSlot, nextSlot, resetTyped, selectSlot, slots, tryToComplete, updateSlot]);
277
- const handlePaste = (0, react_1.useCallback)((event) => {
278
- if (readonly || disabled)
279
- return;
280
- event.preventDefault();
281
- const digits = event.clipboardData.getData('text').replace(/\D/g, '');
282
- if (!digits)
283
- return;
284
- fillFromDigits(digits);
285
- emit();
286
- }, [disabled, emit, fillFromDigits, readonly]);
287
- const applyEdit = (0, react_1.useCallback)((inputType, text) => {
288
- const input = inputRef.current;
289
- if (!input)
290
- return;
291
- onEdit?.();
292
- if (inputType.startsWith('delete')) {
293
- resetTyped();
294
- clearSlot(slotFromIndex(input.selectionStart));
295
- emit();
296
- return;
297
- }
298
- const digits = text.replace(/\D/g, '');
299
- if (!inputType.startsWith('insert') || !digits)
300
- return;
301
- // Автозаполнение, `fill` и drop присылают строку целиком.
302
- if (text.length > 1) {
303
- fillFromDigits(digits);
304
- }
305
- else {
306
- typeDigit(slotFromIndex(input.selectionStart), digits);
307
- }
308
- emit();
309
- }, [clearSlot, emit, fillFromDigits, inputRef, onEdit, resetTyped, slotFromIndex, typeDigit]);
310
- // Композицию IME браузер не даёт отменить — откатываем её в input.
311
- const pendingEditRef = (0, react_1.useRef)(null);
312
- const handleBeforeInput = (0, react_1.useCallback)((event) => {
313
- const input = inputRef.current;
314
- if (!input || readonly || disabled)
315
- return;
316
- ensureMask();
317
- if (!event.cancelable) {
318
- pendingEditRef.current = { value: input.value, start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 };
319
- return;
320
- }
321
- event.preventDefault();
322
- applyEdit(event.inputType, event.data ?? event.dataTransfer?.getData('text') ?? '');
323
- }, [applyEdit, disabled, ensureMask, inputRef, readonly]);
324
- const handleInput = (0, react_1.useCallback)((event) => {
325
- const input = inputRef.current;
326
- if (!input || readonly || disabled)
327
- return;
328
- const pending = pendingEditRef.current;
329
- pendingEditRef.current = null;
330
- if (pending) {
331
- const inputType = event instanceof InputEvent ? event.inputType : '';
332
- const inserted = input.value.slice(pending.start, input.value.length - (pending.value.length - pending.end));
333
- input.value = pending.value;
334
- setValue(pending.value);
335
- input.setSelectionRange(pending.start, pending.end);
336
- // Цифру композиции вводит её фиксация (insertText), иначе цифра запишется дважды.
337
- if (!inputType.includes('Composition'))
338
- applyEdit(inputType, inserted);
339
- return;
340
- }
341
- // Автозаполнение меняет значение без beforeinput.
342
- const digits = input.value.replace(/\D/g, '');
343
- input.value = mask;
344
- setValue(mask);
345
- fillFromDigits(digits);
346
279
  emit();
347
- }, [applyEdit, disabled, emit, fillFromDigits, inputRef, mask, readonly, setValue]);
348
- // React 18 не отдаёт `inputType` в `onBeforeInput`, поэтому слушаем нативные события.
349
- const onNativeBeforeInput = (0, utils_1.useEventHandler)(handleBeforeInput);
350
- const onNativeInput = (0, utils_1.useEventHandler)(handleInput);
351
- const attachedInputRef = (0, react_1.useRef)(null);
352
- const nativeInputRef = (0, react_1.useCallback)((node) => {
353
- /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- callback-ref вызывается только в браузере */
354
- attachedInputRef.current?.removeEventListener('beforeinput', onNativeBeforeInput);
355
- attachedInputRef.current?.removeEventListener('input', onNativeInput);
356
- attachedInputRef.current = node;
357
- node?.addEventListener('beforeinput', onNativeBeforeInput);
358
- node?.addEventListener('input', onNativeInput);
359
- /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
360
- }, [onNativeBeforeInput, onNativeInput]);
361
- return {
362
- handleKeyDown,
363
- handleClick,
364
- handleFocus,
365
- handleBlur,
366
- handlePaste,
367
- nativeInputRef,
368
- selectSlot,
369
- firstSlot,
370
- lastSlot,
371
- };
280
+ }, [disabled, emit, ensureMask, lastSlot, nextSlot, readonly, selectSlot, slots, tryToComplete, updateSlot]);
281
+ return { handleKeyDown, handleClick, handleFocus, handleBlur, handlePaste, selectSlot, firstSlot, lastSlot };
372
282
  }
@@ -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, singleSeg.nativeInputRef), className: fieldStyles.fieldInput, value: singleInputValue, placeholder: placeholderMask,
325
- // Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
326
- // игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
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).
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,9 +10,8 @@ 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. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
14
- * сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
15
- * Пример: `39` для day → `09`, `13` для month → `03`.
13
+ * 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
14
+ * значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
16
15
  */
17
16
  export declare function formatMask(input: string, mode: MaskMode, showSeconds?: boolean): string;
18
17
  /** Парсит `DD.MM.YYYY` или `DD.MM.YYYY, HH:mm[:ss]` в `Date`. Возвращает `undefined` при невалидной/неполной строке. */