@eifi1/ui-kit 0.6.1 → 0.7.1

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 (271) 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 +5 -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 +5 -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 +38 -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 +11 -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 +5 -0
  85. package/dist/components/number-input.d.ts +5 -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 +5 -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/settings-fields.d.ts +2 -0
  101. package/dist/components/swatch-picker.d.ts +10 -1
  102. package/dist/components/swatch-picker.js +2 -0
  103. package/dist/components/swatch-picker.js.map +1 -1
  104. package/dist/components/swipeable-row.d.ts +11 -2
  105. package/dist/components/swipeable-row.js +5 -3
  106. package/dist/components/swipeable-row.js.map +1 -1
  107. package/dist/components/tile-radio.js +5 -7
  108. package/dist/components/tile-radio.js.map +1 -1
  109. package/dist/components/time-input.d.ts +2 -0
  110. package/dist/components/toggle-group.d.ts +13 -3
  111. package/dist/components/toggle-group.js +21 -1
  112. package/dist/components/toggle-group.js.map +1 -1
  113. package/dist/components/toggle-legend.js +1 -2
  114. package/dist/components/toggle-legend.js.map +1 -1
  115. package/dist/components/tooltip.d.ts +10 -4
  116. package/dist/components/tooltip.js +23 -6
  117. package/dist/components/tooltip.js.map +1 -1
  118. package/dist/components/treemap.d.ts +6 -1
  119. package/dist/components/treemap.js +16 -6
  120. package/dist/components/treemap.js.map +1 -1
  121. package/dist/components/ui.d.ts +36 -8
  122. package/dist/components/ui.js +27 -19
  123. package/dist/components/ui.js.map +1 -1
  124. package/dist/components/use-anchor-dir.d.ts +15 -0
  125. package/dist/components/use-anchor-dir.js +14 -0
  126. package/dist/components/use-anchor-dir.js.map +1 -0
  127. package/dist/components/use-mobile-reveal.d.ts +8 -1
  128. package/dist/components/use-mobile-reveal.js +3 -2
  129. package/dist/components/use-mobile-reveal.js.map +1 -1
  130. package/dist/components/use-table-state.d.ts +4 -4
  131. package/dist/components/use-table-state.js +12 -5
  132. package/dist/components/use-table-state.js.map +1 -1
  133. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  134. package/dist/data-table.d.ts +1 -1
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/feedback/feedback-attachment.d.ts +22 -3
  137. package/dist/feedback/feedback-attachment.js +15 -7
  138. package/dist/feedback/feedback-attachment.js.map +1 -1
  139. package/dist/feedback/feedback-dialog.d.ts +3 -2
  140. package/dist/feedback/feedback-dialog.js.map +1 -1
  141. package/dist/feedback.d.ts +1 -1
  142. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  143. package/dist/hooks/use-windowed-rows.js +31 -0
  144. package/dist/hooks/use-windowed-rows.js.map +1 -0
  145. package/dist/i18n/defaults.d.ts +5 -0
  146. package/dist/i18n/defaults.js +5 -1
  147. package/dist/i18n/defaults.js.map +1 -1
  148. package/dist/i18n/kit-labels.d.ts +7 -0
  149. package/dist/i18n/kit-labels.js.map +1 -1
  150. package/dist/i18n/locales/de-CH.d.ts +45 -0
  151. package/dist/i18n/locales/de-CH.js +21 -0
  152. package/dist/i18n/locales/de-CH.js.map +1 -0
  153. package/dist/i18n/locales/de.d.ts +45 -0
  154. package/dist/i18n/locales/de.js +344 -0
  155. package/dist/i18n/locales/de.js.map +1 -0
  156. package/dist/i18n/locales/es.d.ts +45 -0
  157. package/dist/i18n/locales/es.js +339 -0
  158. package/dist/i18n/locales/es.js.map +1 -0
  159. package/dist/i18n/locales/fr.d.ts +45 -0
  160. package/dist/i18n/locales/fr.js +344 -0
  161. package/dist/i18n/locales/fr.js.map +1 -0
  162. package/dist/i18n/locales/hu.d.ts +45 -0
  163. package/dist/i18n/locales/hu.js +343 -0
  164. package/dist/i18n/locales/hu.js.map +1 -0
  165. package/dist/i18n/locales/it.d.ts +45 -0
  166. package/dist/i18n/locales/it.js +342 -0
  167. package/dist/i18n/locales/it.js.map +1 -0
  168. package/dist/i18n/locales/zh.d.ts +45 -0
  169. package/dist/i18n/locales/zh.js +338 -0
  170. package/dist/i18n/locales/zh.js.map +1 -0
  171. package/dist/index.d.ts +7 -4
  172. package/dist/index.js +4 -1
  173. package/dist/index.js.map +1 -1
  174. package/dist/lib/direction.d.ts +24 -0
  175. package/dist/lib/direction.js +19 -0
  176. package/dist/lib/direction.js.map +1 -0
  177. package/dist/rhf/form.d.ts +2 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/rhf.d.ts +2 -0
  180. package/dist/search/command-palette.d.ts +5 -0
  181. package/dist/search/command-palette.js +80 -50
  182. package/dist/search/command-palette.js.map +1 -1
  183. package/dist/shell/app-shell.d.ts +13 -1
  184. package/dist/shell/app-shell.js +134 -85
  185. package/dist/shell/app-shell.js.map +1 -1
  186. package/dist/shell/topbar-action-menu.js +1 -1
  187. package/dist/shell/topbar-action-menu.js.map +1 -1
  188. package/dist/shell/topbar-controls.d.ts +7 -3
  189. package/dist/shell/topbar-controls.js +38 -31
  190. package/dist/shell/topbar-controls.js.map +1 -1
  191. package/dist/theme/chart-palette.d.ts +7 -1
  192. package/dist/theme/chart-palette.js +2 -1
  193. package/dist/theme/chart-palette.js.map +1 -1
  194. package/dist/tour/tour.d.ts +11 -5
  195. package/dist/tour/tour.js +124 -58
  196. package/dist/tour/tour.js.map +1 -1
  197. package/dist/wizard/use-wizard.js +30 -13
  198. package/dist/wizard/use-wizard.js.map +1 -1
  199. package/dist/wizard/wizard-summary.js +1 -1
  200. package/dist/wizard/wizard-summary.js.map +1 -1
  201. package/package.json +12 -2
  202. package/src/components/account-settings.tsx +6 -2
  203. package/src/components/amount-input.tsx +10 -8
  204. package/src/components/autocomplete.tsx +110 -24
  205. package/src/components/calculator.tsx +5 -1
  206. package/src/components/chart-zoom.tsx +8 -1
  207. package/src/components/chart.tsx +68 -7
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +74 -5
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/tile-radio.tsx +5 -7
  240. package/src/components/toggle-group.tsx +39 -3
  241. package/src/components/toggle-legend.tsx +5 -2
  242. package/src/components/tooltip.tsx +40 -12
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +79 -33
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +201 -130
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -1,10 +1,11 @@
1
- import { useCallback, useEffect, useId, useRef, useState } from "react";
1
+ import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { NavLink, matchPath, useLocation } from "react-router";
5
5
  import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
