@eifi1/ui-kit 0.4.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 (229) hide show
  1. package/README.md +219 -0
  2. package/dist/components/account-settings.d.ts +71 -0
  3. package/dist/components/account-settings.js +159 -0
  4. package/dist/components/account-settings.js.map +1 -0
  5. package/dist/components/alert-banner.d.ts +20 -0
  6. package/dist/components/alert-banner.js +39 -0
  7. package/dist/components/alert-banner.js.map +1 -0
  8. package/dist/components/amount-input.d.ts +117 -0
  9. package/dist/components/amount-input.js +243 -0
  10. package/dist/components/amount-input.js.map +1 -0
  11. package/dist/components/calculator.d.ts +42 -0
  12. package/dist/components/calculator.js +161 -0
  13. package/dist/components/calculator.js.map +1 -0
  14. package/dist/components/chart.d.ts +58 -0
  15. package/dist/components/chart.js +158 -0
  16. package/dist/components/chart.js.map +1 -0
  17. package/dist/components/combobox-core.d.ts +77 -0
  18. package/dist/components/combobox-core.js +263 -0
  19. package/dist/components/combobox-core.js.map +1 -0
  20. package/dist/components/combobox.d.ts +141 -0
  21. package/dist/components/combobox.js +550 -0
  22. package/dist/components/combobox.js.map +1 -0
  23. package/dist/components/currency-select.d.ts +37 -0
  24. package/dist/components/currency-select.js +131 -0
  25. package/dist/components/currency-select.js.map +1 -0
  26. package/dist/components/data-table-filter-popover.d.ts +28 -0
  27. package/dist/components/data-table-filter-popover.js +201 -0
  28. package/dist/components/data-table-filter-popover.js.map +1 -0
  29. package/dist/components/data-table-filters.d.ts +5 -0
  30. package/dist/components/data-table-filters.js +118 -0
  31. package/dist/components/data-table-filters.js.map +1 -0
  32. package/dist/components/data-table-labels.d.ts +33 -0
  33. package/dist/components/data-table-labels.js +53 -0
  34. package/dist/components/data-table-labels.js.map +1 -0
  35. package/dist/components/data-table-pagination.d.ts +18 -0
  36. package/dist/components/data-table-pagination.js +92 -0
  37. package/dist/components/data-table-pagination.js.map +1 -0
  38. package/dist/components/data-table-sort.d.ts +23 -0
  39. package/dist/components/data-table-sort.js +55 -0
  40. package/dist/components/data-table-sort.js.map +1 -0
  41. package/dist/components/data-table.d.ts +5 -0
  42. package/dist/components/data-table.js +926 -0
  43. package/dist/components/data-table.js.map +1 -0
  44. package/dist/components/date-picker.d.ts +85 -0
  45. package/dist/components/date-picker.js +253 -0
  46. package/dist/components/date-picker.js.map +1 -0
  47. package/dist/components/dropdown.d.ts +98 -0
  48. package/dist/components/dropdown.js +122 -0
  49. package/dist/components/dropdown.js.map +1 -0
  50. package/dist/components/entity-combobox.d.ts +49 -0
  51. package/dist/components/entity-combobox.js +115 -0
  52. package/dist/components/entity-combobox.js.map +1 -0
  53. package/dist/components/file-dropzone.d.ts +18 -0
  54. package/dist/components/file-dropzone.js +101 -0
  55. package/dist/components/file-dropzone.js.map +1 -0
  56. package/dist/components/full-bleed-dialog.d.ts +57 -0
  57. package/dist/components/full-bleed-dialog.js +68 -0
  58. package/dist/components/full-bleed-dialog.js.map +1 -0
  59. package/dist/components/grouped-picker.d.ts +37 -0
  60. package/dist/components/grouped-picker.js +87 -0
  61. package/dist/components/grouped-picker.js.map +1 -0
  62. package/dist/components/hover-menu.d.ts +16 -0
  63. package/dist/components/hover-menu.js +113 -0
  64. package/dist/components/hover-menu.js.map +1 -0
  65. package/dist/components/mini-calendar.d.ts +33 -0
  66. package/dist/components/mini-calendar.js +133 -0
  67. package/dist/components/mini-calendar.js.map +1 -0
  68. package/dist/components/modal.d.ts +76 -0
  69. package/dist/components/modal.js +156 -0
  70. package/dist/components/modal.js.map +1 -0
  71. package/dist/components/multi-entity-combobox.d.ts +47 -0
  72. package/dist/components/multi-entity-combobox.js +116 -0
  73. package/dist/components/multi-entity-combobox.js.map +1 -0
  74. package/dist/components/multi-select.d.ts +39 -0
  75. package/dist/components/multi-select.js +132 -0
  76. package/dist/components/multi-select.js.map +1 -0
  77. package/dist/components/number-input.d.ts +74 -0
  78. package/dist/components/number-input.js +114 -0
  79. package/dist/components/number-input.js.map +1 -0
  80. package/dist/components/numpad-sheet.d.ts +45 -0
  81. package/dist/components/numpad-sheet.js +102 -0
  82. package/dist/components/numpad-sheet.js.map +1 -0
  83. package/dist/components/picker-sheet.d.ts +44 -0
  84. package/dist/components/picker-sheet.js +78 -0
  85. package/dist/components/picker-sheet.js.map +1 -0
  86. package/dist/components/popover.d.ts +20 -0
  87. package/dist/components/popover.js +46 -0
  88. package/dist/components/popover.js.map +1 -0
  89. package/dist/components/search-field.d.ts +52 -0
  90. package/dist/components/search-field.js +64 -0
  91. package/dist/components/search-field.js.map +1 -0
  92. package/dist/components/settings-fields.d.ts +39 -0
  93. package/dist/components/settings-fields.js +17 -0
  94. package/dist/components/settings-fields.js.map +1 -0
  95. package/dist/components/swipeable-row.d.ts +60 -0
  96. package/dist/components/swipeable-row.js +115 -0
  97. package/dist/components/swipeable-row.js.map +1 -0
  98. package/dist/components/toggle-group.d.ts +34 -0
  99. package/dist/components/toggle-group.js +90 -0
  100. package/dist/components/toggle-group.js.map +1 -0
  101. package/dist/components/tooltip.d.ts +86 -0
  102. package/dist/components/tooltip.js +171 -0
  103. package/dist/components/tooltip.js.map +1 -0
  104. package/dist/components/ui.d.ts +239 -0
  105. package/dist/components/ui.js +512 -0
  106. package/dist/components/ui.js.map +1 -0
  107. package/dist/components/use-mobile-reveal.d.ts +31 -0
  108. package/dist/components/use-mobile-reveal.js +33 -0
  109. package/dist/components/use-mobile-reveal.js.map +1 -0
  110. package/dist/components/use-table-state.d.ts +54 -0
  111. package/dist/components/use-table-state.js +153 -0
  112. package/dist/components/use-table-state.js.map +1 -0
  113. package/dist/components/user-avatar.d.ts +23 -0
  114. package/dist/components/user-avatar.js +33 -0
  115. package/dist/components/user-avatar.js.map +1 -0
  116. package/dist/components/wizard-stepper.d.ts +14 -0
  117. package/dist/components/wizard-stepper.js +27 -0
  118. package/dist/components/wizard-stepper.js.map +1 -0
  119. package/dist/data-table-filters-aXyf0Xub.d.ts +261 -0
  120. package/dist/feedback/feedback-attachment.d.ts +63 -0
  121. package/dist/feedback/feedback-attachment.js +160 -0
  122. package/dist/feedback/feedback-attachment.js.map +1 -0
  123. package/dist/feedback/feedback-dialog.d.ts +72 -0
  124. package/dist/feedback/feedback-dialog.js +109 -0
  125. package/dist/feedback/feedback-dialog.js.map +1 -0
  126. package/dist/feedback/feedback-inbox.d.ts +226 -0
  127. package/dist/feedback/feedback-inbox.js +315 -0
  128. package/dist/feedback/feedback-inbox.js.map +1 -0
  129. package/dist/hooks/use-anchored-panel.d.ts +80 -0
  130. package/dist/hooks/use-anchored-panel.js +71 -0
  131. package/dist/hooks/use-anchored-panel.js.map +1 -0
  132. package/dist/hooks/use-anchored-rect.d.ts +24 -0
  133. package/dist/hooks/use-anchored-rect.js +40 -0
  134. package/dist/hooks/use-anchored-rect.js.map +1 -0
  135. package/dist/hooks/use-body-scroll-lock.d.ts +9 -0
  136. package/dist/hooks/use-body-scroll-lock.js +41 -0
  137. package/dist/hooks/use-body-scroll-lock.js.map +1 -0
  138. package/dist/hooks/use-close-transition.d.ts +41 -0
  139. package/dist/hooks/use-close-transition.js +36 -0
  140. package/dist/hooks/use-close-transition.js.map +1 -0
  141. package/dist/hooks/use-dismiss.d.ts +34 -0
  142. package/dist/hooks/use-dismiss.js +41 -0
  143. package/dist/hooks/use-dismiss.js.map +1 -0
  144. package/dist/hooks/use-media-query.d.ts +8 -0
  145. package/dist/hooks/use-media-query.js +18 -0
  146. package/dist/hooks/use-media-query.js.map +1 -0
  147. package/dist/hooks/use-overlay-history.d.ts +10 -0
  148. package/dist/hooks/use-overlay-history.js +99 -0
  149. package/dist/hooks/use-overlay-history.js.map +1 -0
  150. package/dist/hooks/use-row-swipe.d.ts +30 -0
  151. package/dist/hooks/use-row-swipe.js +128 -0
  152. package/dist/hooks/use-row-swipe.js.map +1 -0
  153. package/dist/index.d.ts +75 -0
  154. package/dist/index.js +90 -0
  155. package/dist/index.js.map +1 -0
  156. package/dist/lib/calc.d.ts +89 -0
  157. package/dist/lib/calc.js +180 -0
  158. package/dist/lib/calc.js.map +1 -0
  159. package/dist/lib/cn.d.ts +6 -0
  160. package/dist/lib/cn.js +7 -0
  161. package/dist/lib/cn.js.map +1 -0
  162. package/dist/lib/dates.d.ts +84 -0
  163. package/dist/lib/dates.js +108 -0
  164. package/dist/lib/dates.js.map +1 -0
  165. package/dist/lib/logger.d.ts +17 -0
  166. package/dist/lib/logger.js +30 -0
  167. package/dist/lib/logger.js.map +1 -0
  168. package/dist/lib/safe-storage.d.ts +23 -0
  169. package/dist/lib/safe-storage.js +20 -0
  170. package/dist/lib/safe-storage.js.map +1 -0
  171. package/dist/search/command-palette.d.ts +71 -0
  172. package/dist/search/command-palette.js +198 -0
  173. package/dist/search/command-palette.js.map +1 -0
  174. package/dist/shell/app-shell.d.ts +64 -0
  175. package/dist/shell/app-shell.js +237 -0
  176. package/dist/shell/app-shell.js.map +1 -0
  177. package/dist/shell/option-switcher-menu.d.ts +24 -0
  178. package/dist/shell/option-switcher-menu.js +44 -0
  179. package/dist/shell/option-switcher-menu.js.map +1 -0
  180. package/dist/shell/role-switcher.d.ts +19 -0
  181. package/dist/shell/role-switcher.js +28 -0
  182. package/dist/shell/role-switcher.js.map +1 -0
  183. package/dist/shell/top-bar.d.ts +15 -0
  184. package/dist/shell/top-bar.js +29 -0
  185. package/dist/shell/top-bar.js.map +1 -0
  186. package/dist/shell/topbar-action-menu.d.ts +44 -0
  187. package/dist/shell/topbar-action-menu.js +49 -0
  188. package/dist/shell/topbar-action-menu.js.map +1 -0
  189. package/dist/shell/topbar-controls.d.ts +54 -0
  190. package/dist/shell/topbar-controls.js +152 -0
  191. package/dist/shell/topbar-controls.js.map +1 -0
  192. package/dist/theme/chart-palette.d.ts +60 -0
  193. package/dist/theme/chart-palette.js +79 -0
  194. package/dist/theme/chart-palette.js.map +1 -0
  195. package/dist/theme/palette-presets.d.ts +39 -0
  196. package/dist/theme/palette-presets.js +440 -0
  197. package/dist/theme/palette-presets.js.map +1 -0
  198. package/dist/theme/palette-store.d.ts +45 -0
  199. package/dist/theme/palette-store.js +52 -0
  200. package/dist/theme/palette-store.js.map +1 -0
  201. package/dist/theme/theme-store.d.ts +57 -0
  202. package/dist/theme/theme-store.js +96 -0
  203. package/dist/theme/theme-store.js.map +1 -0
  204. package/dist/tour/tour.d.ts +104 -0
  205. package/dist/tour/tour.js +351 -0
  206. package/dist/tour/tour.js.map +1 -0
  207. package/dist/wizard/stepper-nav.d.ts +30 -0
  208. package/dist/wizard/stepper-nav.js +146 -0
  209. package/dist/wizard/stepper-nav.js.map +1 -0
  210. package/dist/wizard/types.d.ts +124 -0
  211. package/dist/wizard/types.js +24 -0
  212. package/dist/wizard/types.js.map +1 -0
  213. package/dist/wizard/use-wizard.d.ts +6 -0
  214. package/dist/wizard/use-wizard.js +222 -0
  215. package/dist/wizard/use-wizard.js.map +1 -0
  216. package/dist/wizard/validation.d.ts +23 -0
  217. package/dist/wizard/validation.js +13 -0
  218. package/dist/wizard/validation.js.map +1 -0
  219. package/dist/wizard/wizard-context.d.ts +19 -0
  220. package/dist/wizard/wizard-context.js +15 -0
  221. package/dist/wizard/wizard-context.js.map +1 -0
  222. package/dist/wizard/wizard-step.d.ts +11 -0
  223. package/dist/wizard/wizard-step.js +12 -0
  224. package/dist/wizard/wizard-step.js.map +1 -0
  225. package/dist/wizard/wizard-summary.d.ts +18 -0
  226. package/dist/wizard/wizard-summary.js +41 -0
  227. package/dist/wizard/wizard-summary.js.map +1 -0
  228. package/package.json +87 -0
  229. package/tokens.css +254 -0
