@inkra/react 0.3.0 → 0.5.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 (245) hide show
  1. package/README.md +113 -3
  2. package/dist/Button.js +3 -2
  3. package/dist/PencilIcon.d.ts +10 -0
  4. package/dist/PencilIcon.js +83 -39
  5. package/dist/PencilLine.js +9 -1
  6. package/dist/PencilText.js +15 -9
  7. package/dist/accordion/Accordion.js +65 -5
  8. package/dist/alert-dialog/index.js +14 -4
  9. package/dist/audio-player/index.d.ts +2 -0
  10. package/dist/audio-player/index.js +131 -34
  11. package/dist/breadcrumb/index.js +4 -1
  12. package/dist/calendar/index.d.ts +4 -1
  13. package/dist/calendar/index.js +37 -12
  14. package/dist/calendar/locales.d.ts +7 -0
  15. package/dist/calendar/locales.js +2 -0
  16. package/dist/carousel/index.js +20 -10
  17. package/dist/choice-grid/index.d.ts +6 -1
  18. package/dist/choice-grid/index.js +63 -33
  19. package/dist/color-picker/GradientEditor.d.ts +40 -0
  20. package/dist/color-picker/GradientEditor.js +64 -0
  21. package/dist/color-picker/gradient.d.ts +25 -0
  22. package/dist/color-picker/gradient.js +60 -0
  23. package/dist/color-picker/index.d.ts +27 -7
  24. package/dist/color-picker/index.js +144 -40
  25. package/dist/color-swatch/index.d.ts +10 -0
  26. package/dist/color-swatch/index.js +9 -0
  27. package/dist/command/index.js +27 -4
  28. package/dist/content/index.d.ts +5 -0
  29. package/dist/content/index.js +15 -3
  30. package/dist/context-menu/index.js +1 -1
  31. package/dist/data-table/index.d.ts +7 -1
  32. package/dist/data-table/index.js +67 -16
  33. package/dist/data-table/model.d.ts +2 -0
  34. package/dist/data-table/model.js +7 -0
  35. package/dist/date-picker/index.d.ts +2 -0
  36. package/dist/date-picker/index.js +20 -21
  37. package/dist/day-agenda/index.d.ts +6 -1
  38. package/dist/day-agenda/index.js +49 -21
  39. package/dist/day-agenda/layout.js +65 -9
  40. package/dist/display/ProgressRing.js +7 -3
  41. package/dist/display/index.d.ts +4 -1
  42. package/dist/display/index.js +3 -2
  43. package/dist/dockable-panel/index.js +15 -13
  44. package/dist/drawer/index.js +11 -5
  45. package/dist/editable/index.d.ts +24 -2
  46. package/dist/editable/index.js +148 -40
  47. package/dist/empty/illustrations.d.ts +8 -0
  48. package/dist/empty/illustrations.js +78 -0
  49. package/dist/empty/index.d.ts +6 -1
  50. package/dist/empty/index.js +11 -3
  51. package/dist/flip/index.js +28 -8
  52. package/dist/form/Field.js +11 -10
  53. package/dist/form/Input.js +4 -1
  54. package/dist/form/Textarea.d.ts +3 -1
  55. package/dist/form/Textarea.js +9 -5
  56. package/dist/heatmap/index.js +4 -1
  57. package/dist/hotkey/index.d.ts +1 -0
  58. package/dist/hotkey/index.js +9 -4
  59. package/dist/hover-card/index.js +1 -1
  60. package/dist/i18n/catalog.d.ts +5 -0
  61. package/dist/i18n/catalog.js +289 -0
  62. package/dist/i18n/index.d.ts +23 -0
  63. package/dist/i18n/index.js +50 -0
  64. package/dist/image-cropper/index.js +21 -17
  65. package/dist/image-viewer/VideoResource.d.ts +21 -0
  66. package/dist/image-viewer/VideoResource.js +196 -0
  67. package/dist/image-viewer/index.d.ts +14 -0
  68. package/dist/image-viewer/index.js +141 -86
  69. package/dist/image-viewer/lucideGeometry.d.ts +3 -0
  70. package/dist/image-viewer/lucideGeometry.js +19 -0
  71. package/dist/index.d.ts +4 -0
  72. package/dist/index.js +4 -0
  73. package/dist/input-number/index.js +13 -5
  74. package/dist/internal/cache.d.ts +4 -0
  75. package/dist/internal/cache.js +18 -1
  76. package/dist/internal/iconGeometry.d.ts +2 -0
  77. package/dist/internal/iconGeometry.js +32 -0
  78. package/dist/internal/renderTokens.d.ts +4 -0
  79. package/dist/internal/renderTokens.js +4 -0
  80. package/dist/internal/stroke.d.ts +7 -0
  81. package/dist/internal/stroke.js +63 -7
  82. package/dist/internal/surfaceBudget.d.ts +20 -0
  83. package/dist/internal/surfaceBudget.js +69 -0
  84. package/dist/internal/surfaceRenderer.js +87 -28
  85. package/dist/internal/surfaceVisibility.d.ts +3 -0
  86. package/dist/internal/surfaceVisibility.js +60 -0
  87. package/dist/internal/textRenderer.d.ts +3 -0
  88. package/dist/internal/textRenderer.js +13 -5
  89. package/dist/internal/usePencilSurface.js +17 -1
  90. package/dist/internal/wash.js +4 -3
  91. package/dist/journey-map/index.d.ts +1 -1
  92. package/dist/journey-map/index.js +6 -3
  93. package/dist/journey-map/terrain.js +10 -5
  94. package/dist/keypad/index.js +4 -1
  95. package/dist/layout/index.js +1 -1
  96. package/dist/log/index.js +42 -33
  97. package/dist/markdown/CodeBlock.d.ts +19 -0
  98. package/dist/markdown/CodeBlock.js +86 -0
  99. package/dist/markdown/Deletion.d.ts +2 -0
  100. package/dist/markdown/Deletion.js +69 -0
  101. package/dist/markdown/icons.d.ts +14 -0
  102. package/dist/markdown/icons.js +16 -0
  103. package/dist/markdown/index.d.ts +17 -0
  104. package/dist/markdown/index.js +177 -0
  105. package/dist/node-graph/index.d.ts +3 -1
  106. package/dist/node-graph/index.js +63 -28
  107. package/dist/number-ticker/index.js +4 -1
  108. package/dist/number-ticker/precision.js +9 -2
  109. package/dist/overflow-list/index.js +9 -6
  110. package/dist/overlay/Overlay.js +8 -3
  111. package/dist/overlay/Popover.js +3 -3
  112. package/dist/overlay/themeBridge.js +14 -2
  113. package/dist/pagination/index.d.ts +10 -1
  114. package/dist/pagination/index.js +13 -9
  115. package/dist/pan-zoom/index.d.ts +2 -0
  116. package/dist/pan-zoom/index.js +12 -8
  117. package/dist/pencil.d.ts +1 -0
  118. package/dist/pencil.js +4 -4
  119. package/dist/radar/index.d.ts +2 -0
  120. package/dist/radar/index.js +44 -21
  121. package/dist/range-slider/index.js +4 -3
  122. package/dist/rating/index.d.ts +4 -0
  123. package/dist/rating/index.js +131 -24
  124. package/dist/resizable/index.d.ts +1 -1
  125. package/dist/resizable/index.js +5 -2
  126. package/dist/scroll-area/index.d.ts +1 -1
  127. package/dist/scroll-area/index.js +5 -2
  128. package/dist/select/MultipleSelect.js +5 -4
  129. package/dist/select/SearchableSelect.js +64 -7
  130. package/dist/select/Select.d.ts +1 -0
  131. package/dist/select/Select.js +6 -2
  132. package/dist/select/StepperSelect.d.ts +29 -0
  133. package/dist/select/StepperSelect.js +122 -0
  134. package/dist/select/index.d.ts +2 -0
  135. package/dist/select/index.js +1 -0
  136. package/dist/select/stepperModel.d.ts +7 -0
  137. package/dist/select/stepperModel.js +29 -0
  138. package/dist/selection/Checkbox.js +4 -3
  139. package/dist/selection/RadioGroup.js +29 -13
  140. package/dist/selection/Switch.js +4 -3
  141. package/dist/selection/useNativeChecked.d.ts +2 -1
  142. package/dist/selection/useNativeChecked.js +26 -8
  143. package/dist/slider/index.js +50 -6
  144. package/dist/sortable/index.js +17 -11
  145. package/dist/sparkline/index.js +21 -6
  146. package/dist/sparkline/model.js +11 -4
  147. package/dist/steps/index.js +4 -1
  148. package/dist/styles/accordion.css +228 -0
  149. package/dist/styles/alert-dialog.css +139 -0
  150. package/dist/styles/audio-player.css +22 -0
  151. package/dist/styles/base.css +638 -0
  152. package/dist/styles/breadcrumb.css +7 -0
  153. package/dist/styles/calendar.css +62 -0
  154. package/dist/styles/carousel.css +173 -0
  155. package/dist/styles/choice-grid.css +226 -0
  156. package/dist/styles/color-picker.css +281 -0
  157. package/dist/styles/color-swatch.css +47 -0
  158. package/dist/styles/command.css +52 -0
  159. package/dist/styles/content.css +79 -0
  160. package/dist/styles/context-menu.css +35 -0
  161. package/dist/styles/data-table.css +31 -0
  162. package/dist/styles/date-picker.css +107 -0
  163. package/dist/styles/day-agenda.css +30 -0
  164. package/dist/styles/display.css +304 -0
  165. package/dist/styles/dockable-panel.css +110 -0
  166. package/dist/styles/drawer.css +133 -0
  167. package/dist/styles/dropdown-menu.css +68 -0
  168. package/dist/styles/editable.css +145 -0
  169. package/dist/styles/empty.css +108 -0
  170. package/dist/styles/flip.css +101 -0
  171. package/dist/styles/form.css +634 -0
  172. package/dist/styles/heatmap.css +256 -0
  173. package/dist/styles/hotkey.css +189 -0
  174. package/dist/styles/hover-card.css +27 -0
  175. package/dist/styles/image-cropper.css +205 -0
  176. package/dist/styles/image-viewer.css +422 -0
  177. package/dist/styles/input-number.css +21 -0
  178. package/dist/styles/journey-map.css +19 -0
  179. package/dist/styles/keypad.css +92 -0
  180. package/dist/styles/layout.css +72 -0
  181. package/dist/styles/leaflet.css +661 -0
  182. package/dist/styles/log.css +203 -0
  183. package/dist/styles/markdown.css +379 -0
  184. package/dist/styles/node-graph.css +142 -0
  185. package/dist/styles/number-ticker.css +83 -0
  186. package/dist/styles/overflow-list.css +51 -0
  187. package/dist/styles/overlay.css +284 -0
  188. package/dist/styles/pagination.css +21 -0
  189. package/dist/styles/pan-zoom.css +136 -0
  190. package/dist/styles/radar.css +1 -0
  191. package/dist/styles/rating.css +121 -0
  192. package/dist/styles/resizable.css +108 -0
  193. package/dist/styles/scroll-area.css +122 -0
  194. package/dist/styles/select.css +554 -0
  195. package/dist/styles/selection.css +264 -0
  196. package/dist/styles/slider.css +145 -0
  197. package/dist/styles/slot.css +109 -0
  198. package/dist/styles/sortable.css +159 -0
  199. package/dist/styles/sparkline.css +24 -0
  200. package/dist/styles/steps.css +27 -0
  201. package/dist/styles/tabs.css +79 -0
  202. package/dist/styles/tag-input.css +213 -0
  203. package/dist/styles/time-picker.css +66 -0
  204. package/dist/styles/timeline.css +24 -0
  205. package/dist/styles/toast.css +160 -0
  206. package/dist/styles/toggle-group.css +107 -0
  207. package/dist/styles/tooltip.css +78 -0
  208. package/dist/styles/transfer.css +148 -0
  209. package/dist/styles/tree-select.css +131 -0
  210. package/dist/styles/tree.css +59 -0
  211. package/dist/styles/typewriter.css +62 -0
  212. package/dist/styles/upload.css +60 -0
  213. package/dist/styles/virtual-list.css +112 -0
  214. package/dist/styles.css +2091 -421
  215. package/dist/tabs/index.d.ts +9 -0
  216. package/dist/tabs/index.js +69 -16
  217. package/dist/tabs/useOverflow.d.ts +20 -0
  218. package/dist/tabs/useOverflow.js +145 -0
  219. package/dist/tag-input/index.d.ts +7 -2
  220. package/dist/tag-input/index.js +81 -42
  221. package/dist/theme/index.d.ts +11 -0
  222. package/dist/theme/index.js +23 -0
  223. package/dist/time-picker/index.js +45 -27
  224. package/dist/timeline/index.js +4 -1
  225. package/dist/toast/index.d.ts +19 -11
  226. package/dist/toast/index.js +51 -8
  227. package/dist/toast/manager.d.ts +42 -0
  228. package/dist/toast/manager.js +85 -0
  229. package/dist/toast/useToastNavigation.d.ts +3 -0
  230. package/dist/toast/useToastNavigation.js +75 -0
  231. package/dist/tokens.css +4 -0
  232. package/dist/tooltip/Tooltip.js +3 -3
  233. package/dist/transfer/index.d.ts +5 -0
  234. package/dist/transfer/index.js +120 -53
  235. package/dist/transfer/model.d.ts +4 -0
  236. package/dist/transfer/model.js +37 -11
  237. package/dist/tree/index.d.ts +5 -1
  238. package/dist/tree/index.js +11 -6
  239. package/dist/tree-select/index.js +70 -31
  240. package/dist/tree-select/model.d.ts +11 -5
  241. package/dist/tree-select/model.js +111 -83
  242. package/dist/typewriter/index.js +10 -10
  243. package/dist/upload/index.js +50 -33
  244. package/dist/virtual-list/index.js +11 -6
  245. package/package.json +423 -3
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useInkraLocale } from '../i18n/index.js';
2
3
  import { forwardRef, useEffect, useId, useMemo, useRef, useState } from 'react';
