@inkra/react 0.2.0 → 0.4.3

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 (246) hide show
  1. package/README.md +101 -7
  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.d.ts +1 -1
  9. package/dist/alert-dialog/index.js +18 -6
  10. package/dist/audio-player/index.d.ts +2 -0
  11. package/dist/audio-player/index.js +126 -31
  12. package/dist/calendar/index.d.ts +4 -1
  13. package/dist/calendar/index.js +31 -12
  14. package/dist/carousel/index.js +13 -6
  15. package/dist/choice-grid/index.d.ts +6 -1
  16. package/dist/choice-grid/index.js +61 -33
  17. package/dist/color-picker/GradientEditor.d.ts +39 -0
  18. package/dist/color-picker/GradientEditor.js +63 -0
  19. package/dist/color-picker/gradient.d.ts +25 -0
  20. package/dist/color-picker/gradient.js +60 -0
  21. package/dist/color-picker/index.d.ts +27 -7
  22. package/dist/color-picker/index.js +134 -32
  23. package/dist/color-swatch/index.d.ts +10 -0
  24. package/dist/color-swatch/index.js +9 -0
  25. package/dist/command/index.d.ts +24 -2
  26. package/dist/command/index.js +29 -6
  27. package/dist/content/index.d.ts +5 -0
  28. package/dist/content/index.js +12 -3
  29. package/dist/context-menu/index.d.ts +2 -2
  30. package/dist/context-menu/index.js +4 -3
  31. package/dist/data-table/index.d.ts +7 -1
  32. package/dist/data-table/index.js +64 -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 +10 -9
  37. package/dist/day-agenda/index.d.ts +6 -1
  38. package/dist/day-agenda/index.js +46 -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 +5 -5
  44. package/dist/drawer/index.d.ts +1 -1
  45. package/dist/drawer/index.js +9 -6
  46. package/dist/dropdown-menu/index.d.ts +1 -1
  47. package/dist/dropdown-menu/index.js +3 -2
  48. package/dist/editable/index.d.ts +24 -2
  49. package/dist/editable/index.js +142 -37
  50. package/dist/empty/illustrations.d.ts +8 -0
  51. package/dist/empty/illustrations.js +78 -0
  52. package/dist/empty/index.d.ts +6 -1
  53. package/dist/empty/index.js +11 -3
  54. package/dist/flip/index.js +28 -8
  55. package/dist/form/EditablePencilText.js +34 -0
  56. package/dist/form/Field.js +11 -10
  57. package/dist/form/Input.js +1 -1
  58. package/dist/form/Textarea.d.ts +3 -1
  59. package/dist/form/Textarea.js +6 -4
  60. package/dist/hotkey/index.d.ts +1 -0
  61. package/dist/hotkey/index.js +6 -4
  62. package/dist/hover-card/index.js +1 -1
  63. package/dist/image-cropper/index.js +4 -2
  64. package/dist/image-viewer/VideoResource.d.ts +21 -0
  65. package/dist/image-viewer/VideoResource.js +194 -0
  66. package/dist/image-viewer/index.d.ts +14 -0
  67. package/dist/image-viewer/index.js +118 -65
  68. package/dist/image-viewer/lucideGeometry.d.ts +3 -0
  69. package/dist/image-viewer/lucideGeometry.js +19 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +2 -0
  72. package/dist/input-number/index.d.ts +6 -0
  73. package/dist/input-number/index.js +131 -15
  74. package/dist/input-number/model.d.ts +13 -0
  75. package/dist/input-number/model.js +50 -0
  76. package/dist/internal/cache.d.ts +4 -0
  77. package/dist/internal/cache.js +18 -1
  78. package/dist/internal/iconGeometry.d.ts +2 -0
  79. package/dist/internal/iconGeometry.js +32 -0
  80. package/dist/internal/renderTokens.d.ts +4 -0
  81. package/dist/internal/renderTokens.js +4 -0
  82. package/dist/internal/stroke.d.ts +7 -0
  83. package/dist/internal/stroke.js +63 -7
  84. package/dist/internal/surfaceBudget.d.ts +20 -0
  85. package/dist/internal/surfaceBudget.js +69 -0
  86. package/dist/internal/surfaceRenderer.js +87 -28
  87. package/dist/internal/surfaceVisibility.d.ts +3 -0
  88. package/dist/internal/surfaceVisibility.js +60 -0
  89. package/dist/internal/textRenderer.d.ts +2 -0
  90. package/dist/internal/textRenderer.js +3 -3
  91. package/dist/internal/usePencilSurface.js +17 -1
  92. package/dist/internal/wash.js +4 -3
  93. package/dist/journey-map/terrain.js +10 -5
  94. package/dist/layout/index.js +1 -1
  95. package/dist/log/index.js +29 -22
  96. package/dist/markdown/CodeBlock.d.ts +19 -0
  97. package/dist/markdown/CodeBlock.js +86 -0
  98. package/dist/markdown/Deletion.d.ts +2 -0
  99. package/dist/markdown/Deletion.js +69 -0
  100. package/dist/markdown/icons.d.ts +14 -0
  101. package/dist/markdown/icons.js +16 -0
  102. package/dist/markdown/index.d.ts +17 -0
  103. package/dist/markdown/index.js +175 -0
  104. package/dist/node-graph/index.d.ts +2 -0
  105. package/dist/node-graph/index.js +49 -25
  106. package/dist/number-ticker/index.d.ts +11 -9
  107. package/dist/number-ticker/index.js +14 -11
  108. package/dist/number-ticker/model.d.ts +38 -5
  109. package/dist/number-ticker/model.js +139 -10
  110. package/dist/number-ticker/precision.d.ts +7 -0
  111. package/dist/number-ticker/precision.js +141 -0
  112. package/dist/overflow-list/index.js +5 -2
  113. package/dist/overlay/Overlay.d.ts +1 -1
  114. package/dist/overlay/Overlay.js +5 -3
  115. package/dist/overlay/Popover.d.ts +1 -1
  116. package/dist/overlay/Popover.js +6 -5
  117. package/dist/overlay/themeBridge.d.ts +12 -1
  118. package/dist/overlay/themeBridge.js +41 -3
  119. package/dist/pagination/index.d.ts +10 -1
  120. package/dist/pagination/index.js +6 -4
  121. package/dist/pan-zoom/index.d.ts +2 -0
  122. package/dist/pan-zoom/index.js +7 -6
  123. package/dist/pencil.d.ts +1 -0
  124. package/dist/pencil.js +4 -4
  125. package/dist/radar/index.d.ts +4 -0
  126. package/dist/radar/index.js +41 -21
  127. package/dist/rating/index.d.ts +4 -0
  128. package/dist/rating/index.js +128 -24
  129. package/dist/resizable/index.js +1 -1
  130. package/dist/scroll-area/index.d.ts +1 -1
  131. package/dist/scroll-area/index.js +5 -2
  132. package/dist/select/MultipleSelect.js +5 -4
  133. package/dist/select/SearchableSelect.js +61 -6
  134. package/dist/select/Select.d.ts +1 -0
  135. package/dist/select/Select.js +3 -1
  136. package/dist/select/StepperSelect.d.ts +29 -0
  137. package/dist/select/StepperSelect.js +119 -0
  138. package/dist/select/index.d.ts +2 -0
  139. package/dist/select/index.js +1 -0
  140. package/dist/select/stepperModel.d.ts +7 -0
  141. package/dist/select/stepperModel.js +29 -0
  142. package/dist/selection/Checkbox.js +4 -3
  143. package/dist/selection/RadioGroup.js +29 -13
  144. package/dist/selection/Switch.js +4 -3
  145. package/dist/selection/useNativeChecked.d.ts +2 -1
  146. package/dist/selection/useNativeChecked.js +26 -8
  147. package/dist/slider/index.js +50 -6
  148. package/dist/sortable/index.js +15 -6
  149. package/dist/sparkline/index.js +17 -2
  150. package/dist/sparkline/model.js +11 -4
  151. package/dist/styles/accordion.css +228 -0
  152. package/dist/styles/alert-dialog.css +139 -0
  153. package/dist/styles/audio-player.css +22 -0
  154. package/dist/styles/base.css +638 -0
  155. package/dist/styles/breadcrumb.css +7 -0
  156. package/dist/styles/calendar.css +62 -0
  157. package/dist/styles/carousel.css +173 -0
  158. package/dist/styles/choice-grid.css +226 -0
  159. package/dist/styles/color-picker.css +281 -0
  160. package/dist/styles/color-swatch.css +47 -0
  161. package/dist/styles/command.css +52 -0
  162. package/dist/styles/content.css +79 -0
  163. package/dist/styles/context-menu.css +35 -0
  164. package/dist/styles/data-table.css +31 -0
  165. package/dist/styles/date-picker.css +107 -0
  166. package/dist/styles/day-agenda.css +30 -0
  167. package/dist/styles/display.css +304 -0
  168. package/dist/styles/dockable-panel.css +110 -0
  169. package/dist/styles/drawer.css +133 -0
  170. package/dist/styles/dropdown-menu.css +68 -0
  171. package/dist/styles/editable.css +145 -0
  172. package/dist/styles/empty.css +108 -0
  173. package/dist/styles/flip.css +101 -0
  174. package/dist/styles/form.css +634 -0
  175. package/dist/styles/heatmap.css +256 -0
  176. package/dist/styles/hotkey.css +189 -0
  177. package/dist/styles/hover-card.css +27 -0
  178. package/dist/styles/image-cropper.css +205 -0
  179. package/dist/styles/image-viewer.css +422 -0
  180. package/dist/styles/input-number.css +21 -0
  181. package/dist/styles/journey-map.css +19 -0
  182. package/dist/styles/keypad.css +92 -0
  183. package/dist/styles/layout.css +72 -0
  184. package/dist/styles/leaflet.css +661 -0
  185. package/dist/styles/log.css +203 -0
  186. package/dist/styles/markdown.css +379 -0
  187. package/dist/styles/node-graph.css +142 -0
  188. package/dist/styles/number-ticker.css +83 -0
  189. package/dist/styles/overflow-list.css +51 -0
  190. package/dist/styles/overlay.css +284 -0
  191. package/dist/styles/pagination.css +21 -0
  192. package/dist/styles/pan-zoom.css +136 -0
  193. package/dist/styles/radar.css +1 -0
  194. package/dist/styles/rating.css +121 -0
  195. package/dist/styles/resizable.css +108 -0
  196. package/dist/styles/scroll-area.css +122 -0
  197. package/dist/styles/select.css +557 -0
  198. package/dist/styles/selection.css +264 -0
  199. package/dist/styles/slider.css +145 -0
  200. package/dist/styles/slot.css +109 -0
  201. package/dist/styles/sortable.css +159 -0
  202. package/dist/styles/sparkline.css +24 -0
  203. package/dist/styles/steps.css +27 -0
  204. package/dist/styles/tabs.css +79 -0
  205. package/dist/styles/tag-input.css +213 -0
  206. package/dist/styles/time-picker.css +66 -0
  207. package/dist/styles/timeline.css +24 -0
  208. package/dist/styles/toast.css +160 -0
  209. package/dist/styles/toggle-group.css +107 -0
  210. package/dist/styles/tooltip.css +78 -0
  211. package/dist/styles/transfer.css +148 -0
  212. package/dist/styles/tree-select.css +131 -0
  213. package/dist/styles/tree.css +59 -0
  214. package/dist/styles/typewriter.css +62 -0
  215. package/dist/styles/upload.css +60 -0
  216. package/dist/styles/virtual-list.css +112 -0
  217. package/dist/styles.css +2241 -443
  218. package/dist/tabs/index.d.ts +9 -0
  219. package/dist/tabs/index.js +66 -16
  220. package/dist/tabs/useOverflow.d.ts +20 -0
  221. package/dist/tabs/useOverflow.js +145 -0
  222. package/dist/tag-input/index.d.ts +7 -2
  223. package/dist/tag-input/index.js +70 -26
  224. package/dist/time-picker/index.js +38 -20
  225. package/dist/toast/index.d.ts +19 -11
  226. package/dist/toast/index.js +48 -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.d.ts +1 -1
  233. package/dist/tooltip/Tooltip.js +6 -5
  234. package/dist/transfer/index.d.ts +5 -0
  235. package/dist/transfer/index.js +93 -29
  236. package/dist/transfer/model.d.ts +4 -0
  237. package/dist/transfer/model.js +37 -11
  238. package/dist/tree/index.d.ts +5 -1
  239. package/dist/tree/index.js +5 -5
  240. package/dist/tree-select/index.js +67 -30
  241. package/dist/tree-select/model.d.ts +11 -5
  242. package/dist/tree-select/model.js +111 -83
  243. package/dist/typewriter/index.js +10 -10
  244. package/dist/upload/index.js +30 -16
  245. package/dist/virtual-list/index.js +10 -7
  246. package/package.json +416 -5