6
6
  import type { LucideIcon } from "lucide-react";
7
7
  import { cn } from "../lib/cn";
8
+ import { dirOf, type Direction } from "../lib/direction";
8
9
  import { readStored, writeStored } from "../lib/safe-storage";
9
10
  import { Tooltip } from "../components/tooltip";
10
11
  import { useAnchoredRect } from "../hooks/use-anchored-rect";
@@ -92,8 +93,23 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
92
93
  * bottom bar — the phone's counterpart of the sidebar's second level. Default true;
93
94
  * `false` keeps the single bar, where a group entry only links to its own `to`. */
94
95
  mobileSubNav?: boolean;
96
+ /**
97
+ * Lay the shell out inside its PARENT'S box instead of the viewport: `h-full` rather
98
+ * than `h-dvh`, its own scroll container at every width, a bottom bar pinned to the
99
+ * shell rather than the window, `--app-nav-h` published on the shell alone, and no
100
+ * persisted collapse state unless `collapseStorageKey` is passed. For an app preview,
101
+ * a device frame, a docs example.
102
+ *
103
+ * Implied for a shell rendered inside another AppShell, which is the case this
104
+ * exists for: the inner one used to publish its own nav height on `<html>`, fight
105
+ * the outer one over it, and remove it on unmount from under the outer app.
106
+ */
107
+ embedded?: boolean;
95
108
  }
