@eifi1/ui-kit 0.48.1 → 0.49.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 (318) hide show
  1. package/README.md +28 -13
  2. package/dist/admin/admin-action-confirm.js +1 -1
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/admin/invitations-panel.js +5 -1
  5. package/dist/admin/invitations-panel.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +11 -5
  7. package/dist/admin/user-roster.js +15 -4
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/accept-invitation.js +2 -2
  10. package/dist/auth/accept-invitation.js.map +1 -1
  11. package/dist/auth/not-found-page.js +1 -1
  12. package/dist/auth/not-found-page.js.map +1 -1
  13. package/dist/auth/reset-password-form.js +2 -2
  14. package/dist/auth/reset-password-form.js.map +1 -1
  15. package/dist/auth/verify-email.js +2 -2
  16. package/dist/auth/verify-email.js.map +1 -1
  17. package/dist/billing/plan-card.js +2 -2
  18. package/dist/billing/plan-card.js.map +1 -1
  19. package/dist/components/breadcrumbs.d.ts +19 -1
  20. package/dist/components/breadcrumbs.js +11 -4
  21. package/dist/components/breadcrumbs.js.map +1 -1
  22. package/dist/components/button-look.d.ts +37 -7
  23. package/dist/components/button-look.js +33 -6
  24. package/dist/components/button-look.js.map +1 -1
  25. package/dist/components/button.d.ts +4 -1
  26. package/dist/components/button.js +2 -2
  27. package/dist/components/button.js.map +1 -1
  28. package/dist/components/calendar-heatmap.js +2 -1
  29. package/dist/components/calendar-heatmap.js.map +1 -1
  30. package/dist/components/chart-edge.d.ts +10 -0
  31. package/dist/components/chart-edge.js +57 -0
  32. package/dist/components/chart-edge.js.map +1 -0
  33. package/dist/components/chart.js +2 -1
  34. package/dist/components/chart.js.map +1 -1
  35. package/dist/components/chip.d.ts +8 -1
  36. package/dist/components/chip.js +4 -1
  37. package/dist/components/chip.js.map +1 -1
  38. package/dist/components/confirm-context.d.ts +5 -0
  39. package/dist/components/confirm-context.js +7 -0
  40. package/dist/components/confirm-context.js.map +1 -0
  41. package/dist/components/confirm-dialog.js +3 -3
  42. package/dist/components/confirm-dialog.js.map +1 -1
  43. package/dist/components/danger-confirm.d.ts +2 -2
  44. package/dist/components/danger-confirm.js +1 -1
  45. package/dist/components/danger-confirm.js.map +1 -1
  46. package/dist/components/data-table-fit.d.ts +8 -1
  47. package/dist/components/data-table-fit.js +28 -4
  48. package/dist/components/data-table-fit.js.map +1 -1
  49. package/dist/components/data-table-groups.d.ts +3 -1
  50. package/dist/components/data-table-groups.js +11 -5
  51. package/dist/components/data-table-groups.js.map +1 -1
  52. package/dist/components/data-table-head.d.ts +8 -1
  53. package/dist/components/data-table-head.js +11 -3
  54. package/dist/components/data-table-head.js.map +1 -1
  55. package/dist/components/data-table-leading-row.d.ts +3 -1
  56. package/dist/components/data-table-leading-row.js +2 -1
  57. package/dist/components/data-table-leading-row.js.map +1 -1
  58. package/dist/components/data-table-mobile.d.ts +48 -2
  59. package/dist/components/data-table-mobile.js +87 -10
  60. package/dist/components/data-table-mobile.js.map +1 -1
  61. package/dist/components/data-table-row-actions.d.ts +2 -1
  62. package/dist/components/data-table-row-actions.js +9 -2
  63. package/dist/components/data-table-row-actions.js.map +1 -1
  64. package/dist/components/data-table-row.d.ts +84 -0
  65. package/dist/components/data-table-row.js +261 -0
  66. package/dist/components/data-table-row.js.map +1 -0
  67. package/dist/components/data-table-selection.d.ts +3 -0
  68. package/dist/components/data-table-selection.js +12 -1
  69. package/dist/components/data-table-selection.js.map +1 -1
  70. package/dist/components/data-table-virtual.d.ts +98 -0
  71. package/dist/components/data-table-virtual.js +369 -0
  72. package/dist/components/data-table-virtual.js.map +1 -0
  73. package/dist/components/data-table.d.ts +74 -2
  74. package/dist/components/data-table.js +241 -221
  75. package/dist/components/data-table.js.map +1 -1
  76. package/dist/components/error-boundary.js +2 -2
  77. package/dist/components/error-boundary.js.map +1 -1
  78. package/dist/components/floating-panel.d.ts +36 -13
  79. package/dist/components/floating-panel.js +27 -7
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +1 -1
  82. package/dist/components/form-actions.js +1 -1
  83. package/dist/components/form-actions.js.map +1 -1
  84. package/dist/components/icon-button.d.ts +11 -0
  85. package/dist/components/icon-button.js +7 -5
  86. package/dist/components/icon-button.js.map +1 -1
  87. package/dist/components/nav-pills.d.ts +8 -1
  88. package/dist/components/nav-pills.js +61 -38
  89. package/dist/components/nav-pills.js.map +1 -1
  90. package/dist/components/page-header-context.d.ts +8 -0
  91. package/dist/components/page-header-context.js +7 -0
  92. package/dist/components/page-header-context.js.map +1 -0
  93. package/dist/components/page-header.d.ts +14 -2
  94. package/dist/components/page-header.js +10 -1
  95. package/dist/components/page-header.js.map +1 -1
  96. package/dist/components/pie-chart.js +2 -1
  97. package/dist/components/pie-chart.js.map +1 -1
  98. package/dist/components/progress-bar.js +5 -4
  99. package/dist/components/progress-bar.js.map +1 -1
  100. package/dist/components/reauth-dialog.js +1 -1
  101. package/dist/components/reauth-dialog.js.map +1 -1
  102. package/dist/components/row-actions-labels.js +3 -1
  103. package/dist/components/row-actions-labels.js.map +1 -1
  104. package/dist/components/row-actions.d.ts +43 -9
  105. package/dist/components/row-actions.js +78 -15
  106. package/dist/components/row-actions.js.map +1 -1
  107. package/dist/components/section-nav.d.ts +37 -0
  108. package/dist/components/section-nav.js +33 -0
  109. package/dist/components/section-nav.js.map +1 -0
  110. package/dist/components/series-chart.js +8 -3
  111. package/dist/components/series-chart.js.map +1 -1
  112. package/dist/components/share-card.js +1 -1
  113. package/dist/components/share-card.js.map +1 -1
  114. package/dist/components/signature-pad.js +1 -1
  115. package/dist/components/signature-pad.js.map +1 -1
  116. package/dist/components/sparkline.js +2 -1
  117. package/dist/components/sparkline.js.map +1 -1
  118. package/dist/components/stat-tile.js +14 -7
  119. package/dist/components/stat-tile.js.map +1 -1
  120. package/dist/components/status-dot.d.ts +5 -1
  121. package/dist/components/status-dot.js +4 -1
  122. package/dist/components/status-dot.js.map +1 -1
  123. package/dist/components/tooltip.js +1 -1
  124. package/dist/components/tooltip.js.map +1 -1
  125. package/dist/components/translation-review-editor.js +2 -2
  126. package/dist/components/translation-review-editor.js.map +1 -1
  127. package/dist/components/translation-review.js +2 -2
  128. package/dist/components/translation-review.js.map +1 -1
  129. package/dist/components/treemap.d.ts +5 -2
  130. package/dist/components/treemap.js +26 -6
  131. package/dist/components/treemap.js.map +1 -1
  132. package/dist/components/use-table-state.js +37 -6
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/data-table.d.ts +1 -0
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/demo/demo-banner.js +1 -1
  137. package/dist/demo/demo-banner.js.map +1 -1
  138. package/dist/demo/demo-ended.js +1 -1
  139. package/dist/demo/demo-ended.js.map +1 -1
  140. package/dist/demo/demo-start.js +1 -1
  141. package/dist/demo/demo-start.js.map +1 -1
  142. package/dist/eslint/index.js +1 -1
  143. package/dist/feedback/feedback-inbox.js +1 -1
  144. package/dist/feedback/feedback-inbox.js.map +1 -1
  145. package/dist/hooks/use-anchored-panel.js +2 -1
  146. package/dist/hooks/use-anchored-panel.js.map +1 -1
  147. package/dist/hooks/use-dismiss.d.ts +9 -1
  148. package/dist/hooks/use-dismiss.js +25 -5
  149. package/dist/hooks/use-dismiss.js.map +1 -1
  150. package/dist/hooks/use-document-title.d.ts +14 -0
  151. package/dist/hooks/use-document-title.js +22 -0
  152. package/dist/hooks/use-document-title.js.map +1 -0
  153. package/dist/i18n/german.js +6 -2
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +31 -1
  156. package/dist/i18n/kit-labels.js +43 -2
  157. package/dist/i18n/kit-labels.js.map +1 -1
  158. package/dist/i18n/locales/en.js +6 -2
  159. package/dist/i18n/locales/en.js.map +2 -2
  160. package/dist/i18n/locales/es.js +6 -2
  161. package/dist/i18n/locales/es.js.map +1 -1
  162. package/dist/i18n/locales/fr.js +6 -2
  163. package/dist/i18n/locales/fr.js.map +1 -1
  164. package/dist/i18n/locales/hu.js +6 -2
  165. package/dist/i18n/locales/hu.js.map +1 -1
  166. package/dist/i18n/locales/it.js +6 -2
  167. package/dist/i18n/locales/it.js.map +1 -1
  168. package/dist/i18n/locales/zh.js +6 -2
  169. package/dist/i18n/locales/zh.js.map +1 -1
  170. package/dist/i18n/review.js +2 -0
  171. package/dist/i18n/review.js.map +1 -1
  172. package/dist/i18n/shared-labels.js +1 -0
  173. package/dist/i18n/shared-labels.js.map +1 -1
  174. package/dist/index.d.ts +8 -1
  175. package/dist/index.js +11 -0
  176. package/dist/index.js.map +1 -1
  177. package/dist/landing/landing-actions.js +2 -2
  178. package/dist/landing/landing-actions.js.map +1 -1
  179. package/dist/landing/public-header.js +2 -2
  180. package/dist/landing/public-header.js.map +1 -1
  181. package/dist/lib/clipboard-minus.d.ts +27 -0
  182. package/dist/lib/clipboard-minus.js +63 -0
  183. package/dist/lib/clipboard-minus.js.map +1 -0
  184. package/dist/lib/document-title.d.ts +17 -0
  185. package/dist/lib/document-title.js +29 -0
  186. package/dist/lib/document-title.js.map +1 -0
  187. package/dist/lib/float-clearance.js +8 -2
  188. package/dist/lib/float-clearance.js.map +1 -1
  189. package/dist/lib/format.d.ts +35 -10
  190. package/dist/lib/format.js +55 -11
  191. package/dist/lib/format.js.map +1 -1
  192. package/dist/lib/minus.d.ts +22 -0
  193. package/dist/lib/minus.js +14 -0
  194. package/dist/lib/minus.js.map +1 -0
  195. package/dist/lib/status-tone.d.ts +30 -0
  196. package/dist/lib/status-tone.js +17 -0
  197. package/dist/lib/status-tone.js.map +1 -0
  198. package/dist/settings/settings-layout.js +3 -2
  199. package/dist/settings/settings-layout.js.map +1 -1
  200. package/dist/shell/account-menu-language.d.ts +1 -1
  201. package/dist/shell/account-menu-language.js +9 -1
  202. package/dist/shell/account-menu-language.js.map +1 -1
  203. package/dist/shell/app-shell-labels.js +2 -1
  204. package/dist/shell/app-shell-labels.js.map +1 -1
  205. package/dist/shell/app-shell.d.ts +58 -16
  206. package/dist/shell/app-shell.js +158 -44
  207. package/dist/shell/app-shell.js.map +1 -1
  208. package/dist/shell/language-code.d.ts +3 -0
  209. package/dist/shell/language-code.js +8 -0
  210. package/dist/shell/language-code.js.map +1 -0
  211. package/dist/shell/topbar-controls.d.ts +14 -6
  212. package/dist/shell/topbar-controls.js +14 -17
  213. package/dist/shell/topbar-controls.js.map +1 -1
  214. package/dist/testing/css-variables.generated.d.ts +1 -1
  215. package/dist/testing/css-variables.generated.js +1 -1
  216. package/dist/testing/css-variables.generated.js.map +1 -1
  217. package/dist/theme/chart-palette.d.ts +82 -15
  218. package/dist/theme/chart-palette.js +51 -24
  219. package/dist/theme/chart-palette.js.map +1 -1
  220. package/dist/theme/palette-presets.d.ts +9 -0
  221. package/dist/theme/palette-presets.js +77 -62
  222. package/dist/theme/palette-presets.js.map +1 -1
  223. package/dist/tour/tour.js +1 -1
  224. package/dist/tour/tour.js.map +1 -1
  225. package/dist/wizard/stepper-nav.d.ts +1 -1
  226. package/dist/wizard/stepper-nav.js +3 -3
  227. package/dist/wizard/stepper-nav.js.map +1 -1
  228. package/package.json +1 -1
  229. package/src/admin/admin-action-confirm.tsx +1 -1
  230. package/src/admin/invitations-panel.tsx +7 -3
  231. package/src/admin/user-roster.tsx +23 -6
  232. package/src/auth/accept-invitation.tsx +3 -3
  233. package/src/auth/not-found-page.tsx +1 -1
  234. package/src/auth/reset-password-form.tsx +3 -3
  235. package/src/auth/verify-email.tsx +3 -3
  236. package/src/billing/plan-card.tsx +2 -2
  237. package/src/components/breadcrumbs.tsx +32 -4
  238. package/src/components/button-look.ts +86 -15
  239. package/src/components/button.tsx +6 -3
  240. package/src/components/calendar-heatmap.tsx +2 -1
  241. package/src/components/chart-edge.ts +73 -0
  242. package/src/components/chart.tsx +3 -1
  243. package/src/components/chip.tsx +10 -1
  244. package/src/components/confirm-context.ts +7 -0
  245. package/src/components/confirm-dialog.tsx +3 -3
  246. package/src/components/danger-confirm.tsx +3 -3
  247. package/src/components/data-table-fit.ts +58 -8
  248. package/src/components/data-table-groups.tsx +15 -4
  249. package/src/components/data-table-head.tsx +25 -2
  250. package/src/components/data-table-leading-row.tsx +4 -1
  251. package/src/components/data-table-mobile.tsx +162 -11
  252. package/src/components/data-table-row-actions.tsx +12 -3
  253. package/src/components/data-table-row.tsx +485 -0
  254. package/src/components/data-table-selection.ts +17 -1
  255. package/src/components/data-table-virtual.ts +547 -0
  256. package/src/components/data-table.tsx +367 -326
  257. package/src/components/error-boundary.tsx +2 -2
  258. package/src/components/floating-panel.tsx +67 -16
  259. package/src/components/form-actions.tsx +2 -2
  260. package/src/components/icon-button.tsx +21 -8
  261. package/src/components/nav-pills.tsx +29 -2
  262. package/src/components/page-header-context.ts +8 -0
  263. package/src/components/page-header.tsx +34 -2
  264. package/src/components/pie-chart.tsx +2 -1
  265. package/src/components/progress-bar.tsx +6 -3
  266. package/src/components/reauth-dialog.tsx +1 -1
  267. package/src/components/row-actions-labels.ts +2 -0
  268. package/src/components/row-actions.tsx +154 -33
  269. package/src/components/section-nav.tsx +65 -0
  270. package/src/components/series-chart.tsx +12 -3
  271. package/src/components/share-card.tsx +1 -1
  272. package/src/components/signature-pad.tsx +1 -1
  273. package/src/components/sparkline.tsx +2 -1
  274. package/src/components/stat-tile.tsx +19 -7
  275. package/src/components/status-dot.tsx +7 -1
  276. package/src/components/tooltip.tsx +3 -1
  277. package/src/components/translation-review-editor.tsx +2 -2
  278. package/src/components/translation-review.tsx +2 -2
  279. package/src/components/treemap.tsx +34 -8
  280. package/src/components/use-table-state.ts +63 -8
  281. package/src/data-table.ts +5 -3
  282. package/src/demo/demo-banner.tsx +1 -1
  283. package/src/demo/demo-ended.tsx +1 -1
  284. package/src/demo/demo-start.tsx +1 -1
  285. package/src/feedback/feedback-inbox.tsx +1 -1
  286. package/src/hooks/use-anchored-panel.ts +9 -1
  287. package/src/hooks/use-dismiss.ts +43 -4
  288. package/src/hooks/use-document-title.ts +31 -0
  289. package/src/i18n/german.ts +4 -0
  290. package/src/i18n/kit-labels.tsx +76 -1
  291. package/src/i18n/locales/es.ts +4 -0
  292. package/src/i18n/locales/fr.ts +4 -0
  293. package/src/i18n/locales/hu.ts +4 -0
  294. package/src/i18n/locales/it.ts +4 -0
  295. package/src/i18n/locales/zh.ts +4 -0
  296. package/src/i18n/review.ts +2 -0
  297. package/src/i18n/shared-labels.ts +1 -0
  298. package/src/index.ts +13 -0
  299. package/src/landing/landing-actions.tsx +2 -2
  300. package/src/landing/public-header.tsx +2 -2
  301. package/src/lib/clipboard-minus.ts +94 -0
  302. package/src/lib/document-title.ts +41 -0
  303. package/src/lib/float-clearance.ts +19 -3
  304. package/src/lib/format.ts +115 -30
  305. package/src/lib/minus.ts +40 -0
  306. package/src/lib/status-tone.ts +43 -0
  307. package/src/settings/settings-layout.tsx +3 -2
  308. package/src/shell/account-menu-language.tsx +14 -2
  309. package/src/shell/app-shell-labels.ts +1 -0
  310. package/src/shell/app-shell.tsx +298 -69
  311. package/src/shell/language-code.ts +5 -0
  312. package/src/shell/topbar-controls.tsx +35 -15
  313. package/src/testing/css-variables.generated.ts +6 -6
  314. package/src/theme/chart-palette.ts +102 -20
  315. package/src/theme/palette-presets.ts +121 -84
  316. package/src/tour/tour.tsx +1 -1
  317. package/src/wizard/stepper-nav.tsx +4 -4
  318. package/tokens.css +28 -18