@@ -7,12 +7,11 @@ import { PencilLine } from '../PencilLine.js';
7
7
  import { PencilSurface } from '../PencilSurface.js';
8
8
  import { PencilText } from '../PencilText.js';
9
9
  import { Checkbox } from '../selection/Checkbox.js';
10
- import { moveTransferKeys, normalizeTargetKeys, splitTransferItems, uniqueTransferItems, } from './model.js';
10
+ import { moveTransferKeys, normalizeTargetKeys, resolveTransferFocusIntent, splitTransferItems, transferKeysEqual, uniqueTransferItems, } from './model.js';
11
11
  const searchPaths = [
12
12
  { 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
13
  ];
14
14
  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
15
  const defaultLabels = {
17
16
  sourceTitle: 'Available',
18
17
  targetTitle: 'Selected',
@@ -33,6 +32,8 @@ const defaultLabels = {
33
32
  moveSelectedToTarget: 'Move selected visible items to selected',
34
33
  moveSelectedToSource: 'Move selected visible items to available',
35
34
  batchActions: 'Transfer selected visible items',
35
+ previousPage: 'Previous page',
36
+ nextPage: 'Next page',
36
37
  };
37
38
  function searchableText(item) {
38
39
  const parts = [
@@ -43,8 +44,13 @@ function searchableText(item) {
43
44
  ];
44
45
  return parts.filter(Boolean).join(' ').toLocaleLowerCase();
45
46
  }
46
- function defaultFilter(item, query) {
47
- return searchableText(item).includes(query.trim().toLocaleLowerCase());
47
+ function filterPanelItems(items, query, side, filterItem) {
48
+ if (query.length === 0)
49
+ return items;
50
+ if (filterItem)
51
+ return items.filter(item => filterItem(item, query, side));
52
+ const needle = query.trim().toLocaleLowerCase();
53
+ return items.filter(item => searchableText(item).includes(needle));
48
54
  }
49
55
  function defaultCount({ visible, total, selected }) {
50
56
  return selected > 0 ? `${selected} selected · ${visible}/${total}` : `${visible}/${total}`;
@@ -54,46 +60,95 @@ function itemCopy(item) {
54
60
  ? _jsx(PencilText, { children: String(item.label) })
55
61
  : item.label }), item.description == null ? null : _jsx("span", { className: "inkra-transfer__item-description", children: item.description })] });
56
62
  }