96
109
 
110
+ /** True below an AppShell — how a nested shell knows it is one. */
111
+ const AppShellNesting = createContext(false);
112
+
97
113
  /**
98
114
  * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit
99
115
  * ON it can stop guessing.
@@ -114,7 +130,11 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
114
130
  * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get
115
131
  * the right answer at both widths without a breakpoint of its own.
116
132
  */
117
- function useNavHeightVar(ref: React.RefObject<HTMLElement | null>): void {
133
+ function useNavHeightVar(
134
+ ref: React.RefObject<HTMLElement | null>,
135
+ shellRef: React.RefObject<HTMLElement | null>,
136
+ embedded: boolean,
137
+ ): void {
118
138
  // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not
119
139
  // being rendered, so the nav going `display:none` above `md` fires no callback and
120
140
  // would leave the last phone height published — a desktop footer would then sit 56px
@@ -123,7 +143,15 @@ function useNavHeightVar(ref: React.RefObject<HTMLElement | null>): void {
123
143
  const isMdUp = useMediaQuery("(min-width: 768px)", false);
124
144
  useEffect(() => {
125
145
  const node = ref.current;
126
- const root = document.documentElement;
146
+ const shell = shellRef.current;
147
+ // Always on the shell's own root, so everything inside it — a nested shell's
148
+ // content included — reads the nearest bar's height. On <html> as well only for
149
+ // the OUTERMOST shell, for what it portals to <body> (a sheet docking on the bar).
150
+ // The DOM check backs up the context for a shell mounted in a separate React root.
151
+ const global = !embedded && !shell?.parentElement?.closest("[data-app-shell]");
152
+ const targets = [shell, global ? document.documentElement : null].filter(
153
+ (el): el is HTMLElement => el !== null,
154
+ );
127
155
  // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not
128
156
  // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and
129
157
  // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four
@@ -141,11 +169,10 @@ function useNavHeightVar(ref: React.RefObject<HTMLElement | null>): void {
141
169
  // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and
142
170
  // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case
143
171
  // where the height lands on a whole pixel and floor leaves no overlap at all.
144
- const publish = () =>
145
- root.style.setProperty(
146
- "--app-nav-h",
147
- `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`,
148
- );
172
+ const publish = () => {
173
+ const value = `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`;
174
+ for (const el of targets) el.style.setProperty("--app-nav-h", value);
175
+ };
149
176
  publish();
150
177
  // The observer is the refinement, not the mechanism: `publish()` above is already
151
178
  // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.
@@ -156,9 +183,9 @@ function useNavHeightVar(ref: React.RefObject<HTMLElement | null>): void {
156
183
  if (ro && node) ro.observe(node);
157
184
  return () => {
158
185
  ro?.disconnect();
159
- root.style.removeProperty("--app-nav-h");
186
+ for (const el of targets) el.style.removeProperty("--app-nav-h");
160
187
  };
161
- }, [ref, isMdUp]);
188
+ }, [ref, shellRef, embedded, isMdUp]);
162
189
  }
163
190
 
164
191
  /**
@@ -174,15 +201,21 @@ export function AppShell({
174
201
  children,
175
202
  footer,
176
203
  sidebarFooter,
177
- collapseStorageKey = "appLayout.sidebarCollapsed",
204
+ collapseStorageKey: collapseStorageKeyProp,
178
205
  collapseLabel,
179
206
  expandLabel,
180
207
  subNav = "flyout",
181
208
  mobileSubNav = true,
182
209
  toggleGroupLabel,
210
+ embedded: embeddedProp,
183
211
  className,
184
212
  ...rest
185
213
  }: AppShellProps) {
214
+ const embedded = useContext(AppShellNesting) || !!embeddedProp;
215
+ // An embedded shell sharing the default key would overwrite the outer app's sidebar
216
+ // preference with its own — so it persists only when given a key of its own.
217
+ const collapseStorageKey =
218
+ collapseStorageKeyProp ?? (embedded ? null : "appLayout.sidebarCollapsed");
186
219
  // The three props are per-shell overrides of the provider's `appShell`; one left
187
220
  // undefined falls through to the provider rather than to English.
188
221
  const labels = useKitLabels("appShell", DEFAULT_APP_SHELL_LABELS, {
@@ -195,16 +228,19 @@ export function AppShell({
195
228
  // `localStorage` THROWS where site data is blocked (Safari private browsing, a
196
229
  // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing
197
230
  // in the application mounted at all.
198
- const [collapsed, setCollapsed] = useState(() => readStored(collapseStorageKey) === "1");
231
+ const [collapsed, setCollapsed] = useState(
232
+ () => collapseStorageKey !== null && readStored(collapseStorageKey) === "1",
233
+ );
199
234
  // The inline sidebar is an ACCORDION: one group open at a time, keyed by its `to`.
200
235
  // Held here rather than per group so opening one can close the other — two lists
201
236
  // open at once pushed every entry below them off a laptop screen.
202
237
  const [openGroup, setOpenGroup] = useState<string | null>(null);
203
238
  // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.
204
239
  const navRef = useRef<HTMLDivElement>(null);
205
- useNavHeightVar(navRef);
240
+ const rootRef = useRef<HTMLDivElement>(null);
241
+ useNavHeightVar(navRef, rootRef, embedded);
206
242
  useEffect(() => {
207
- writeStored(collapseStorageKey, collapsed ? "1" : "0");
243
+ if (collapseStorageKey !== null) writeStored(collapseStorageKey, collapsed ? "1" : "0");
208
244
  }, [collapsed, collapseStorageKey]);
209
245
 
210
246
  return (
@@ -212,126 +248,152 @@ export function AppShell({
212
248
  // wrappers below — those are the kit's anchors for its own guided tour (#313/#322),
213
249
  // and a second element answering to the same selector is a tour that highlights the
214
250
  // wrong thing or nothing at all.
215
- <div {...rest} className={cn("relative flex flex-col min-h-screen md:h-dvh md:overflow-hidden", className)}>
216
- {topBar}
217
- <div className="flex flex-1 min-h-0">
218
- <aside
219
- 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 ${
220
- collapsed ? "md:w-14" : "md:w-60"
221
- }`}
222
- >
223
- {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty
224
- space below the items); the data-tour marker goes on the INNER,
225
- fit-content wrapper so the guided-tour spotlight hugs the actual nav
226
- items instead of the whole tall column (feedback #322). The marker
227
- also tags the mobile bottom bar below; the tour targets
228
- `[data-tour="nav"]` and resolves to whichever is visible (#313). */}
229
- <nav className="flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden">
230
- <div data-tour="nav" className="space-y-1">
231
- {nav.map((item) => (
232
- <SidebarNavItem
233
- key={item.to}
234
- item={item}
235
- collapsed={collapsed}
236
- inline={subNav === "inline"}
237
- toggleGroupLabel={labels.toggleGroup}
238
- openGroup={openGroup}
239
- setOpenGroup={setOpenGroup}
240
- />
241
- ))}
242
- </div>
243
- </nav>
244
- {sidebarFooter?.(collapsed)}
245
- <div className="border-t border-[var(--border)] p-2">
246
- {collapsed ? (
247
- <Tooltip label={labels.expand} side="right" portal className="block">
251
+ <AppShellNesting.Provider value={true}>
252
+ <div
253
+ {...rest}
254
+ ref={rootRef}
255
+ data-app-shell=""
256
+ className={cn(
257
+ "relative flex flex-col",
258
+ embedded
259
+ ? // `contain: layout` makes this box the containing block for the `fixed`
260
+ // bottom bar below, so it pins to the shell rather than to the window.
261
+ "h-full min-h-0 overflow-hidden [contain:layout]"
262
+ : "min-h-screen md:h-dvh md:overflow-hidden",
263
+ className,
264
+ )}
265
+ >
266
+ {topBar}
267
+ <div className="flex flex-1 min-h-0">
268
+ <aside
269
+ className={cn(
270
+ // `border-e`: the sidebar sits at the START, which is the right in RTL.
271
+ "hidden md:flex md:flex-col border-e border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden",
272
+ // Embedded, the row it sits in is already the shell's height; the sticky
273
+ // offsets below are measured against the window's top bar.
274
+ embedded ? "md:self-stretch" : "md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)]",
275
+ collapsed ? "md:w-14" : "md:w-60",
276
+ )}
277
+ >
278
+ {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty
279
+ space below the items); the data-tour marker goes on the INNER,
280
+ fit-content wrapper so the guided-tour spotlight hugs the actual nav
281
+ items instead of the whole tall column (feedback #322). The marker
282
+ also tags the mobile bottom bar below; the tour targets
283
+ `[data-tour="nav"]` and resolves to whichever is visible (#313). */}
284
+ <nav className="flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden">
285
+ <div data-tour="nav" className="space-y-1">
286
+ {nav.map((item) => (
287
+ <SidebarNavItem
288
+ key={item.to}
289
+ item={item}
290
+ collapsed={collapsed}
291
+ inline={subNav === "inline"}
292
+ toggleGroupLabel={labels.toggleGroup}
293
+ openGroup={openGroup}
294
+ setOpenGroup={setOpenGroup}
295
+ />
296
+ ))}
297
+ </div>
298
+ </nav>
299
+ {sidebarFooter?.(collapsed)}
300
+ <div className="border-t border-[var(--border)] p-2">
301
+ {collapsed ? (
302
+ <Tooltip label={labels.expand} side="end" portal className="block">
303
+ <button
304
+ type="button"
305
+ onClick={() => setCollapsed(false)}
306
+ aria-label={labels.expand}
307
+ className="flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
308
+ >
309
+ <PanelLeftOpen className="size-4 shrink-0 rtl:-scale-x-100" />
310
+ </button>
311
+ </Tooltip>
312
+ ) : (
248
313
  <button
249
314
  type="button"
250
- onClick={() => setCollapsed(false)}
251
- aria-label={labels.expand}
252
- className="flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
315
+ onClick={() => setCollapsed(true)}
316
+ aria-label={labels.collapse}
317
+ className="flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
253
318
  >
254
- <PanelLeftOpen className="size-4 shrink-0" />
319
+ <PanelLeftClose className="size-4 shrink-0 rtl:-scale-x-100" />
320
+ <span className="truncate">{labels.collapse}</span>
255
321
  </button>
256
- </Tooltip>
257
- ) : (
258
- <button
259
- type="button"
260
- onClick={() => setCollapsed(true)}
261
- aria-label={labels.collapse}
262
- className="flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
263
- >
264
- <PanelLeftClose className="size-4 shrink-0" />
265
- <span className="truncate">{labels.collapse}</span>
266
- </button>
267
- )}
268
- </div>
269
- </aside>
322
+ )}
323
+ </div>
324
+ </aside>
270
325
 
271
- {/* Content column beside the sidebar. main grows to fill so the footer
272
- sits at the bottom — at the viewport edge when content is short,
273
- beside the sidebar rather than under it. */}
274
- <div className="flex flex-1 flex-col min-w-0 md:min-h-0">
275
- {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or
276
- not it is currently needed. Without it this element — the app's only
277
- scroll container from md up — narrows its own client box by the
278
- scrollbar width the moment a page's content outgrows it, and widens
279
- it again when the next page fits. Every centred `mx-auto` container
280
- inside then jumps sideways by half a scrollbar, and any fluid-width
281
- content changes width outright. Keksdose feedback #403 caught it as
282
- "slight width increase/decrease" when switching to the notifications
283
- settings section and back: that section is the only one tall enough
284
- to scroll. Platform-dependent, which is why it is easy to miss —
285
- overlay scrollbars (macOS, most Linux builds) take no layout space,
286
- classic ones (Windows) take ~15px.
326
+ {/* Content column beside the sidebar. main grows to fill so the footer
327
+ sits at the bottom — at the viewport edge when content is short,
328
+ beside the sidebar rather than under it. */}
329
+ <div className="flex flex-1 flex-col min-w-0 md:min-h-0">
330
+ {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or
331
+ not it is currently needed. Without it this element — the app's only
332
+ scroll container from md up — narrows its own client box by the
333
+ scrollbar width the moment a page's content outgrows it, and widens
334
+ it again when the next page fits. Every centred `mx-auto` container
335
+ inside then jumps sideways by half a scrollbar, and any fluid-width
336
+ content changes width outright. Keksdose feedback #403 caught it as
337
+ "slight width increase/decrease" when switching to the notifications
338
+ settings section and back: that section is the only one tall enough
339
+ to scroll. Platform-dependent, which is why it is easy to miss —
340
+ overlay scrollbars (macOS, most Linux builds) take no layout space,
341
+ classic ones (Windows) take ~15px.
287
342
 
288
- `both-edges`, not the bare `stable`: a one-sided reservation keeps the
289
- width stable but moves the middle. On a page short enough not to
290
- scroll, nothing is painted into the reserved track, so every centred
291
- `mx-auto` container sits half a scrollbar left of the optical centre
292
- and every full-bleed child stops ~15px short on the right. Keksdose
293
- feedback #491 (/settings#data) and #498 (/budgets) both reported it as
294
- "the left boundary looks smaller / cut off compared to the right" on
295
- the one element per page whose frame is a saturated colour — the rose
296
- destructive-action cards. Reserving the track on both edges keeps the
297
- content centred whether or not the scrollbar is showing. */}
298
- {/* `relative` makes <main> the containing block for everything absolutely
299
- positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no
300
- positioned ancestor it resolves against the INITIAL containing block, is
301
- laid out at its offset from the top of the DOCUMENT, and grows
302
- `<html>`'s scroll height past the viewport — a second scrollbar beside this
303
- one that scrolls into empty space. Guarding it per component (see
304
- sr-only-containment.test) could never be complete: any page's own
305
- `sr-only` span re-opened it. Here it is closed for every child at once. */}
306
- <main className="relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]">
307
- {children}
308
- </main>
309
- {footer}
343
+ `both-edges`, not the bare `stable`: a one-sided reservation keeps the
344
+ width stable but moves the middle. On a page short enough not to
345
+ scroll, nothing is painted into the reserved track, so every centred
346
+ `mx-auto` container sits half a scrollbar left of the optical centre
347
+ and every full-bleed child stops ~15px short on the right. Keksdose
348
+ feedback #491 (/settings#data) and #498 (/budgets) both reported it as
349
+ "the left boundary looks smaller / cut off compared to the right" on
350
+ the one element per page whose frame is a saturated colour — the rose
351
+ destructive-action cards. Reserving the track on both edges keeps the
352
+ content centred whether or not the scrollbar is showing. */}
353
+ {/* `relative` makes <main> the containing block for everything absolutely
354
+ positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no
355
+ positioned ancestor it resolves against the INITIAL containing block, is
356
+ laid out at its offset from the top of the DOCUMENT, and grows
357
+ `<html>`'s scroll height past the viewport — a second scrollbar beside this
358
+ one that scrolls into empty space. Guarding it per component (see
359
+ sr-only-containment.test) could never be complete: any page's own
360
+ `sr-only` span re-opened it. Here it is closed for every child at once. */}
361
+ <main
362
+ className={cn(
363
+ "relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]",
364
+ // Embedded there is no document scroll to lean on below `md` either.
365
+ embedded && "min-h-0 overflow-y-auto",
366
+ )}
367
+ >
368
+ {children}
369
+ </main>
370
+ {footer}
371
+ </div>
310
372
  </div>
311
- </div>
312
373
 
313
- {/* The phone navigation: the group bar, and above it — when the current page
314
- belongs to a group with pages of its own — the row of those pages. The pair
315
- is the phone's version of the sidebar's two levels; without the upper row a
316
- group's `items` were simply unreachable below `md`. ONE measured box, so
317
- `--app-nav-h` covers both rows and whatever docks on the bar sits on the
318
- whole of it. */}
319
- <div
320
- ref={navRef}
321
- className="md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]"
322
- >
323
- {mobileSubNav && <MobileSubNav nav={nav} />}
324
- <nav
325
- data-tour="nav"
326
- className="grid"
327
- style={{ gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` }}
374
+ {/* The phone navigation: the group bar, and above it — when the current page
375
+ belongs to a group with pages of its own — the row of those pages. The pair
376
+ is the phone's version of the sidebar's two levels; without the upper row a
377
+ group's `items` were simply unreachable below `md`. ONE measured box, so
378
+ `--app-nav-h` covers both rows and whatever docks on the bar sits on the
379
+ whole of it. */}
380
+ <div
381
+ ref={navRef}
382
+ className="md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]"
328
383
  >
329
- {nav.map((item) => (
330
- <MobileNavItem key={item.to} item={item} />
331
- ))}
332
- </nav>
384
+ {mobileSubNav && <MobileSubNav nav={nav} />}
385
+ <nav
386
+ data-tour="nav"
387
+ className="grid"
388
+ style={{ gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` }}
389
+ >
390
+ {nav.map((item) => (
391
+ <MobileNavItem key={item.to} item={item} />
392
+ ))}
393
+ </nav>
394
+ </div>
333
395
  </div>
334
- </div>
396
+ </AppShellNesting.Provider>
335
397
  );
336
398
  }
337
399
 
@@ -501,13 +563,13 @@ function SidebarNavItem({
501
563
  & sl…" hides the one word that tells two pages apart, and there is height
502
564
  to spare. (The phone bar keeps `truncate` — its cells must stay one row.) */}
503
565
  {!collapsed && <span className="min-w-0 flex-1 break-words leading-snug">{item.label}</span>}
504
- {!collapsed && hasSub && <ChevronRight className="size-3.5 shrink-0 opacity-60" />}
566
+ {!collapsed && hasSub && <ChevronRight className="size-3.5 shrink-0 opacity-60 rtl:-scale-x-100" />}
505
567
  </NavLink>
506
568
  );
507
569
 
508
570
  if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;
509
571
  return collapsed ? (
510
- <Tooltip label={item.label} side="right" portal className="block">
572
+ <Tooltip label={item.label} side="end" portal className="block">
511
573
  {link}
512
574
  </Tooltip>
513
575
  ) : (
@@ -672,11 +734,18 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
672
734
  const wrapperRef = useRef<HTMLDivElement>(null);
673
735
  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
674
736
  const [open, setOpen] = useState(false);
737
+ // The flyout is portalled out of the subtree whose `dir` it inherited; read on open.
738
+ const [dir, setDir] = useState<Direction>("ltr");
675
739
 
676
- // Anchor the flyout to the item's right edge; the hook re-measures on
740
+ // Anchor the flyout to the item's END edge — its right, or its left in RTL, where
741
+ // the sidebar sits on the right of the window. The hook re-measures on
677
742
  // scroll/resize (the nav list can scroll).
678
743
  const rect = useAnchoredRect(wrapperRef, open);
679
- const pos = rect ? { top: rect.top, left: rect.right } : null;
744
+ const pos = rect
745
+ ? dir === "rtl"
746
+ ? { top: rect.top, right: window.innerWidth - rect.left }
747
+ : { top: rect.top, left: rect.right }
748
+ : null;
680
749
 
681
750
  const cancelClose = useCallback(() => {
682
751
  if (closeTimer.current) {
@@ -694,6 +763,7 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
694
763
  cancelClose();
695
764
  if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();
696
765
  activeFlyoutClose = closeNow;
766
+ setDir(dirOf(wrapperRef.current));
697
767
  setOpen(true);
698
768
  }, [cancelClose, closeNow]);
699
769
  const scheduleClose = useCallback(() => {
@@ -729,8 +799,9 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
729
799
  pos &&
730
800
  createPortal(
731
801
  <div
732
- style={{ position: "fixed", top: pos.top, left: pos.left }}
733
- className="z-40 pl-1"
802
+ dir={dir}
803
+ style={{ position: "fixed", ...pos }}
804
+ className="z-40 ps-1"
734
805
  onMouseEnter={cancelClose}
735
806
  onMouseLeave={scheduleClose}
736
807
  >
@@ -60,7 +60,7 @@ export function TopBarActionMenu({
60
60
  <span className="flex w-full items-center gap-2">
61
61
  {entry.icon}
62
62
  {entry.label}
63
- {entry.trailing && <span className="ml-auto pl-2">{entry.trailing}</span>}
63
+ {entry.trailing && <span className="ms-auto ps-2">{entry.trailing}</span>}
64
64
  </span>
65
65
  );
66
66
  return (
@@ -11,9 +11,19 @@ import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels";
11
11
  export const TOPBAR_TRIGGER_CLASS =
12
12
  "size-9 rounded-md flex items-center justify-center text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]";
13
13
 
14
- /** Shared row styling for the top-bar hover-menu items. */
14
+ /** Shared row styling for the top-bar hover-menu items. `text-start`, so a label that
15
+ * wraps aligns to the reading edge in RTL too. */
15
16
  export const TOPBAR_MENU_ITEM_CLASS =
16
- "flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-left text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]";
17
+ "flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-start text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]";
18
+
19
+ /** The small uppercase heading row the top-bar menus share (`heading`). */
20
+ function MenuHeading({ children }: { children: string }) {
21
+ return (
22
+ <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
23
+ {children}
24
+ </li>
25
+ );
26
+ }
17
27
 
18
28
  /** Sun/Moon toggle for the light/dark theme. Controlled: the app owns the mode. */
19
29
  export function ThemeToggle({
@@ -92,11 +102,7 @@ export function PaletteMenu({
92
102
  >
93
103
  {(close) => (
94
104
  <ul className="w-60 py-1">
95
- {heading && (
96
- <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
97
- {heading}
98
- </li>
99
- )}
105
+ {heading && <MenuHeading>{heading}</MenuHeading>}
100
106
  {palettes.map((p) => {
101
107
  const ts = p[mode];
102
108
  const swatches = [ts.bgPage, ts.brand, ts.moneyIncome, ts.moneyExpense, ts.moneyNet];
@@ -123,7 +129,7 @@ export function PaletteMenu({
123
129
  />
124
130
  ))}
125
131
  </span>
126
- <span className="flex min-w-0 flex-col text-left">
132
+ <span className="flex min-w-0 flex-col text-start">
127
133
  <span className="truncate">{p.name}</span>
128
134
  <span className="truncate text-[11px] text-[var(--text-placeholder)]">
129
135
  {p.blurb}
@@ -164,12 +170,16 @@ export function LanguageMenu({
164
170
  current,
165
171
  onChange,
166
172
  ariaLabel,
173
+ heading,
167
174
  }: {
168
175
  options: LanguageOption[];
169
176
  current: string | undefined;
170
177
  onChange: (code: string) => void;
171
178
  /** Default: `topBar.language` from the {@link UiKitProvider}, else English. */
172
179
  ariaLabel?: string;
180
+ /** Optional small heading row above the list — as on `TopBarActionMenu`,
181
+ * `OptionSwitcherMenu` and `PaletteMenu`. */
182
+ heading?: string;
173
183
  }) {
174
184
  const labels = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS, { language: ariaLabel });
175
185
  const active = options.find((o) => o.code === current);
@@ -198,6 +208,7 @@ export function LanguageMenu({
198
208
  >
199
209
  {(close) => (
200
210
  <ul className="py-1">
211
+ {heading && <MenuHeading>{heading}</MenuHeading>}
201
212
  {options.map((lang) => {
202
213
  const isActive = lang.code === current;
203
214
  return (
@@ -8,9 +8,16 @@ import { contrast, parseHex as parseRgbHex } from "./color";
8
8
 
9
9
  const PALETTE_SIZE = 9;
10
10
 
11
- /** CVD-safe categorical colour for series `index`, as a theme-aware CSS var. */
11
+ /** CVD-safe categorical colour for series `index`, as a theme-aware CSS var.
12
+ *
13
+ * Wraps in both directions, the way `strokeDash` does: JS `%` keeps the sign,
14
+ * so `-1 % 9` is `-1` and the plain formula named `--chart-0`, a token that does not
15
+ * exist — a series with no stroke at all. A fractional index is truncated, and one
16
+ * that is not a number (NaN, ±Infinity — an `indexOf` miss divided by something)
17
+ * takes the first colour rather than `var(--chart-NaN)`. */
12
18
  export function paletteFor(index: number): string {
13
- return `var(--chart-${(index % PALETTE_SIZE) + 1})`;
19
+ const i = Number.isFinite(index) ? Math.trunc(index) : 0;
20
+ return `var(--chart-${(((i % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE) + 1})`;
14
21
  }
15
22
 
16
23
  /** Semantic money colours, theme-aware via tokens. Pair with +/- and arrow