@@ -4,6 +4,7 @@ import { Globe } from "lucide-react";
4
4
  import { Disclosure } from "../components/disclosure";
5
5
  import { MenuItem } from "../components/menu-item";
6
6
  import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels";
7
+ import { languageCodeLabel } from "./language-code";
7
8
 
8
9
  /** One offered language in the account menu's sub-list. */
9
10
  export interface AccountMenuLanguageOption {
@@ -56,7 +57,7 @@ export interface AccountMenuLanguageProps {
56
57
  /**
57
58
  * The language choice INSIDE the account menu (docs/top-bar-harmonization.md §2.3),
58
59
  * keksdose's sub-list: a `Disclosure variant="menu"` row — the globe, "Language", the
59
- * active language's flag and the chevron — that opens in place onto one checked
60
+ * active language's flag and code ("DE") and the chevron — that opens in place onto one checked
60
61
  * `MenuItem` per language. Inline rather than a nested popover: a popover inside a
61
62
  * popover fights the outside click and has nowhere to go on a phone (keksdose #140).
62
63
  *
@@ -87,7 +88,18 @@ export function AccountMenuLanguage({
87
88
  <Globe aria-hidden className="size-4" />
88
89
  {label ?? words.language}
89
90
  </span>
90
- {active && flagOf(active)}
91
+ {active && (
92
+ // The flag alone could not tell de-CH, fr-CH and it-CH apart (one Swiss
93
+ // flag each), so the code sits beside it (0.49 KIT-10); the row's name
94
+ // carries the language's own name.
95
+ <span className="flex items-center gap-1.5">
96
+ {flagOf(active)}
97
+ <span aria-hidden className="text-xs font-semibold tabular-nums tracking-wide text-secondary">
98
+ {languageCodeLabel(active.code)}
99
+ </span>
100
+ <span className="sr-only">{active.label}</span>
101
+ </span>
102
+ )}
91
103
  </span>
92
104
  }
93
105
  >
@@ -7,4 +7,5 @@ import type { AppShellMoreLabels } from "./app-shell";
7
7
  export const DEFAULT_APP_SHELL_MORE_LABELS: AppShellMoreLabels = {
8
8
  more: "More",
9
9
  moreTitle: "More pages",
10
+ overview: "Overview",
10
11
  };
@@ -50,6 +50,19 @@ export interface AppShellNavItem {
50
50
  * from Large up (keksdose dev#613, which read `useTextSize()` in its layout to decide).
51
51
  * Default `"normal"`: the short label at every size, as before. */
52
52
  shortLabelFrom?: TextSize;
53
+ /** 0.49: `shortLabel` is another word for the page, not a shortening of `label` —
54
+ * keksdose's "Activity" for "Transactions". Without it a development build warns once
55
+ * when `shortLabel` does not start with `label`'s first word (an abbreviation of that
56
+ * word, "Trans." for "Transactions", counts), because a bar cell that names a page
57
+ * differently from the sidebar, the title and the palette is a page the reader
58
+ * looks for twice. */
59
+ shortLabelIsAlias?: boolean;
60
+ /** 0.49: which entries sit in the phone bar when they do not all fit
61
+ * ({@link AppShellProps.mobileMaxItems}). Lower first: the entries with a priority take
62
+ * the bar's cells in its order, then the rest in the list's order; the bar still shows
63
+ * its entries in the list's order, and the others go under "More". Unset: the list's
64
+ * order decides (as before). */
65
+ mobilePriority?: number;
53
66
  /** 0.31.0: leave this entry out of the MOBILE BOTTOM BAR; the sidebar keeps it.
54
67
  * For an entry a phone reaches another way — the brand link to the start page —
55
68
  * when one cell fewer is what keeps the others' labels whole (the bar divides its
@@ -65,8 +78,10 @@ export interface AppShellNavItem {
65
78
  dataTour?: string;
66
79
  /** Optional sub-items. When present, hovering (or focusing) the item opens a
67
80
  * flyout to the right of the sidebar with a header (this item's label) and the
68
- * sub-items as icon + title links. Works collapsed or expanded; the mobile
69
- * bottom bar ignores sub-items and links to `to`. */
81
+ * sub-items as icon + title links. Works collapsed or expanded. On the phone (0.49)
82
+ * the bar's cell opens a sheet of them instead of linking to `to` — the sheet lists
83
+ * `to` first as "Overview" when no sub-item is that page — and the pages of the group
84
+ * the reader is in show as the row above the bar (`mobileSubNav`). */
70
85
  items?: AppShellSubItem[];
71
86
  }
72
87
 
@@ -128,22 +143,65 @@ export interface AppShellMoreLabels {
128
143
  more: string;
129
144
  /** The sheet's heading, and so its accessible name. */
130
145
  moreTitle: string;
146
+ /** 0.49: a parent entry's own page in the sheet its bar cell opens, above its pages,
147
+ * when no page is that one — "Overview". */
148
+ overview: string;
131
149
  }
132
150
 
133
151
  // Defined in ./app-shell-labels, which holds no component code (0.34, KS U8).
134
152
  export { DEFAULT_APP_SHELL_MORE_LABELS };
135
153
 
136
154
  /**
137
- * The most entries the phone bar shows before the rest go under "More", per text size
138
- * (§10.7): every entry at Normal, four at Large, three at Extra large. A fifth of a
139
- * 360 px phone at 150 % is 48 px of Normal-sized room, and the cells hold a word each.
155
+ * The most cells the phone bar holds, "More" included, per text size (0.49 KIT-6; 0.32
156
+ * §10.7): five at Normal and Large, four at Extra large. Eight entries are four and
157
+ * "More" at Normal; a fifth of a 390 px phone holds one word of the bar's type, and a
158
+ * sixth no longer does once the type grows. A fifth of a 360 px phone at 150 % is 48 px
159
+ * of Normal-sized room.
160
+ */
161
+ export const DEFAULT_MOBILE_MAX_ITEMS: Readonly<Record<TextSize, number>> = {
162
+ normal: 5,
163
+ large: 5,
164
+ xlarge: 4,
165
+ };
166
+
167
+ /**
168
+ * @deprecated 0.49 — use {@link DEFAULT_MOBILE_MAX_ITEMS} and `AppShell`'s
169
+ * `mobileMaxItems`: the bar is sized in cells, "More" included; removed in 0.51. This is
170
+ * the same default counted as entries before "More" (one fewer).
140
171
  */
141
172
  export const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>> = {
142
- normal: Number.POSITIVE_INFINITY,
143
- large: 4,
144
- xlarge: 3,
173
+ normal: DEFAULT_MOBILE_MAX_ITEMS.normal - 1,
174
+ large: DEFAULT_MOBILE_MAX_ITEMS.large - 1,
175
+ xlarge: DEFAULT_MOBILE_MAX_ITEMS.xlarge - 1,
145
176
  };
146
177
 
178
+ /**
179
+ * The phone bar's cells (0.49 KIT-6): every entry when there are at most `maxItems`,
180
+ * else `maxItems − 1` entries and a "More" cell for the rest. Which entries stay is
181
+ * `priority`'s call — lower first, entries without one after them in the list's order —
182
+ * and both halves keep the list's order. Never fewer than one entry beside More.
183
+ */
184
+ export function pickMobileBar<T>(
185
+ entries: readonly T[],
186
+ maxItems: number,
187
+ priority?: (entry: T) => number | undefined,
188
+ ): { shown: T[]; hidden: T[] } {
189
+ const cells = Math.max(1, Math.floor(maxItems));
190
+ if (entries.length <= cells) return { shown: [...entries], hidden: [] };
191
+ const keep = Math.max(1, cells - 1);
192
+ const rank = (i: number) => priority?.(entries[i]!) ?? Number.POSITIVE_INFINITY;
193
+ const chosen = new Set(
194
+ entries
195
+ .map((_, i) => i)
196
+ .sort((a, b) => rank(a) - rank(b) || a - b)
197
+ .slice(0, keep),
198
+ );
199
+ return {
200
+ shown: entries.filter((_, i) => chosen.has(i)),
201
+ hidden: entries.filter((_, i) => !chosen.has(i)),
202
+ };
203
+ }
204
+
147
205
  /**
148
206
  * Split the bar's entries into the cells shown and the ones under "More", in the app's
149
207
  * order (§10.7: the app puts its four most used first, or orders by role). One hidden
@@ -151,11 +209,87 @@ export const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>> = {
151
209
  * all: the cell count is the same either way, and a real entry beats a detour.
152
210
  */
153
211
  export function splitMobileBar<T>(entries: readonly T[], max: number): { shown: T[]; hidden: T[] } {
212
+ // Entries before More, as 0.32 counted them; {@link pickMobileBar} counts cells.
154
213
  const limit = Math.max(1, Math.floor(max));
155
214
  if (entries.length <= limit + 1) return { shown: [...entries], hidden: [] };
156
215
  return { shown: entries.slice(0, limit), hidden: entries.slice(limit) };
157
216
  }
158
217
 
218
+ /** The bar's cell count at `size`: `mobileMaxItems`, else the deprecated `mobileBarMax`
219
+ * read as cells (one more), else the default. */
220
+ function mobileCells(
221
+ maxItems: AppShellProps["mobileMaxItems"],
222
+ barMax: AppShellProps["mobileBarMax"],
223
+ size: TextSize,
224
+ ): number {
225
+ if (maxItems !== undefined) {
226
+ return typeof maxItems === "number" ? maxItems : (maxItems[size] ?? DEFAULT_MOBILE_MAX_ITEMS[size]);
227
+ }
228
+ if (barMax !== undefined) {
229
+ const entries = typeof barMax === "number" ? barMax : barMax[size];
230
+ if (entries !== undefined) return entries + 1;
231
+ }
232
+ return DEFAULT_MOBILE_MAX_ITEMS[size];
233
+ }
234
+
235
+ /** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
236
+ * production one, never under vitest. Read per call so a test can switch it on. */
237
+ function devWarnings(): boolean {
238
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
239
+ }
240
+
241
+ const warnedNav = new Set<string>();
242
+
243
+ function warnNavOnce(key: string, message: string): void {
244
+ if (warnedNav.has(key)) return;
245
+ warnedNav.add(key);
246
+ console.warn(message);
247
+ }
248
+
249
+ /** The first word of a label, lower-cased: letters and digits up to the first other
250
+ * character ("Import & Export" → "import", "Trans." → "trans"). */
251
+ function firstWord(text: string): string {
252
+ return (text.trim().match(/^[\p{L}\p{N}]+/u)?.[0] ?? "").toLocaleLowerCase();
253
+ }
254
+
255
+ /** Whether `short` names `label`: its first word is `label`'s first word or an
256
+ * abbreviation of it ("Trans." for "Transactions"), or the other way round. */
257
+ function shortLabelNamesLabel(short: string, label: string): boolean {
258
+ const s = firstWord(short);
259
+ const l = firstWord(label);
260
+ return s === "" || l === "" || l.startsWith(s) || s.startsWith(l);
261
+ }
262
+
263
+ const TEXT_SIZE_WORDS: Record<TextSize, string> = { normal: "Normal", large: "Large", xlarge: "Extra large" };
264
+
265
+ /** A development build's notes on `nav` (0.49 KIT-6): a `shortLabel` that is another word
266
+ * than its `label` without `shortLabelIsAlias`, and the deprecated `mobileBarMax`. */
267
+ function useNavDevWarnings(nav: AppShellNavItem[], deprecatedBarMax: boolean): void {
268
+ useEffect(() => {
269
+ if (!devWarnings()) return;
270
+ if (deprecatedBarMax) {
271
+ warnNavOnce(
272
+ "mobileBarMax",
273
+ "[ui-kit] AppShell mobileBarMax is deprecated: use mobileMaxItems, which counts the bar's cells with " +
274
+ '"More" (mobileBarMax={4} is mobileMaxItems={5}). Removed in 0.51.',
275
+ );
276
+ }
277
+ for (const item of nav) {
278
+ if (item.shortLabel === undefined || item.shortLabelIsAlias) continue;
279
+ if (shortLabelNamesLabel(item.shortLabel, item.label)) continue;
280
+ const from = item.shortLabelFrom ?? "normal";
281
+ const where = from === "normal" ? "at every text size" : `from ${TEXT_SIZE_WORDS[from]} text up`;
282
+ warnNavOnce(
283
+ `shortLabel|${item.to}|${item.shortLabel}|${item.label}`,
284
+ `[ui-kit] AppShell nav "${item.to}": shortLabel "${item.shortLabel}" does not start with ` +
285
+ `"${item.label.trim().split(/\s+/)[0]}", the first word of its label "${item.label}". The phone bar ` +
286
+ `shows it ${where}, so the page has another name there than in the sidebar and its title. ` +
287
+ "Shorten the label instead, or pass shortLabelIsAlias: true if the other word is meant.",
288
+ );
289
+ }
290
+ }, [nav, deprecatedBarMax]);
291
+ }
292
+
159
293
  /**
160
294
  * Exported and `<div>`-shaped. This is the outermost element of every consuming app, so
161
295
  * there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —
@@ -222,17 +356,23 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
222
356
  */
223
357
  mobileSubNavLayout?: "wrap" | "scroll";
224
358
  /**
225
- * The most entries the phone bar shows; the rest move into a "More" cell that opens a
226
- * sheet listing them, each group with its pages (0.32, docs/text-size-harmonization.md
227
- * §4, §10.7). The bar takes `nav` in the app's order — after `mobileHidden` — so the
228
- * app puts its most used entries first (or orders by role: kastlan's manager and
229
- * accountant see different firsts).
359
+ * The most cells the phone bar holds, "More" included (0.49 KIT-6). When `nav` (after
360
+ * `mobileHidden`) has more entries, the bar keeps `mobileMaxItems − 1` of them and a
361
+ * last "More" cell opens a sheet with the rest, each group with its pages. Which
362
+ * entries stay is the items' `mobilePriority`, else the list's order (so the app puts
363
+ * its most used entries first, or orders by role: kastlan's manager and accountant see
364
+ * different firsts).
230
365
  *
231
- * Default {@link DEFAULT_MOBILE_BAR_MAX}: unlimited at Normal, 4 at Large, 3 at Extra
232
- * large, because the cells' words grow with the text and the phone does not. A number
233
- * replaces the default at every size; `{ large: 5 }` replaces it at one size only.
234
- * Never fewer than one entry; and one entry over the limit shows in full rather than
235
- * behind a More that would take the same cell.
366
+ * Default {@link DEFAULT_MOBILE_MAX_ITEMS}: 5 at Normal and Large, 4 at Extra large —
367
+ * eight entries are four and "More". A number replaces the default at every size;
368
+ * `{ xlarge: 5 }` replaces it at one size only. Never fewer than one entry beside More.
369
+ */
370
+ mobileMaxItems?: number | Partial<Record<TextSize, number>>;
371
+ /**
372
+ * @deprecated 0.49 — use `mobileMaxItems`, which counts the bar's cells, "More"
373
+ * included: `mobileBarMax={4}` is `mobileMaxItems={5}`; removed in 0.51. Read as that
374
+ * (one more, per size), with a development warning; ignored when `mobileMaxItems` is
375
+ * given.
236
376
  */
237
377
  mobileBarMax?: number | Partial<Record<TextSize, number>>;
238
378
  /** The More cell's words over the provider's `appShellMore`. */
@@ -357,6 +497,7 @@ export function AppShell({
357
497
  subNav = "flyout",
358
498
  mobileSubNav = true,
359
499
  mobileSubNavLayout: mobileSubNavLayoutProp,
500
+ mobileMaxItems,
360
501
  mobileBarMax,
361
502
  moreLabels,
362
503
  toggleGroupLabel,
@@ -370,15 +511,14 @@ export function AppShell({
370
511
  const large = textSize !== "normal";
371
512
  const mobileSubNavLayout = mobileSubNavLayoutProp ?? (large ? "scroll" : "wrap");
372
513
  // The bottom bar's cells: every entry the phone does not reach another way, up to the
373
- // text size's limit; the rest go under More (§10.7).
374
- const barMax =
375
- typeof mobileBarMax === "number"
376
- ? mobileBarMax
377
- : (mobileBarMax?.[textSize] ?? DEFAULT_MOBILE_BAR_MAX[textSize]);
378
- const { shown: barNav, hidden: moreNav } = splitMobileBar(
514
+ // text size's limit, More included; the rest go under More (§10.7, KIT-6).
515
+ const maxItems = mobileCells(mobileMaxItems, mobileBarMax, textSize);
516
+ const { shown: barNav, hidden: moreNav } = pickMobileBar(
379
517
  nav.filter((item) => !item.mobileHidden),
380
- barMax,
518
+ maxItems,
519
+ (item) => item.mobilePriority,
381
520
  );
521
+ useNavDevWarnings(nav, mobileBarMax !== undefined && mobileMaxItems === undefined);
382
522
  // An embedded shell sharing the default key would overwrite the outer app's sidebar
383
523
  // preference with its own — so it persists only when given a key of its own.
384
524
  const collapseStorageKey =
@@ -552,10 +692,12 @@ export function AppShell({
552
692
  {/* Below `md` the bottom room is the nav plus the larger of 1.5rem and what the
553
693
  floating launchers take above it (`--app-float-clearance`, published by
554
694
  every kit FAB, FloatingPanel and FloatingActionGroup while mounted — keksdose
555
- dev#612: the last row ended under the help launcher). */}
695
+ dev#612: the last row ended under the help launcher). From `md` up, where
696
+ there is no nav, the launchers' room alone (0.49 KIT-11): the last row
697
+ scrolls clear of a corner button there too. */}
556
698
  <main
557
699
  className={cn(
558
- "relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+max(1.5rem,var(--app-float-clearance,0px)))] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]",
700
+ "relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+max(1.5rem,var(--app-float-clearance,0px)))] md:pb-[var(--app-float-clearance,0px)] md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]",
559
701
  // The list page's fill (0.40, docs/page-layout-harmonization.md §1.3): while
560
702
  // a `fillHeight` DataTable is inside, a flex column — a banner above the
561
703
  // page keeps its own height and the page (a `FillHeight`) takes the rest,
@@ -564,7 +706,13 @@ export function AppShell({
564
706
  // into the rest from a zero basis, and a window too short for the page's
565
707
  // floor scrolls `<main>`. Every other page lays out as before. Below `md`
566
708
  // the document scrolls and nothing fills.
567
- "md:has-[[data-fill-height]]:flex md:has-[[data-fill-height]]:flex-col md:has-[[data-fill-height]]:*:shrink-0",
709
+ // The children's `shrink-0` carries no `:has`: `flex-shrink` does nothing
710
+ // outside a flex container, and a `:has(…) > *` rule cost a style recalc of
711
+ // the whole page on every DOM insertion under it (17–45 ms per scrolled step
712
+ // of a `virtualize` table, 0.48.2). `[&&>*]` doubles the class, so it stays
713
+ // as specific as FillHeight's `has-[…]:flex-1` (whose `flex: 1` would set
714
+ // shrink 1) and, emitted after it, still wins.
715
+ "md:has-[[data-fill-height]]:flex md:has-[[data-fill-height]]:flex-col md:[&&>*]:shrink-0",
568
716
  // Embedded there is no document scroll to lean on below `md` either.
569
717
  embedded && "min-h-0 overflow-y-auto",
570
718
  )}
@@ -591,9 +739,13 @@ export function AppShell({
591
739
  className="grid"
592
740
  style={{ gridTemplateColumns: `repeat(${barNav.length + (moreNav.length ? 1 : 0)}, minmax(0, 1fr))` }}
593
741
  >
594
- {barNav.map((item) => (
595
- <MobileNavItem key={item.to} item={item} />
596
- ))}
742
+ {barNav.map((item) =>
743
+ item.items?.length ? (
744
+ <MobileGroupCell key={item.to} item={item} labels={moreLabels} />
745
+ ) : (
746
+ <MobileNavItem key={item.to} item={item} />
747
+ ),
748
+ )}
597
749
  {moreNav.length > 0 && <MobileMoreCell hidden={moreNav} labels={moreLabels} />}
598
750
  </nav>
599
751
  </div>
@@ -773,11 +925,7 @@ function MobileCellLabel({ children }: { children: ReactNode }) {
773
925
  * nothing marked on every sub-page (the same defect the sidebar had). */
774
926
  function MobileNavItem({ item }: { item: AppShellNavItem }) {
775
927
  const subActive = useSubItemActive(item);
776
- const { size } = useTextSize();
777
- const short =
778
- item.shortLabel !== undefined && TEXT_SIZES.indexOf(size) >= TEXT_SIZES.indexOf(item.shortLabelFrom ?? "normal")
779
- ? item.shortLabel
780
- : undefined;
928
+ const label = useCellLabel(item);
781
929
  return (
782
930
  <NavLink
783
931
  to={item.to}
@@ -788,11 +936,122 @@ function MobileNavItem({ item }: { item: AppShellNavItem }) {
788
936
  <span className="relative">
789
937
  <item.icon className="size-5" />
790
938
  </span>
791
- <MobileCellLabel>{short ?? item.label}</MobileCellLabel>
939
+ <MobileCellLabel>{label}</MobileCellLabel>
940
+ </NavLink>
941
+ );
942
+ }
943
+
944
+ /** The bar cell's label: `shortLabel` from its text size up (0.48), else `label`. */
945
+ function useCellLabel(item: AppShellNavItem): string {
946
+ const { size } = useTextSize();
947
+ return item.shortLabel !== undefined &&
948
+ TEXT_SIZES.indexOf(size) >= TEXT_SIZES.indexOf(item.shortLabelFrom ?? "normal")
949
+ ? item.shortLabel
950
+ : item.label;
951
+ }
952
+
953
+ /** A link in one of the phone's sheets — an entry (`level="entry"`) or a page under one. */
954
+ function SheetLink({
955
+ to,
956
+ end,
957
+ icon: Icon,
958
+ label,
959
+ onClick,
960
+ level,
961
+ }: {
962
+ to: string;
963
+ end?: boolean;
964
+ icon: LucideIcon;
965
+ label: string;
966
+ onClick: () => void;
967
+ level: "entry" | "page";
968
+ }) {
969
+ return (
970
+ <NavLink
971
+ to={to}
972
+ end={end ?? true}
973
+ onClick={onClick}
974
+ className={({ isActive }) =>
975
+ cn(
976
+ level === "entry"
977
+ ? "flex min-h-11 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors"
978
+ : "flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
979
+ isActive ? "bg-inverse text-inverse" : level === "entry" ? "text-primary hover:bg-hover" : "text-secondary hover:bg-hover",
980
+ )
981
+ }
982
+ >
983
+ <Icon aria-hidden className={level === "entry" ? "size-5 shrink-0" : "size-4 shrink-0 opacity-70"} />
984
+ <span className="min-w-0 break-words leading-snug">{label}</span>
792
985
  </NavLink>
793
986
  );
794
987
  }
795
988
 
989
+ /**
990
+ * A bar cell for an entry with pages (0.49 KIT-6): on a phone the entry opens a sheet of
991
+ * its pages instead of linking to its own `to` — a tap on "Reports" used to land on the
992
+ * overview with the pages one more step away, in the row above the bar, which only
993
+ * appears once you are in the group. The sheet lists the entry's own page first, as
994
+ * "Overview", when none of the pages is that page. Marked while the entry's page or one
995
+ * of its pages is current, as the link was.
996
+ *
997
+ * Tours find the entry by `a[href]` or its `dataTour`, as on the More cell: an inert,
998
+ * pointer-transparent anchor over the button carries them (see {@link MobileMoreCell}).
999
+ */
1000
+ function MobileGroupCell({
1001
+ item,
1002
+ labels: labelsProp,
1003
+ }: {
1004
+ item: AppShellNavItem;
1005
+ labels?: LabelOverride<AppShellMoreLabels>;
1006
+ }) {
1007
+ const labels: AppShellMoreLabels = useKitLabels("appShellMore", DEFAULT_APP_SHELL_MORE_LABELS, labelsProp);
1008
+ const { pathname } = useLocation();
1009
+ const [open, setOpen] = useState(false);
1010
+ const label = useCellLabel(item);
1011
+ const active = entryIsCurrent(item, pathname);
1012
+ const close = () => setOpen(false);
1013
+ const pages = item.items ?? [];
1014
+ const ownPageListed = pages.some((sub) => sub.to === item.to);
1015
+ return (
1016
+ <div data-slot="app-shell-group" className="relative min-w-0">
1017
+ <button
1018
+ type="button"
1019
+ aria-haspopup="dialog"
1020
+ aria-expanded={open}
1021
+ // The cell may show a shortLabel; the button's name is the entry's.
1022
+ aria-label={label === item.label ? undefined : item.label}
1023
+ onClick={() => setOpen(true)}
1024
+ className={mobileCellClass(active)}
1025
+ >
1026
+ <span className="relative">
1027
+ <item.icon aria-hidden className="size-5" />
1028
+ </span>
1029
+ <MobileCellLabel>{label}</MobileCellLabel>
1030
+ </button>
1031
+ <TourAnchor to={item.to} dataTour={item.dataTour} />
1032
+ {pages
1033
+ .filter((sub) => sub.to !== item.to)
1034
+ .map((sub) => (
1035
+ <TourAnchor key={sub.to} to={sub.to} />
1036
+ ))}
1037
+ <DialogFrame open={open} onClose={close} title={item.label} closeButton bodyClassName="space-y-1">
1038
+ <ul className="space-y-1">
1039
+ {!ownPageListed && (
1040
+ <li>
1041
+ <SheetLink to={item.to} end={item.end} icon={item.icon} label={labels.overview} onClick={close} level="entry" />
1042
+ </li>
1043
+ )}
1044
+ {pages.map((sub) => (
1045
+ <li key={sub.to}>
1046
+ <SheetLink to={sub.to} end={sub.end} icon={sub.icon} label={sub.label} onClick={close} level="entry" />
1047
+ </li>
1048
+ ))}
1049
+ </ul>
1050
+ </DialogFrame>
1051
+ </div>
1052
+ );
1053
+ }
1054
+
796
1055
  /** Whether the current route is `item`'s page or one of its pages. */
797
1056
  function entryIsCurrent(item: AppShellNavItem, pathname: string): boolean {
798
1057
  return (
@@ -868,42 +1127,12 @@ function MobileMoreCell({
868
1127
  <ul className="space-y-1">
869
1128
  {hidden.map((item) => (
870
1129
  <li key={item.to}>
871
- <NavLink
872
- to={item.to}
873
- end={item.end ?? true}
874
- onClick={close}
875
- className={({ isActive }) =>
876
- cn(
877
- "flex min-h-11 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
878
- isActive
879
- ? "bg-inverse text-inverse"
880
- : "text-primary hover:bg-hover",
881
- )
882
- }
883
- >
884
- <item.icon aria-hidden className="size-5 shrink-0" />
885
- <span className="min-w-0 break-words leading-snug">{item.label}</span>
886
- </NavLink>
1130
+ <SheetLink to={item.to} end={item.end} icon={item.icon} label={item.label} onClick={close} level="entry" />
887
1131
  {!!item.items?.length && (
888
1132
  <ul aria-label={item.label} className="ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-subtle ps-2">
889
1133
  {item.items.map((sub) => (
890
1134
  <li key={sub.to}>
891
- <NavLink
892
- to={sub.to}
893
- end={sub.end ?? true}
894
- onClick={close}
895
- className={({ isActive }) =>
896
- cn(
897
- "flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
898
- isActive
899
- ? "bg-inverse text-inverse"
900
- : "text-secondary hover:bg-hover",
901
- )
902
- }
903
- >
904
- <sub.icon aria-hidden className="size-4 shrink-0 opacity-70" />
905
- <span className="min-w-0 break-words leading-snug">{sub.label}</span>
906
- </NavLink>
1135
+ <SheetLink to={sub.to} end={sub.end} icon={sub.icon} label={sub.label} onClick={close} level="page" />
907
1136
  </li>
908
1137
  ))}
909
1138
  </ul>
@@ -0,0 +1,5 @@
1
+ /** "de-CH" → "DE": the language subtag in capitals, the code the language triggers show
2
+ * (0.49 KIT-10). Internal. */
3
+ export function languageCodeLabel(code: string): string {
4
+ return code.split(/[-_]/)[0].toUpperCase();
5
+ }
@@ -5,6 +5,7 @@ import type { PalettePreset } from "../theme/palette-presets";
5
5
  import type { ThemeMode } from "../theme/theme-store";
6
6
  import { Tooltip } from "../components/tooltip";
7
7
  import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels";
8
+ import { languageCodeLabel } from "./language-code";
8
9
 
9
10
  /** Shared square icon-button styling for top-bar triggers, so app-owned controls
10
11
  * (account/feedback menus) line up with the shared ones. */
@@ -179,33 +180,51 @@ export interface LanguageOption {
179
180
  country: string;
180
181
  }
181
182
 
183
+ /** What {@link LanguageMenu}'s trigger shows of the active language (0.49 KIT-10). */
184
+ export type LanguageMenuTrigger = "flag+code" | "code" | "flag";
185
+
186
+ /** The trigger with a code: as tall as the others, as wide as flag and code need. */
187
+ const LANGUAGE_TRIGGER_WIDE_CLASS = TOPBAR_TRIGGER_CLASS.replace("size-9", "h-9 min-w-9 gap-1.5 px-2");
188
+
189
+ const FLAG_CLASS =
190
+ "inline-block h-[0.9375rem] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
191
+
182
192
  /** Language switcher: a hover menu of options with flags and a check on the
183
193
  * active one. Controlled — the app owns the current code and change handler.
184
194
  *
185
- * The trigger is the *active* option's flag, not an icon: lucide's `Languages`
195
+ * The trigger is the *active* option's flag and its language code in capitals
196
+ * ("DE", "FR", "IT"; `trigger`, 0.49 KIT-10), not an icon: lucide's `Languages`
186
197
  * glyph (文A) is the same mark Chrome puts in the omnibox for "translate this
187
198
  * page", so it read as machine-translation rather than "pick the UI language"
188
- * (Keksdose feedback #121). The flag also shows which language is currently on,
189
- * which no icon can, and costs the same width on a crowded mobile bar. A globe
190
- * covers the case where `current` matches nothing in `options`. */
199
+ * (Keksdose feedback #121). A flag alone could not tell de-CH, fr-CH and it-CH
200
+ * apart — one Swiss flag each — so the code is beside it. It is named "Choose
201
+ * language, current: Deutsch" (`topBar.languageCurrent`). A globe covers the
202
+ * case where `current` matches nothing in `options`. */
191
203
  export function LanguageMenu({
192
204
  options,
193
205
  current,
194
206
  onChange,
195
207
  "aria-label": ariaLabel,
196
208
  heading,
209
+ trigger = "flag+code",
197
210
  }: {
198
211
  options: LanguageOption[];
199
212
  current: string | undefined;
200
213
  onChange: (code: string) => void;
201
- /** Default: `topBar.language` from the {@link UiKitProvider}, else English. */
214
+ /** The menu's name, and the trigger's as given. Default: the menu `topBar.language`,
215
+ * the trigger `topBar.languageCurrent(<the active label>)`, from the
216
+ * {@link UiKitProvider}, else English. */
202
217
  "aria-label"?: string;
203
218
  /** Optional small heading row above the list — as on `TopBarActionMenu`,
204
219
  * `OptionSwitcherMenu` and `PaletteMenu`. */
205
220
  heading?: string;
221
+ /** What the trigger shows: `"flag+code"` (default), `"code"` or `"flag"`. */
222
+ trigger?: LanguageMenuTrigger;
206
223
  }) {
207
224
  const labels = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS, { language: ariaLabel });
208
225
  const active = options.find((o) => o.code === current);
226
+ const triggerName = ariaLabel ?? (active ? labels.languageCurrent(active.label) : labels.language);
227
+ const showsCode = active !== undefined && trigger !== "flag";
209
228
  return (
210
229
  <HoverMenu
211
230
  aria-label={labels.language}
@@ -214,14 +233,18 @@ export function LanguageMenu({
214
233
  <button
215
234
  type="button"
216
235
  onClick={toggle}
217
- aria-label={labels.language}
218
- className={TOPBAR_TRIGGER_CLASS}
236
+ aria-label={triggerName}
237
+ className={showsCode ? LANGUAGE_TRIGGER_WIDE_CLASS : TOPBAR_TRIGGER_CLASS}
219
238
  >
220
239
  {active ? (
221
- <span
222
- aria-hidden
223
- className={`fi fi-${active.country} inline-block h-[0.9375rem] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]`}
224
- />
240
+ <>
241
+ {trigger !== "code" && <span aria-hidden className={`fi fi-${active.country} ${FLAG_CLASS}`} />}
242
+ {showsCode && (
243
+ <span aria-hidden className="text-xs font-semibold tabular-nums tracking-wide">
244
+ {languageCodeLabel(active.code)}
245
+ </span>
246
+ )}
247
+ </>
225
248
  ) : (
226
249
  <Globe className="size-5" />
227
250
  )}
@@ -245,10 +268,7 @@ export function LanguageMenu({
245
268
  className={TOPBAR_MENU_ITEM_CLASS}
246
269
  >
247
270
  <span className="flex items-center gap-2">
248
- <span
249
- aria-hidden
250
- className={`fi fi-${lang.country} inline-block h-[0.9375rem] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]`}
251
- />
271
+ <span aria-hidden className={`fi fi-${lang.country} ${FLAG_CLASS}`} />
252
272
  {lang.label}
253
273
  </span>
254
274
  {isActive && <Check className="size-4 text-secondary" />}