57
- export const Transfer = forwardRef(function Transfer({ className, defaultTargetKeys = [], disabled = false, filterItem, formatCount = defaultCount, items, labels: labelOverrides, onTargetKeysChange, renderItem, searchable = true, targetKeys, ...props }, ref) {
63
+ export const Transfer = forwardRef(function Transfer({ className, defaultTargetKeys = [], disabled = false, filterItem, formatCount = defaultCount, items, labels: labelOverrides, onTargetKeysChange, pageSize, pageLabel = (page, count) => `${page} / ${count}`, renderItem, searchable = true, targetKeys, ...props }, ref) {
58
64
  const generatedId = useId();
59
65
  const controlled = targetKeys !== undefined;
60
66
  const uniqueItems = useMemo(() => uniqueTransferItems(items), [items]);
61
67
  const [uncontrolledTargetKeys, setUncontrolledTargetKeys] = useState(() => normalizeTargetKeys(items, defaultTargetKeys));
62
- const currentTargetKeys = normalizeTargetKeys(uniqueItems, controlled ? targetKeys : uncontrolledTargetKeys);
63
- const panels = splitTransferItems(uniqueItems, currentTargetKeys);
68
+ const requestedTargetKeys = controlled ? targetKeys : uncontrolledTargetKeys;
69
+ const currentTargetKeys = useMemo(() => normalizeTargetKeys(uniqueItems, requestedTargetKeys), [uniqueItems, requestedTargetKeys]);
70
+ const currentTargetSet = useMemo(() => new Set(currentTargetKeys), [currentTargetKeys]);
71
+ const itemsById = useMemo(() => new Map(uniqueItems.map(item => [item.id, item])), [uniqueItems]);
72
+ const panels = useMemo(() => splitTransferItems(uniqueItems, currentTargetKeys), [uniqueItems, currentTargetKeys]);
64
73
  const labels = { ...defaultLabels, ...labelOverrides };
65
74
  const [queries, setQueries] = useState({ source: '', target: '' });
75
+ const [pages, setPages] = useState({ source: 0, target: 0 });
76
+ const pageLength = pageSize !== undefined && Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : undefined;
66
77
  const [selected, setSelected] = useState(() => ({ source: new Set(), target: new Set() }));
67
78
  const [pendingFocus, setPendingFocus] = useState(null);
68
79
  const itemRefs = useRef(new Map());
69
80
  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') };
81
+ const visibleSource = useMemo(() => filterPanelItems(panels.source, queries.source, 'source', filterItem), [panels.source, queries.source, filterItem]);
82
+ const visibleTarget = useMemo(() => filterPanelItems(panels.target, queries.target, 'target', filterItem), [panels.target, queries.target, filterItem]);
83
+ const pageIndex = (side, count) => pageLength === undefined ? 0 : Math.min(pages[side], Math.max(0, Math.ceil(count / pageLength) - 1));
84
+ const sourcePage = pageIndex('source', visibleSource.length);
85
+ const targetPage = pageIndex('target', visibleTarget.length);
86
+ const shownSource = useMemo(() => pageLength === undefined ? visibleSource : visibleSource.slice(sourcePage * pageLength, (sourcePage + 1) * pageLength), [visibleSource, sourcePage, pageLength]);
87
+ const shownTarget = useMemo(() => pageLength === undefined ? visibleTarget : visibleTarget.slice(targetPage * pageLength, (targetPage + 1) * pageLength), [visibleTarget, targetPage, pageLength]);
88
+ const visibleItems = { source: shownSource, target: shownTarget };
75
89
  useEffect(() => {
76
- const valid = new Set(uniqueItems.map(item => item.id));
77
90
  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)));
91
+ const source = new Set([...current.source].filter(key => itemsById.has(key) && !currentTargetSet.has(key)));
92
+ const target = new Set([...current.target].filter(key => itemsById.has(key) && currentTargetSet.has(key)));
80
93
  if (source.size === current.source.size && target.size === current.target.size)
81
94
  return current;
82
95
  return { source, target };
83
96
  });
84
- }, [items, currentTargetKeys.join('\u0000')]);
97
+ }, [itemsById, currentTargetSet]);
85
98
  useEffect(() => {
86
99
  if (!pendingFocus)
87
100
  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;
101
+ if (disabled || !itemsById.has(pendingFocus.id)) {
102
+ setPendingFocus(null);
103
+ return;
104
+ }
105
+ const valueResolution = transferKeysEqual(currentTargetKeys, pendingFocus.expectedTargetKeys)
106
+ ? 'accepted'
107
+ : transferKeysEqual(currentTargetKeys, pendingFocus.previousTargetKeys) ? 'unchanged' : 'mismatch';
108
+ const ownerDocument = pendingFocus.origin?.ownerDocument ?? document;
109
+ const activeElement = ownerDocument.activeElement;
110
+ const originStillOwnsFocus = activeElement === pendingFocus.origin;
111
+ const focusFellBackToDocument = activeElement === ownerDocument.body || activeElement === ownerDocument.documentElement;
112
+ const originUnavailable = pendingFocus.origin != null
113
+ && (!pendingFocus.origin.isConnected || pendingFocus.origin.matches(':disabled'))
114
+ && focusFellBackToDocument;
115
+ if (valueResolution !== 'accepted') {
116
+ if (resolveTransferFocusIntent(valueResolution, originStillOwnsFocus, originUnavailable) !== 'wait') {
117
+ setPendingFocus(null);
118
+ return;
119
+ }
120
+ const cancel = (event) => {
121
+ const target = event.target;
122
+ const documentFallback = target === ownerDocument.body || target === ownerDocument.documentElement;
123
+ const unavailable = pendingFocus.origin != null
124
+ && (!pendingFocus.origin.isConnected || pendingFocus.origin.matches(':disabled'));
125
+ if (target !== pendingFocus.origin && !(documentFallback && unavailable))
126
+ setPendingFocus(null);
127
+ };
128
+ ownerDocument.addEventListener('focusin', cancel);
129
+ return () => ownerDocument.removeEventListener('focusin', cancel);
130
+ }
131
+ if (resolveTransferFocusIntent('accepted', originStillOwnsFocus, originUnavailable) !== 'focus') {
132
+ setPendingFocus(null);
133
+ return;
134
+ }
135
+ if (pageLength !== undefined) {
136
+ const matching = pendingFocus.side === 'source' ? visibleSource : visibleTarget;
137
+ const index = matching.findIndex(item => item.id === pendingFocus.id);
138
+ const nextPage = index < 0 ? undefined : Math.floor(index / pageLength);
139
+ if (nextPage !== undefined && nextPage !== pages[pendingFocus.side]) {
140
+ setPages(current => ({ ...current, [pendingFocus.side]: nextPage }));
141
+ return;
142
+ }
143
+ }
144
+ const control = itemRefs.current.get(`${pendingFocus.side}:${pendingFocus.id}`);
92
145
  (control ?? panelRefs.current[pendingFocus.side])?.focus({ preventScroll: true });
93
146
  setPendingFocus(null);
94
- }, [pendingFocus, currentTargetKeys.join('\u0000')]);
147
+ }, [disabled, pendingFocus, currentTargetKeys, itemsById, pageLength, pages, visibleSource, visibleTarget]);
95
148
  const setItemSelected = (side, id, checked) => {
96
149
  setSelected(current => {
150
+ if (current[side].has(id) === checked)
151
+ return current;
97
152
  const next = new Set(current[side]);
98
153
  if (checked)
99
154
  next.add(id);
@@ -111,11 +166,11 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
111
166
  return { ...current, [side]: next };
112
167
  });
113
168
  };