@@ -0,0 +1,198 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { Search } from "lucide-react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { useOverlayHistory } from "../hooks/use-overlay-history.js";
7
+ const DEFAULT_LABELS = {
8
+ placeholder: "Search\u2026",
9
+ empty: "No results",
10
+ loading: "Searching\u2026"
11
+ };
12
+ function useCommandKey(onOpen) {
13
+ useEffect(() => {
14
+ const handler = (e) => {
15
+ if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
16
+ e.preventDefault();
17
+ onOpen();
18
+ }
19
+ };
20
+ document.addEventListener("keydown", handler);
21
+ return () => document.removeEventListener("keydown", handler);
22
+ }, [onOpen]);
23
+ }
24
+ function CommandPalette({
25
+ open,
26
+ onClose,
27
+ search,
28
+ revision,
29
+ labels
30
+ }) {
31
+ const l = { ...DEFAULT_LABELS, ...labels };
32
+ const [query, setQuery] = useState("");
33
+ const [results, setResults] = useState([]);
34
+ const [loading, setLoading] = useState(false);
35
+ const [active, setActive] = useState(0);
36
+ const inputRef = useRef(null);
37
+ const listRef = useRef(null);
38
+ const searchRef = useRef(search);
39
+ searchRef.current = search;
40
+ const reqId = useRef(0);
41
+ useOverlayHistory(open, onClose);
42
+ useEffect(() => {
43
+ if (!open) return;
44
+ setQuery("");
45
+ setActive(0);
46
+ const id = requestAnimationFrame(() => inputRef.current?.focus());
47
+ return () => cancelAnimationFrame(id);
48
+ }, [open]);
49
+ useEffect(() => {
50
+ if (!open) return;
51
+ const id = ++reqId.current;
52
+ setLoading(true);
53
+ const run = async () => {
54
+ try {
55
+ const r = await Promise.resolve(searchRef.current(query));
56
+ if (reqId.current === id) {
57
+ setResults(r);
58
+ setActive(0);
59
+ }
60
+ } finally {
61
+ if (reqId.current === id) setLoading(false);
62
+ }
63
+ };
64
+ const t = setTimeout(run, query ? 150 : 0);
65
+ return () => clearTimeout(t);
66
+ }, [query, open, revision]);
67
+ const { groups, flat } = useMemo(() => {
68
+ const order = [];
69
+ const byGroup = /* @__PURE__ */ new Map();
70
+ for (const item of results) {
71
+ if (!byGroup.has(item.group)) {
72
+ byGroup.set(item.group, []);
73
+ order.push(item.group);
74
+ }
75
+ byGroup.get(item.group).push(item);
76
+ }
77
+ return {
78
+ groups: order.map((g) => ({ group: g, items: byGroup.get(g) })),
79
+ flat: order.flatMap((g) => byGroup.get(g))
80
+ };
81
+ }, [results]);
82
+ const choose = (item) => {
83
+ if (!item) return;
84
+ onClose();
85
+ item.onSelect();
86
+ };
87
+ const onKeyDown = (e) => {
88
+ if (e.key === "ArrowDown") {
89
+ e.preventDefault();
90
+ setActive((i) => Math.min(i + 1, flat.length - 1));
91
+ } else if (e.key === "ArrowUp") {
92
+ e.preventDefault();
93
+ setActive((i) => Math.max(i - 1, 0));
94
+ } else if (e.key === "Enter") {
95
+ e.preventDefault();
96
+ choose(flat[active]);
97
+ } else if (e.key === "Escape") {
98
+ e.preventDefault();
99
+ onClose();
100
+ }
101
+ };
102
+ useEffect(() => {
103
+ const el = listRef.current?.querySelector(`[data-index="${active}"]`);
104
+ el?.scrollIntoView({ block: "nearest" });
105
+ }, [active]);
106
+ if (!open) return null;
107
+ let flatIndex = -1;
108
+ return createPortal(
109
+ /* @__PURE__ */ jsx(
110
+ "div",
111
+ {
112
+ className: "fixed inset-0 z-[70] flex items-start justify-center bg-black/40 p-4 pt-[10vh]",
113
+ onMouseDown: (e) => {
114
+ if (e.target === e.currentTarget) onClose();
115
+ },
116
+ children: /* @__PURE__ */ jsxs(
117
+ "div",
118
+ {
119
+ role: "dialog",
120
+ "aria-modal": "true",
121
+ "aria-label": l.placeholder,
122
+ className: "flex max-h-[70vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900",
123
+ children: [
124
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b border-slate-100 px-3 dark:border-slate-800", children: [
125
+ /* @__PURE__ */ jsx(Search, { className: "size-4 shrink-0 text-slate-400 dark:text-slate-500" }),
126
+ /* @__PURE__ */ jsx(
127
+ "input",
128
+ {
129
+ ref: inputRef,
130
+ role: "combobox",
131
+ "aria-expanded": true,
132
+ "aria-controls": "command-palette-list",
133
+ "aria-activedescendant": flat[active] ? `command-item-${flat[active].id}` : void 0,
134
+ value: query,
135
+ onChange: (e) => setQuery(e.target.value),
136
+ onKeyDown,
137
+ placeholder: l.placeholder,
138
+ className: "w-full bg-transparent py-3 text-sm outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500"
139
+ }
140
+ ),
141
+ loading && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[11px] text-slate-400 dark:text-slate-500", children: l.loading })
142
+ ] }),
143
+ /* @__PURE__ */ jsxs("ul", { id: "command-palette-list", ref: listRef, role: "listbox", className: "min-h-0 flex-1 overflow-y-auto py-1", children: [
144
+ flat.length === 0 && !loading && /* @__PURE__ */ jsx("li", { className: "px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400", children: l.empty }),
145
+ groups.map(({ group, items }) => /* @__PURE__ */ jsxs("li", { children: [
146
+ /* @__PURE__ */ jsx("div", { className: "px-3 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500", children: group }),
147
+ /* @__PURE__ */ jsx("ul", { children: items.map((item) => {
148
+ flatIndex += 1;
149
+ const idx = flatIndex;
150
+ const isActive = idx === active;
151
+ const shared = {
152
+ id: `command-item-${item.id}`,
153
+ role: "option",
154
+ "aria-selected": isActive,
155
+ "data-index": idx,
156
+ onMouseMove: () => setActive(idx),
157
+ className: cn(
158
+ "flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm",
159
+ isActive ? "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100" : "text-slate-700 dark:text-slate-300"
160
+ )
161
+ };
162
+ const inner = /* @__PURE__ */ jsxs(Fragment, { children: [
163
+ item.icon && /* @__PURE__ */ jsx("span", { className: "flex size-4 shrink-0 items-center justify-center text-slate-400 dark:text-slate-500", children: item.icon }),
164
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate", children: item.label }),
165
+ item.hint && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-xs text-slate-400 dark:text-slate-500", children: item.hint })
166
+ ] });
167
+ return /* @__PURE__ */ jsx("li", { children: item.href ? /* @__PURE__ */ jsx(
168
+ "a",
169
+ {
170
+ ...shared,
171
+ href: item.href,
172
+ draggable: false,
173
+ onClick: (e) => {
174
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
175
+ return;
176
+ e.preventDefault();
177
+ choose(item);
178
+ },
179
+ children: inner
180
+ }
181
+ ) : /* @__PURE__ */ jsx("button", { type: "button", ...shared, onClick: () => choose(item), children: inner }) }, item.id);
182
+ }) })
183
+ ] }, group))
184
+ ] }),
185
+ l.hint && /* @__PURE__ */ jsx("div", { className: "border-t border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500", children: l.hint })
186
+ ]
187
+ }
188
+ )
189
+ }
190
+ ),
191
+ document.body
192
+ );
193
+ }
194
+ export {
195
+ CommandPalette,
196
+ useCommandKey
197
+ };
198
+ //# sourceMappingURL=command-palette.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/search/command-palette.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Search } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\n\nexport interface CommandItem {\n id: string;\n /** Primary text. */\n label: string;\n /** Secondary text on the right (e.g. an amount, a date, a group hint). */\n hint?: string;\n /** Heading this item is grouped under. */\n group: string;\n /** Leading icon / element. */\n icon?: ReactNode;\n /** Invoked when the item is chosen (navigate, run an action, …). */\n onSelect: () => void;\n /** Where this item leads, when it is a navigation rather than an action. Renders\n * the row as a real anchor so it can be middle-/⌘-clicked into a new tab\n * (Keksdose feedback #451); `onSelect` still runs for a plain click and for ↵, so\n * in-app navigation stays client-side. `href` only adds what the browser can do\n * with a link and JavaScript cannot fake — the rejected alternative, an\n * `onAuxClick` calling `window.open`, gets the middle click and nothing else. */\n href?: string;\n}\n\nexport interface CommandPaletteLabels {\n placeholder: string;\n empty: string;\n loading: string;\n /** Hint shown in the footer, e.g. \"↑↓ to navigate · ↵ to select · esc to close\". */\n hint?: string;\n}\n\nconst DEFAULT_LABELS: CommandPaletteLabels = {\n placeholder: \"Search…\",\n empty: \"No results\",\n loading: \"Searching…\",\n};\n\ninterface CommandPaletteProps {\n open: boolean;\n onClose: () => void;\n /**\n * Returns results for a query (called debounced as the user types; an empty\n * query is allowed — return defaults like pages/recent). Sync or async; stale\n * async responses are ignored.\n *\n * Its IDENTITY is not a re-run signal — it is read through a ref, so the palette\n * never re-searches merely because the caller rebuilt the callback. That is\n * deliberate: a provider closing over a list rendered from a query allocates a new\n * function on most renders, and re-running the search on each would make typing\n * stutter. Use {@link CommandPaletteProps.revision} when the DATA behind the\n * provider changes.\n */\n search: (query: string) => CommandItem[] | Promise<CommandItem[]>;\n /**\n * Anything whose change means the current results are out of date — typically the\n * arrays the provider searches over. Compared by identity (as an effect dependency\n * would be), so pass stable references: a `data = []` default allocates a fresh\n * array on every render while a query is pending and would re-search forever.\n *\n * Without it, a provider whose data arrives AFTER the palette opened never gets\n * asked again: the user's last keystroke settles the debounce, the queries resolve a\n * moment later, and the groups they would have contributed are simply absent until\n * another keystroke. That is the ordinary first open on a cold cache.\n */\n revision?: unknown;\n labels?: Partial<CommandPaletteLabels>;\n}\n\n/** Registers a global ⌘K / Ctrl-K shortcut that calls `onOpen`. */\nexport function useCommandKey(onOpen: () => void): void {\n useEffect(() => {\n const handler = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && (e.key === \"k\" || e.key === \"K\")) {\n e.preventDefault();\n onOpen();\n }\n };\n document.addEventListener(\"keydown\", handler);\n return () => document.removeEventListener(\"keydown\", handler);\n }, [onOpen]);\n}\n\n/**\n * A ⌘K-style command palette: a top-centred modal with a search field and a\n * grouped, keyboard-navigable result list. Domain-free — the app supplies\n * results via the `search` provider (pages, actions, records, …); each result\n * carries its own `onSelect`.\n */\nexport function CommandPalette({\n open,\n onClose,\n search,\n revision,\n labels,\n}: CommandPaletteProps) {\n const l = { ...DEFAULT_LABELS, ...labels };\n const [query, setQuery] = useState(\"\");\n const [results, setResults] = useState<CommandItem[]>([]);\n const [loading, setLoading] = useState(false);\n const [active, setActive] = useState(0);\n const inputRef = useRef<HTMLInputElement>(null);\n const listRef = useRef<HTMLUListElement>(null);\n const searchRef = useRef(search);\n searchRef.current = search;\n const reqId = useRef(0);\n\n // Back dismisses the palette (Keksdose feedback #172). Declared with the other\n // hooks, above the `if (!open) return null` below — a hook past a conditional\n // return changes the hook order between renders. Passing `open` rather than\n // mounting-while-open is what lets it sit here.\n useOverlayHistory(open, onClose);\n\n // Reset + focus when opened.\n useEffect(() => {\n if (!open) return;\n setQuery(\"\");\n setActive(0);\n const id = requestAnimationFrame(() => inputRef.current?.focus());\n return () => cancelAnimationFrame(id);\n }, [open]);\n\n // Debounced, race-safe search.\n useEffect(() => {\n if (!open) return;\n const id = ++reqId.current;\n setLoading(true);\n const run = async () => {\n try {\n const r = await Promise.resolve(searchRef.current(query));\n if (reqId.current === id) {\n setResults(r);\n setActive(0);\n }\n } finally {\n if (reqId.current === id) setLoading(false);\n }\n };\n const t = setTimeout(run, query ? 150 : 0);\n return () => clearTimeout(t);\n // `revision`, not `search`: see the prop's docstring. The debounce is re-armed on a\n // revision change, which is right — data arriving is not a keystroke and there is\n // nothing to feel laggy about.\n }, [query, open, revision]);\n\n // Group results, preserving first-seen group order; keep a flat list for nav.\n const { groups, flat } = useMemo(() => {\n const order: string[] = [];\n const byGroup = new Map<string, CommandItem[]>();\n for (const item of results) {\n if (!byGroup.has(item.group)) {\n byGroup.set(item.group, []);\n order.push(item.group);\n }\n byGroup.get(item.group)!.push(item);\n }\n return {\n groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),\n flat: order.flatMap((g) => byGroup.get(g)!),\n };\n }, [results]);\n\n const choose = (item: CommandItem | undefined) => {\n if (!item) return;\n onClose();\n item.onSelect();\n };\n\n const onKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActive((i) => Math.min(i + 1, flat.length - 1));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActive((i) => Math.max(i - 1, 0));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n choose(flat[active]);\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n onClose();\n }\n };\n\n // Keep the active row scrolled into view.\n useEffect(() => {\n const el = listRef.current?.querySelector<HTMLElement>(`[data-index=\"${active}\"]`);\n el?.scrollIntoView({ block: \"nearest\" });\n }, [active]);\n\n if (!open) return null;\n\n let flatIndex = -1;\n return createPortal(\n <div\n className=\"fixed inset-0 z-[70] flex items-start justify-center bg-black/40 p-4 pt-[10vh]\"\n onMouseDown={(e) => {\n if (e.target === e.currentTarget) onClose();\n }}\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={l.placeholder}\n className=\"flex max-h-[70vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900\"\n >\n <div className=\"flex items-center gap-2 border-b border-slate-100 px-3 dark:border-slate-800\">\n <Search className=\"size-4 shrink-0 text-slate-400 dark:text-slate-500\" />\n <input\n ref={inputRef}\n role=\"combobox\"\n aria-expanded\n aria-controls=\"command-palette-list\"\n aria-activedescendant={flat[active] ? `command-item-${flat[active].id}` : undefined}\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n onKeyDown={onKeyDown}\n placeholder={l.placeholder}\n className=\"w-full bg-transparent py-3 text-sm outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500\"\n />\n {loading && <span className=\"shrink-0 text-[11px] text-slate-400 dark:text-slate-500\">{l.loading}</span>}\n </div>\n\n <ul id=\"command-palette-list\" ref={listRef} role=\"listbox\" className=\"min-h-0 flex-1 overflow-y-auto py-1\">\n {flat.length === 0 && !loading && (\n <li className=\"px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400\">{l.empty}</li>\n )}\n {groups.map(({ group, items }) => (\n <li key={group}>\n <div className=\"px-3 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500\">\n {group}\n </div>\n <ul>\n {items.map((item) => {\n flatIndex += 1;\n const idx = flatIndex;\n const isActive = idx === active;\n // Written once and worn by either tag below, so the anchor and the\n // button can never drift apart in looks or in listbox semantics.\n // `role=\"option\"` on an <a href> is fine: the browser's middle-click\n // behaviour keys off the element, not the ARIA role.\n const shared = {\n id: `command-item-${item.id}`,\n role: \"option\" as const,\n \"aria-selected\": isActive,\n \"data-index\": idx,\n onMouseMove: () => setActive(idx),\n className: cn(\n \"flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm\",\n isActive\n ? \"bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100\"\n : \"text-slate-700 dark:text-slate-300\",\n ),\n };\n const inner = (\n <>\n {item.icon && (\n <span className=\"flex size-4 shrink-0 items-center justify-center text-slate-400 dark:text-slate-500\">\n {item.icon}\n </span>\n )}\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n {item.hint && (\n <span className=\"shrink-0 text-xs text-slate-400 dark:text-slate-500\">{item.hint}</span>\n )}\n </>\n );\n return (\n <li key={item.id}>\n {item.href ? (\n <a\n {...shared}\n href={item.href}\n draggable={false}\n onClick={(e) => {\n // A modified click belongs to the browser (feedback\n // #451) — and the palette deliberately stays OPEN for\n // it: ⌘-clicking three results in a row is the reason to\n // want this, and closing after the first would undo it.\n if (\n e.button !== 0 ||\n e.metaKey ||\n e.ctrlKey ||\n e.shiftKey ||\n e.altKey\n )\n return;\n e.preventDefault();\n choose(item);\n }}\n >\n {inner}\n </a>\n ) : (\n <button type=\"button\" {...shared} onClick={() => choose(item)}>\n {inner}\n </button>\n )}\n </li>\n );\n })}\n </ul>\n </li>\n ))}\n </ul>\n\n {l.hint && (\n <div className=\"border-t border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500\">\n {l.hint}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,64 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { LucideIcon } from 'lucide-react';
4
+
5
+ interface AppShellSubItem {
6
+ to: string;
7
+ label: string;
8
+ icon: LucideIcon;
9
+ /** Passed to NavLink's `end` (exact match). Defaults to true. */
10
+ end?: boolean;
11
+ }
12
+ interface AppShellNavItem {
13
+ to: string;
14
+ label: string;
15
+ /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its
16
+ * tooltips and any palette built on this list keep `label`).
17
+ *
18
+ * The bar divides the viewport into `nav.length` equal cells, so a label's room
19
+ * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,
20
+ * and "Import & Export" wrapped to two lines and pushed the row taller than its
21
+ * neighbours (keksdose feedback live #210). A per-item override is better than
22
+ * shortening the real label, which the sidebar has ample room for and which is the
23
+ * name the rest of the product uses. */
24
+ shortLabel?: string;
25
+ icon: LucideIcon;
26
+ /** Passed to NavLink's `end` (exact match). Defaults to true. */
27
+ end?: boolean;
28
+ /** Optional `data-tour` value rendered on this item's link (both the desktop
29
+ * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav
30
+ * entry precisely instead of matching by href (feedback #338). */
31
+ dataTour?: string;
32
+ /** Optional sub-items. When present, hovering (or focusing) the item opens a
33
+ * flyout to the right of the sidebar with a header (this item's label) and the
34
+ * sub-items as icon + title links. Works collapsed or expanded; the mobile
35
+ * bottom bar ignores sub-items and links to `to`. */
36
+ items?: AppShellSubItem[];
37
+ }
38
+ interface AppShellProps {
39
+ /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */
40
+ nav: AppShellNavItem[];
41
+ /** The composed top bar (e.g. the shared TopBar with app-owned actions). */
42
+ topBar: ReactNode;
43
+ /** Main content — typically the router `<Outlet />`. */
44
+ children: ReactNode;
45
+ /** Desktop-only footer rendered below the content (hidden on mobile). */
46
+ footer?: ReactNode;
47
+ /** Extra sidebar content above the collapse toggle (e.g. a version link).
48
+ * Receives the collapsed state so it can render compact vs. full. */
49
+ sidebarFooter?: (collapsed: boolean) => ReactNode;
50
+ /** localStorage key for the persisted collapse state. */
51
+ collapseStorageKey?: string;
52
+ collapseLabel?: string;
53
+ expandLabel?: string;
54
+ }
55
+ /**
56
+ * The responsive application shell: a top bar, a collapsible desktop sidebar
57
+ * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main
58
+ * content area, and an optional desktop footer. Router-aware via react-router
59
+ * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions
60
+ * are all supplied by the app.
61
+ */
62
+ declare function AppShell({ nav, topBar, children, footer, sidebarFooter, collapseStorageKey, collapseLabel, expandLabel, }: AppShellProps): react.JSX.Element;
63
+
64
+ export { AppShell, type AppShellNavItem, type AppShellSubItem };
@@ -0,0 +1,237 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { NavLink } from "react-router";
5
+ import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
6
+ import { cn } from "../lib/cn.js";
7
+ import { readStored, writeStored } from "../lib/safe-storage.js";
8
+ import { Tooltip } from "../components/tooltip.js";
9
+ import { useAnchoredRect } from "../hooks/use-anchored-rect.js";
10
+ import { useEscapeKey } from "../hooks/use-dismiss.js";
11
+ import { useMediaQuery } from "../hooks/use-media-query.js";
12
+ function useNavHeightVar(ref) {
13
+ const isMdUp = useMediaQuery("(min-width: 768px)", false);
14
+ useEffect(() => {
15
+ const node = ref.current;
16
+ const root = document.documentElement;
17
+ const publish = () => root.style.setProperty(
18
+ "--app-nav-h",
19
+ `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`
20
+ );
21
+ publish();
22
+ const ro = node && typeof ResizeObserver !== "undefined" ? new ResizeObserver(publish) : null;
23
+ if (ro && node) ro.observe(node);
24
+ return () => {
25
+ ro?.disconnect();
26
+ root.style.removeProperty("--app-nav-h");
27
+ };
28
+ }, [ref, isMdUp]);
29
+ }
30
+ function AppShell({
31
+ nav,
32
+ topBar,
33
+ children,
34
+ footer,
35
+ sidebarFooter,
36
+ collapseStorageKey = "appLayout.sidebarCollapsed",
37
+ collapseLabel = "Collapse sidebar",
38
+ expandLabel = "Expand sidebar"
39
+ }) {
40
+ const [collapsed, setCollapsed] = useState(() => readStored(collapseStorageKey) === "1");
41
+ const navRef = useRef(null);
42
+ useNavHeightVar(navRef);
43
+ useEffect(() => {
44
+ writeStored(collapseStorageKey, collapsed ? "1" : "0");
45
+ }, [collapsed, collapseStorageKey]);
46
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col min-h-screen md:h-dvh md:overflow-hidden", children: [
47
+ topBar,
48
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-1 min-h-0", children: [
49
+ /* @__PURE__ */ jsxs(
50
+ "aside",
51
+ {
52
+ className: `hidden md:flex md:flex-col md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)] border-r border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden ${collapsed ? "md:w-14" : "md:w-60"}`,
53
+ children: [
54
+ /* @__PURE__ */ jsx("nav", { className: "flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden", children: /* @__PURE__ */ jsx("div", { "data-tour": "nav", className: "space-y-1", children: nav.map((item) => /* @__PURE__ */ jsx(SidebarNavItem, { item, collapsed }, item.to)) }) }),
55
+ sidebarFooter?.(collapsed),
56
+ /* @__PURE__ */ jsx("div", { className: "border-t border-[var(--border)] p-2", children: collapsed ? /* @__PURE__ */ jsx(Tooltip, { label: expandLabel, side: "right", portal: true, className: "block", children: /* @__PURE__ */ jsx(
57
+ "button",
58
+ {
59
+ type: "button",
60
+ onClick: () => setCollapsed(false),
61
+ "aria-label": expandLabel,
62
+ className: "flex w-full items-center justify-center min-h-9 rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
63
+ children: /* @__PURE__ */ jsx(PanelLeftOpen, { className: "size-4 shrink-0" })
64
+ }
65
+ ) }) : /* @__PURE__ */ jsxs(
66
+ "button",
67
+ {
68
+ type: "button",
69
+ onClick: () => setCollapsed(true),
70
+ "aria-label": collapseLabel,
71
+ className: "flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
72
+ children: [
73
+ /* @__PURE__ */ jsx(PanelLeftClose, { className: "size-4 shrink-0" }),
74
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: collapseLabel })
75
+ ]
76
+ }
77
+ ) })
78
+ ]
79
+ }
80
+ ),
81
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-1 flex-col min-w-0 md:min-h-0", children: [
82
+ /* @__PURE__ */ jsx("main", { className: "flex-1 max-w-full overflow-x-clip pb-20 md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]", children }),
83
+ footer
84
+ ] })
85
+ ] }),
86
+ /* @__PURE__ */ jsx(
87
+ "nav",
88
+ {
89
+ ref: navRef,
90
+ "data-tour": "nav",
91
+ className: "md:hidden fixed bottom-0 inset-x-0 z-30 bg-[var(--bg-surface)] border-t border-[var(--border)] grid",
92
+ style: { gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` },
93
+ children: nav.map((item) => /* @__PURE__ */ jsxs(
94
+ NavLink,
95
+ {
96
+ to: item.to,
97
+ end: item.end ?? true,
98
+ "data-tour": item.dataTour,
99
+ className: ({ isActive }) => (
100
+ // The active page needs a clear marker, not just a subtle text
101
+ // shade (feedback #327): brand-accent icon + label, bolder weight,
102
+ // and a top accent bar spanning the cell so it reads at a glance.
103
+ cn(
104
+ "relative flex flex-col items-center justify-center py-2 text-[11px] gap-0.5 transition-colors",
105
+ "before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors",
106
+ isActive ? "font-semibold text-[var(--brand)] before:bg-[var(--brand)]" : "text-slate-500 dark:text-slate-400 before:bg-transparent"
107
+ )
108
+ ),
109
+ children: [
110
+ /* @__PURE__ */ jsx("span", { className: "relative", children: /* @__PURE__ */ jsx(item.icon, { className: "size-5" }) }),
111
+ /* @__PURE__ */ jsx("span", { className: "max-w-full truncate px-0.5", children: item.shortLabel ?? item.label })
112
+ ]
113
+ },
114
+ item.to
115
+ ))
116
+ }
117
+ )
118
+ ] });
119
+ }
120
+ const navLinkClass = (collapsed) => ({ isActive }) => `flex items-center gap-3 min-h-9 ${collapsed ? "justify-center px-2" : "px-3"} py-2 rounded-md text-sm ${isActive ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" : "text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"}`;
121
+ function SidebarNavItem({ item, collapsed }) {
122
+ const hasSub = !!item.items?.length;
123
+ const link = /* @__PURE__ */ jsxs(
124
+ NavLink,
125
+ {
126
+ to: item.to,
127
+ end: item.end ?? true,
128
+ "data-tour": item.dataTour,
129
+ className: navLinkClass(collapsed),
130
+ "aria-haspopup": hasSub ? "menu" : void 0,
131
+ children: [
132
+ /* @__PURE__ */ jsx("span", { className: "relative shrink-0", children: /* @__PURE__ */ jsx(item.icon, { className: "size-4" }) }),
133
+ !collapsed && /* @__PURE__ */ jsx("span", { className: "flex-1 truncate", children: item.label }),
134
+ !collapsed && hasSub && /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5 shrink-0 opacity-60" })
135
+ ]
136
+ }
137
+ );
138
+ if (hasSub) return /* @__PURE__ */ jsx(SidebarFlyout, { item, children: link });
139
+ return collapsed ? /* @__PURE__ */ jsx(Tooltip, { label: item.label, side: "right", portal: true, className: "block", children: link }) : link;
140
+ }
141
+ let activeFlyoutClose = null;
142
+ function SidebarFlyout({ item, children }) {
143
+ const wrapperRef = useRef(null);
144
+ const closeTimer = useRef(null);
145
+ const [open, setOpen] = useState(false);
146
+ const rect = useAnchoredRect(wrapperRef, open);
147
+ const pos = rect ? { top: rect.top, left: rect.right } : null;
148
+ const cancelClose = useCallback(() => {
149
+ if (closeTimer.current) {
150
+ clearTimeout(closeTimer.current);
151
+ closeTimer.current = null;
152
+ }
153
+ }, []);
154
+ const closeNow = useCallback(() => {
155
+ cancelClose();
156
+ setOpen(false);
157
+ }, [cancelClose]);
158
+ const openMenu = useCallback(() => {
159
+ cancelClose();
160
+ if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();
161
+ activeFlyoutClose = closeNow;
162
+ setOpen(true);
163
+ }, [cancelClose, closeNow]);
164
+ const scheduleClose = useCallback(() => {
165
+ cancelClose();
166
+ closeTimer.current = setTimeout(() => {
167
+ setOpen(false);
168
+ if (activeFlyoutClose === closeNow) activeFlyoutClose = null;
169
+ }, 140);
170
+ }, [cancelClose, closeNow]);
171
+ useEscapeKey(() => setOpen(false), open);
172
+ useEffect(
173
+ () => () => {
174
+ cancelClose();
175
+ if (activeFlyoutClose === closeNow) activeFlyoutClose = null;
176
+ },
177
+ [cancelClose, closeNow]
178
+ );
179
+ return /* @__PURE__ */ jsxs(
180
+ "div",
181
+ {
182
+ ref: wrapperRef,
183
+ className: "relative",
184
+ onMouseEnter: openMenu,
185
+ onMouseLeave: scheduleClose,
186
+ onFocus: openMenu,
187
+ onBlur: scheduleClose,
188
+ children: [
189
+ children,
190
+ open && pos && createPortal(
191
+ /* @__PURE__ */ jsx(
192
+ "div",
193
+ {
194
+ style: { position: "fixed", top: pos.top, left: pos.left },
195
+ className: "z-40 pl-1",
196
+ onMouseEnter: cancelClose,
197
+ onMouseLeave: scheduleClose,
198
+ children: /* @__PURE__ */ jsxs(
199
+ "div",
200
+ {
201
+ role: "menu",
202
+ "aria-label": item.label,
203
+ className: "min-w-52 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900",
204
+ children: [
205
+ /* @__PURE__ */ jsx("div", { className: "border-b border-slate-100 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400", children: item.label }),
206
+ /* @__PURE__ */ jsx("ul", { className: "py-1", children: item.items.map((sub) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
207
+ NavLink,
208
+ {
209
+ to: sub.to,
210
+ end: sub.end ?? true,
211
+ role: "menuitem",
212
+ onClick: () => setOpen(false),
213
+ className: ({ isActive }) => cn(
214
+ "flex items-center gap-2.5 px-3 py-2 text-sm",
215
+ isActive ? "bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100" : "text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
216
+ ),
217
+ children: [
218
+ /* @__PURE__ */ jsx(sub.icon, { className: "size-4 shrink-0 text-slate-400 dark:text-slate-500" }),
219
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: sub.label })
220
+ ]
221
+ }
222
+ ) }, sub.to)) })
223
+ ]
224
+ }
225
+ )
226
+ }
227
+ ),
228
+ document.body
229
+ )
230
+ ]
231
+ }
232
+ );
233
+ }
234
+ export {
235
+ AppShell
236
+ };
237
+ //# sourceMappingURL=app-shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/app-shell.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { NavLink } from \"react-router\";\nimport { ChevronRight, PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { useAnchoredRect } from \"../hooks/use-anchored-rect\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\n\nexport interface AppShellSubItem {\n to: string;\n label: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n}\n\nexport interface AppShellNavItem {\n to: string;\n label: string;\n /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its\n * tooltips and any palette built on this list keep `label`).\n *\n * The bar divides the viewport into `nav.length` equal cells, so a label's room\n * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,\n * and \"Import & Export\" wrapped to two lines and pushed the row taller than its\n * neighbours (keksdose feedback live #210). A per-item override is better than\n * shortening the real label, which the sidebar has ample room for and which is the\n * name the rest of the product uses. */\n shortLabel?: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n /** Optional `data-tour` value rendered on this item's link (both the desktop\n * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav\n * entry precisely instead of matching by href (feedback #338). */\n dataTour?: string;\n /** Optional sub-items. When present, hovering (or focusing) the item opens a\n * flyout to the right of the sidebar with a header (this item's label) and the\n * sub-items as icon + title links. Works collapsed or expanded; the mobile\n * bottom bar ignores sub-items and links to `to`. */\n items?: AppShellSubItem[];\n}\n\ninterface AppShellProps {\n /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */\n nav: AppShellNavItem[];\n /** The composed top bar (e.g. the shared TopBar with app-owned actions). */\n topBar: ReactNode;\n /** Main content — typically the router `<Outlet />`. */\n children: ReactNode;\n /** Desktop-only footer rendered below the content (hidden on mobile). */\n footer?: ReactNode;\n /** Extra sidebar content above the collapse toggle (e.g. a version link).\n * Receives the collapsed state so it can render compact vs. full. */\n sidebarFooter?: (collapsed: boolean) => ReactNode;\n /** localStorage key for the persisted collapse state. */\n collapseStorageKey?: string;\n collapseLabel?: string;\n expandLabel?: string;\n}\n\n/**\n * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit\n * ON it can stop guessing.\n *\n * Keksdose live #314 is what this is for. Its rework — *\"Stick it to the bottom\n * touching the bottom icon bar with budget, accounts, … Currently there is a small gap\n * which confuses\"* — was a receipt footer pinned at `bottom-20`, because `<main>`\n * reserves `pb-20` for this nav and 20 looked like the nav's height. It is not: the\n * padding is generous CLEARANCE, and the nav is content-sized (icon + label + `py-2`,\n * about 56px), so the footer floated ~24px above the bar.\n *\n * Measured rather than named, because the height is not a constant anyone owns: it is\n * an icon, a translated label that can wrap or truncate, and whatever the platform does\n * with the safe-area inset. A hard-coded `bottom-14` would be the same guess one number\n * lower, and it would be wrong again the first time a label needed two lines.\n *\n * `0px` when the nav is not rendered — above `md` it is `display:none`, which observes\n * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get\n * the right answer at both widths without a breakpoint of its own.\n */\nfunction useNavHeightVar(ref: React.RefObject<HTMLElement | null>): void {\n // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not\n // being rendered, so the nav going `display:none` above `md` fires no callback and\n // would leave the last phone height published — a desktop footer would then sit 56px\n // off the bottom after a live resize. The query is the one the nav's own `md:hidden`\n // compiles to, so the two cannot disagree about where the boundary is.\n const isMdUp = useMediaQuery(\"(min-width: 768px)\", false);\n useEffect(() => {\n const node = ref.current;\n const root = document.documentElement;\n // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not\n // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and\n // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four\n // was *\"No change. Still a pixel inbetween\"* — the previous fix was arithmetically\n // a no-op on the very value it was meant to correct.\n //\n // 56 is the wrong side. A sticky element with `bottom: X` puts its bottom edge X\n // above the viewport bottom, and the bar's TOP edge is at its own height: so\n // X = 56 lands the footer at y=760.0 against a bar starting at y=760.5, i.e. half\n // a CSS pixel of page showing through — about 1.4 device pixels on his phone,\n // which is exactly the hairline in the screenshot.\n //\n // The two errors are not symmetric, and I had them backwards the first time.\n // Too SMALL tucks the sticky element under a bar that is opaque and painted above\n // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and\n // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case\n // where the height lands on a whole pixel and floor leaves no overlap at all.\n const publish = () =>\n root.style.setProperty(\n \"--app-nav-h\",\n `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`,\n );\n publish();\n // The observer is the refinement, not the mechanism: `publish()` above is already\n // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.\n // ONE cleanup for every branch, though — an early `return` on the no-observer path\n // left the variable behind on unmount, which a stale `bottom:` offset on whatever\n // rendered next would have inherited.\n const ro = node && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(publish) : null;\n if (ro && node) ro.observe(node);\n return () => {\n ro?.disconnect();\n root.style.removeProperty(\"--app-nav-h\");\n };\n }, [ref, isMdUp]);\n}\n\n/**\n * The responsive application shell: a top bar, a collapsible desktop sidebar\n * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main\n * content area, and an optional desktop footer. Router-aware via react-router\n * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions\n * are all supplied by the app.\n */\nexport function AppShell({\n nav,\n topBar,\n children,\n footer,\n sidebarFooter,\n collapseStorageKey = \"appLayout.sidebarCollapsed\",\n collapseLabel = \"Collapse sidebar\",\n expandLabel = \"Expand sidebar\",\n}: AppShellProps) {\n // Through the guarded helpers, not `window.localStorage` directly. The read runs\n // inside a `useState` initialiser — i.e. during render of the top-level shell — and\n // `localStorage` THROWS where site data is blocked (Safari private browsing, a\n // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing\n // in the application mounted at all.\n const [collapsed, setCollapsed] = useState(() => readStored(collapseStorageKey) === \"1\");\n // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.\n const navRef = useRef<HTMLElement>(null);\n useNavHeightVar(navRef);\n useEffect(() => {\n writeStored(collapseStorageKey, collapsed ? \"1\" : \"0\");\n }, [collapsed, collapseStorageKey]);\n\n return (\n <div className=\"flex flex-col min-h-screen md:h-dvh md:overflow-hidden\">\n {topBar}\n <div className=\"flex flex-1 min-h-0\">\n <aside\n className={`hidden md:flex md:flex-col md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)] border-r border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden ${\n collapsed ? \"md:w-14\" : \"md:w-60\"\n }`}\n >\n {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty\n space below the items); the data-tour marker goes on the INNER,\n fit-content wrapper so the guided-tour spotlight hugs the actual nav\n items instead of the whole tall column (feedback #322). The marker\n also tags the mobile bottom bar below; the tour targets\n `[data-tour=\"nav\"]` and resolves to whichever is visible (#313). */}\n <nav className=\"flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden\">\n <div data-tour=\"nav\" className=\"space-y-1\">\n {nav.map((item) => (\n <SidebarNavItem key={item.to} item={item} collapsed={collapsed} />\n ))}\n </div>\n </nav>\n {sidebarFooter?.(collapsed)}\n <div className=\"border-t border-[var(--border)] p-2\">\n {collapsed ? (\n <Tooltip label={expandLabel} side=\"right\" portal className=\"block\">\n <button\n type=\"button\"\n onClick={() => setCollapsed(false)}\n aria-label={expandLabel}\n className=\"flex w-full items-center justify-center min-h-9 rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200\"\n >\n <PanelLeftOpen className=\"size-4 shrink-0\" />\n </button>\n </Tooltip>\n ) : (\n <button\n type=\"button\"\n onClick={() => setCollapsed(true)}\n aria-label={collapseLabel}\n className=\"flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200\"\n >\n <PanelLeftClose className=\"size-4 shrink-0\" />\n <span className=\"truncate\">{collapseLabel}</span>\n </button>\n )}\n </div>\n </aside>\n\n {/* Content column beside the sidebar. main grows to fill so the footer\n sits at the bottom — at the viewport edge when content is short,\n beside the sidebar rather than under it. */}\n <div className=\"flex flex-1 flex-col min-w-0 md:min-h-0\">\n {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or\n not it is currently needed. Without it this element — the app's only\n scroll container from md up — narrows its own client box by the\n scrollbar width the moment a page's content outgrows it, and widens\n it again when the next page fits. Every centred `mx-auto` container\n inside then jumps sideways by half a scrollbar, and any fluid-width\n content changes width outright. Keksdose feedback #403 caught it as\n \"slight width increase/decrease\" when switching to the notifications\n settings section and back: that section is the only one tall enough\n to scroll. Platform-dependent, which is why it is easy to miss —\n overlay scrollbars (macOS, most Linux builds) take no layout space,\n classic ones (Windows) take ~15px.\n\n `both-edges`, not the bare `stable`: a one-sided reservation keeps the\n width stable but moves the middle. On a page short enough not to\n scroll, nothing is painted into the reserved track, so every centred\n `mx-auto` container sits half a scrollbar left of the optical centre\n and every full-bleed child stops ~15px short on the right. Keksdose\n feedback #491 (/settings#data) and #498 (/budgets) both reported it as\n \"the left boundary looks smaller / cut off compared to the right\" on\n the one element per page whose frame is a saturated colour — the rose\n destructive-action cards. Reserving the track on both edges keeps the\n content centred whether or not the scrollbar is showing. */}\n <main className=\"flex-1 max-w-full overflow-x-clip pb-20 md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]\">\n {children}\n </main>\n {footer}\n </div>\n </div>\n\n <nav\n ref={navRef}\n data-tour=\"nav\"\n className=\"md:hidden fixed bottom-0 inset-x-0 z-30 bg-[var(--bg-surface)] border-t border-[var(--border)] grid\"\n style={{ gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` }}\n >\n {nav.map((item) => (\n <NavLink\n key={item.to}\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={({ isActive }) =>\n // The active page needs a clear marker, not just a subtle text\n // shade (feedback #327): brand-accent icon + label, bolder weight,\n // and a top accent bar spanning the cell so it reads at a glance.\n cn(\n \"relative flex flex-col items-center justify-center py-2 text-[11px] gap-0.5 transition-colors\",\n \"before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors\",\n isActive\n ? \"font-semibold text-[var(--brand)] before:bg-[var(--brand)]\"\n : \"text-slate-500 dark:text-slate-400 before:bg-transparent\",\n )\n }\n >\n <span className=\"relative\">\n <item.icon className=\"size-5\" />\n </span>\n {/* One line, always. `truncate` is the backstop for the case a\n `shortLabel` was not supplied (or a translation is longer than its\n author expected): an ellipsis in one cell is a far smaller problem\n than a bar whose rows are different heights. */}\n <span className=\"max-w-full truncate px-0.5\">\n {item.shortLabel ?? item.label}\n </span>\n </NavLink>\n ))}\n </nav>\n </div>\n );\n}\n\nconst navLinkClass = (collapsed: boolean) =>\n ({ isActive }: { isActive: boolean }) =>\n `flex items-center gap-3 min-h-9 ${collapsed ? \"justify-center px-2\" : \"px-3\"} py-2 rounded-md text-sm ${\n isActive\n ? \"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900\"\n : \"text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800\"\n }`;\n\n/** A single desktop sidebar entry. Plain link (with a tooltip when collapsed) —\n * unless it has sub-items, in which case hovering/focusing opens a flyout. */\nfunction SidebarNavItem({ item, collapsed }: { item: AppShellNavItem; collapsed: boolean }) {\n const hasSub = !!item.items?.length;\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={navLinkClass(collapsed)}\n aria-haspopup={hasSub ? \"menu\" : undefined}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n {!collapsed && <span className=\"flex-1 truncate\">{item.label}</span>}\n {!collapsed && hasSub && <ChevronRight className=\"size-3.5 shrink-0 opacity-60\" />}\n </NavLink>\n );\n\n if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;\n return collapsed ? (\n <Tooltip label={item.label} side=\"right\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n link\n );\n}\n\n// Only ONE sidebar flyout should be on screen at a time. Each open flyout parks its\n// \"close now\" here so the next item to open can dismiss it immediately, instead of\n// leaving the previous panel up for the 140ms travel delay while the cursor moves\n// down the nav list — which showed two panels at once.\nlet activeFlyoutClose: (() => void) | null = null;\n\n/**\n * Wraps a nav item that has sub-items: on hover or keyboard focus it opens a\n * portalled flyout to the right of the sidebar (portalled so the sidebar's\n * `overflow-hidden` can't clip it) with the item's label as a header and each\n * sub-item as an icon + title link. A short close delay lets the cursor travel\n * from the item onto the panel; Escape / outside-move close it.\n */\nfunction SidebarFlyout({ item, children }: { item: AppShellNavItem; children: ReactNode }) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const [open, setOpen] = useState(false);\n\n // Anchor the flyout to the item's right edge; the hook re-measures on\n // scroll/resize (the nav list can scroll).\n const rect = useAnchoredRect(wrapperRef, open);\n const pos = rect ? { top: rect.top, left: rect.right } : null;\n\n const cancelClose = useCallback(() => {\n if (closeTimer.current) {\n clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n }, []);\n // Stable identity: the coordinator above holds this so a sibling item can close\n // THIS flyout without waiting out its travel delay.\n const closeNow = useCallback(() => {\n cancelClose();\n setOpen(false);\n }, [cancelClose]);\n const openMenu = useCallback(() => {\n cancelClose();\n if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();\n activeFlyoutClose = closeNow;\n setOpen(true);\n }, [cancelClose, closeNow]);\n const scheduleClose = useCallback(() => {\n cancelClose();\n closeTimer.current = setTimeout(() => {\n setOpen(false);\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n }, 140);\n }, [cancelClose, closeNow]);\n\n // Close on Escape while open.\n useEscapeKey(() => setOpen(false), open);\n\n useEffect(\n () => () => {\n cancelClose();\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n },\n [cancelClose, closeNow],\n );\n\n return (\n <div\n ref={wrapperRef}\n className=\"relative\"\n onMouseEnter={openMenu}\n onMouseLeave={scheduleClose}\n onFocus={openMenu}\n onBlur={scheduleClose}\n >\n {children}\n {open &&\n pos &&\n createPortal(\n <div\n style={{ position: \"fixed\", top: pos.top, left: pos.left }}\n className=\"z-40 pl-1\"\n onMouseEnter={cancelClose}\n onMouseLeave={scheduleClose}\n >\n <div\n role=\"menu\"\n aria-label={item.label}\n className=\"min-w-52 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900\"\n >\n <div className=\"border-b border-slate-100 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400\">\n {item.label}\n </div>\n <ul className=\"py-1\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-2.5 px-3 py-2 text-sm\",\n isActive\n ? \"bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100\"\n : \"text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 text-slate-400 dark:text-slate-500\" />\n <span className=\"truncate\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>,\n document.body,\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,24 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ interface OptionSwitcherOption<T extends string> {
5
+ value: T;
6
+ label: ReactNode;
7
+ }
8
+ /**
9
+ * A top-bar hover menu that switches between a small set of options, with a
10
+ * check on the active one and an optional heading. Generic glue over
11
+ * {@link HoverMenu} — e.g. a dev role/impersonation switcher, an environment
12
+ * picker. Controlled: the app owns `value` and `onSelect`.
13
+ */
14
+ declare function OptionSwitcherMenu<T extends string>({ icon, ariaLabel, title, heading, options, value, onSelect, }: {
15
+ icon: ReactNode;
16
+ ariaLabel: string;
17
+ title?: string;
18
+ heading?: string;
19
+ options: OptionSwitcherOption<T>[];
20
+ value: T;
21
+ onSelect: (value: T) => void;
22
+ }): react.JSX.Element;
23
+
24
+ export { OptionSwitcherMenu, type OptionSwitcherOption };