@eifi1/ui-kit 0.6.1 → 0.7.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 (263) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +3 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +33 -6
  9. package/dist/components/autocomplete.js +62 -16
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +3 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/chip.d.ts +34 -14
  20. package/dist/components/chip.js +63 -28
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +18 -1
  23. package/dist/components/choice-card.js +21 -8
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/combobox-core.d.ts +61 -2
  26. package/dist/components/combobox-core.js +68 -17
  27. package/dist/components/combobox-core.js.map +1 -1
  28. package/dist/components/combobox.d.ts +11 -7
  29. package/dist/components/combobox.js +51 -28
  30. package/dist/components/combobox.js.map +1 -1
  31. package/dist/components/currency-select.js +3 -3
  32. package/dist/components/currency-select.js.map +1 -1
  33. package/dist/components/danger-confirm.d.ts +30 -6
  34. package/dist/components/danger-confirm.js +51 -32
  35. package/dist/components/danger-confirm.js.map +1 -1
  36. package/dist/components/data-table-filter-popover.d.ts +1 -1
  37. package/dist/components/data-table-filters.d.ts +1 -1
  38. package/dist/components/data-table-pagination.d.ts +6 -3
  39. package/dist/components/data-table-pagination.js +6 -5
  40. package/dist/components/data-table-pagination.js.map +1 -1
  41. package/dist/components/data-table.d.ts +1 -1
  42. package/dist/components/data-table.js +332 -248
  43. package/dist/components/data-table.js.map +1 -1
  44. package/dist/components/date-picker.js +17 -7
  45. package/dist/components/date-picker.js.map +1 -1
  46. package/dist/components/dialog-frame.d.ts +28 -3
  47. package/dist/components/dialog-frame.js +47 -11
  48. package/dist/components/dialog-frame.js.map +1 -1
  49. package/dist/components/disclosure.d.ts +22 -2
  50. package/dist/components/disclosure.js +41 -8
  51. package/dist/components/disclosure.js.map +1 -1
  52. package/dist/components/dropdown.d.ts +7 -3
  53. package/dist/components/dropdown.js +6 -2
  54. package/dist/components/dropdown.js.map +1 -1
  55. package/dist/components/entity-combobox.d.ts +17 -8
  56. package/dist/components/entity-combobox.js +7 -5
  57. package/dist/components/entity-combobox.js.map +1 -1
  58. package/dist/components/file-button.d.ts +36 -5
  59. package/dist/components/file-button.js +23 -2
  60. package/dist/components/file-button.js.map +1 -1
  61. package/dist/components/file-dropzone.d.ts +9 -5
  62. package/dist/components/file-dropzone.js +14 -19
  63. package/dist/components/file-dropzone.js.map +1 -1
  64. package/dist/components/grouped-picker.js +2 -2
  65. package/dist/components/grouped-picker.js.map +1 -1
  66. package/dist/components/hover-menu.d.ts +8 -3
  67. package/dist/components/hover-menu.js +88 -4
  68. package/dist/components/hover-menu.js.map +1 -1
  69. package/dist/components/icon-picker.d.ts +10 -1
  70. package/dist/components/icon-picker.js +5 -1
  71. package/dist/components/icon-picker.js.map +1 -1
  72. package/dist/components/measured-grid.d.ts +165 -0
  73. package/dist/components/measured-grid.js +559 -0
  74. package/dist/components/measured-grid.js.map +1 -0
  75. package/dist/components/mini-calendar.js +7 -8
  76. package/dist/components/mini-calendar.js.map +1 -1
  77. package/dist/components/month-picker.js +13 -9
  78. package/dist/components/month-picker.js.map +1 -1
  79. package/dist/components/multi-entity-combobox.js +2 -2
  80. package/dist/components/multi-entity-combobox.js.map +1 -1
  81. package/dist/components/multi-select.d.ts +9 -1
  82. package/dist/components/multi-select.js +22 -8
  83. package/dist/components/multi-select.js.map +1 -1
  84. package/dist/components/number-field.d.ts +3 -0
  85. package/dist/components/number-input.d.ts +3 -0
  86. package/dist/components/number-input.js +3 -3
  87. package/dist/components/number-input.js.map +1 -1
  88. package/dist/components/numpad-sheet.d.ts +3 -0
  89. package/dist/components/numpad-sheet.js +3 -3
  90. package/dist/components/numpad-sheet.js.map +1 -1
  91. package/dist/components/picker-sheet.js +1 -1
  92. package/dist/components/picker-sheet.js.map +1 -1
  93. package/dist/components/popover.js +11 -2
  94. package/dist/components/popover.js.map +1 -1
  95. package/dist/components/series-chart-ticks.d.ts +13 -0
  96. package/dist/components/series-chart-ticks.js +29 -0
  97. package/dist/components/series-chart-ticks.js.map +1 -0
  98. package/dist/components/series-chart.js +33 -27
  99. package/dist/components/series-chart.js.map +1 -1
  100. package/dist/components/swatch-picker.d.ts +10 -1
  101. package/dist/components/swatch-picker.js +2 -0
  102. package/dist/components/swatch-picker.js.map +1 -1
  103. package/dist/components/swipeable-row.d.ts +11 -2
  104. package/dist/components/swipeable-row.js +5 -3
  105. package/dist/components/swipeable-row.js.map +1 -1
  106. package/dist/components/tile-radio.js +5 -7
  107. package/dist/components/tile-radio.js.map +1 -1
  108. package/dist/components/toggle-group.d.ts +13 -3
  109. package/dist/components/toggle-group.js +21 -1
  110. package/dist/components/toggle-group.js.map +1 -1
  111. package/dist/components/toggle-legend.js +1 -2
  112. package/dist/components/toggle-legend.js.map +1 -1
  113. package/dist/components/treemap.d.ts +6 -1
  114. package/dist/components/treemap.js +16 -6
  115. package/dist/components/treemap.js.map +1 -1
  116. package/dist/components/ui.d.ts +31 -7
  117. package/dist/components/ui.js +26 -18
  118. package/dist/components/ui.js.map +1 -1
  119. package/dist/components/use-anchor-dir.d.ts +15 -0
  120. package/dist/components/use-anchor-dir.js +14 -0
  121. package/dist/components/use-anchor-dir.js.map +1 -0
  122. package/dist/components/use-mobile-reveal.d.ts +8 -1
  123. package/dist/components/use-mobile-reveal.js +3 -2
  124. package/dist/components/use-mobile-reveal.js.map +1 -1
  125. package/dist/components/use-table-state.d.ts +4 -4
  126. package/dist/components/use-table-state.js +12 -5
  127. package/dist/components/use-table-state.js.map +1 -1
  128. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  129. package/dist/data-table.d.ts +1 -1
  130. package/dist/data-table.js.map +1 -1
  131. package/dist/feedback/feedback-attachment.d.ts +22 -3
  132. package/dist/feedback/feedback-attachment.js +15 -7
  133. package/dist/feedback/feedback-attachment.js.map +1 -1
  134. package/dist/feedback/feedback-dialog.d.ts +3 -2
  135. package/dist/feedback/feedback-dialog.js.map +1 -1
  136. package/dist/feedback.d.ts +1 -1
  137. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  138. package/dist/hooks/use-windowed-rows.js +31 -0
  139. package/dist/hooks/use-windowed-rows.js.map +1 -0
  140. package/dist/i18n/defaults.d.ts +3 -0
  141. package/dist/i18n/defaults.js +5 -1
  142. package/dist/i18n/defaults.js.map +1 -1
  143. package/dist/i18n/kit-labels.d.ts +5 -0
  144. package/dist/i18n/kit-labels.js.map +1 -1
  145. package/dist/i18n/locales/de-CH.d.ts +43 -0
  146. package/dist/i18n/locales/de-CH.js +21 -0
  147. package/dist/i18n/locales/de-CH.js.map +1 -0
  148. package/dist/i18n/locales/de.d.ts +43 -0
  149. package/dist/i18n/locales/de.js +344 -0
  150. package/dist/i18n/locales/de.js.map +1 -0
  151. package/dist/i18n/locales/es.d.ts +43 -0
  152. package/dist/i18n/locales/es.js +339 -0
  153. package/dist/i18n/locales/es.js.map +1 -0
  154. package/dist/i18n/locales/fr.d.ts +43 -0
  155. package/dist/i18n/locales/fr.js +344 -0
  156. package/dist/i18n/locales/fr.js.map +1 -0
  157. package/dist/i18n/locales/hu.d.ts +43 -0
  158. package/dist/i18n/locales/hu.js +343 -0
  159. package/dist/i18n/locales/hu.js.map +1 -0
  160. package/dist/i18n/locales/it.d.ts +43 -0
  161. package/dist/i18n/locales/it.js +342 -0
  162. package/dist/i18n/locales/it.js.map +1 -0
  163. package/dist/i18n/locales/zh.d.ts +43 -0
  164. package/dist/i18n/locales/zh.js +338 -0
  165. package/dist/i18n/locales/zh.js.map +1 -0
  166. package/dist/index.d.ts +6 -3
  167. package/dist/index.js +4 -1
  168. package/dist/index.js.map +1 -1
  169. package/dist/lib/direction.d.ts +24 -0
  170. package/dist/lib/direction.js +19 -0
  171. package/dist/lib/direction.js.map +1 -0
  172. package/dist/rhf/form.js.map +1 -1
  173. package/dist/search/command-palette.d.ts +5 -0
  174. package/dist/search/command-palette.js +80 -50
  175. package/dist/search/command-palette.js.map +1 -1
  176. package/dist/shell/app-shell.d.ts +13 -1
  177. package/dist/shell/app-shell.js +133 -84
  178. package/dist/shell/app-shell.js.map +1 -1
  179. package/dist/shell/topbar-action-menu.js +1 -1
  180. package/dist/shell/topbar-action-menu.js.map +1 -1
  181. package/dist/shell/topbar-controls.d.ts +7 -3
  182. package/dist/shell/topbar-controls.js +38 -31
  183. package/dist/shell/topbar-controls.js.map +1 -1
  184. package/dist/theme/chart-palette.d.ts +7 -1
  185. package/dist/theme/chart-palette.js +2 -1
  186. package/dist/theme/chart-palette.js.map +1 -1
  187. package/dist/tour/tour.d.ts +11 -5
  188. package/dist/tour/tour.js +124 -58
  189. package/dist/tour/tour.js.map +1 -1
  190. package/dist/wizard/use-wizard.js +30 -13
  191. package/dist/wizard/use-wizard.js.map +1 -1
  192. package/dist/wizard/wizard-summary.js +1 -1
  193. package/dist/wizard/wizard-summary.js.map +1 -1
  194. package/package.json +12 -2
  195. package/src/components/account-settings.tsx +6 -2
  196. package/src/components/amount-input.tsx +10 -8
  197. package/src/components/autocomplete.tsx +110 -24
  198. package/src/components/calculator.tsx +5 -1
  199. package/src/components/chart-zoom.tsx +8 -1
  200. package/src/components/chart.tsx +68 -7
  201. package/src/components/chip.tsx +131 -49
  202. package/src/components/choice-card.tsx +77 -36
  203. package/src/components/combobox-core.tsx +147 -20
  204. package/src/components/combobox.tsx +100 -44
  205. package/src/components/currency-select.tsx +6 -6
  206. package/src/components/danger-confirm.tsx +90 -36
  207. package/src/components/data-table-pagination.tsx +32 -19
  208. package/src/components/data-table.tsx +198 -36
  209. package/src/components/date-picker.tsx +24 -7
  210. package/src/components/dialog-frame.tsx +97 -22
  211. package/src/components/disclosure.tsx +75 -13
  212. package/src/components/dropdown.tsx +17 -5
  213. package/src/components/entity-combobox.tsx +26 -13
  214. package/src/components/file-button.tsx +74 -5
  215. package/src/components/file-dropzone.tsx +44 -35
  216. package/src/components/grouped-picker.tsx +2 -2
  217. package/src/components/hover-menu.tsx +139 -10
  218. package/src/components/icon-picker.tsx +14 -1
  219. package/src/components/measured-grid.tsx +852 -0
  220. package/src/components/mini-calendar.tsx +11 -8
  221. package/src/components/month-picker.tsx +18 -11
  222. package/src/components/multi-entity-combobox.tsx +2 -2
  223. package/src/components/multi-select.tsx +34 -7
  224. package/src/components/number-input.tsx +4 -4
  225. package/src/components/numpad-sheet.tsx +5 -3
  226. package/src/components/picker-sheet.tsx +1 -1
  227. package/src/components/popover.tsx +18 -3
  228. package/src/components/series-chart-ticks.ts +52 -0
  229. package/src/components/series-chart.tsx +39 -31
  230. package/src/components/swatch-picker.tsx +11 -0
  231. package/src/components/swipeable-row.tsx +21 -6
  232. package/src/components/tile-radio.tsx +5 -7
  233. package/src/components/toggle-group.tsx +39 -3
  234. package/src/components/toggle-legend.tsx +5 -2
  235. package/src/components/treemap.tsx +28 -5
  236. package/src/components/ui.tsx +74 -30
  237. package/src/components/use-anchor-dir.ts +20 -0
  238. package/src/components/use-mobile-reveal.ts +10 -2
  239. package/src/components/use-table-state.ts +44 -7
  240. package/src/data-table.ts +1 -0
  241. package/src/feedback/feedback-attachment.tsx +36 -9
  242. package/src/feedback/feedback-dialog.tsx +3 -2
  243. package/src/hooks/use-windowed-rows.ts +69 -0
  244. package/src/i18n/defaults.ts +4 -0
  245. package/src/i18n/kit-labels.tsx +4 -0
  246. package/src/i18n/locales/de-CH.ts +30 -0
  247. package/src/i18n/locales/de.ts +365 -0
  248. package/src/i18n/locales/es.ts +358 -0
  249. package/src/i18n/locales/fr.ts +371 -0
  250. package/src/i18n/locales/hu.ts +359 -0
  251. package/src/i18n/locales/it.ts +360 -0
  252. package/src/i18n/locales/zh.ts +348 -0
  253. package/src/index.ts +10 -1
  254. package/src/lib/direction.ts +37 -0
  255. package/src/rhf/form.tsx +21 -0
  256. package/src/search/command-palette.tsx +77 -19
  257. package/src/shell/app-shell.tsx +200 -129
  258. package/src/shell/topbar-action-menu.tsx +1 -1
  259. package/src/shell/topbar-controls.tsx +19 -8
  260. package/src/theme/chart-palette.ts +9 -2
  261. package/src/tour/tour.tsx +119 -18
  262. package/src/wizard/use-wizard.ts +55 -17
  263. package/src/wizard/wizard-summary.tsx +1 -1