114
- const move = (keys, direction, action) => {
169
+ const move = (keys, direction, action, origin) => {
115
170
  const sourceSide = direction === 'to-target' ? 'source' : 'target';
116
171
  const destinationSide = direction === 'to-target' ? 'target' : 'source';
117
172
  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);
173
+ const movedKeys = [...new Set(keys)].filter(key => allowed.has(key));
119
174
  if (movedKeys.length === 0)
120
175
  return;
121
176
  const next = moveTransferKeys(uniqueItems, currentTargetKeys, movedKeys, direction);
@@ -126,12 +181,18 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
126
181
  movedKeys.forEach(key => source.delete(key));
127
182
  return { ...current, [sourceSide]: source };
128
183
  });
129
- setPendingFocus({ id: movedKeys[0], side: destinationSide });
184
+ setPendingFocus({
185
+ id: movedKeys[0],
186
+ side: destinationSide,
187
+ origin,
188
+ previousTargetKeys: [...currentTargetKeys],
189
+ expectedTargetKeys: next,
190
+ });
130
191
  onTargetKeysChange?.(next, {
131
192
  action,
132
193
  direction,
133
194
  movedKeys,
134
- movedItems: movedKeys.map(key => uniqueItems.find(item => item.id === key)).filter((item) => item !== undefined),
195
+ movedItems: movedKeys.map(key => itemsById.get(key)).filter((item) => item !== undefined),
135
196
  });