3
4
  import { Button } from '../Button.js';
4
5
  import { Input } from '../form/Input.js';
@@ -7,33 +8,34 @@ import { PencilLine } from '../PencilLine.js';
7
8
  import { PencilSurface } from '../PencilSurface.js';
8
9
  import { PencilText } from '../PencilText.js';
9
10
  import { Checkbox } from '../selection/Checkbox.js';
10
- import { moveTransferKeys, normalizeTargetKeys, splitTransferItems, uniqueTransferItems, } from './model.js';
11
+ import { moveTransferKeys, normalizeTargetKeys, resolveTransferFocusIntent, splitTransferItems, transferKeysEqual, uniqueTransferItems, } from './model.js';
11
12
  const searchPaths = [
12
13
  { d: 'M 11 4 A 7 7 0 1 0 11 18 A 7 7 0 1 0 11 4 M 16 16 L 21 21' },
13
14
  ];
14
15
  const rightPaths = [{ d: 'M 5 12 L 19 12 M 13 6 L 19 12 L 13 18' }];
15
- const leftPaths = [{ d: 'M 19 12 L 5 12 M 11 6 L 5 12 L 11 18' }];
16
- const defaultLabels = {
17
- sourceTitle: 'Available',
18
- targetTitle: 'Selected',
19
- sourceSearchLabel: 'Search available items',
20
- targetSearchLabel: 'Search selected items',
21
- sourceClearSearch: 'Clear available item search',
22
- targetClearSearch: 'Clear selected item search',
23
- sourceSearchPlaceholder: 'Search available',
24
- targetSearchPlaceholder: 'Search selected',
25
- selectAllSource: 'Select all visible available items',
26
- selectAllTarget: 'Select all visible selected items',
27
- sourceEmpty: 'No available items',
28
- targetEmpty: 'No selected items',
29
- sourceNoResults: 'No available items match this search',
30
- targetNoResults: 'No selected items match this search',
31
- moveToTarget: 'Move to selected',
32
- moveToSource: 'Move to available',
33
- moveSelectedToTarget: 'Move selected visible items to selected',
34
- moveSelectedToSource: 'Move selected visible items to available',
35
- batchActions: 'Transfer selected visible items',
36
- };
16
+ const defaultLabels = (t) => ({
17
+ sourceTitle: t('transfer.available'),
18
+ targetTitle: t('common.selected'),
19
+ sourceSearchLabel: t('transfer.sourceSearch'),
20
+ targetSearchLabel: t('transfer.targetSearch'),
21
+ sourceClearSearch: t('transfer.sourceClear'),
22
+ targetClearSearch: t('transfer.targetClear'),
23
+ sourceSearchPlaceholder: t('transfer.sourcePlaceholder'),
24
+ targetSearchPlaceholder: t('transfer.targetPlaceholder'),
25
+ selectAllSource: t('transfer.allSource'),
26
+ selectAllTarget: t('transfer.allTarget'),
27
+ sourceEmpty: t('transfer.sourceEmpty'),
28
+ targetEmpty: t('transfer.targetEmpty'),
29
+ sourceNoResults: t('transfer.sourceNoResults'),
30
+ targetNoResults: t('transfer.targetNoResults'),
31
+ moveToTarget: t('transfer.toTarget'),
32
+ moveToSource: t('transfer.toSource'),
33
+ moveSelectedToTarget: t('transfer.selectedToTarget'),
34
+ moveSelectedToSource: t('transfer.selectedToSource'),
35
+ batchActions: t('transfer.batch'),
36
+ previousPage: t('common.previousPage'),
37
+ nextPage: t('common.nextPage'),
38
+ });
37
39
  function searchableText(item) {
38
40
  const parts = [
39
41
  item.searchText,
@@ -43,57 +45,113 @@ function searchableText(item) {
43
45
  ];
44
46
  return parts.filter(Boolean).join(' ').toLocaleLowerCase();
45
47
  }
46
- function defaultFilter(item, query) {
47
- return searchableText(item).includes(query.trim().toLocaleLowerCase());
48
+ function filterPanelItems(items, query, side, filterItem) {
49
+ if (query.length === 0)
50
+ return items;
51
+ if (filterItem)
52
+ return items.filter(item => filterItem(item, query, side));
53
+ const needle = query.trim().toLocaleLowerCase();
54
+ return items.filter(item => searchableText(item).includes(needle));
48
55
  }
49
- function defaultCount({ visible, total, selected }) {
50
- return selected > 0 ? `${selected} selected · ${visible}/${total}` : `${visible}/${total}`;
56
+ function defaultCount({ visible, total, selected }, t) {
57
+ return selected > 0 ? t('transfer.count', { selected, visible, total }) : `${visible}/${total}`;
51
58
  }
52
59
  function itemCopy(item) {
53
60
  return _jsxs("span", { className: "inkra-transfer__item-copy", children: [_jsx("span", { className: "inkra-transfer__item-label", children: typeof item.label === 'string' || typeof item.label === 'number'
54
61
  ? _jsx(PencilText, { children: String(item.label) })
55
62
  : item.label }), item.description == null ? null : _jsx("span", { className: "inkra-transfer__item-description", children: item.description })] });
56
63
  }
57
- export const Transfer = forwardRef(function Transfer({ className, defaultTargetKeys = [], disabled = false, filterItem, formatCount = defaultCount, items, labels: labelOverrides, onTargetKeysChange, renderItem, searchable = true, targetKeys, ...props }, ref) {
64
+ export const Transfer = forwardRef(function Transfer({ className, defaultTargetKeys = [], disabled = false, filterItem, formatCount: formatCountProp, items, labels: labelOverrides, onTargetKeysChange, pageSize, pageLabel = (page, count) => `${page} / ${count}`, renderItem, searchable = true, targetKeys, ...props }, ref) {
65
+ const { t } = useInkraLocale();
66
+ const formatCount = formatCountProp ?? ((details) => defaultCount(details, t));
58
67
  const generatedId = useId();
59
68
  const controlled = targetKeys !== undefined;
60
69
  const uniqueItems = useMemo(() => uniqueTransferItems(items), [items]);
61
70
  const [uncontrolledTargetKeys, setUncontrolledTargetKeys] = useState(() => normalizeTargetKeys(items, defaultTargetKeys));
62
- const currentTargetKeys = normalizeTargetKeys(uniqueItems, controlled ? targetKeys : uncontrolledTargetKeys);
63
- const panels = splitTransferItems(uniqueItems, currentTargetKeys);
64
- const labels = { ...defaultLabels, ...labelOverrides };
71
+ const requestedTargetKeys = controlled ? targetKeys : uncontrolledTargetKeys;
72
+ const currentTargetKeys = useMemo(() => normalizeTargetKeys(uniqueItems, requestedTargetKeys), [uniqueItems, requestedTargetKeys]);
73
+ const currentTargetSet = useMemo(() => new Set(currentTargetKeys), [currentTargetKeys]);
74
+ const itemsById = useMemo(() => new Map(uniqueItems.map(item => [item.id, item])), [uniqueItems]);
75
+ const panels = useMemo(() => splitTransferItems(uniqueItems, currentTargetKeys), [uniqueItems, currentTargetKeys]);
76
+ const labels = { ...defaultLabels(t), ...labelOverrides };
65
77
  const [queries, setQueries] = useState({ source: '', target: '' });
78
+ const [pages, setPages] = useState({ source: 0, target: 0 });
79
+ const pageLength = pageSize !== undefined && Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : undefined;
66
80
  const [selected, setSelected] = useState(() => ({ source: new Set(), target: new Set() }));
67
81
  const [pendingFocus, setPendingFocus] = useState(null);
68
82
  const itemRefs = useRef(new Map());
69
83
  const panelRefs = useRef({ source: null, target: null });
70
- const visible = (side) => panels[side].filter(item => {
71
- const query = queries[side];
72
- return query.length === 0 || (filterItem ? filterItem(item, query, side) : defaultFilter(item, query));
73
- });
74
- const visibleItems = { source: visible('source'), target: visible('target') };
84
+ const visibleSource = useMemo(() => filterPanelItems(panels.source, queries.source, 'source', filterItem), [panels.source, queries.source, filterItem]);
85
+ const visibleTarget = useMemo(() => filterPanelItems(panels.target, queries.target, 'target', filterItem), [panels.target, queries.target, filterItem]);
86
+ const pageIndex = (side, count) => pageLength === undefined ? 0 : Math.min(pages[side], Math.max(0, Math.ceil(count / pageLength) - 1));
87
+ const sourcePage = pageIndex('source', visibleSource.length);
88
+ const targetPage = pageIndex('target', visibleTarget.length);
89
+ const shownSource = useMemo(() => pageLength === undefined ? visibleSource : visibleSource.slice(sourcePage * pageLength, (sourcePage + 1) * pageLength), [visibleSource, sourcePage, pageLength]);
90
+ const shownTarget = useMemo(() => pageLength === undefined ? visibleTarget : visibleTarget.slice(targetPage * pageLength, (targetPage + 1) * pageLength), [visibleTarget, targetPage, pageLength]);
91
+ const visibleItems = { source: shownSource, target: shownTarget };
75
92
  useEffect(() => {
76
- const valid = new Set(uniqueItems.map(item => item.id));
77
93
  setSelected(current => {
78
- const source = new Set([...current.source].filter(key => valid.has(key) && !currentTargetKeys.includes(key)));
79
- const target = new Set([...current.target].filter(key => valid.has(key) && currentTargetKeys.includes(key)));
94
+ const source = new Set([...current.source].filter(key => itemsById.has(key) && !currentTargetSet.has(key)));
95
+ const target = new Set([...current.target].filter(key => itemsById.has(key) && currentTargetSet.has(key)));
80
96
  if (source.size === current.source.size && target.size === current.target.size)
81
97
  return current;
82
98
  return { source, target };
83
99
  });
84
- }, [items, currentTargetKeys.join('\u0000')]);
100
+ }, [itemsById, currentTargetSet]);
85
101
  useEffect(() => {
86
102
  if (!pendingFocus)
87
103
  return;
88
- const targetContainsItem = pendingFocus.side === 'target'
89
- ? currentTargetKeys.includes(pendingFocus.id)
90
- : !currentTargetKeys.includes(pendingFocus.id);
91
- const control = targetContainsItem ? itemRefs.current.get(`${pendingFocus.side}:${pendingFocus.id}`) : null;
104
+ if (disabled || !itemsById.has(pendingFocus.id)) {
105
+ setPendingFocus(null);
106
+ return;
107
+ }
108
+ const valueResolution = transferKeysEqual(currentTargetKeys, pendingFocus.expectedTargetKeys)
109
+ ? 'accepted'
110
+ : transferKeysEqual(currentTargetKeys, pendingFocus.previousTargetKeys) ? 'unchanged' : 'mismatch';
111
+ const ownerDocument = pendingFocus.origin?.ownerDocument ?? document;
112
+ const activeElement = ownerDocument.activeElement;
113
+ const originStillOwnsFocus = activeElement === pendingFocus.origin;
114
+ const focusFellBackToDocument = activeElement === ownerDocument.body || activeElement === ownerDocument.documentElement;
115
+ const originUnavailable = pendingFocus.origin != null
116
+ && (!pendingFocus.origin.isConnected || pendingFocus.origin.matches(':disabled'))
117
+ && focusFellBackToDocument;
118
+ if (valueResolution !== 'accepted') {
119
+ if (resolveTransferFocusIntent(valueResolution, originStillOwnsFocus, originUnavailable) !== 'wait') {
120
+ setPendingFocus(null);
121
+ return;
122
+ }
123
+ const cancel = (event) => {
124
+ const target = event.target;
125
+ const documentFallback = target === ownerDocument.body || target === ownerDocument.documentElement;
126
+ const unavailable = pendingFocus.origin != null
127
+ && (!pendingFocus.origin.isConnected || pendingFocus.origin.matches(':disabled'));
128
+ if (target !== pendingFocus.origin && !(documentFallback && unavailable))
129
+ setPendingFocus(null);
130
+ };
131
+ ownerDocument.addEventListener('focusin', cancel);
132
+ return () => ownerDocument.removeEventListener('focusin', cancel);
133
+ }
134
+ if (resolveTransferFocusIntent('accepted', originStillOwnsFocus, originUnavailable) !== 'focus') {
135
+ setPendingFocus(null);
136
+ return;
137
+ }
138
+ if (pageLength !== undefined) {
139
+ const matching = pendingFocus.side === 'source' ? visibleSource : visibleTarget;
140
+ const index = matching.findIndex(item => item.id === pendingFocus.id);
141
+ const nextPage = index < 0 ? undefined : Math.floor(index / pageLength);
142
+ if (nextPage !== undefined && nextPage !== pages[pendingFocus.side]) {
143
+ setPages(current => ({ ...current, [pendingFocus.side]: nextPage }));
144
+ return;
145
+ }
146
+ }
147
+ const control = itemRefs.current.get(`${pendingFocus.side}:${pendingFocus.id}`);
92
148
  (control ?? panelRefs.current[pendingFocus.side])?.focus({ preventScroll: true });
93
149
  setPendingFocus(null);
94
- }, [pendingFocus, currentTargetKeys.join('\u0000')]);
150
+ }, [disabled, pendingFocus, currentTargetKeys, itemsById, pageLength, pages, visibleSource, visibleTarget]);
95
151
  const setItemSelected = (side, id, checked) => {
96
152
  setSelected(current => {
153
+ if (current[side].has(id) === checked)
154
+ return current;
97
155
  const next = new Set(current[side]);
98
156
  if (checked)
99
157
  next.add(id);
@@ -111,11 +169,11 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
111
169
  return { ...current, [side]: next };
112
170
  });
113
171
  };
114
- const move = (keys, direction, action) => {
172
+ const move = (keys, direction, action, origin) => {
115
173
  const sourceSide = direction === 'to-target' ? 'source' : 'target';
116
174
  const destinationSide = direction === 'to-target' ? 'target' : 'source';
117
175
  const allowed = new Set(panels[sourceSide].filter(item => !item.disabled && !disabled).map(item => item.id));
118
- const movedKeys = keys.filter((key, index) => allowed.has(key) && keys.indexOf(key) === index);
176
+ const movedKeys = [...new Set(keys)].filter(key => allowed.has(key));
119
177
  if (movedKeys.length === 0)
120
178
  return;
121
179
  const next = moveTransferKeys(uniqueItems, currentTargetKeys, movedKeys, direction);
@@ -126,12 +184,18 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
126
184
  movedKeys.forEach(key => source.delete(key));
127
185
  return { ...current, [sourceSide]: source };
128
186
  });
129
- setPendingFocus({ id: movedKeys[0], side: destinationSide });
187
+ setPendingFocus({
188
+ id: movedKeys[0],
189
+ side: destinationSide,
190
+ origin,
191
+ previousTargetKeys: [...currentTargetKeys],
192
+ expectedTargetKeys: next,
193
+ });
130
194
  onTargetKeysChange?.(next, {
131
195
  action,
132
196
  direction,
133
197
  movedKeys,
134
- movedItems: movedKeys.map(key => uniqueItems.find(item => item.id === key)).filter((item) => item !== undefined),
198
+ movedItems: movedKeys.map(key => itemsById.get(key)).filter((item) => item !== undefined),
135
199
  });
136
200
  };
137
201
  const renderPanel = (side) => {
@@ -154,7 +218,10 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
154
218
  const selectAllLabel = source ? labels.selectAllSource : labels.selectAllTarget;
155
219
  const direction = source ? 'to-target' : 'to-source';
156
220
  const moveLabel = source ? labels.moveToTarget : labels.moveToSource;
157
- return _jsxs(PencilSurface, { "aria-labelledby": titleId, as: "section", className: "inkra-transfer__panel", "data-searchable": searchable || undefined, "data-side": side, ref: element => { panelRefs.current[side] = element; }, seed: `inkra-transfer-${generatedId}-${side}`, tabIndex: -1, variant: "paper", children: [_jsxs("header", { className: "inkra-transfer__header", children: [_jsx("span", { className: "inkra-transfer__title", id: titleId, children: typeof panelTitle === 'string' ? _jsx(PencilText, { children: panelTitle }) : panelTitle }), _jsx("span", { "aria-live": "polite", className: "inkra-transfer__count", children: count })] }), _jsx(PencilLine, { "aria-hidden": "true", className: "inkra-transfer__divider", seed: `inkra-transfer-${generatedId}-${side}-header` }), searchable && _jsx(Input, { "aria-label": searchLabel, clearable: true, clearButtonLabel: clearSearchLabel, disabled: disabled, onValueChange: value => setQueries(current => ({ ...current, [side]: value })), placeholder: searchPlaceholder, startIcon: _jsx(PencilIcon, { decorative: true, paths: searchPaths, size: 18, sourceSize: 24 }), type: "search", value: queries[side], variant: "outline" }), _jsx("div", { className: "inkra-transfer__selection-bar", children: _jsx(Checkbox, { checked: allVisibleSelected, disabled: disabled || selectable.length === 0, indeterminate: someVisibleSelected, label: selectAllLabel, onCheckedChange: checked => toggleAll(side, checked), seed: `inkra-transfer-${generatedId}-${side}-all` }) }), _jsx("ul", { className: "inkra-transfer__list", "aria-labelledby": titleId, children: shown.length === 0 ? _jsx("li", { className: "inkra-transfer__empty", children: empty }) : shown.map(item => {
221
+ return _jsxs(PencilSurface, { "aria-labelledby": titleId, as: "section", className: "inkra-transfer__panel", "data-searchable": searchable || undefined, "data-side": side, ref: element => { panelRefs.current[side] = element; }, seed: `inkra-transfer-${generatedId}-${side}`, tabIndex: -1, variant: "paper", children: [_jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-transfer__panel-focus", variant: "outline", seed: `inkra-transfer-${generatedId}-${side}-focus` }), _jsxs("header", { className: "inkra-transfer__header", children: [_jsx("span", { className: "inkra-transfer__title", id: titleId, children: typeof panelTitle === 'string' ? _jsx(PencilText, { children: panelTitle }) : panelTitle }), _jsx("span", { "aria-live": "polite", className: "inkra-transfer__count", children: count })] }), _jsx(PencilLine, { "aria-hidden": "true", className: "inkra-transfer__divider", seed: `inkra-transfer-${generatedId}-${side}-header` }), searchable && _jsx(Input, { "aria-label": searchLabel, clearable: true, clearButtonLabel: clearSearchLabel, disabled: disabled, onValueChange: value => {
222
+ setQueries(current => ({ ...current, [side]: value }));
223
+ setPages(current => ({ ...current, [side]: 0 }));
224
+ }, placeholder: searchPlaceholder, startIcon: _jsx(PencilIcon, { decorative: true, paths: searchPaths, size: 18, sourceSize: 24 }), type: "search", value: queries[side], variant: "outline" }), _jsx("div", { className: "inkra-transfer__selection-bar", children: _jsx(Checkbox, { checked: allVisibleSelected, disabled: disabled || selectable.length === 0, indeterminate: someVisibleSelected, label: selectAllLabel, onCheckedChange: checked => toggleAll(side, checked), seed: `inkra-transfer-${generatedId}-${side}-all` }) }), _jsx("ul", { className: "inkra-transfer__list", "aria-labelledby": titleId, children: shown.length === 0 ? _jsx("li", { className: "inkra-transfer__empty", children: empty }) : shown.map(item => {
158
225
  const itemDisabled = disabled || item.disabled === true;
159
226
  const itemSelected = selected[side].has(item.id);
160
227
  const copy = renderItem?.(item, { side, selected: itemSelected, disabled: itemDisabled, query: queries[side] }) ?? itemCopy(item);
@@ -164,11 +231,11 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
164
231
  itemRefs.current.set(key, element);
165
232
  else
166
233
  itemRefs.current.delete(key);
167
- }, seed: `inkra-transfer-${generatedId}-${side}-${item.id}` }), _jsx(Button, { "aria-label": `${moveLabel}: ${typeof item.label === 'string' || typeof item.label === 'number' ? String(item.label) : item.searchText ?? item.id}`, disabled: itemDisabled, onClick: () => move([item.id], direction, 'single'), seed: `inkra-transfer-${generatedId}-${side}-${item.id}-move`, size: "icon-sm", title: moveLabel, variant: "ghost", children: _jsx(PencilIcon, { className: "inkra-transfer__direction-icon", decorative: true, paths: source ? rightPaths : leftPaths, size: 18, sourceSize: 24 }) })] }, item.id);
168
- }) })] });
234
+ }, seed: `inkra-transfer-${generatedId}-${side}-${item.id}` }), _jsx(Button, { "aria-label": `${moveLabel}: ${typeof item.label === 'string' || typeof item.label === 'number' ? String(item.label) : item.searchText ?? item.id}`, disabled: itemDisabled, onClick: event => move([item.id], direction, 'single', event.currentTarget), seed: `inkra-transfer-${generatedId}-${side}-${item.id}-move`, size: "icon-sm", title: moveLabel, variant: "ghost", children: _jsx(PencilIcon, { className: "inkra-transfer__direction-icon", "data-direction": direction, decorative: true, paths: rightPaths, size: 18, sourceSize: 24 }) })] }, item.id);
235
+ }) }), pageLength !== undefined && _jsxs("nav", { "aria-label": typeof panelTitle === 'string' ? panelTitle : undefined, children: [_jsx(Button, { size: "sm", variant: "ghost", "aria-label": labels.previousPage, disabled: disabled || pageIndex(side, (source ? visibleSource : visibleTarget).length) === 0, onClick: () => setPages(current => ({ ...current, [side]: pageIndex(side, (source ? visibleSource : visibleTarget).length) - 1 })), children: labels.previousPage }), _jsx("span", { "aria-live": "polite", children: pageLabel((source ? visibleSource : visibleTarget).length === 0 ? 0 : pageIndex(side, (source ? visibleSource : visibleTarget).length) + 1, Math.ceil((source ? visibleSource : visibleTarget).length / pageLength)) }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": labels.nextPage, disabled: disabled || (pageIndex(side, (source ? visibleSource : visibleTarget).length) + 1) * pageLength >= (source ? visibleSource : visibleTarget).length, onClick: () => setPages(current => ({ ...current, [side]: pageIndex(side, (source ? visibleSource : visibleTarget).length) + 1 })), children: labels.nextPage })] })] });
169
236
  };
170
237
  const sourceBatchKeys = selectableVisible('source').filter(item => selected.source.has(item.id)).map(item => item.id);
171
238
  const targetBatchKeys = selectableVisible('target').filter(item => selected.target.has(item.id)).map(item => item.id);
172
- return _jsxs("div", { ...props, "aria-disabled": disabled || undefined, className: ['inkra-transfer', className].filter(Boolean).join(' '), "data-disabled": disabled || undefined, ref: ref, children: [renderPanel('source'), _jsxs("div", { className: "inkra-transfer__actions", role: "group", "aria-label": labels.batchActions, children: [_jsx(Button, { "aria-label": labels.moveSelectedToTarget, disabled: disabled || sourceBatchKeys.length === 0, onClick: () => move(sourceBatchKeys, 'to-target', 'batch'), seed: `inkra-transfer-${generatedId}-batch-target`, size: "icon", title: labels.moveSelectedToTarget, variant: "outline", children: _jsx(PencilIcon, { className: "inkra-transfer__direction-icon", decorative: true, paths: rightPaths, size: 20, sourceSize: 24 }) }), _jsx(Button, { "aria-label": labels.moveSelectedToSource, disabled: disabled || targetBatchKeys.length === 0, onClick: () => move(targetBatchKeys, 'to-source', 'batch'), seed: `inkra-transfer-${generatedId}-batch-source`, size: "icon", title: labels.moveSelectedToSource, variant: "outline", children: _jsx(PencilIcon, { className: "inkra-transfer__direction-icon", decorative: true, paths: leftPaths, size: 20, sourceSize: 24 }) })] }), renderPanel('target')] });
239
+ return _jsx("div", { ...props, "aria-disabled": disabled || undefined, className: ['inkra-transfer', className].filter(Boolean).join(' '), "data-disabled": disabled || undefined, ref: ref, children: _jsxs("div", { className: "inkra-transfer__layout", children: [renderPanel('source'), _jsxs("div", { className: "inkra-transfer__actions", role: "group", "aria-label": labels.batchActions, children: [_jsx(Button, { "aria-label": labels.moveSelectedToTarget, disabled: disabled || sourceBatchKeys.length === 0, onClick: event => move(sourceBatchKeys, 'to-target', 'batch', event.currentTarget), seed: `inkra-transfer-${generatedId}-batch-target`, size: "icon", title: labels.moveSelectedToTarget, variant: "outline", children: _jsx(PencilIcon, { className: "inkra-transfer__direction-icon", "data-direction": "to-target", decorative: true, paths: rightPaths, size: 20, sourceSize: 24 }) }), _jsx(Button, { "aria-label": labels.moveSelectedToSource, disabled: disabled || targetBatchKeys.length === 0, onClick: event => move(targetBatchKeys, 'to-source', 'batch', event.currentTarget), seed: `inkra-transfer-${generatedId}-batch-source`, size: "icon", title: labels.moveSelectedToSource, variant: "outline", children: _jsx(PencilIcon, { className: "inkra-transfer__direction-icon", "data-direction": "to-source", decorative: true, paths: rightPaths, size: 20, sourceSize: 24 }) })] }), renderPanel('target')] }) });
173
240
  });
174
241
  export { moveTransferKeys, normalizeTargetKeys, splitTransferItems, uniqueTransferItems } from './model.js';
@@ -1,8 +1,12 @@
1
1
  export type TransferDirection = 'to-target' | 'to-source';
2
2
  export type TransferSide = 'source' | 'target';
3
+ export type TransferFocusIntent = 'wait' | 'cancel' | 'focus';
4
+ export type TransferValueResolution = 'unchanged' | 'accepted' | 'mismatch';
3
5
  export interface TransferIdentity {
4
6
  id: string;
5
7
  }
8
+ export declare function resolveTransferFocusIntent(valueResolution: TransferValueResolution, originOwnsFocus: boolean, originUnavailable: boolean): TransferFocusIntent;
9
+ export declare function transferKeysEqual(left: readonly string[], right: readonly string[]): boolean;
6
10
  export declare function uniqueTransferItems<Item extends TransferIdentity>(items: readonly Item[]): Item[];
7
11
  export declare function normalizeTargetKeys<Item extends TransferIdentity>(items: readonly Item[], targetKeys: readonly string[]): string[];
8
12
  export declare function splitTransferItems<Item extends TransferIdentity>(items: readonly Item[], targetKeys: readonly string[]): {
@@ -1,3 +1,13 @@
1
+ export function resolveTransferFocusIntent(valueResolution, originOwnsFocus, originUnavailable) {
2
+ if (valueResolution === 'mismatch')
3
+ return 'cancel';
4
+ if (valueResolution === 'unchanged')
5
+ return originOwnsFocus || originUnavailable ? 'wait' : 'cancel';
6
+ return originOwnsFocus || originUnavailable ? 'focus' : 'cancel';
7
+ }
8
+ export function transferKeysEqual(left, right) {
9
+ return left.length === right.length && left.every((key, index) => key === right[index]);
10
+ }
1
11
  export function uniqueTransferItems(items) {
2
12
  const seen = new Set();
3
13
  return items.filter(item => {
@@ -8,20 +18,36 @@ export function uniqueTransferItems(items) {
8
18
  });
9
19
  }
10
20
  export function normalizeTargetKeys(items, targetKeys) {
11
- const uniqueItems = uniqueTransferItems(items);
12
21
  const requested = new Set(targetKeys);
13
- const knownIds = new Set(uniqueItems.map(item => item.id));
14
- const orderedKnown = uniqueItems.filter(item => requested.has(item.id)).map(item => item.id);
15
- const unknown = targetKeys.filter((key, index) => !knownIds.has(key) && targetKeys.indexOf(key) === index);
16
- return [...orderedKnown, ...unknown];
22
+ const knownIds = new Set();
23
+ const orderedKnown = [];
24
+ for (const item of items) {
25
+ if (knownIds.has(item.id))
26
+ continue;
27
+ knownIds.add(item.id);
28
+ if (requested.has(item.id))
29
+ orderedKnown.push(item.id);
30
+ }
31
+ // Set iteration preserves the first occurrence of unknown controlled keys,
32
+ // without scanning the entire key array for each unknown key.
33
+ for (const key of requested) {
34
+ if (!knownIds.has(key))
35
+ orderedKnown.push(key);
36
+ }
37
+ return orderedKnown;
17
38
  }
18
39
  export function splitTransferItems(items, targetKeys) {
19
- const target = new Set(targetKeys);
20
- const uniqueItems = uniqueTransferItems(items);
21
- return {
22
- source: uniqueItems.filter(item => !target.has(item.id)),
23
- target: uniqueItems.filter(item => target.has(item.id)),
24
- };
40
+ const targetKeysSet = new Set(targetKeys);
41
+ const seen = new Set();
42
+ const source = [];
43
+ const target = [];
44
+ for (const item of items) {
45
+ if (seen.has(item.id))
46
+ continue;
47
+ seen.add(item.id);
48
+ (targetKeysSet.has(item.id) ? target : source).push(item);
49
+ }
50
+ return { source, target };
25
51
  }
26
52
  export function moveTransferKeys(items, targetKeys, movedKeys, direction) {
27
53
  const next = new Set(targetKeys);
@@ -6,18 +6,22 @@ export type TreeRootProps<T extends object = object> = Omit<AriaTreeProps<T>, 'c
6
6
  className?: string;
7
7
  style?: CSSProperties;
8
8
  emptyState?: ReactNode;
9
+ /** Indentation per level in CSS pixels. Nonfinite values use 24. */
10
+ indent?: number;
11
+ size?: 'sm' | 'md' | 'lg';
9
12
  };
10
13
  export type TreeItemProps<T = object> = Omit<AriaTreeItemProps<T>, 'className'> & {
11
14
  className?: string;
12
15
  };
13
16
  export interface TreeItemContentState {
17
+ level: number;
14
18
  isExpanded: boolean;
15
19
  hasChildItems: boolean;
16
20
  isSelected: boolean;
17
21
  isDisabled: boolean;
18
22
  }
19
23
  export interface TreeItemContentProps {
20
- children: ReactNode;
24
+ children: ReactNode | ((state: TreeItemContentState) => ReactNode);
21
25
  className?: string;
22
26
  icon?: ReactNode | ((state: TreeItemContentState) => ReactNode);
23
27
  disclosureIcon?: ReactNode | ((state: TreeItemContentState) => ReactNode);
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useInkraLocale } from '../i18n/index.js';
2
3
  import { Tree as AriaTree, TreeItem as AriaTreeItem, TreeItemContent as AriaTreeItemContent, } from 'react-aria-components/Tree';
3
4
  import { Button as AriaButton } from 'react-aria-components/Button';
4
5
  import { forwardRef } from 'react';
@@ -6,26 +7,30 @@ import { PencilIcon } from '../PencilIcon.js';
6
7
  import { PencilSurface } from '../PencilSurface.js';
7
8
  import { PencilText } from '../PencilText.js';
8
9
  const chevronIcon = [{ d: 'M 7 10 L 12 15 L 17 10' }];
9
- function TreeRootInner({ className, emptyState = 'No items', selectionMode = 'single', selectionBehavior = 'toggle', ...props }, ref) {
10
- return _jsx(AriaTree, { ...props, ref: ref, className: ['inkra-tree', className].filter(Boolean).join(' '), selectionMode: selectionMode, selectionBehavior: selectionBehavior, renderEmptyState: () => _jsx("div", { className: "inkra-tree__empty", children: emptyState }) });
10
+ function TreeRootInner(inkraProps, ref) {
11
+ const { t } = useInkraLocale();
12
+ const { className, style, indent = 24, size = 'md', emptyState = t('common.noItems'), selectionMode = 'single', selectionBehavior = 'toggle', ...props } = inkraProps;
13
+ return _jsx(AriaTree, { ...props, ref: ref, className: ['inkra-tree', className].filter(Boolean).join(' '), "data-size": size, style: { '--inkra-tree-indent': `${Number.isFinite(indent) ? Math.max(0, indent) : 24}px`, ...style }, selectionMode: selectionMode, selectionBehavior: selectionBehavior, renderEmptyState: () => _jsx("div", { className: "inkra-tree__empty", children: emptyState }) });
11
14
  }
12
15
  const TreeRoot = forwardRef(TreeRootInner);
13
16
  const TreeItem = forwardRef(function TreeItem({ className, ...props }, ref) {
14
17
  return _jsx(AriaTreeItem, { ...props, ref: ref, className: ['inkra-tree__item', className].filter(Boolean).join(' ') });
15
18
  });
16
- const TreeItemContent = forwardRef(function TreeItemContent({ children, className, icon, disclosureIcon, expandLabel = 'Expand item', collapseLabel = 'Collapse item', }, ref) {
19
+ const TreeItemContent = forwardRef(function TreeItemContent(inkraProps, ref) {
20
+ const { t } = useInkraLocale();
21
+ const { children, className, icon, disclosureIcon, expandLabel = t('tree.expand'), collapseLabel = t('tree.collapse'), } = inkraProps;
17
22
  return _jsx(AriaTreeItemContent, { ref: ref, children: ({ hasChildItems, isExpanded, isSelected, isFocusVisible, isDisabled, level, id, }) => {
18
- const state = { isExpanded, hasChildItems, isSelected, isDisabled };
23
+ const state = { isExpanded, hasChildItems, isSelected, isDisabled, level };
19
24
  const resolvedIcon = typeof icon === 'function' ? icon(state) : icon;
20
25
  const resolvedDisclosure = typeof disclosureIcon === 'function' ? disclosureIcon(state) : disclosureIcon;
21
- return _jsxs("div", { className: ['inkra-tree__item-content', className].filter(Boolean).join(' '), "data-expanded": isExpanded || undefined, "data-selected": isSelected || undefined, "data-disabled": isDisabled || undefined, "data-has-icon": resolvedIcon != null || undefined, style: { '--inkra-tree-level': level }, children: [isSelected && _jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-tree__selection", variant: "selection", seed: `tree-${String(id)}-selection` }), isFocusVisible && _jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-tree__focus", variant: "outline", seed: `tree-${String(id)}-focus` }), hasChildItems ? _jsx(AriaButton, { slot: "chevron", className: "inkra-tree__disclosure", "aria-label": isExpanded ? collapseLabel : expandLabel, children: ({ isFocusVisible: disclosureFocused }) => _jsxs(_Fragment, { children: [disclosureFocused && _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-tree__disclosure-focus", variant: "outline" }), _jsx("span", { className: "inkra-tree__disclosure-icon", "aria-hidden": "true", children: disclosureIcon === undefined ? _jsx(PencilIcon, { className: "inkra-tree__chevron", paths: chevronIcon, size: 18, sourceSize: 24 }) : resolvedDisclosure })] }) }) : _jsx("span", { className: "inkra-tree__disclosure-spacer", "aria-hidden": "true" }), resolvedIcon != null && _jsx("span", { className: "inkra-tree__icon", "aria-hidden": "true", children: resolvedIcon }), _jsx("span", { className: "inkra-tree__copy", children: children })] });
26
+ return _jsxs("div", { className: ['inkra-tree__item-content', className].filter(Boolean).join(' '), "data-expanded": isExpanded || undefined, "data-selected": isSelected || undefined, "data-disabled": isDisabled || undefined, "data-has-icon": resolvedIcon != null || undefined, style: { '--inkra-tree-level': level }, children: [isSelected && _jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-tree__selection", variant: "selection", seed: `tree-${String(id)}-selection` }), isFocusVisible && _jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-tree__focus", variant: "outline", seed: `tree-${String(id)}-focus` }), hasChildItems ? _jsx(AriaButton, { slot: "chevron", className: "inkra-tree__disclosure", "aria-label": isExpanded ? collapseLabel : expandLabel, children: ({ isFocusVisible: disclosureFocused }) => _jsxs(_Fragment, { children: [disclosureFocused && _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-tree__disclosure-focus", variant: "outline" }), _jsx("span", { className: "inkra-tree__disclosure-icon", "aria-hidden": "true", children: disclosureIcon === undefined ? _jsx(PencilIcon, { className: "inkra-tree__chevron", paths: chevronIcon, size: 18, sourceSize: 24 }) : resolvedDisclosure })] }) }) : _jsx("span", { className: "inkra-tree__disclosure-spacer", "aria-hidden": "true" }), resolvedIcon != null && _jsx("span", { className: "inkra-tree__icon", "aria-hidden": "true", children: resolvedIcon }), _jsx("span", { className: "inkra-tree__copy", children: typeof children === 'function' ? children(state) : children })] });
22
27
  } });
23
28
  });
24
29
  const TreeItemTitle = forwardRef(function TreeItemTitle({ className, children, ...props }, ref) {
25
30
  return _jsx("span", { ...props, ref: ref, className: ['inkra-tree__title', className].filter(Boolean).join(' '), children: typeof children === 'string' ? _jsx(PencilText, { children: children }) : children });
26
31
  });
27
32
  const TreeItemDescription = forwardRef(function TreeItemDescription({ className, children, ...props }, ref) {
28
- return _jsx("span", { ...props, ref: ref, className: ['inkra-tree__description', className].filter(Boolean).join(' '), children: children });
33
+ return _jsx("span", { ...props, ref: ref, className: ['inkra-tree__description', className].filter(Boolean).join(' '), children: typeof children === 'string' ? _jsx(PencilText, { children: children }) : children });
29
34
  });
30
35
  export const Tree = {
31
36
  Root: TreeRoot,
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useInkraLocale } from '../i18n/index.js';
2
3
  import { forwardRef, useEffect, useId, useMemo, useRef, useState, } from 'react';
3
- import { Button } from '../Button.js';
4
+ import { Button, ButtonGroup } from '../Button.js';
4
5
  import { Input } from '../form/Input.js';
5
6
  import { mergeAriaIds, useFieldContext } from '../form/context.js';
6
7
  import { PencilIcon } from '../PencilIcon.js';
7
8
  import { PencilText } from '../PencilText.js';
8
9
  import { Popover } from '../overlay/index.js';
9
10
  import { Tree } from '../tree/index.js';
10
- import { filterTreeSelectNodes, indexTreeSelectNodes, updateTreeSelectSelection, } from './model.js';
11
+ import { filterTreeSelectNodesFromIndex, indexTreeSelectNodes, updateTreeSelectSelectionFromIndex, } from './model.js';
11
12
  const chevronPaths = [{ d: 'M6 9l6 6 6-6' }];
12
13
  const clearPaths = [{ d: 'M6 6l12 12M18 6L6 18' }];
13
14
  const searchPaths = [{ d: 'M21 21l-4.5-4.5' }, { d: 'M18 10a8 8 0 1 1-16 0a8 8 0 1 1 16 0' }];
@@ -20,8 +21,44 @@ function selectionFromProps(props, fallback = []) {
20
21
  function keys(selection) {
21
22
  return selection === 'all' ? [] : [...selection].map(String);
22
23
  }
24
+ // The portal owns mounting (including its exit animation). Keep collection
25
+ // construction here rather than doing work for content the portal never mounts.
26
+ const TreeSelectTree = forwardRef(function TreeSelectTree({ nodes, renderLabel, selectedSet, query, collapseLabel, expandLabel, treeLabel, emptyContent, effectiveExpanded, onExpandedChange, onSelectionChange, multiple, }, ref) {
27
+ // Default labels are selection-independent; only custom label renderers need
28
+ // a new collection when the selected keys change.
29
+ const labelSelection = renderLabel ? selectedSet : undefined;
30
+ const treeItems = useMemo(() => {
31
+ // Construct the same nested tree without growing the JavaScript call stack.
32
+ // Enter jobs keep renderLabel callbacks in the original depth-first order.
33
+ const result = [];
34
+ const stack = [];
35
+ for (let index = nodes.length - 1; index >= 0; index--)
36
+ stack.push({ node: nodes[index], output: result });
37
+ while (stack.length) {
38
+ const { node, output, finish } = stack.pop();
39
+ if (finish) {
40
+ output.push(_jsxs(Tree.Item, { className: "inkra-tree-select__item", id: node.id, isDisabled: node.disabled, textValue: node.label, children: [_jsxs(Tree.ItemContent, { collapseLabel: collapseLabel, expandLabel: expandLabel, children: [_jsx(Tree.ItemTitle, { children: finish.label }), node.description ? _jsx(Tree.ItemDescription, { children: node.description }) : null] }), node.children?.length ? finish.children : null] }, node.id));
41
+ continue;
42
+ }
43
+ const label = renderLabel?.(node, {
44
+ branch: Boolean(node.children?.length),
45
+ disabled: Boolean(node.disabled),
46
+ selected: labelSelection?.has(node.id) ?? false,
47
+ query,
48
+ }) ?? node.label;
49
+ const children = [];
50
+ stack.push({ node, output, finish: { label, children } });
51
+ for (let index = (node.children?.length ?? 0) - 1; index >= 0; index--) {
52
+ stack.push({ node: node.children[index], output: children });
53
+ }
54
+ }
55
+ return result;
56
+ }, [nodes, renderLabel, labelSelection, query, collapseLabel, expandLabel]);
57
+ return _jsx(Tree.Root, { "aria-label": treeLabel, className: "inkra-tree-select__tree", emptyState: emptyContent, expandedKeys: effectiveExpanded, onExpandedChange: onExpandedChange, onSelectionChange: onSelectionChange, ref: ref, selectedKeys: selectedSet, selectionMode: multiple ? 'multiple' : 'single', children: treeItems });
58
+ });
23
59
  export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
24
- const { items, branchSelection = 'leaf', selectionPropagation = 'none', renderLabel, renderValue, placeholder = 'Select an item', searchPlaceholder = 'Search tree', emptyContent = 'No matching items', clearable = true, clearLabel = 'Clear selection', treeLabel = 'Tree options', expandLabel = 'Expand item', collapseLabel = 'Collapse item', size = 'md', name, form, disabled, readOnly, required, invalid, open: controlledOpen, defaultOpen = false, onOpenChange, expandedIds, defaultExpandedIds = [], onExpandedIdsChange, side = 'bottom', align = 'start', sideOffset = 8, collisionPadding = 8, id, className, multiple: _multiple, value: _value, defaultValue: _defaultValue, onValueChange: _onValueChange, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...rest } = props;
60
+ const { t } = useInkraLocale();
61
+ const { items, branchSelection = 'leaf', selectionPropagation = 'none', renderLabel, renderValue, placeholder = t('treeSelect.placeholder'), searchPlaceholder = t('treeSelect.search'), emptyContent = t('treeSelect.empty'), clearable = true, clearLabel = t('select.clear'), treeLabel = t('treeSelect.options'), expandLabel = t('tree.expand'), collapseLabel = t('tree.collapse'), size = 'md', name, form, disabled, readOnly, required, invalid, open: controlledOpen, defaultOpen = false, onOpenChange, expandedIds, defaultExpandedIds = [], onExpandedIdsChange, side = 'bottom', align = 'start', sideOffset = 8, collisionPadding = 8, id, className, multiple: _multiple, value: _value, defaultValue: _defaultValue, onValueChange: _onValueChange, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...rest } = props;
25
62
  const field = useFieldContext();
26
63
  const generatedId = useId();
27
64
  const controlId = field?.controlId ?? id ?? `inkra-tree-select-${generatedId}`;
@@ -31,25 +68,25 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
31
68
  const resolvedInvalid = Boolean(invalid || field?.invalid);
32
69
  const describedBy = mergeAriaIds(ariaDescribedBy, field?.descriptionId, field?.errorId);
33
70
  const normalized = useMemo(() => indexTreeSelectNodes(items), [items]);
34
- const defaultSelection = useRef(selectionFromProps(props));
35
71
  const controlledSelection = props.value !== undefined;
36
- const [uncontrolledSelection, setUncontrolledSelection] = useState(defaultSelection.current);
37
- const selectedIds = controlledSelection ? selectionFromProps(props) : uncontrolledSelection;
72
+ const [uncontrolledSelection, setUncontrolledSelection] = useState(() => selectionFromProps(props));
73
+ const defaultSelection = useRef(uncontrolledSelection);
74
+ const selectedIds = useMemo(() => controlledSelection ? selectionFromProps(props) : uncontrolledSelection, [controlledSelection, props.value, props.multiple, uncontrolledSelection]);
38
75
  const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
39
76
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
40
77
  const isOpen = controlledOpen ?? uncontrolledOpen;
41
78
  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(() => [...new Set(defaultExpandedIds)]);
42
79
  const baseExpanded = expandedIds ?? uncontrolledExpanded;
43
80
  const [query, setQuery] = useState('');
44
- const filtered = useMemo(() => filterTreeSelectNodes(normalized.nodes, query), [normalized.nodes, query]);
81
+ const filtered = useMemo(() => filterTreeSelectNodesFromIndex(normalized, query), [normalized, query]);
45
82
  const effectiveExpanded = useMemo(() => new Set([...baseExpanded, ...filtered.expandedIds]), [baseExpanded, filtered.expandedIds]);
46
83
  const triggerRef = useRef(null);
47
84
  const searchRef = useRef(null);
48
85
  const treeRef = useRef(null);
49
86
  const formAnchorRef = useRef(null);
50
- const selectedNodes = selectedIds
87
+ const selectedNodes = useMemo(() => selectedIds
51
88
  .map(selectedId => normalized.byId.get(selectedId))
52
- .filter((node) => node !== undefined);
89
+ .filter((node) => node !== undefined), [selectedIds, normalized]);
53
90
  const setOpen = (next) => {
54
91
  if (resolvedDisabled || resolvedReadOnly)
55
92
  return;
@@ -68,20 +105,28 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
68
105
  props.onValueChange?.(next[0] ?? null, details);
69
106
  };
70
107
  useEffect(() => {
71
- if (controlledSelection)
72
- return;
73
108
  const currentForm = formAnchorRef.current?.form;
74
109
  if (!currentForm)
75
110
  return;
76
- const reset = () => {
77
- setUncontrolledSelection(defaultSelection.current);
78
- setQuery('');
79
- if (controlledOpen === undefined)
80
- setUncontrolledOpen(false);
111
+ let resetTimer;
112
+ const reset = (event) => {
113
+ clearTimeout(resetTimer);
114
+ resetTimer = setTimeout(() => {
115
+ if (event.defaultPrevented)
116
+ return;
117
+ if (!controlledSelection)
118
+ setUncontrolledSelection(defaultSelection.current);
119
+ setQuery('');
120
+ if (controlledOpen === undefined)
121
+ setUncontrolledOpen(false);
122
+ }, 0);
81
123
  };
82
124
  currentForm.addEventListener('reset', reset);
83
- return () => currentForm.removeEventListener('reset', reset);
84
- }, [controlledOpen, controlledSelection]);
125
+ return () => {
126
+ clearTimeout(resetTimer);
127
+ currentForm.removeEventListener('reset', reset);
128
+ };
129
+ }, [controlledOpen, controlledSelection, form]);
85
130
  const handleSelectionChange = (nextSelection) => {
86
131
  if (resolvedDisabled || resolvedReadOnly)
87
132
  return;
@@ -92,7 +137,7 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
92
137
  if (!toggledId)
93
138
  return;
94
139
  const selected = added === toggledId;
95
- const next = updateTreeSelectSelection(normalized.nodes, selectedIds, toggledId, selected, {
140
+ const next = updateTreeSelectSelectionFromIndex(normalized, selectedIds, toggledId, selected, {
96
141
  multiple: Boolean(props.multiple),
97
142
  branchSelection,
98
143
  selectionPropagation,
@@ -120,23 +165,17 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
120
165
  event.preventDefault();
121
166
  treeRef.current?.focus();
122
167
  };
123
- const renderNodes = (nodes) => nodes.map(node => {
124
- const branch = Boolean(node.children?.length);
125
- const label = renderLabel?.(node, {
126
- branch,
127
- disabled: Boolean(node.disabled),
128
- selected: selectedSet.has(node.id),
129
- query,
130
- }) ?? node.label;
131
- return _jsxs(Tree.Item, { className: "inkra-tree-select__item", id: node.id, isDisabled: node.disabled, textValue: node.label, children: [_jsxs(Tree.ItemContent, { collapseLabel: collapseLabel, expandLabel: expandLabel, children: [_jsx(Tree.ItemTitle, { children: label }), node.description ? _jsx(Tree.ItemDescription, { children: node.description }) : null] }), node.children?.length ? renderNodes(node.children) : null] }, node.id);
132
- });
133
168
  const fallbackValue = selectedIds.map(selectedId => normalized.byId.get(selectedId)?.label ?? selectedId).join(', ');
134
169
  const valueContent = renderValue?.(selectedNodes, selectedIds)
135
170
  ?? (fallbackValue ? _jsx(PencilText, { ellipsis: true, children: fallbackValue }) : _jsx(PencilText, { ellipsis: true, children: placeholder }));
136
171
  const indicatorSize = size === 'sm' ? 24 : size === 'lg' ? 32 : 28;
137
172
  const exposedOpen = resolvedDisabled || resolvedReadOnly ? false : isOpen;
138
- return _jsxs("div", { ...rest, className: ['inkra-tree-select', className].filter(Boolean).join(' '), "data-disabled": resolvedDisabled || undefined, "data-invalid": resolvedInvalid || undefined, "data-readonly": resolvedReadOnly || undefined, "data-size": size, ref: ref, children: [_jsxs(Popover.Root, { open: exposedOpen, onOpenChange: setOpen, children: [_jsxs("div", { className: "inkra-tree-select__control", children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx(Button, { "aria-describedby": describedBy, "aria-invalid": resolvedInvalid || undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-readonly": resolvedReadOnly || undefined, "aria-required": resolvedRequired || undefined, className: "inkra-tree-select__trigger", disabled: resolvedDisabled, endIcon: _jsx(PencilIcon, { paths: chevronPaths, size: indicatorSize, sourceSize: 24 }), fullWidth: true, id: controlId, ref: triggerRef, size: size, variant: "outline", children: _jsx("span", { className: "inkra-tree-select__value", "data-placeholder": !selectedIds.length || undefined, children: valueContent }) }) }), clearable ? _jsx(Button, { "aria-label": clearLabel, className: "inkra-tree-select__clear", "data-visible": selectedIds.length > 0 || undefined, disabled: resolvedDisabled || resolvedReadOnly || selectedIds.length === 0, onClick: () => emitSelection([], { reason: 'clear' }), size: size === 'sm' ? 'icon-sm' : size === 'lg' ? 'icon-lg' : 'icon', tabIndex: selectedIds.length ? undefined : -1, variant: "ghost", children: _jsx(PencilIcon, { paths: clearPaths, size: 24, sourceSize: 24 }) }) : null] }), _jsx(Popover.Portal, { children: _jsxs(Popover.Content, { align: align, "aria-label": treeLabel, className: "inkra-tree-select__popover", collisionPadding: collisionPadding, onOpenAutoFocus: (event) => {
173
+ return _jsxs("div", { ...rest, className: ['inkra-tree-select', className].filter(Boolean).join(' '), "data-disabled": resolvedDisabled || undefined, "data-invalid": resolvedInvalid || undefined, "data-readonly": resolvedReadOnly || undefined, "data-size": size, ref: ref, children: [_jsxs(Popover.Root, { open: exposedOpen, onOpenChange: setOpen, children: [_jsxs(ButtonGroup, { "aria-label": ariaLabel ?? (ariaLabelledBy ? undefined : treeLabel), "aria-labelledby": ariaLabelledBy, className: "inkra-tree-select__control", "data-size": size, disabled: resolvedDisabled, fullWidth: true, size: size, variant: "outline", children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx(Button, { "aria-describedby": describedBy, "aria-invalid": resolvedInvalid || undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-readonly": resolvedReadOnly || undefined, "aria-required": resolvedRequired || undefined, className: "inkra-tree-select__trigger", disabled: resolvedDisabled, endIcon: _jsx(PencilIcon, { paths: chevronPaths, size: indicatorSize, sourceSize: 24 }), fullWidth: true, id: controlId, ref: triggerRef, size: size, children: _jsx("span", { className: "inkra-tree-select__value", "data-placeholder": !selectedIds.length || undefined, children: valueContent }) }) }), clearable ? _jsx(Button, { "aria-hidden": !selectedIds.length || undefined, "aria-label": clearLabel, className: "inkra-tree-select__clear", disabled: resolvedDisabled || resolvedReadOnly || selectedIds.length === 0, onPointerDown: event => event.preventDefault(), onClick: () => {
174
+ emitSelection([], { reason: 'clear' });
175
+ setOpen(false);
176
+ triggerRef.current?.focus({ preventScroll: true });
177
+ }, size: size === 'sm' ? 'icon-sm' : size === 'lg' ? 'icon-lg' : 'icon', tabIndex: selectedIds.length ? undefined : -1, variant: "ghost", children: _jsx(PencilIcon, { paths: clearPaths, size: 24, sourceSize: 24 }) }) : null] }), _jsx(Popover.Portal, { children: _jsxs(Popover.Content, { align: align, "aria-label": treeLabel, className: "inkra-tree-select__popover", collisionPadding: collisionPadding, onOpenAutoFocus: (event) => {
139
178
  event.preventDefault();
140
179
  searchRef.current?.focus();
141
- }, side: side, sideOffset: sideOffset, children: [_jsx("div", { className: "inkra-tree-select__search", children: _jsx(Input, { "aria-label": searchPlaceholder, clearable: true, clearButtonLabel: clearLabel, onKeyDown: handleSearchKeyDown, onValueChange: setQuery, placeholder: searchPlaceholder, ref: searchRef, size: size, startIcon: _jsx(PencilIcon, { paths: searchPaths, size: 22, sourceSize: 24 }), type: "search", value: query, variant: "underline" }) }), _jsx(Tree.Root, { "aria-label": treeLabel, className: "inkra-tree-select__tree", emptyState: emptyContent, expandedKeys: effectiveExpanded, onExpandedChange: handleExpandedChange, onSelectionChange: handleSelectionChange, ref: treeRef, selectedKeys: new Set(selectedIds), selectionMode: props.multiple ? 'multiple' : 'single', children: renderNodes(filtered.nodes) })] }) })] }), _jsx("input", { "aria-hidden": "true", className: "inkra-tree-select__form-anchor", form: form, ref: formAnchorRef, tabIndex: -1, type: "hidden" }), name && !resolvedDisabled ? selectedIds.map(selectedId => _jsx("input", { form: form, name: name, type: "hidden", value: selectedId }, selectedId)) : null] });
180
+ }, side: side, sideOffset: sideOffset, children: [_jsx("div", { className: "inkra-tree-select__search", children: _jsx(Input, { "aria-label": searchPlaceholder, clearable: true, clearButtonLabel: clearLabel, onKeyDown: handleSearchKeyDown, onValueChange: setQuery, placeholder: searchPlaceholder, ref: searchRef, size: size, startIcon: _jsx(PencilIcon, { paths: searchPaths, size: 22, sourceSize: 24 }), type: "search", value: query, variant: "underline" }) }), _jsx(TreeSelectTree, { collapseLabel: collapseLabel, effectiveExpanded: effectiveExpanded, emptyContent: emptyContent, expandLabel: expandLabel, multiple: Boolean(props.multiple), nodes: filtered.nodes, onExpandedChange: handleExpandedChange, onSelectionChange: handleSelectionChange, query: query, ref: treeRef, renderLabel: renderLabel, selectedSet: selectedSet, treeLabel: treeLabel })] }) })] }), _jsx("input", { "aria-hidden": "true", className: "inkra-tree-select__form-anchor", form: form, ref: formAnchorRef, tabIndex: -1, type: "hidden" }), name && !resolvedDisabled ? selectedIds.map(selectedId => _jsx("input", { form: form, name: name, type: "hidden", value: selectedId }, selectedId)) : null] });
142
181
  });