@@ -2,14 +2,32 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { cn } from "../lib/cn.js";
5
- import { useOutsideClick } from "../hooks/use-dismiss.js";
5
+ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss.js";
6
6
  const OPEN_DELAY_MS = 120;
7
7
  const CLOSE_DELAY_MS = 120;
8
+ const ALIGN_CLASS = { start: "start-0", end: "end-0", left: "left-0", right: "right-0" };
9
+ const ITEM_ROLES = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
10
+ const PLAIN_ITEMS = "button:not([role]),a[href]:not([role])";
11
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';
12
+ function applyMenuSemantics(menu) {
13
+ for (const el of menu.querySelectorAll(PLAIN_ITEMS)) el.setAttribute("role", "menuitem");
14
+ const items = Array.from(menu.querySelectorAll(ITEM_ROLES));
15
+ for (const item of items) {
16
+ item.setAttribute("tabindex", "-1");
17
+ for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {
18
+ if (!el.hasAttribute("role")) el.setAttribute("role", "none");
19
+ }
20
+ }
21
+ for (const el of menu.querySelectorAll("ul:not([role]),ol:not([role]),li:not([role])")) {
22
+ el.setAttribute("role", "none");
23
+ }
24
+ return items.filter((el) => !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true");
25
+ }
8
26
  let activeClose = null;
9
27
  function HoverMenu({
10
28
  trigger,
11
29
  children,
12
- align = "right",
30
+ align = "end",
13
31
  panelClassName,
14
32
  className,
15
33
  ariaLabel,
@@ -62,6 +80,67 @@ function HoverMenu({
62
80
  };
63
81
  }, [open, close]);
64
82
  useOutsideClick(wrapperRef, close, open);
83
+ const menuRef = useRef(null);
84
+ const focusOnOpenRef = useRef(null);
85
+ const triggerEl = () => Array.from(wrapperRef.current?.querySelectorAll(FOCUSABLE) ?? []).find(
86
+ (el) => !menuRef.current?.contains(el)
87
+ ) ?? null;
88
+ const items = () => menuRef.current ? applyMenuSemantics(menuRef.current) : [];
89
+ useEscapeKey(() => {
90
+ const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;
91
+ close();
92
+ if (hadFocus) triggerEl()?.focus();
93
+ }, open);
94
+ useLayoutEffect(() => {
95
+ if (!open) return;
96
+ const list = items();
97
+ const want = focusOnOpenRef.current;
98
+ focusOnOpenRef.current = null;
99
+ if (want) (want === "last" ? list[list.length - 1] : list[0])?.focus();
100
+ });
101
+ useLayoutEffect(() => {
102
+ const el = triggerEl();
103
+ if (!el) return;
104
+ if (!el.hasAttribute("aria-haspopup")) el.setAttribute("aria-haspopup", "menu");
105
+ if (el.getAttribute("aria-haspopup") === "menu") el.setAttribute("aria-expanded", String(open));
106
+ });
107
+ const onKeyDown = (e) => {
108
+ const target = e.target;
109
+ const inMenu = menuRef.current?.contains(target) ?? false;
110
+ if (!inMenu) {
111
+ if (target !== triggerEl()) return;
112
+ if (e.key === "Enter" || e.key === " ") {
113
+ if (!open) focusOnOpenRef.current = "first";
114
+ } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
115
+ e.preventDefault();
116
+ const edge = e.key === "ArrowDown" ? "first" : "last";
117
+ if (open) {
118
+ const list2 = items();
119
+ (edge === "last" ? list2[list2.length - 1] : list2[0])?.focus();
120
+ } else {
121
+ focusOnOpenRef.current = edge;
122
+ openedByHoverRef.current = false;
123
+ setPhase("open");
124
+ }
125
+ }
126
+ return;
127
+ }
128
+ if (e.key === "Tab") {
129
+ close();
130
+ return;
131
+ }
132
+ const list = items();
133
+ if (list.length === 0) return;
134
+ const at = list.indexOf(document.activeElement);
135
+ let next = null;
136
+ if (e.key === "ArrowDown") next = at < 0 ? 0 : (at + 1) % list.length;
137
+ else if (e.key === "ArrowUp") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;
138
+ else if (e.key === "Home") next = 0;
139
+ else if (e.key === "End") next = list.length - 1;
140
+ if (next === null) return;
141
+ e.preventDefault();
142
+ list[next].focus();
143
+ };
65
144
  const panelRef = useRef(null);
66
145
  const [shiftX, setShiftX] = useState(0);
67
146
  useLayoutEffect(() => {
@@ -89,7 +168,10 @@ function HoverMenu({
89
168
  className: cn("relative", className),
90
169
  onMouseEnter: handleMouseEnter,
91
170
  onMouseLeave: handleMouseLeave,
92
- "aria-label": ariaLabelAttr ?? ariaLabel,
171
+ onKeyDown: (e) => {
172
+ rest.onKeyDown?.(e);
173
+ onKeyDown(e);
174
+ },
93
175
  children: [
94
176
  trigger({ open, toggle }),
95
177
  open && /* @__PURE__ */ jsx(
@@ -98,13 +180,15 @@ function HoverMenu({
98
180
  ref: panelRef,
99
181
  className: cn(
100
182
  "absolute top-full z-40 pt-2",
101
- align === "right" ? "right-0" : "left-0"
183
+ ALIGN_CLASS[align]
102
184
  ),
103
185
  style: shiftX ? { transform: `translateX(${shiftX}px)` } : void 0,
104
186
  children: /* @__PURE__ */ jsx(
105
187
  "div",
106
188
  {
189
+ ref: menuRef,
107
190
  role: "menu",
191
+ "aria-label": ariaLabelAttr ?? ariaLabel,
108
192
  className: cn(
109
193
  "min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
110
194
  panelClassName
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useOutsideClick } from \"../hooks/use-dismiss\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n align?: \"left\" | \"right\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /**\n * @deprecated Use the DOM spelling `aria-label`, which now reaches the wrapper like\n * any other attribute. Kept working because keksdose, kastlan and lenkbank all pass\n * this today and a rename to save one line of resolution is not a patch release; it\n * loses to `aria-label` wherever both are given. Removal is a later minor.\n */\n ariaLabel?: string;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"right\",\n panelClassName,\n className,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n const openedByHoverRef = useRef(false);\n\n const close = useCallback(() => {\n openedByHoverRef.current = false;\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(\n () =>\n setPhase((p) => {\n if (p === \"open\" || p === \"closing\") return openedByHoverRef.current ? p : \"closed\";\n openedByHoverRef.current = false;\n return \"open\";\n }),\n [],\n );\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n openedByHoverRef.current = true;\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin. Recomputed on each open; `shiftX` is\n // subtracted first so the measurement is against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n useLayoutEffect(() => {\n if (!open) {\n setShiftX(0);\n return;\n }\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const margin = 8;\n const naturalLeft = rect.left - shiftX;\n const naturalRight = rect.right - shiftX;\n let next = 0;\n if (naturalRight > window.innerWidth - margin) next = window.innerWidth - margin - naturalRight;\n if (naturalLeft + next < margin) next = margin - naturalLeft;\n setShiftX(next);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={wrapperRef}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n // The DOM spelling wins; {@link HoverMenuProps.ariaLabel} is the deprecated alias\n // three apps still pass, so it stays as the fallback rather than as the answer.\n aria-label={ariaLabelAttr ?? ariaLabel}\n >\n {trigger({ open, toggle })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n align === \"right\" ? \"right-0\" : \"left-0\",\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n role=\"menu\"\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with. `start`/`end` follow the\n * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`\n * are physical and kept for existing callers. Default `end` — the same right-aligned\n * panel as before in LTR, mirrored in RTL.\n */\n align?: \"start\" | \"end\" | \"left\" | \"right\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /**\n * @deprecated Use the DOM spelling `aria-label`, which names the `role=\"menu\"` panel. Kept working because keksdose, kastlan and lenkbank all pass\n * this today and a rename to save one line of resolution is not a patch release; it\n * loses to `aria-label` wherever both are given. Removal is a later minor.\n */\n ariaLabel?: string;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\", left: \"left-0\", right: \"right-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n const openedByHoverRef = useRef(false);\n\n const close = useCallback(() => {\n openedByHoverRef.current = false;\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(\n () =>\n setPhase((p) => {\n if (p === \"open\" || p === \"closing\") return openedByHoverRef.current ? p : \"closed\";\n openedByHoverRef.current = false;\n return \"open\";\n }),\n [],\n );\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n openedByHoverRef.current = true;\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Escape closes, from anywhere — it did nothing at all before. Focus goes back to the\n // trigger only when it was inside the menu: a panel opened by hover while the user is\n // typing elsewhere must not yank the caret over here on the way out.\n useEscapeKey(() => {\n const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;\n close();\n if (hadFocus) triggerEl()?.focus();\n }, open);\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard.\n if (!open) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n openedByHoverRef.current = false;\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin. Recomputed on each open; `shiftX` is\n // subtracted first so the measurement is against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n useLayoutEffect(() => {\n if (!open) {\n setShiftX(0);\n return;\n }\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const margin = 8;\n const naturalLeft = rect.left - shiftX;\n const naturalRight = rect.right - shiftX;\n let next = 0;\n if (naturalRight > window.innerWidth - margin) next = window.innerWidth - margin - naturalRight;\n if (naturalLeft + next < margin) next = margin - naturalLeft;\n setShiftX(next);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={wrapperRef}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onKeyDown={(e) => {\n rest.onKeyDown?.(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n ALIGN_CLASS[align],\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name. The DOM spelling wins;\n // {@link HoverMenuProps.ariaLabel} is the deprecated alias three apps still\n // pass, so it stays as the fallback rather than as the answer.\n aria-label={ariaLabelAttr ?? ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
@@ -54,6 +54,15 @@ interface IconPickerProps<T extends string> extends Omit<ComponentPropsWithoutRe
54
54
  searchable?: boolean;
55
55
  /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
56
56
  size?: TileSize;
57
+ /**
58
+ * Classes for every tile's frame, merged after the kit's own — so a caller's
59
+ * `size-*` beats `size`'s. For a tile that has to follow a container query or a
60
+ * breakpoint (keksdose grows its tiles to 40px in a narrow card:
61
+ * `tileClassName="@max-md:size-10"`) without reaching into the kit's markup with an
62
+ * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s
63
+ * dimensions; the tile grows round it.
64
+ */
65
+ tileClassName?: string;
57
66
  disabled?: boolean;
58
67
  labels?: Partial<IconPickerLabels>;
59
68
  }
@@ -67,6 +76,6 @@ interface IconPickerProps<T extends string> extends Omit<ComponentPropsWithoutRe
67
76
  * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a
68
77
  * right-to-left page), Home and End.
69
78
  */
70
- declare function IconPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, searchable, size, disabled, labels, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest }: IconPickerProps<T>): react.JSX.Element;
79
+ declare function IconPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, searchable, size, tileClassName, disabled, labels, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest }: IconPickerProps<T>): react.JSX.Element;
71
80
 
72
81
  export { DEFAULT_ICON_PICKER_LABELS, type IconOption, IconPicker, type IconPickerLabels, type IconPickerProps };
@@ -24,6 +24,7 @@ function IconPicker({
24
24
  activation = "automatic",
25
25
  searchable = false,
26
26
  size = "md",
27
+ tileClassName,
27
28
  disabled = false,
28
29
  labels,
29
30
  className,
@@ -84,7 +85,10 @@ function IconPicker({
84
85
  const Icon = item.key === null ? void 0 : byValue.get(item.key)?.icon;
85
86
  return Icon ? /* @__PURE__ */ jsx(Icon, { "aria-hidden": true, className: TILE_SIZE[size].glyph }) : null;
86
87
  },
87
- tileClassName: (_item, selected) => selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
88
+ tileClassName: (_item, selected) => cn(
89
+ selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]",
90
+ tileClassName
91
+ )
88
92
  }
89
93
  )
90
94
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/icon-picker.tsx"],"sourcesContent":["import { useId, useMemo, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ComponentType } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { SearchField } from \"./search-field\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface IconPickerLabels {\n /** The \"no icon\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n /** The search box's accessible name and placeholder, when `searchable` is set. */\n search: string;\n /** Shown in place of the tiles when the search matches nothing. */\n noResults: string;\n /** Announced as the search narrows the grid. Receives the number of matches. */\n resultCount: (count: number) => string;\n}\n\nexport const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {\n none: \"No icon\",\n mixed: \"Mixed: the selected items have different icons\",\n search: \"Search icons\",\n noResults: \"No icons match\",\n resultCount: (count) => (count === 1 ? \"1 icon\" : `${count} icons`),\n};\n\nexport interface IconOption<T extends string> {\n value: T;\n /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`\n * — the tile's name is `label`. */\n icon: ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n /** What the symbol IS, in the user's language (\"Game controller\") — not the icon\n * library's identifier (`Gamepad2`), which is nobody's word for anything. */\n label: string;\n /** More words the search matches (\"games\", \"play\"), besides `label`. */\n keywords?: string[];\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Leisure\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface IconPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: IconOption<T>[];\n /** The chosen icon, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no icon\" tile whose value is `null`. */\n allowNone?: boolean;\n /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is\n * described as mixed. */\n mixed?: boolean;\n /**\n * \"automatic\" (default): an arrow key moves AND chooses, the APG radio group.\n * \"manual\": arrows move focus only; Space or Enter chooses. For a picker whose every\n * change is expensive — a bulk edit that saves as it goes.\n */\n activation?: \"automatic\" | \"manual\";\n /** A search box above the grid, matching `label` and `keywords`. For a set large\n * enough that scanning it is slower than typing. */\n searchable?: boolean;\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n disabled?: boolean;\n labels?: Partial<IconPickerLabels>;\n}\n\n/** Case- and accent-insensitive, so \"cafe\" finds \"Café\". */\nfunction fold(text: string, locale: string | undefined): string {\n return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase(locale);\n}\n\n/**\n * A grid of symbols that is one radio group, with an optional search.\n *\n * Name the group with `aria-label` or `aria-labelledby`. The name lands on the\n * radiogroup itself, not on the outer box: with `searchable` the outer box also holds\n * the search field, which has a name of its own.\n *\n * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a\n * right-to-left page), Home and End.\n */\nexport function IconPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n searchable = false,\n size = \"md\",\n disabled = false,\n labels,\n className,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: IconPickerProps<T>) {\n const text = useKitLabels(\"iconPicker\", DEFAULT_ICON_PICKER_LABELS, labels);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale();\n const mixedId = useId();\n const [query, setQuery] = useState(\"\");\n const needle = fold(query.trim(), locale);\n\n const visible = useMemo(\n () =>\n needle\n ? options.filter((o) =>\n [o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),\n )\n : options,\n [options, needle, locale],\n );\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n // \"None\" stays through a search: it is the way back to no icon, and it matches\n // no word anybody would type for it.\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n\n return (\n <div {...rest} className={cn(\"space-y-2\", className)}>\n {searchable && (\n <SearchField\n value={query}\n onChange={setQuery}\n aria-label={text.search}\n clearLabel={common.clear}\n disabled={disabled}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(\" \") || undefined}\n aria-disabled={disabled || undefined}\n className=\"flex flex-wrap items-center gap-1.5\"\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;\n return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;\n }}\n tileClassName={(_item, selected) =>\n selected ? \"text-[var(--text-primary)]\" : \"text-[var(--text-secondary)]\"\n }\n />\n </div>\n {needle && visible.length === 0 && (\n <p className=\"text-xs text-[var(--text-muted)]\">{text.noResults}</p>\n )}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n {searchable && (\n // Typing moves no focus and the grid changes silently, so the count is said.\n // `sr-only-fixed`: this sits inside a consumer's markup and must not extend\n // the document height (see tokens.css).\n <span role=\"status\" aria-live=\"polite\" aria-atomic className=\"sr-only-fixed\">\n {needle ? text.resultCount(visible.length) : \"\"}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/icon-picker.tsx"],"sourcesContent":["import { useId, useMemo, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ComponentType } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { SearchField } from \"./search-field\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface IconPickerLabels {\n /** The \"no icon\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n /** The search box's accessible name and placeholder, when `searchable` is set. */\n search: string;\n /** Shown in place of the tiles when the search matches nothing. */\n noResults: string;\n /** Announced as the search narrows the grid. Receives the number of matches. */\n resultCount: (count: number) => string;\n}\n\nexport const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {\n none: \"No icon\",\n mixed: \"Mixed: the selected items have different icons\",\n search: \"Search icons\",\n noResults: \"No icons match\",\n resultCount: (count) => (count === 1 ? \"1 icon\" : `${count} icons`),\n};\n\nexport interface IconOption<T extends string> {\n value: T;\n /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`\n * — the tile's name is `label`. */\n icon: ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n /** What the symbol IS, in the user's language (\"Game controller\") — not the icon\n * library's identifier (`Gamepad2`), which is nobody's word for anything. */\n label: string;\n /** More words the search matches (\"games\", \"play\"), besides `label`. */\n keywords?: string[];\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Leisure\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface IconPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: IconOption<T>[];\n /** The chosen icon, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no icon\" tile whose value is `null`. */\n allowNone?: boolean;\n /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is\n * described as mixed. */\n mixed?: boolean;\n /**\n * \"automatic\" (default): an arrow key moves AND chooses, the APG radio group.\n * \"manual\": arrows move focus only; Space or Enter chooses. For a picker whose every\n * change is expensive — a bulk edit that saves as it goes.\n */\n activation?: \"automatic\" | \"manual\";\n /** A search box above the grid, matching `label` and `keywords`. For a set large\n * enough that scanning it is slower than typing. */\n searchable?: boolean;\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n /**\n * Classes for every tile's frame, merged after the kit's own — so a caller's\n * `size-*` beats `size`'s. For a tile that has to follow a container query or a\n * breakpoint (keksdose grows its tiles to 40px in a narrow card:\n * `tileClassName=\"@max-md:size-10\"`) without reaching into the kit's markup with an\n * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s\n * dimensions; the tile grows round it.\n */\n tileClassName?: string;\n disabled?: boolean;\n labels?: Partial<IconPickerLabels>;\n}\n\n/** Case- and accent-insensitive, so \"cafe\" finds \"Café\". */\nfunction fold(text: string, locale: string | undefined): string {\n return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase(locale);\n}\n\n/**\n * A grid of symbols that is one radio group, with an optional search.\n *\n * Name the group with `aria-label` or `aria-labelledby`. The name lands on the\n * radiogroup itself, not on the outer box: with `searchable` the outer box also holds\n * the search field, which has a name of its own.\n *\n * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a\n * right-to-left page), Home and End.\n */\nexport function IconPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n searchable = false,\n size = \"md\",\n tileClassName,\n disabled = false,\n labels,\n className,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: IconPickerProps<T>) {\n const text = useKitLabels(\"iconPicker\", DEFAULT_ICON_PICKER_LABELS, labels);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale();\n const mixedId = useId();\n const [query, setQuery] = useState(\"\");\n const needle = fold(query.trim(), locale);\n\n const visible = useMemo(\n () =>\n needle\n ? options.filter((o) =>\n [o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),\n )\n : options,\n [options, needle, locale],\n );\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n // \"None\" stays through a search: it is the way back to no icon, and it matches\n // no word anybody would type for it.\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n\n return (\n <div {...rest} className={cn(\"space-y-2\", className)}>\n {searchable && (\n <SearchField\n value={query}\n onChange={setQuery}\n aria-label={text.search}\n clearLabel={common.clear}\n disabled={disabled}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(\" \") || undefined}\n aria-disabled={disabled || undefined}\n className=\"flex flex-wrap items-center gap-1.5\"\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;\n return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;\n }}\n tileClassName={(_item, selected) =>\n cn(\n selected ? \"text-[var(--text-primary)]\" : \"text-[var(--text-secondary)]\",\n tileClassName,\n )\n }\n />\n </div>\n {needle && visible.length === 0 && (\n <p className=\"text-xs text-[var(--text-muted)]\">{text.noResults}</p>\n )}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n {searchable && (\n // Typing moves no focus and the grid changes silently, so the count is said.\n // `sr-only-fixed`: this sits inside a consumer's markup and must not extend\n // the document height (see tokens.css).\n <span role=\"status\" aria-live=\"polite\" aria-atomic className=\"sr-only-fixed\">\n {needle ? text.resultCount(visible.length) : \"\"}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,165 @@
1
+ import * as react from 'react';
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
+
4
+ /**
5
+ * A measured table: a fixed set of numeric columns, typed cell by cell or pasted as a
6
+ * block out of a spreadsheet — and the hook that holds it as a draft.
7
+ *
8
+ * Moved from Lenkbank (`shared/ui/measured-grid.tsx`), where four gear editors and a
9
+ * setpoint dialog type rig measurements into it. Nothing in it knows what a column
10
+ * holds: the caller passes `{ label, unit }[]`, and the vocabulary of this file is
11
+ * cells, rows, a caret and a paste. The lexing under it is `@eifi1/ui-kit/table-text`.
12
+ *
13
+ * What it is NOT: a `DataTable` mode (that one finds rows among many; this one types
14
+ * numbers into columns), a spreadsheet (no formulas, no multi-cell selection, no undo
15
+ * beyond the browser's per-input one), or typed columns — every cell is a string that
16
+ * should read as a number.
17
+ */
18
+ /**
19
+ * Every string the grid can speak — the `measuredGrid` namespace of
20
+ * `<UiKitProvider labels>`, overridable per instance through the `labels` prop. The
21
+ * ones that carry a number are functions of it, for the reason `DataTableLabels` gives:
22
+ * the grammar around a count moves with it in most languages.
23
+ */
24
+ interface MeasuredGridLabels {
25
+ /** Accessible name of the cells/text toggle. */
26
+ view: string;
27
+ cellsView: string;
28
+ textView: string;
29
+ addRow: string;
30
+ /** The row's remove button, given its 1-based number. */
31
+ removeRow: (row: number) => string;
32
+ /** Empties the whole table. */
33
+ clear: string;
34
+ /** The footer's standing note that a block can be pasted into any cell. */
35
+ pasteHint: string;
36
+ /**
37
+ * Accessible name of one cell's input, given its column's label and 1-based row.
38
+ * A screen reader in a grid announces the headers on its own when the caret crosses
39
+ * one; this is what it reads when the caret lands on an input with no crossing — and
40
+ * what a voice-control user says to reach it.
41
+ */
42
+ cell: (column: string, row: number) => string;
43
+ /** The row-number column's header, which shows only "#". */
44
+ rowNumber: string;
45
+ /** The remove-button column's header, which shows nothing. */
46
+ rowActions: string;
47
+ /** The grid's description: how its keyboard works. Read once, on entry. */
48
+ keyboardHint: string;
49
+ /** The text view could not read this 1-based line of what was typed or pasted. */
50
+ lineError: (line: number) => string;
51
+ /** How many filled rows (measured points) the table holds. */
52
+ points: (count: number) => string;
53
+ /** How many cells are typed but are not numbers. */
54
+ problems: (count: number) => string;
55
+ }
56
+ /** English defaults. Exported as the `measuredGrid` namespace of `UiKitLabels`
57
+ * (src/i18n), the complete reference a translation is written against. */
58
+ declare const DEFAULT_MEASURED_GRID_LABELS: MeasuredGridLabels;
59
+ /** One column of a measured table: what it holds and what it is measured in. The unit
60
+ * is separate from the name because the header shows it small and underneath, and
61
+ * because a column with no unit (a count, an index) should not render empty brackets. */
62
+ interface MeasuredGridColumn {
63
+ label: string;
64
+ unit?: string;
65
+ }
66
+ /** The two ways to look at the same table. */
67
+ type MeasuredGridView = "cells" | "text";
68
+ interface UseMeasuredRowsOptions {
69
+ /** Decimals a computed number is rounded to on its way into a cell. Default 4. */
70
+ digits?: number;
71
+ /** BCP 47 tag whose decimal mark the cells are written with, else the
72
+ * `<UiKitProvider locale>`, else the runtime's. */
73
+ locale?: string;
74
+ }
75
+ interface MeasuredRows {
76
+ /** The draft, as text — what the grid shows and edits. */
77
+ cells: string[][];
78
+ setCells: (next: string[][]) => void;
79
+ /** The filled rows as numbers — EMPTY until the table is `ready`. */
80
+ rows: number[][];
81
+ /** Replace the whole table with computed numbers (a generated shape, say). */
82
+ setRows: (next: number[][]) => void;
83
+ /** At least two filled rows, and every cell in them is a number. */
84
+ ready: boolean;
85
+ /** How many cells are typed but are not numbers — what the grid marks invalid. */
86
+ problems: number;
87
+ /** Everything `MeasuredGrid` needs, so a caller spreads it in one line. */
88
+ props: {
89
+ cells: string[][];
90
+ onCells: (next: string[][]) => void;
91
+ };
92
+ }
93
+ /**
94
+ * The draft a measured table is edited as.
95
+ *
96
+ * **Strings, not numbers.** A cell is text while it is being typed — `"-"`, `"1."` and
97
+ * `"1e"` are all states a number passes through on its way to being one, and a grid
98
+ * that parses on every keystroke turns each of them into a `NaN` and then into a `0`.
99
+ * So the draft is text, and the numbers are what falls out when every cell is one.
100
+ *
101
+ * **"Whole" is a question this answers**, because every editor asks it before it
102
+ * offers to save: `ready` wants at least two filled rows with a number in every cell.
103
+ * An empty cell is a hole in the measurement, not a zero; a wholly empty row is not a
104
+ * row at all (it is what walking off the bottom of the grid leaves behind).
105
+ *
106
+ * `initial` is read once, like `useState`'s.
107
+ */
108
+ declare function useMeasuredRows(initial: number[][] | (() => number[][]), options?: UseMeasuredRowsOptions): MeasuredRows;
109
+ interface MeasuredGridProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "onChange"> {
110
+ /** What the table is — shown above it, and the grid's accessible name. */
111
+ label: string;
112
+ /** A note under the table (which columns a layout expects, say). Also the grid's
113
+ * description. */
114
+ hint?: ReactNode;
115
+ columns: MeasuredGridColumn[];
116
+ /** The draft — typically `useMeasuredRows().props`. */
117
+ cells: string[][];
118
+ onCells: (next: string[][]) => void;
119
+ /** Read-only: the cells can still be walked and read, nothing can be changed. */
120
+ disabled?: boolean;
121
+ /** Which views the toggle offers, the first being where it opens. Default both;
122
+ * one view hides the toggle. */
123
+ views?: ReadonlyArray<MeasuredGridView>;
124
+ /** Row height in pixels, for the windowing. Fixed on purpose. Default 28. */
125
+ rowHeight?: number;
126
+ /** BCP 47 tag for the decimal mark of cells written by a paste or the text view,
127
+ * and for the row numbers; else the `<UiKitProvider locale>`. */
128
+ locale?: string;
129
+ labels?: Partial<MeasuredGridLabels>;
130
+ }
131
+ /**
132
+ * A measured table as a grid of cells, one number each, with a text view beside it.
133
+ *
134
+ * The grid is the primary form because a linkage typed off a drawing arrives as
135
+ * numbers somebody reads out loud, and a textarea made that an exercise in counting
136
+ * tab characters. The paste still works: a block pasted into any cell fills from that
137
+ * cell outwards, growing the table to fit. The text view is kept for what a grid is
138
+ * worst at — reading a thousand-row export back in one piece.
139
+ *
140
+ * **It is a grid in the APG sense**, which the Lenkbank original was not (a `<table>`
141
+ * of inputs, fifteen tab stops per row). One tab stop — the roving tabindex — and the
142
+ * keyboard inside it, as a spreadsheet has it:
143
+ *
144
+ * - **Arrows** move between cells. Left/Right flip under `dir="rtl"`.
145
+ * - **Home/End** go to the first/last column; with Ctrl (or ⌘), to the first/last
146
+ * cell of the table. **PageUp/PageDown** move a screenful.
147
+ * - **Enter** (and ArrowDown) move down a column, and past the last row ADD one —
148
+ * typing down a column is how measured data is entered, and stopping at the last
149
+ * row to reach for a button is what makes people paste instead. Shift+Enter moves up.
150
+ * - **Typing** replaces the cell: arriving by keyboard selects its text, so the first
151
+ * key overwrites it. That is the edit mode data entry wants, entered without a key.
152
+ * **F2** edits in place instead (caret at the end). Either way the cell is then
153
+ * *editing*: Left/Right move the caret, and cross to the next cell only at the edge
154
+ * of the text; Home/End stay in the text. **Escape** puts back what the cell held
155
+ * when the caret arrived, and leaves editing.
156
+ *
157
+ * APG's grid pattern pairs Enter with F2 as the keys that start editing. Enter is
158
+ * spent on "next row" here on purpose — it is the one key a person typing a column of
159
+ * numbers presses between every one of them, and every spreadsheet spends it the same
160
+ * way — and typing-to-replace makes an explicit "start editing" key unnecessary for
161
+ * the common case. F2 remains for the uncommon one.
162
+ */
163
+ declare function MeasuredGrid({ label, hint, columns, cells, onCells, disabled, views, rowHeight, locale: localeProp, labels: labelsProp, className, ...rest }: MeasuredGridProps): react.JSX.Element;
164
+
165
+ export { DEFAULT_MEASURED_GRID_LABELS, MeasuredGrid, type MeasuredGridColumn, type MeasuredGridLabels, type MeasuredGridProps, type MeasuredGridView, type MeasuredRows, type UseMeasuredRowsOptions, useMeasuredRows };