136
197
  };
137
198
  const renderPanel = (side) => {
@@ -154,7 +215,10 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
154
215
  const selectAllLabel = source ? labels.selectAllSource : labels.selectAllTarget;
155
216
  const direction = source ? 'to-target' : 'to-source';
156
217
  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 => {
218
+ 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 => {
219
+ setQueries(current => ({ ...current, [side]: value }));
220
+ setPages(current => ({ ...current, [side]: 0 }));
221
+ }, 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
222
  const itemDisabled = disabled || item.disabled === true;
159
223
  const itemSelected = selected[side].has(item.id);
160
224
  const copy = renderItem?.(item, { side, selected: itemSelected, disabled: itemDisabled, query: queries[side] }) ?? itemCopy(item);
@@ -164,11 +228,11 @@ export const Transfer = forwardRef(function Transfer({ className, defaultTargetK
164
228
  itemRefs.current.set(key, element);
165
229
  else
166
230
  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
- }) })] });
231
+ }, 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);
232
+ }) }), 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
233
  };
170
234
  const sourceBatchKeys = selectableVisible('source').filter(item => selected.source.has(item.id)).map(item => item.id);
171
235
  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')] });
236
+ 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
237
  });
174
238
  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);
@@ -6,8 +6,8 @@ import { PencilIcon } from '../PencilIcon.js';
6
6
  import { PencilSurface } from '../PencilSurface.js';
7
7
  import { PencilText } from '../PencilText.js';
8
8
  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 }) });
9
+ function TreeRootInner({ className, style, indent = 24, size = 'md', emptyState = 'No items', selectionMode = 'single', selectionBehavior = 'toggle', ...props }, ref) {
10
+ 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
11
  }
12
12
  const TreeRoot = forwardRef(TreeRootInner);
13
13
  const TreeItem = forwardRef(function TreeItem({ className, ...props }, ref) {
@@ -15,17 +15,17 @@ const TreeItem = forwardRef(function TreeItem({ className, ...props }, ref) {
15
15
  });
16
16
  const TreeItemContent = forwardRef(function TreeItemContent({ children, className, icon, disclosureIcon, expandLabel = 'Expand item', collapseLabel = 'Collapse item', }, ref) {
17
17
  return _jsx(AriaTreeItemContent, { ref: ref, children: ({ hasChildItems, isExpanded, isSelected, isFocusVisible, isDisabled, level, id, }) => {
18
- const state = { isExpanded, hasChildItems, isSelected, isDisabled };
18
+ const state = { isExpanded, hasChildItems, isSelected, isDisabled, level };
19
19
  const resolvedIcon = typeof icon === 'function' ? icon(state) : icon;
20
20
  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 })] });
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: typeof children === 'function' ? children(state) : children })] });
22
22
  } });
23
23
  });
24
24
  const TreeItemTitle = forwardRef(function TreeItemTitle({ className, children, ...props }, ref) {
25
25
  return _jsx("span", { ...props, ref: ref, className: ['inkra-tree__title', className].filter(Boolean).join(' '), children: typeof children === 'string' ? _jsx(PencilText, { children: children }) : children });
26
26
  });
27
27
  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 });
28
+ return _jsx("span", { ...props, ref: ref, className: ['inkra-tree__description', className].filter(Boolean).join(' '), children: typeof children === 'string' ? _jsx(PencilText, { children: children }) : children });
29
29
  });
30
30
  export const Tree = {
31
31
  Root: TreeRoot,
@@ -1,13 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useEffect, useId, useMemo, useRef, useState, } from 'react';
3
- import { Button } from '../Button.js';
3
+ import { Button, ButtonGroup } from '../Button.js';
4
4
  import { Input } from '../form/Input.js';
5
5
  import { mergeAriaIds, useFieldContext } from '../form/context.js';
6
6
  import { PencilIcon } from '../PencilIcon.js';
7
7
  import { PencilText } from '../PencilText.js';
8
8
  import { Popover } from '../overlay/index.js';
9
9
  import { Tree } from '../tree/index.js';
10
- import { filterTreeSelectNodes, indexTreeSelectNodes, updateTreeSelectSelection, } from './model.js';
10
+ import { filterTreeSelectNodesFromIndex, indexTreeSelectNodes, updateTreeSelectSelectionFromIndex, } from './model.js';
11
11
  const chevronPaths = [{ d: 'M6 9l6 6 6-6' }];
12
12
  const clearPaths = [{ d: 'M6 6l12 12M18 6L6 18' }];
13
13
  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,6 +20,41 @@ function selectionFromProps(props, fallback = []) {
20
20
  function keys(selection) {
21
21
  return selection === 'all' ? [] : [...selection].map(String);
22
22
  }
23
+ // The portal owns mounting (including its exit animation). Keep collection
24
+ // construction here rather than doing work for content the portal never mounts.
25
+ const TreeSelectTree = forwardRef(function TreeSelectTree({ nodes, renderLabel, selectedSet, query, collapseLabel, expandLabel, treeLabel, emptyContent, effectiveExpanded, onExpandedChange, onSelectionChange, multiple, }, ref) {
26
+ // Default labels are selection-independent; only custom label renderers need
27
+ // a new collection when the selected keys change.
28
+ const labelSelection = renderLabel ? selectedSet : undefined;
29
+ const treeItems = useMemo(() => {
30
+ // Construct the same nested tree without growing the JavaScript call stack.
31
+ // Enter jobs keep renderLabel callbacks in the original depth-first order.
32
+ const result = [];
33
+ const stack = [];
34
+ for (let index = nodes.length - 1; index >= 0; index--)
35
+ stack.push({ node: nodes[index], output: result });
36
+ while (stack.length) {
37
+ const { node, output, finish } = stack.pop();
38
+ if (finish) {
39
+ 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));
40
+ continue;
41
+ }
42
+ const label = renderLabel?.(node, {
43
+ branch: Boolean(node.children?.length),
44
+ disabled: Boolean(node.disabled),
45
+ selected: labelSelection?.has(node.id) ?? false,
46
+ query,
47
+ }) ?? node.label;
48
+ const children = [];
49
+ stack.push({ node, output, finish: { label, children } });
50
+ for (let index = (node.children?.length ?? 0) - 1; index >= 0; index--) {
51
+ stack.push({ node: node.children[index], output: children });
52
+ }
53
+ }
54
+ return result;
55
+ }, [nodes, renderLabel, labelSelection, query, collapseLabel, expandLabel]);
56
+ 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 });
57
+ });
23
58
  export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
24
59
  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;
25
60
  const field = useFieldContext();
@@ -31,25 +66,25 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
31
66
  const resolvedInvalid = Boolean(invalid || field?.invalid);
32
67
  const describedBy = mergeAriaIds(ariaDescribedBy, field?.descriptionId, field?.errorId);
33
68
  const normalized = useMemo(() => indexTreeSelectNodes(items), [items]);
34
- const defaultSelection = useRef(selectionFromProps(props));
35
69
  const controlledSelection = props.value !== undefined;
36
- const [uncontrolledSelection, setUncontrolledSelection] = useState(defaultSelection.current);
37
- const selectedIds = controlledSelection ? selectionFromProps(props) : uncontrolledSelection;
70
+ const [uncontrolledSelection, setUncontrolledSelection] = useState(() => selectionFromProps(props));
71
+ const defaultSelection = useRef(uncontrolledSelection);
72
+ const selectedIds = useMemo(() => controlledSelection ? selectionFromProps(props) : uncontrolledSelection, [controlledSelection, props.value, props.multiple, uncontrolledSelection]);
38
73
  const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
39
74
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
40
75
  const isOpen = controlledOpen ?? uncontrolledOpen;
41
76
  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(() => [...new Set(defaultExpandedIds)]);
42
77
  const baseExpanded = expandedIds ?? uncontrolledExpanded;
43
78
  const [query, setQuery] = useState('');
44
- const filtered = useMemo(() => filterTreeSelectNodes(normalized.nodes, query), [normalized.nodes, query]);
79
+ const filtered = useMemo(() => filterTreeSelectNodesFromIndex(normalized, query), [normalized, query]);
45
80
  const effectiveExpanded = useMemo(() => new Set([...baseExpanded, ...filtered.expandedIds]), [baseExpanded, filtered.expandedIds]);
46
81
  const triggerRef = useRef(null);
47
82
  const searchRef = useRef(null);
48
83
  const treeRef = useRef(null);
49
84
  const formAnchorRef = useRef(null);
50
- const selectedNodes = selectedIds
85
+ const selectedNodes = useMemo(() => selectedIds
51
86
  .map(selectedId => normalized.byId.get(selectedId))
52
- .filter((node) => node !== undefined);
87
+ .filter((node) => node !== undefined), [selectedIds, normalized]);
53
88
  const setOpen = (next) => {
54
89
  if (resolvedDisabled || resolvedReadOnly)
55
90
  return;
@@ -68,20 +103,28 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
68
103
  props.onValueChange?.(next[0] ?? null, details);
69
104
  };
70
105
  useEffect(() => {
71
- if (controlledSelection)
72
- return;
73
106
  const currentForm = formAnchorRef.current?.form;
74
107
  if (!currentForm)
75
108
  return;
76
- const reset = () => {
77
- setUncontrolledSelection(defaultSelection.current);
78
- setQuery('');
79
- if (controlledOpen === undefined)
80
- setUncontrolledOpen(false);
109
+ let resetTimer;
110
+ const reset = (event) => {
111
+ clearTimeout(resetTimer);
112
+ resetTimer = setTimeout(() => {
113
+ if (event.defaultPrevented)
114
+ return;
115
+ if (!controlledSelection)
116
+ setUncontrolledSelection(defaultSelection.current);
117
+ setQuery('');
118
+ if (controlledOpen === undefined)
119
+ setUncontrolledOpen(false);
120
+ }, 0);
81
121
  };
82
122
  currentForm.addEventListener('reset', reset);
83
- return () => currentForm.removeEventListener('reset', reset);
84
- }, [controlledOpen, controlledSelection]);
123
+ return () => {
124
+ clearTimeout(resetTimer);
125
+ currentForm.removeEventListener('reset', reset);
126
+ };
127
+ }, [controlledOpen, controlledSelection, form]);
85
128
  const handleSelectionChange = (nextSelection) => {
86
129
  if (resolvedDisabled || resolvedReadOnly)
87
130
  return;
@@ -92,7 +135,7 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
92
135
  if (!toggledId)
93
136
  return;
94
137
  const selected = added === toggledId;
95
- const next = updateTreeSelectSelection(normalized.nodes, selectedIds, toggledId, selected, {
138
+ const next = updateTreeSelectSelectionFromIndex(normalized, selectedIds, toggledId, selected, {
96
139
  multiple: Boolean(props.multiple),
97
140
  branchSelection,
98
141
  selectionPropagation,
@@ -120,23 +163,17 @@ export const TreeSelect = forwardRef(function TreeSelect(props, ref) {
120
163
  event.preventDefault();
121
164
  treeRef.current?.focus();
122
165
  };
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
166
  const fallbackValue = selectedIds.map(selectedId => normalized.byId.get(selectedId)?.label ?? selectedId).join(', ');
134
167
  const valueContent = renderValue?.(selectedNodes, selectedIds)
135
168
  ?? (fallbackValue ? _jsx(PencilText, { ellipsis: true, children: fallbackValue }) : _jsx(PencilText, { ellipsis: true, children: placeholder }));
136
169
  const indicatorSize = size === 'sm' ? 24 : size === 'lg' ? 32 : 28;
137
170
  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) => {
171
+ 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: () => {
172
+ emitSelection([], { reason: 'clear' });
173
+ setOpen(false);
174
+ triggerRef.current?.focus({ preventScroll: true });
175
+ }, 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
176
  event.preventDefault();
140
177
  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] });
178
+ }, 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
179
  });
@@ -21,11 +21,17 @@ export interface FilteredTreeSelectNodes {
21
21
  visibleIds: ReadonlySet<string>;
22
22
  expandedIds: ReadonlySet<string>;
23
23
  }
24
- export declare function normalizeTreeSelectNodes(nodes: readonly TreeSelectNode[]): readonly TreeSelectNode[];
25
- export declare function indexTreeSelectNodes(nodes: readonly TreeSelectNode[]): TreeSelectIndex;
26
- export declare function filterTreeSelectNodes(nodes: readonly TreeSelectNode[], query: string): FilteredTreeSelectNodes;
27
- export declare function updateTreeSelectSelection(nodes: readonly TreeSelectNode[], current: readonly string[], toggledId: string, selected: boolean, options: {
24
+ interface TreeSelectSelectionOptions {
28
25
  multiple: boolean;
29
26
  branchSelection: TreeSelectBranchSelection;
30
27
  selectionPropagation: TreeSelectSelectionPropagation;
31
- }): string[];
28
+ }
29
+ export declare function normalizeTreeSelectNodes(nodes: readonly TreeSelectNode[]): readonly TreeSelectNode[];
30
+ export declare function indexTreeSelectNodes(nodes: readonly TreeSelectNode[]): TreeSelectIndex;
31
+ export declare function filterTreeSelectNodes(nodes: readonly TreeSelectNode[], query: string): FilteredTreeSelectNodes;
32
+ /** Reuse the component's index instead of cloning every node on each search. */
33
+ export declare function filterTreeSelectNodesFromIndex(index: TreeSelectIndex, query: string): FilteredTreeSelectNodes;
34
+ export declare function updateTreeSelectSelection(nodes: readonly TreeSelectNode[], current: readonly string[], toggledId: string, selected: boolean, options: TreeSelectSelectionOptions): string[];
35
+ /** Reuse the component's index for selection and aggregate each subtree once. */
36
+ export declare function updateTreeSelectSelectionFromIndex(index: TreeSelectIndex, current: readonly string[], toggledId: string, selected: boolean, options: TreeSelectSelectionOptions): string[];
37
+ export {};