@eifi1/ui-kit 0.51.2 → 0.51.4

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 (96) hide show
  1. package/README.md +5 -5
  2. package/dist/components/combobox-core.js +4 -5
  3. package/dist/components/combobox-core.js.map +1 -1
  4. package/dist/components/currency-option-list.js +3 -3
  5. package/dist/components/currency-option-list.js.map +1 -1
  6. package/dist/components/data-table-fit.js +7 -5
  7. package/dist/components/data-table-fit.js.map +1 -1
  8. package/dist/components/data-table-head.js +5 -1
  9. package/dist/components/data-table-head.js.map +1 -1
  10. package/dist/components/data-table.d.ts +8 -0
  11. package/dist/components/data-table.js.map +1 -1
  12. package/dist/components/grouped-picker.js +5 -4
  13. package/dist/components/grouped-picker.js.map +1 -1
  14. package/dist/components/icon-picker.js +5 -4
  15. package/dist/components/icon-picker.js.map +1 -1
  16. package/dist/components/inline-entity-combobox.js +4 -4
  17. package/dist/components/inline-entity-combobox.js.map +1 -1
  18. package/dist/components/legal-labels.js +9 -2
  19. package/dist/components/legal-labels.js.map +1 -1
  20. package/dist/components/legal-page.d.ts +35 -3
  21. package/dist/components/legal-page.js +22 -0
  22. package/dist/components/legal-page.js.map +1 -1
  23. package/dist/components/legal.d.ts +49 -5
  24. package/dist/components/legal.js +31 -3
  25. package/dist/components/legal.js.map +1 -1
  26. package/dist/components/list-labels.js +3 -1
  27. package/dist/components/list-labels.js.map +1 -1
  28. package/dist/components/list.d.ts +18 -1
  29. package/dist/components/list.js +32 -3
  30. package/dist/components/list.js.map +1 -1
  31. package/dist/eslint/index.js +1 -1
  32. package/dist/feedback/feedback-table.d.ts +3 -1
  33. package/dist/feedback/feedback-table.js +18 -4
  34. package/dist/feedback/feedback-table.js.map +1 -1
  35. package/dist/i18n/german.js +12 -3
  36. package/dist/i18n/german.js.map +1 -1
  37. package/dist/i18n/locales/en.js +16 -3
  38. package/dist/i18n/locales/en.js.map +2 -2
  39. package/dist/i18n/locales/es.js +12 -3
  40. package/dist/i18n/locales/es.js.map +1 -1
  41. package/dist/i18n/locales/fr.js +12 -3
  42. package/dist/i18n/locales/fr.js.map +1 -1
  43. package/dist/i18n/locales/hu.js +12 -3
  44. package/dist/i18n/locales/hu.js.map +1 -1
  45. package/dist/i18n/locales/it.js +12 -3
  46. package/dist/i18n/locales/it.js.map +1 -1
  47. package/dist/i18n/locales/zh.js +12 -3
  48. package/dist/i18n/locales/zh.js.map +1 -1
  49. package/dist/i18n/review.js +4 -0
  50. package/dist/i18n/review.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/lib/text-match.d.ts +16 -0
  54. package/dist/lib/text-match.js +11 -1
  55. package/dist/lib/text-match.js.map +1 -1
  56. package/dist/settings/use-settings-focus.d.ts +7 -0
  57. package/dist/settings/use-settings-focus.js +2 -0
  58. package/dist/settings/use-settings-focus.js.map +1 -1
  59. package/dist/shell/dev-menu.d.ts +5 -0
  60. package/dist/shell/dev-menu.js +2 -1
  61. package/dist/shell/dev-menu.js.map +1 -1
  62. package/dist/shell/topbar-action-menu.d.ts +14 -1
  63. package/dist/shell/topbar-action-menu.js +85 -34
  64. package/dist/shell/topbar-action-menu.js.map +1 -1
  65. package/dist/shell.d.ts +1 -1
  66. package/dist/shell.js +2 -1
  67. package/dist/shell.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/components/combobox-core.tsx +5 -5
  70. package/src/components/currency-option-list.tsx +4 -5
  71. package/src/components/data-table-fit.ts +7 -5
  72. package/src/components/data-table-head.tsx +5 -1
  73. package/src/components/data-table.tsx +8 -0
  74. package/src/components/grouped-picker.tsx +15 -8
  75. package/src/components/icon-picker.tsx +6 -4
  76. package/src/components/inline-entity-combobox.tsx +5 -4
  77. package/src/components/legal-labels.ts +8 -1
  78. package/src/components/legal-page.tsx +72 -2
  79. package/src/components/legal.tsx +80 -5
  80. package/src/components/list-labels.ts +2 -0
  81. package/src/components/list.tsx +52 -2
  82. package/src/feedback/feedback-table.tsx +17 -1
  83. package/src/i18n/german.ts +10 -1
  84. package/src/i18n/locales/en.ts +4 -0
  85. package/src/i18n/locales/es.ts +10 -1
  86. package/src/i18n/locales/fr.ts +10 -1
  87. package/src/i18n/locales/hu.ts +10 -1
  88. package/src/i18n/locales/it.ts +10 -1
  89. package/src/i18n/locales/zh.ts +10 -1
  90. package/src/i18n/review.ts +4 -0
  91. package/src/index.ts +1 -0
  92. package/src/lib/text-match.ts +30 -0
  93. package/src/settings/use-settings-focus.ts +8 -0
  94. package/src/shell/dev-menu.tsx +6 -0
  95. package/src/shell/topbar-action-menu.tsx +122 -38
  96. package/src/shell.ts +1 -1
@@ -8,6 +8,14 @@ function foldForSearch(text, locale) {
8
8
  }
9
9
  return lower.normalize("NFD").replace(/\p{M}+/gu, "").replace(/ß/g, "ss");
10
10
  }
11
+ function searchWords(query, locale) {
12
+ return foldForSearch(query, locale).split(/\s+/).filter((word) => word !== "");
13
+ }
14
+ function matchesSearch(words, texts, locale) {
15
+ if (words.length === 0) return true;
16
+ const hay = texts.filter((t) => t !== void 0).map((t) => foldForSearch(t, locale)).join("\n");
17
+ return words.every((word) => hay.includes(word));
18
+ }
11
19
  function rankMatch(label, query, { code, locale } = {}) {
12
20
  const q = foldForSearch(query.trim(), locale);
13
21
  if (!q) return 0;
@@ -18,6 +26,8 @@ function rankMatch(label, query, { code, locale } = {}) {
18
26
  }
19
27
  export {
20
28
  foldForSearch,
21
- rankMatch
29
+ matchesSearch,
30
+ rankMatch,
31
+ searchWords
22
32
  };
23
33
  //# sourceMappingURL=text-match.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/text-match.ts"],"sourcesContent":["/**\n * How typing into a kit list matches its rows — one rule for every list (DUP-5, refactor\n * audit 2026-10).\n *\n * There were six. \"zurich\" found Zürich in CountrySelect and the command palette and in no\n * EntityCombobox, Combobox, MultiSelect or DataTable text filter, where keksdose's\n * Swiss-German payees and categories and kastlan's eighteen entity pickers live; \"İ\"\n * folded by the locale in GroupedPicker and nowhere in the combobox family. The folding\n * only ever widens a match, so moving every list onto it took nothing away.\n */\n\n/**\n * A text as search compares it: the locale's lower case, accents off, ß spelt \"ss\" —\n * \"Zürich\" and \"ZURICH\" both read \"zurich\", \"Straße\" reads \"strasse\". Punctuation and\n * symbols stay: a currency is found by its \"€\", and \"c++\" is not the empty query.\n *\n * Lower-cased by the locale FIRST, then decomposed: the Turkish \"İ\" lower-cases to \"i\"\n * and the dotted capital's mark is then dropped like any other, so \"istanbul\" finds\n * \"İSTANBUL\" in every locale, while \"I\" folds to the dotless \"ı\" in Turkish alone.\n * `locale` left out is the runtime's default; a malformed tag folds as if left out.\n */\nexport function foldForSearch(text: string, locale?: string): string {\n let lower: string;\n try {\n lower = text.toLocaleLowerCase(locale);\n } catch {\n lower = text.toLocaleLowerCase();\n }\n return lower.normalize(\"NFD\").replace(/\\p{M}+/gu, \"\").replace(/ß/g, \"ss\");\n}\n\n/** {@link rankMatch}'s answer: lower is better, `-1` is no match. */\nexport type MatchRank = -1 | 0 | 1 | 2;\n\n/**\n * How well `label` answers `query` — CountrySelect's ranking, for every list that orders\n * its hits:\n *\n * - `0`: the query IS the row — its `code` (\"ch\" means Switzerland before it means Chad)\n * or the whole label;\n * - `1`: the label starts with it;\n * - `2`: the label contains it;\n * - `-1`: no match.\n *\n * Both sides are folded here ({@link foldForSearch}, by `locale`), so pass them as typed\n * and as shown. An empty query matches every row alike (`0`). Sort by the rank with a\n * stable sort, and rows of one rank keep the order they came in.\n */\nexport function rankMatch(\n label: string,\n query: string,\n { code, locale }: { code?: string; locale?: string } = {},\n): MatchRank {\n const q = foldForSearch(query.trim(), locale);\n if (!q) return 0;\n const text = foldForSearch(label, locale);\n if (text === q || (code !== undefined && foldForSearch(code, locale) === q)) return 0;\n if (text.startsWith(q)) return 1;\n return text.includes(q) ? 2 : -1;\n}\n"],"mappings":";AAqBO,SAAS,cAAc,MAAc,QAAyB;AACnE,MAAI;AACJ,MAAI;AACF,YAAQ,KAAK,kBAAkB,MAAM;AAAA,EACvC,QAAQ;AACN,YAAQ,KAAK,kBAAkB;AAAA,EACjC;AACA,SAAO,MAAM,UAAU,KAAK,EAAE,QAAQ,YAAY,EAAE,EAAE,QAAQ,MAAM,IAAI;AAC1E;AAmBO,SAAS,UACd,OACA,OACA,EAAE,MAAM,OAAO,IAAwC,CAAC,GAC7C;AACX,QAAM,IAAI,cAAc,MAAM,KAAK,GAAG,MAAM;AAC5C,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,OAAO,cAAc,OAAO,MAAM;AACxC,MAAI,SAAS,KAAM,SAAS,UAAa,cAAc,MAAM,MAAM,MAAM,EAAI,QAAO;AACpF,MAAI,KAAK,WAAW,CAAC,EAAG,QAAO;AAC/B,SAAO,KAAK,SAAS,CAAC,IAAI,IAAI;AAChC;","names":[]}
1
+ {"version":3,"sources":["../../src/lib/text-match.ts"],"sourcesContent":["/**\n * How typing into a kit list matches its rows — one rule for every list (DUP-5, refactor\n * audit 2026-10).\n *\n * There were six. \"zurich\" found Zürich in CountrySelect and the command palette and in no\n * EntityCombobox, Combobox, MultiSelect or DataTable text filter, where keksdose's\n * Swiss-German payees and categories and kastlan's eighteen entity pickers live; \"İ\"\n * folded by the locale in GroupedPicker and nowhere in the combobox family. The folding\n * only ever widens a match, so moving every list onto it took nothing away.\n */\n\n/**\n * A text as search compares it: the locale's lower case, accents off, ß spelt \"ss\" —\n * \"Zürich\" and \"ZURICH\" both read \"zurich\", \"Straße\" reads \"strasse\". Punctuation and\n * symbols stay: a currency is found by its \"€\", and \"c++\" is not the empty query.\n *\n * Lower-cased by the locale FIRST, then decomposed: the Turkish \"İ\" lower-cases to \"i\"\n * and the dotted capital's mark is then dropped like any other, so \"istanbul\" finds\n * \"İSTANBUL\" in every locale, while \"I\" folds to the dotless \"ı\" in Turkish alone.\n * `locale` left out is the runtime's default; a malformed tag folds as if left out.\n */\nexport function foldForSearch(text: string, locale?: string): string {\n let lower: string;\n try {\n lower = text.toLocaleLowerCase(locale);\n } catch {\n lower = text.toLocaleLowerCase();\n }\n return lower.normalize(\"NFD\").replace(/\\p{M}+/gu, \"\").replace(/ß/g, \"ss\");\n}\n\n/**\n * A picker's query as its words, each folded (0.51.4, keksdose's /prices shop picker):\n * \"Migros Zürich\" → `[\"migros\", \"zurich\"]`. An empty or blank query has no words.\n */\nexport function searchWords(query: string, locale?: string): string[] {\n return foldForSearch(query, locale).split(/\\s+/).filter((word) => word !== \"\");\n}\n\n/**\n * Whether a row matches a picker's query: EVERY word appears in at least one of the row's\n * texts — \"migros 8004\" finds a row named \"Migros · 8004 Zürich\", and a row whose chain is\n * its group and whose place is its label (pass both). Until 0.51.4 the whole query had to\n * be ONE substring of one text, so two words found nothing unless they stood side by side\n * in that order. One word matches exactly as before, and so does the old query when it\n * was a substring. No words match everything.\n *\n * `words` from {@link searchWords}; `texts` as they are (folded here), `undefined` skipped.\n */\nexport function matchesSearch(\n words: readonly string[],\n texts: readonly (string | undefined)[],\n locale?: string,\n): boolean {\n if (words.length === 0) return true;\n // One haystack per row; a word holds no whitespace, so it cannot match across the\n // newline between two texts.\n const hay = texts.filter((t): t is string => t !== undefined).map((t) => foldForSearch(t, locale)).join(\"\\n\");\n return words.every((word) => hay.includes(word));\n}\n\n/** {@link rankMatch}'s answer: lower is better, `-1` is no match. */\nexport type MatchRank = -1 | 0 | 1 | 2;\n\n/**\n * How well `label` answers `query` — CountrySelect's ranking, for every list that orders\n * its hits:\n *\n * - `0`: the query IS the row — its `code` (\"ch\" means Switzerland before it means Chad)\n * or the whole label;\n * - `1`: the label starts with it;\n * - `2`: the label contains it;\n * - `-1`: no match.\n *\n * Both sides are folded here ({@link foldForSearch}, by `locale`), so pass them as typed\n * and as shown. An empty query matches every row alike (`0`). Sort by the rank with a\n * stable sort, and rows of one rank keep the order they came in.\n */\nexport function rankMatch(\n label: string,\n query: string,\n { code, locale }: { code?: string; locale?: string } = {},\n): MatchRank {\n const q = foldForSearch(query.trim(), locale);\n if (!q) return 0;\n const text = foldForSearch(label, locale);\n if (text === q || (code !== undefined && foldForSearch(code, locale) === q)) return 0;\n if (text.startsWith(q)) return 1;\n return text.includes(q) ? 2 : -1;\n}\n"],"mappings":";AAqBO,SAAS,cAAc,MAAc,QAAyB;AACnE,MAAI;AACJ,MAAI;AACF,YAAQ,KAAK,kBAAkB,MAAM;AAAA,EACvC,QAAQ;AACN,YAAQ,KAAK,kBAAkB;AAAA,EACjC;AACA,SAAO,MAAM,UAAU,KAAK,EAAE,QAAQ,YAAY,EAAE,EAAE,QAAQ,MAAM,IAAI;AAC1E;AAMO,SAAS,YAAY,OAAe,QAA2B;AACpE,SAAO,cAAc,OAAO,MAAM,EAAE,MAAM,KAAK,EAAE,OAAO,CAAC,SAAS,SAAS,EAAE;AAC/E;AAYO,SAAS,cACd,OACA,OACA,QACS;AACT,MAAI,MAAM,WAAW,EAAG,QAAO;AAG/B,QAAM,MAAM,MAAM,OAAO,CAAC,MAAmB,MAAM,MAAS,EAAE,IAAI,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,EAAE,KAAK,IAAI;AAC5G,SAAO,MAAM,MAAM,CAAC,SAAS,IAAI,SAAS,IAAI,CAAC;AACjD;AAmBO,SAAS,UACd,OACA,OACA,EAAE,MAAM,OAAO,IAAwC,CAAC,GAC7C;AACX,QAAM,IAAI,cAAc,MAAM,KAAK,GAAG,MAAM;AAC5C,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,OAAO,cAAc,OAAO,MAAM;AACxC,MAAI,SAAS,KAAM,SAAS,UAAa,cAAc,MAAM,MAAM,MAAM,EAAI,QAAO;AACpF,MAAI,KAAK,WAAW,CAAC,EAAG,QAAO;AAC/B,SAAO,KAAK,SAAS,CAAC,IAAI,IAAI;AAChC;","names":[]}
@@ -30,3 +30,10 @@ export declare const SETTINGS_FOCUS_MS = 1800;
30
30
  * as it is.
31
31
  */
32
32
  export declare function useSettingsFocus(anchor?: string | null): void;
33
+ /**
34
+ * {@link useSettingsFocus} under a name for any page (0.51.4, keksdose run 89): the hook
35
+ * was always allowed on an app's own pages (keksdose's /reports/matching lands
36
+ * `?focus=matching-unmatched` on a card), but its name said Settings at every such call
37
+ * site. The same function, so the two cannot drift; `useSettingsFocus` stays.
38
+ */
39
+ export declare const useFocusParam: typeof useSettingsFocus;
@@ -67,9 +67,11 @@ function useSettingsFocus(anchor) {
67
67
  []
68
68
  );
69
69
  }
70
+ const useFocusParam = useSettingsFocus;
70
71
  export {
71
72
  SETTINGS_FOCUS_MS,
72
73
  SETTINGS_FOCUS_RING,
74
+ useFocusParam,
73
75
  useSettingsFocus
74
76
  };
75
77
  //# sourceMappingURL=use-settings-focus.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/settings/use-settings-focus.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { useLocation, useNavigate } from \"react-router\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\n\n/**\n * The ring a `?focus=` target wears for {@link SETTINGS_FOCUS_MS} — keksdose's\n * `HIGHLIGHT`, in tokens: `--brand` for the ring and the page's own surface for the gap,\n * where keksdose named a slate for the dark theme. A ring rather than a background\n * flash: the destructive cards already own a background tint, and a ring reads the same\n * on every card.\n */\nexport const SETTINGS_FOCUS_RING = [\"ring-2\", \"ring-brand\", \"ring-offset-2\", \"ring-offset-page\"];\n\n/** How long the ring stays (§3.5). */\nexport const SETTINGS_FOCUS_MS = 1800;\n\n/** How long a target that is not on the page yet (a lazy card, a card waiting for its\n * data) is waited for before the hook gives up on it. */\nconst WAIT_MS = 5000;\n\nconst HEADING = \"[data-settings-heading], h1, h2, h3, h4, h5, h6\";\n\n/**\n * `?focus=<anchor>` (docs/settings-harmonization.md §3.5): scroll the card into view, ring\n * it for 1.8 s, move focus to its heading for a screen reader, then remove `focus` — and\n * ONLY `focus` — with a replace, so a reload does not ring it again. Every other\n * parameter stays: a tour's `tour` / `tourStep`, the admin roster's filters (dropping URL\n * parts broke keksdose's tours once, dev#495). The hash and `location.state` stay too.\n *\n * A search hit, ⌘K, a tour and a mail link all arrive this way. `SettingsLayout` calls it\n * for its own page; an app's own page can call it with no argument to act on its URL.\n *\n * `anchor`:\n * - left out: the URL's `focus` parameter;\n * - a string: that anchor (the layout passes the URL's, once its route is canonical);\n * - `null`: nothing to do yet — the layout passes it while the address is about to be\n * replaced, so a removal here cannot undo a redirect there.\n *\n * \"Once its group has rendered\": the effect runs after the commit that mounted the\n * group's cards, so the card is there; a card that mounts later (lazy, or behind its\n * data) is waited for, for a few seconds, before the hook gives up and leaves the URL\n * as it is.\n */\nexport function useSettingsFocus(anchor?: string | null): void {\n const location = useLocation();\n const navigate = useNavigate();\n const target = anchor === undefined ? new URLSearchParams(location.search).get(\"focus\") : anchor;\n // The ring outlives the effect that drew it: removing `focus` from the URL re-runs the\n // effect (and cleans it up) at once, and the ring must still stay its 1.8 s.\n const ring = useRef<{ el: HTMLElement; timer: number } | null>(null);\n\n useEffect(() => {\n if (!target) return;\n const { pathname, search, hash, state } = location;\n\n const land = (el: HTMLElement) => {\n el.scrollIntoView?.({ behavior: prefersReducedMotion() ? \"auto\" : \"smooth\", block: \"center\" });\n\n if (ring.current) {\n window.clearTimeout(ring.current.timer);\n ring.current.el.classList.remove(...SETTINGS_FOCUS_RING);\n }\n el.classList.add(...SETTINGS_FOCUS_RING);\n el.setAttribute(\"data-settings-focus\", \"\");\n const timer = window.setTimeout(() => {\n el.classList.remove(...SETTINGS_FOCUS_RING);\n el.removeAttribute(\"data-settings-focus\");\n if (ring.current?.el === el) ring.current = null;\n }, SETTINGS_FOCUS_MS);\n ring.current = { el, timer };\n\n // The heading, so a screen reader starts at the card's name; the card itself when\n // it has none. `preventScroll`: the smooth scroll above is already on its way.\n const heading = el.querySelector<HTMLElement>(HEADING) ?? el;\n if (!heading.hasAttribute(\"tabindex\")) heading.setAttribute(\"tabindex\", \"-1\");\n heading.focus({ preventScroll: true });\n\n const params = new URLSearchParams(search);\n if (params.get(\"focus\") !== target) return;\n params.delete(\"focus\");\n const rest = params.toString();\n navigate({ pathname, search: rest ? `?${rest}` : \"\", hash }, { replace: true, state });\n };\n\n const found = document.getElementById(target);\n if (found) {\n land(found);\n return;\n }\n const observer = new MutationObserver(() => {\n const el = document.getElementById(target);\n if (!el) return;\n observer.disconnect();\n window.clearTimeout(giveUp);\n land(el);\n });\n observer.observe(document.body, { childList: true, subtree: true });\n const giveUp = window.setTimeout(() => observer.disconnect(), WAIT_MS);\n return () => {\n observer.disconnect();\n window.clearTimeout(giveUp);\n };\n // `location`, a new object per navigation: the same hit picked twice is two\n // navigations, and rings twice.\n }, [target, location, navigate]);\n\n useEffect(\n () => () => {\n const current = ring.current;\n if (!current) return;\n window.clearTimeout(current.timer);\n current.el.classList.remove(...SETTINGS_FOCUS_RING);\n current.el.removeAttribute(\"data-settings-focus\");\n },\n [],\n );\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAClC,SAAS,aAAa,mBAAmB;AACzC,SAAS,4BAA4B;AAS9B,MAAM,sBAAsB,CAAC,UAAU,cAAc,iBAAiB,kBAAkB;AAGxF,MAAM,oBAAoB;AAIjC,MAAM,UAAU;AAEhB,MAAM,UAAU;AAuBT,SAAS,iBAAiB,QAA8B;AAC7D,QAAM,WAAW,YAAY;AAC7B,QAAM,WAAW,YAAY;AAC7B,QAAM,SAAS,WAAW,SAAY,IAAI,gBAAgB,SAAS,MAAM,EAAE,IAAI,OAAO,IAAI;AAG1F,QAAM,OAAO,OAAkD,IAAI;AAEnE,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,EAAE,UAAU,QAAQ,MAAM,MAAM,IAAI;AAE1C,UAAM,OAAO,CAAC,OAAoB;AAChC,SAAG,iBAAiB,EAAE,UAAU,qBAAqB,IAAI,SAAS,UAAU,OAAO,SAAS,CAAC;AAE7F,UAAI,KAAK,SAAS;AAChB,eAAO,aAAa,KAAK,QAAQ,KAAK;AACtC,aAAK,QAAQ,GAAG,UAAU,OAAO,GAAG,mBAAmB;AAAA,MACzD;AACA,SAAG,UAAU,IAAI,GAAG,mBAAmB;AACvC,SAAG,aAAa,uBAAuB,EAAE;AACzC,YAAM,QAAQ,OAAO,WAAW,MAAM;AACpC,WAAG,UAAU,OAAO,GAAG,mBAAmB;AAC1C,WAAG,gBAAgB,qBAAqB;AACxC,YAAI,KAAK,SAAS,OAAO,GAAI,MAAK,UAAU;AAAA,MAC9C,GAAG,iBAAiB;AACpB,WAAK,UAAU,EAAE,IAAI,MAAM;AAI3B,YAAM,UAAU,GAAG,cAA2B,OAAO,KAAK;AAC1D,UAAI,CAAC,QAAQ,aAAa,UAAU,EAAG,SAAQ,aAAa,YAAY,IAAI;AAC5E,cAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAErC,YAAM,SAAS,IAAI,gBAAgB,MAAM;AACzC,UAAI,OAAO,IAAI,OAAO,MAAM,OAAQ;AACpC,aAAO,OAAO,OAAO;AACrB,YAAM,OAAO,OAAO,SAAS;AAC7B,eAAS,EAAE,UAAU,QAAQ,OAAO,IAAI,IAAI,KAAK,IAAI,KAAK,GAAG,EAAE,SAAS,MAAM,MAAM,CAAC;AAAA,IACvF;AAEA,UAAM,QAAQ,SAAS,eAAe,MAAM;AAC5C,QAAI,OAAO;AACT,WAAK,KAAK;AACV;AAAA,IACF;AACA,UAAM,WAAW,IAAI,iBAAiB,MAAM;AAC1C,YAAM,KAAK,SAAS,eAAe,MAAM;AACzC,UAAI,CAAC,GAAI;AACT,eAAS,WAAW;AACpB,aAAO,aAAa,MAAM;AAC1B,WAAK,EAAE;AAAA,IACT,CAAC;AACD,aAAS,QAAQ,SAAS,MAAM,EAAE,WAAW,MAAM,SAAS,KAAK,CAAC;AAClE,UAAM,SAAS,OAAO,WAAW,MAAM,SAAS,WAAW,GAAG,OAAO;AACrE,WAAO,MAAM;AACX,eAAS,WAAW;AACpB,aAAO,aAAa,MAAM;AAAA,IAC5B;AAAA,EAGF,GAAG,CAAC,QAAQ,UAAU,QAAQ,CAAC;AAE/B;AAAA,IACE,MAAM,MAAM;AACV,YAAM,UAAU,KAAK;AACrB,UAAI,CAAC,QAAS;AACd,aAAO,aAAa,QAAQ,KAAK;AACjC,cAAQ,GAAG,UAAU,OAAO,GAAG,mBAAmB;AAClD,cAAQ,GAAG,gBAAgB,qBAAqB;AAAA,IAClD;AAAA,IACA,CAAC;AAAA,EACH;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/settings/use-settings-focus.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { useLocation, useNavigate } from \"react-router\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\n\n/**\n * The ring a `?focus=` target wears for {@link SETTINGS_FOCUS_MS} — keksdose's\n * `HIGHLIGHT`, in tokens: `--brand` for the ring and the page's own surface for the gap,\n * where keksdose named a slate for the dark theme. A ring rather than a background\n * flash: the destructive cards already own a background tint, and a ring reads the same\n * on every card.\n */\nexport const SETTINGS_FOCUS_RING = [\"ring-2\", \"ring-brand\", \"ring-offset-2\", \"ring-offset-page\"];\n\n/** How long the ring stays (§3.5). */\nexport const SETTINGS_FOCUS_MS = 1800;\n\n/** How long a target that is not on the page yet (a lazy card, a card waiting for its\n * data) is waited for before the hook gives up on it. */\nconst WAIT_MS = 5000;\n\nconst HEADING = \"[data-settings-heading], h1, h2, h3, h4, h5, h6\";\n\n/**\n * `?focus=<anchor>` (docs/settings-harmonization.md §3.5): scroll the card into view, ring\n * it for 1.8 s, move focus to its heading for a screen reader, then remove `focus` — and\n * ONLY `focus` — with a replace, so a reload does not ring it again. Every other\n * parameter stays: a tour's `tour` / `tourStep`, the admin roster's filters (dropping URL\n * parts broke keksdose's tours once, dev#495). The hash and `location.state` stay too.\n *\n * A search hit, ⌘K, a tour and a mail link all arrive this way. `SettingsLayout` calls it\n * for its own page; an app's own page can call it with no argument to act on its URL.\n *\n * `anchor`:\n * - left out: the URL's `focus` parameter;\n * - a string: that anchor (the layout passes the URL's, once its route is canonical);\n * - `null`: nothing to do yet — the layout passes it while the address is about to be\n * replaced, so a removal here cannot undo a redirect there.\n *\n * \"Once its group has rendered\": the effect runs after the commit that mounted the\n * group's cards, so the card is there; a card that mounts later (lazy, or behind its\n * data) is waited for, for a few seconds, before the hook gives up and leaves the URL\n * as it is.\n */\nexport function useSettingsFocus(anchor?: string | null): void {\n const location = useLocation();\n const navigate = useNavigate();\n const target = anchor === undefined ? new URLSearchParams(location.search).get(\"focus\") : anchor;\n // The ring outlives the effect that drew it: removing `focus` from the URL re-runs the\n // effect (and cleans it up) at once, and the ring must still stay its 1.8 s.\n const ring = useRef<{ el: HTMLElement; timer: number } | null>(null);\n\n useEffect(() => {\n if (!target) return;\n const { pathname, search, hash, state } = location;\n\n const land = (el: HTMLElement) => {\n el.scrollIntoView?.({ behavior: prefersReducedMotion() ? \"auto\" : \"smooth\", block: \"center\" });\n\n if (ring.current) {\n window.clearTimeout(ring.current.timer);\n ring.current.el.classList.remove(...SETTINGS_FOCUS_RING);\n }\n el.classList.add(...SETTINGS_FOCUS_RING);\n el.setAttribute(\"data-settings-focus\", \"\");\n const timer = window.setTimeout(() => {\n el.classList.remove(...SETTINGS_FOCUS_RING);\n el.removeAttribute(\"data-settings-focus\");\n if (ring.current?.el === el) ring.current = null;\n }, SETTINGS_FOCUS_MS);\n ring.current = { el, timer };\n\n // The heading, so a screen reader starts at the card's name; the card itself when\n // it has none. `preventScroll`: the smooth scroll above is already on its way.\n const heading = el.querySelector<HTMLElement>(HEADING) ?? el;\n if (!heading.hasAttribute(\"tabindex\")) heading.setAttribute(\"tabindex\", \"-1\");\n heading.focus({ preventScroll: true });\n\n const params = new URLSearchParams(search);\n if (params.get(\"focus\") !== target) return;\n params.delete(\"focus\");\n const rest = params.toString();\n navigate({ pathname, search: rest ? `?${rest}` : \"\", hash }, { replace: true, state });\n };\n\n const found = document.getElementById(target);\n if (found) {\n land(found);\n return;\n }\n const observer = new MutationObserver(() => {\n const el = document.getElementById(target);\n if (!el) return;\n observer.disconnect();\n window.clearTimeout(giveUp);\n land(el);\n });\n observer.observe(document.body, { childList: true, subtree: true });\n const giveUp = window.setTimeout(() => observer.disconnect(), WAIT_MS);\n return () => {\n observer.disconnect();\n window.clearTimeout(giveUp);\n };\n // `location`, a new object per navigation: the same hit picked twice is two\n // navigations, and rings twice.\n }, [target, location, navigate]);\n\n useEffect(\n () => () => {\n const current = ring.current;\n if (!current) return;\n window.clearTimeout(current.timer);\n current.el.classList.remove(...SETTINGS_FOCUS_RING);\n current.el.removeAttribute(\"data-settings-focus\");\n },\n [],\n );\n}\n\n/**\n * {@link useSettingsFocus} under a name for any page (0.51.4, keksdose run 89): the hook\n * was always allowed on an app's own pages (keksdose's /reports/matching lands\n * `?focus=matching-unmatched` on a card), but its name said Settings at every such call\n * site. The same function, so the two cannot drift; `useSettingsFocus` stays.\n */\nexport const useFocusParam: typeof useSettingsFocus = useSettingsFocus;\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAClC,SAAS,aAAa,mBAAmB;AACzC,SAAS,4BAA4B;AAS9B,MAAM,sBAAsB,CAAC,UAAU,cAAc,iBAAiB,kBAAkB;AAGxF,MAAM,oBAAoB;AAIjC,MAAM,UAAU;AAEhB,MAAM,UAAU;AAuBT,SAAS,iBAAiB,QAA8B;AAC7D,QAAM,WAAW,YAAY;AAC7B,QAAM,WAAW,YAAY;AAC7B,QAAM,SAAS,WAAW,SAAY,IAAI,gBAAgB,SAAS,MAAM,EAAE,IAAI,OAAO,IAAI;AAG1F,QAAM,OAAO,OAAkD,IAAI;AAEnE,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,EAAE,UAAU,QAAQ,MAAM,MAAM,IAAI;AAE1C,UAAM,OAAO,CAAC,OAAoB;AAChC,SAAG,iBAAiB,EAAE,UAAU,qBAAqB,IAAI,SAAS,UAAU,OAAO,SAAS,CAAC;AAE7F,UAAI,KAAK,SAAS;AAChB,eAAO,aAAa,KAAK,QAAQ,KAAK;AACtC,aAAK,QAAQ,GAAG,UAAU,OAAO,GAAG,mBAAmB;AAAA,MACzD;AACA,SAAG,UAAU,IAAI,GAAG,mBAAmB;AACvC,SAAG,aAAa,uBAAuB,EAAE;AACzC,YAAM,QAAQ,OAAO,WAAW,MAAM;AACpC,WAAG,UAAU,OAAO,GAAG,mBAAmB;AAC1C,WAAG,gBAAgB,qBAAqB;AACxC,YAAI,KAAK,SAAS,OAAO,GAAI,MAAK,UAAU;AAAA,MAC9C,GAAG,iBAAiB;AACpB,WAAK,UAAU,EAAE,IAAI,MAAM;AAI3B,YAAM,UAAU,GAAG,cAA2B,OAAO,KAAK;AAC1D,UAAI,CAAC,QAAQ,aAAa,UAAU,EAAG,SAAQ,aAAa,YAAY,IAAI;AAC5E,cAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAErC,YAAM,SAAS,IAAI,gBAAgB,MAAM;AACzC,UAAI,OAAO,IAAI,OAAO,MAAM,OAAQ;AACpC,aAAO,OAAO,OAAO;AACrB,YAAM,OAAO,OAAO,SAAS;AAC7B,eAAS,EAAE,UAAU,QAAQ,OAAO,IAAI,IAAI,KAAK,IAAI,KAAK,GAAG,EAAE,SAAS,MAAM,MAAM,CAAC;AAAA,IACvF;AAEA,UAAM,QAAQ,SAAS,eAAe,MAAM;AAC5C,QAAI,OAAO;AACT,WAAK,KAAK;AACV;AAAA,IACF;AACA,UAAM,WAAW,IAAI,iBAAiB,MAAM;AAC1C,YAAM,KAAK,SAAS,eAAe,MAAM;AACzC,UAAI,CAAC,GAAI;AACT,eAAS,WAAW;AACpB,aAAO,aAAa,MAAM;AAC1B,WAAK,EAAE;AAAA,IACT,CAAC;AACD,aAAS,QAAQ,SAAS,MAAM,EAAE,WAAW,MAAM,SAAS,KAAK,CAAC;AAClE,UAAM,SAAS,OAAO,WAAW,MAAM,SAAS,WAAW,GAAG,OAAO;AACrE,WAAO,MAAM;AACX,eAAS,WAAW;AACpB,aAAO,aAAa,MAAM;AAAA,IAC5B;AAAA,EAGF,GAAG,CAAC,QAAQ,UAAU,QAAQ,CAAC;AAE/B;AAAA,IACE,MAAM,MAAM;AACV,YAAM,UAAU,KAAK;AACrB,UAAI,CAAC,QAAS;AACd,aAAO,aAAa,QAAQ,KAAK;AACjC,cAAQ,GAAG,UAAU,OAAO,GAAG,mBAAmB;AAClD,cAAQ,GAAG,gBAAgB,qBAAqB;AAAA,IAClD;AAAA,IACA,CAAC;AAAA,EACH;AACF;AAQO,MAAM,gBAAyC;","names":[]}
@@ -94,5 +94,10 @@ export interface DevMenuProps<R extends string, P extends string> {
94
94
  *
95
95
  * Nothing renders unless `enabled`, and nothing with no row to show. The panel scrolls
96
96
  * (`TopBarActionMenu`'s `scrollable`): some 25 rows ran off a 390×844 phone.
97
+ *
98
+ * From `md` up the groups stand side by side, one column each, in that order (0.51.4,
99
+ * `TopBarActionMenu`'s `columns`; keksdose dev #640, Marcel: "Each group gets a column").
100
+ * "Back to {role}" stays under the roles. A desktop too narrow for all of them wraps
101
+ * the columns onto a second row; a phone keeps the one scrolling column.
97
102
  */
98
103
  export declare function DevMenu<R extends string, P extends string>({ enabled, roles, currentRole: role, onRole, plans, currentPlan: plan, onPlan, originalRole, storageKey, billingOff, roleLabel, planLabel, labels: labelsProp, sections, active, }: DevMenuProps<R, P>): import("react").JSX.Element | null;
@@ -122,7 +122,8 @@ function DevMenu({
122
122
  iconBadge: active ? { label: words.overridesOn } : null,
123
123
  "aria-label": name,
124
124
  entries,
125
- scrollable: true
125
+ scrollable: true,
126
+ columns: true
126
127
  }
127
128
  );
128
129
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/dev-menu.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { FlaskConical, Undo2 } from \"lucide-react\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TopBarActionMenu, type TopBarMenuEntry } from \"./topbar-action-menu\";\nimport { useTopBarPartsLabels, type DevSwitchersLabels } from \"./top-bar-parts-labels\";\n\n/** Where the role the session started with is kept, unless the app names its own key. */\nexport const DEV_ORIGINAL_ROLE_KEY = \"ui-kit:dev-original-role\";\n\nfunction readKey(key: string): string | null {\n try {\n return sessionStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeKey(key: string, value: string | null): void {\n try {\n if (value === null) sessionStorage.removeItem(key);\n else sessionStorage.setItem(key, value);\n } catch {\n /* storage blocked: \"Back to\" lasts as long as this page */\n }\n}\n\n/**\n * One of the app's own groups in the {@link DevMenu} (docs/top-bar-harmonization.md\n * §2.7.1): keksdose's budget role, its \"today\" override, its resets.\n *\n * The rows are plain {@link TopBarMenuEntry}s, and there is no dev item type beside them\n * (the contract with keksdose, 0.51): a choice of one is radio rows (`checked`), a toggle\n * is a `checkable: \"checkbox\"` row, an action is a plain row. No form fields — a menu is\n * a `role=\"menu\"`, where a text or date input has no place in the arrow keys; a free\n * value is a \"Custom…\" row that opens the app's own dialog.\n */\nexport interface DevMenuSection {\n /** Unique among the sections. The rows' keys need be unique only within theirs: the\n * menu prefixes them with this. */\n key: string;\n /** The group's heading, drawn above its rows and announced as the group's name. */\n title: ReactNode;\n /** The rows, as `TopBarActionMenu` takes them. A `divider` ends the group, as there. */\n entries: readonly TopBarMenuEntry[];\n}\n\nexport interface DevMenuProps<R extends string, P extends string> {\n /**\n * Draw anything at all. The app passes `import.meta.env.DEV` AND its probe of the dev\n * route (§2.7.1): the constant keeps the menu off a production build's screen, the\n * probe off a dev build whose server has the route shut (§6.1) — flasks with nothing\n * behind them. Neither takes code out of the bundle; see `sections`.\n */\n enabled: boolean;\n roles?: readonly R[];\n /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on\n * a JSX element is the ARIA role (as `RoleChip`'s `value`). */\n currentRole?: R;\n /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads\n * `/auth/me` (§2.7, §6.2). */\n onRole?: (role: R) => void;\n plans?: readonly P[];\n /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a\n * 404 while billing is off), the plan group still renders, with no row checked; a\n * `GET /dev/switch` answers it. */\n currentPlan?: P;\n /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */\n onPlan?: (plan: P) => void;\n /**\n * The role to offer \"Back to {role}\" for. Left out, the kit keeps it itself: the role\n * before the first switch of this browser session, in `sessionStorage` under\n * `storageKey` — the server answers with the NEW role after a switch, so only the\n * browser still knows the old one.\n */\n originalRole?: R;\n /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};\n * an app with several accounts on one browser may scope it per user. */\n storageKey?: string;\n /** Billing is off locally: the plan group says plans change nothing, and stays usable\n * (§6.3). */\n billingOff?: boolean;\n /** A role's or a plan's words in the menu. Default: the value itself. */\n roleLabel?: (role: R) => ReactNode;\n planLabel?: (plan: P) => ReactNode;\n /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */\n labels?: LabelOverride<DevSwitchersLabels>;\n /** 0.51: the app's own dev groups, after the kit's role and plan, in this order. Their\n * DEFINITIONS belong in a module only a dev build imports (§2.7.1): `enabled={false}`\n * hides the menu, but the code of a section built inline stays in the bundle. */\n sections?: readonly DevMenuSection[];\n /**\n * 0.51: an override is on that the developer could forget — a \"today\" that is not\n * today, a simulated slow server. The flask wears a dot, and its name says so (\"Dev\n * tools · Role: ADMIN · Plan: free Overrides on\"), since a dot alone tells a screen\n * reader nothing. The app decides what counts; the kit cannot see into its sections.\n */\n active?: boolean;\n}\n\nconst flask = <FlaskConical className=\"size-5 text-warning\" />;\n\n/** A label's text for a sentence, or the raw value when the label is not text. */\nconst textOf = (label: ReactNode, value: string) =>\n typeof label === \"string\" || typeof label === \"number\" ? String(label) : value;\n\n/** A row someone can pick — what makes a menu worth drawing. */\nconst isRow = (entry: TopBarMenuEntry) => entry.kind !== \"heading\" && entry.kind !== \"divider\";\n\n/**\n * The top bar's local-dev menu (docs/top-bar-harmonization.md §2.7, §2.7.1, §6.2, §6.3):\n * ONE amber flask, `RoleSwitcher`'s, over the kit's Account section and the app's own.\n *\n * 0.42's `DevSwitchers` drew a flask per switcher and had no place for anything else,\n * and an app has more dev controls than a role and a plan — keksdose's budget role, a\n * \"today\" override, resets (agreed with keksdose for 0.51). So the kit's part is now the\n * menu, and the app's rows go in it.\n *\n * - **The Account section** comes first: the role group, ending in \"Back to {role}\" once\n * the role differs from the one the session started with (the dev route needs only a\n * signed-in user, so a switch to a lesser role can always switch back); then the plan\n * group, which with `billingOff` notes that plans change nothing. Each renders only\n * with its list and handler.\n * - **The app's `sections`** follow, each a group under its own heading.\n * - **`active`** puts a dot on the flask for an override that is easy to forget.\n *\n * Nothing renders unless `enabled`, and nothing with no row to show. The panel scrolls\n * (`TopBarActionMenu`'s `scrollable`): some 25 rows ran off a 390×844 phone.\n */\nexport function DevMenu<R extends string, P extends string>({\n enabled,\n roles,\n currentRole: role,\n onRole,\n plans,\n currentPlan: plan,\n onPlan,\n originalRole,\n storageKey = DEV_ORIGINAL_ROLE_KEY,\n billingOff = false,\n roleLabel = (r) => r,\n planLabel = (p) => p,\n labels: labelsProp,\n sections = [],\n active = false,\n}: DevMenuProps<R, P>) {\n const bar = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;\n const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));\n // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the\n // dev route — so the kept role is read again then, or \"Back to {role}\" is lost after a\n // reload. Adjusted during render, React's pattern for state that follows a prop.\n const [wasEnabled, setWasEnabled] = useState(enabled);\n if (wasEnabled !== enabled) {\n setWasEnabled(enabled);\n if (enabled) setKept(readKey(storageKey));\n }\n if (!enabled) return null;\n\n const original = (originalRole ?? kept ?? undefined) as R | undefined;\n\n const pickRole = (next: R) => {\n if (originalRole === undefined) {\n if (next === original) {\n // Back where the session began: nothing left to go back to.\n writeKey(storageKey, null);\n setKept(null);\n } else if (kept === null && role !== undefined && next !== role) {\n writeKey(storageKey, role);\n setKept(role);\n }\n }\n onRole?.(next);\n };\n\n const roleGroup: TopBarMenuEntry[] =\n roles && roles.length > 0 && role !== undefined && onRole\n ? [\n { kind: \"heading\", key: \"head-role\", label: words.roleHeading },\n ...roles.map(\n (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),\n ),\n // Behind a rule, which ends the role group: \"Back to\" is a way out, not a role.\n ...(original !== undefined && original !== role\n ? ([\n { kind: \"divider\", key: \"sep-back\" },\n {\n key: \"back\",\n icon: <Undo2 className=\"size-4\" />,\n label: words.backTo(textOf(roleLabel(original), original)),\n onSelect: () => pickRole(original),\n },\n ] satisfies TopBarMenuEntry[])\n : []),\n ]\n : [];\n\n const planGroup: TopBarMenuEntry[] =\n plans && plans.length > 0 && onPlan\n ? [\n {\n kind: \"heading\",\n key: \"head-plan\",\n // The billing-off note is part of the group's NAME, under the heading. In 0.42's\n // own plan menu it was a trailing `<li>`: not a row, so the arrow keys passed\n // over it and a screen reader moving through the menu never reached it. Named,\n // it is read on the way into the plans. And in one menu a trailing note would\n // sit under the app's sections, not under the plans.\n label: billingOff ? (\n <>\n {words.planHeading}{\" \"}\n <span\n data-dev-billing-off=\"\"\n className=\"mt-0.5 block font-normal normal-case tracking-normal text-xs text-muted\"\n >\n {words.billingOff}\n </span>\n </>\n ) : (\n words.planHeading\n ),\n },\n ...plans.map(\n (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),\n ),\n ]\n : [];\n\n // The app's rows keyed under their section: two sections may well both have a \"reset\".\n const appGroups = sections.flatMap((section): TopBarMenuEntry[] => [\n { kind: \"heading\", key: `section:${section.key}`, label: section.title },\n ...section.entries.map((entry): TopBarMenuEntry => ({ ...entry, key: `${section.key}:${entry.key}` })),\n ]);\n\n const entries = [...roleGroup, ...planGroup, ...appGroups];\n if (!entries.some(isRow)) return null;\n\n // The menu, then the role and the plan, as 0.42's two triggers named them: the bar\n // shows the flask only, so its name (and tooltip) is where they are read.\n const name = [\n words.menu,\n roleGroup.length > 0 && role !== undefined ? bar.role(role) : null,\n planGroup.length > 0 && plan !== undefined ? words.plan(plan) : null,\n ]\n .filter((part): part is string => part !== null)\n .join(\" · \");\n\n return (\n <TopBarActionMenu\n icon={flask}\n // The badge's words join the trigger's name, as the tours compass's NEW dot does.\n // `danger`, the badge's default: an amber dot would vanish into the amber flask.\n iconBadge={active ? { label: words.overridesOn } : null}\n aria-label={name}\n entries={entries}\n scrollable\n />\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/shell/dev-menu.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { FlaskConical, Undo2 } from \"lucide-react\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TopBarActionMenu, type TopBarMenuEntry } from \"./topbar-action-menu\";\nimport { useTopBarPartsLabels, type DevSwitchersLabels } from \"./top-bar-parts-labels\";\n\n/** Where the role the session started with is kept, unless the app names its own key. */\nexport const DEV_ORIGINAL_ROLE_KEY = \"ui-kit:dev-original-role\";\n\nfunction readKey(key: string): string | null {\n try {\n return sessionStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeKey(key: string, value: string | null): void {\n try {\n if (value === null) sessionStorage.removeItem(key);\n else sessionStorage.setItem(key, value);\n } catch {\n /* storage blocked: \"Back to\" lasts as long as this page */\n }\n}\n\n/**\n * One of the app's own groups in the {@link DevMenu} (docs/top-bar-harmonization.md\n * §2.7.1): keksdose's budget role, its \"today\" override, its resets.\n *\n * The rows are plain {@link TopBarMenuEntry}s, and there is no dev item type beside them\n * (the contract with keksdose, 0.51): a choice of one is radio rows (`checked`), a toggle\n * is a `checkable: \"checkbox\"` row, an action is a plain row. No form fields — a menu is\n * a `role=\"menu\"`, where a text or date input has no place in the arrow keys; a free\n * value is a \"Custom…\" row that opens the app's own dialog.\n */\nexport interface DevMenuSection {\n /** Unique among the sections. The rows' keys need be unique only within theirs: the\n * menu prefixes them with this. */\n key: string;\n /** The group's heading, drawn above its rows and announced as the group's name. */\n title: ReactNode;\n /** The rows, as `TopBarActionMenu` takes them. A `divider` ends the group, as there. */\n entries: readonly TopBarMenuEntry[];\n}\n\nexport interface DevMenuProps<R extends string, P extends string> {\n /**\n * Draw anything at all. The app passes `import.meta.env.DEV` AND its probe of the dev\n * route (§2.7.1): the constant keeps the menu off a production build's screen, the\n * probe off a dev build whose server has the route shut (§6.1) — flasks with nothing\n * behind them. Neither takes code out of the bundle; see `sections`.\n */\n enabled: boolean;\n roles?: readonly R[];\n /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on\n * a JSX element is the ARIA role (as `RoleChip`'s `value`). */\n currentRole?: R;\n /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads\n * `/auth/me` (§2.7, §6.2). */\n onRole?: (role: R) => void;\n plans?: readonly P[];\n /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a\n * 404 while billing is off), the plan group still renders, with no row checked; a\n * `GET /dev/switch` answers it. */\n currentPlan?: P;\n /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */\n onPlan?: (plan: P) => void;\n /**\n * The role to offer \"Back to {role}\" for. Left out, the kit keeps it itself: the role\n * before the first switch of this browser session, in `sessionStorage` under\n * `storageKey` — the server answers with the NEW role after a switch, so only the\n * browser still knows the old one.\n */\n originalRole?: R;\n /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};\n * an app with several accounts on one browser may scope it per user. */\n storageKey?: string;\n /** Billing is off locally: the plan group says plans change nothing, and stays usable\n * (§6.3). */\n billingOff?: boolean;\n /** A role's or a plan's words in the menu. Default: the value itself. */\n roleLabel?: (role: R) => ReactNode;\n planLabel?: (plan: P) => ReactNode;\n /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */\n labels?: LabelOverride<DevSwitchersLabels>;\n /** 0.51: the app's own dev groups, after the kit's role and plan, in this order. Their\n * DEFINITIONS belong in a module only a dev build imports (§2.7.1): `enabled={false}`\n * hides the menu, but the code of a section built inline stays in the bundle. */\n sections?: readonly DevMenuSection[];\n /**\n * 0.51: an override is on that the developer could forget — a \"today\" that is not\n * today, a simulated slow server. The flask wears a dot, and its name says so (\"Dev\n * tools · Role: ADMIN · Plan: free Overrides on\"), since a dot alone tells a screen\n * reader nothing. The app decides what counts; the kit cannot see into its sections.\n */\n active?: boolean;\n}\n\nconst flask = <FlaskConical className=\"size-5 text-warning\" />;\n\n/** A label's text for a sentence, or the raw value when the label is not text. */\nconst textOf = (label: ReactNode, value: string) =>\n typeof label === \"string\" || typeof label === \"number\" ? String(label) : value;\n\n/** A row someone can pick — what makes a menu worth drawing. */\nconst isRow = (entry: TopBarMenuEntry) => entry.kind !== \"heading\" && entry.kind !== \"divider\";\n\n/**\n * The top bar's local-dev menu (docs/top-bar-harmonization.md §2.7, §2.7.1, §6.2, §6.3):\n * ONE amber flask, `RoleSwitcher`'s, over the kit's Account section and the app's own.\n *\n * 0.42's `DevSwitchers` drew a flask per switcher and had no place for anything else,\n * and an app has more dev controls than a role and a plan — keksdose's budget role, a\n * \"today\" override, resets (agreed with keksdose for 0.51). So the kit's part is now the\n * menu, and the app's rows go in it.\n *\n * - **The Account section** comes first: the role group, ending in \"Back to {role}\" once\n * the role differs from the one the session started with (the dev route needs only a\n * signed-in user, so a switch to a lesser role can always switch back); then the plan\n * group, which with `billingOff` notes that plans change nothing. Each renders only\n * with its list and handler.\n * - **The app's `sections`** follow, each a group under its own heading.\n * - **`active`** puts a dot on the flask for an override that is easy to forget.\n *\n * Nothing renders unless `enabled`, and nothing with no row to show. The panel scrolls\n * (`TopBarActionMenu`'s `scrollable`): some 25 rows ran off a 390×844 phone.\n *\n * From `md` up the groups stand side by side, one column each, in that order (0.51.4,\n * `TopBarActionMenu`'s `columns`; keksdose dev #640, Marcel: \"Each group gets a column\").\n * \"Back to {role}\" stays under the roles. A desktop too narrow for all of them wraps\n * the columns onto a second row; a phone keeps the one scrolling column.\n */\nexport function DevMenu<R extends string, P extends string>({\n enabled,\n roles,\n currentRole: role,\n onRole,\n plans,\n currentPlan: plan,\n onPlan,\n originalRole,\n storageKey = DEV_ORIGINAL_ROLE_KEY,\n billingOff = false,\n roleLabel = (r) => r,\n planLabel = (p) => p,\n labels: labelsProp,\n sections = [],\n active = false,\n}: DevMenuProps<R, P>) {\n const bar = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;\n const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));\n // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the\n // dev route — so the kept role is read again then, or \"Back to {role}\" is lost after a\n // reload. Adjusted during render, React's pattern for state that follows a prop.\n const [wasEnabled, setWasEnabled] = useState(enabled);\n if (wasEnabled !== enabled) {\n setWasEnabled(enabled);\n if (enabled) setKept(readKey(storageKey));\n }\n if (!enabled) return null;\n\n const original = (originalRole ?? kept ?? undefined) as R | undefined;\n\n const pickRole = (next: R) => {\n if (originalRole === undefined) {\n if (next === original) {\n // Back where the session began: nothing left to go back to.\n writeKey(storageKey, null);\n setKept(null);\n } else if (kept === null && role !== undefined && next !== role) {\n writeKey(storageKey, role);\n setKept(role);\n }\n }\n onRole?.(next);\n };\n\n const roleGroup: TopBarMenuEntry[] =\n roles && roles.length > 0 && role !== undefined && onRole\n ? [\n { kind: \"heading\", key: \"head-role\", label: words.roleHeading },\n ...roles.map(\n (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),\n ),\n // Behind a rule, which ends the role group: \"Back to\" is a way out, not a role.\n ...(original !== undefined && original !== role\n ? ([\n { kind: \"divider\", key: \"sep-back\" },\n {\n key: \"back\",\n icon: <Undo2 className=\"size-4\" />,\n label: words.backTo(textOf(roleLabel(original), original)),\n onSelect: () => pickRole(original),\n },\n ] satisfies TopBarMenuEntry[])\n : []),\n ]\n : [];\n\n const planGroup: TopBarMenuEntry[] =\n plans && plans.length > 0 && onPlan\n ? [\n {\n kind: \"heading\",\n key: \"head-plan\",\n // The billing-off note is part of the group's NAME, under the heading. In 0.42's\n // own plan menu it was a trailing `<li>`: not a row, so the arrow keys passed\n // over it and a screen reader moving through the menu never reached it. Named,\n // it is read on the way into the plans. And in one menu a trailing note would\n // sit under the app's sections, not under the plans.\n label: billingOff ? (\n <>\n {words.planHeading}{\" \"}\n <span\n data-dev-billing-off=\"\"\n className=\"mt-0.5 block font-normal normal-case tracking-normal text-xs text-muted\"\n >\n {words.billingOff}\n </span>\n </>\n ) : (\n words.planHeading\n ),\n },\n ...plans.map(\n (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),\n ),\n ]\n : [];\n\n // The app's rows keyed under their section: two sections may well both have a \"reset\".\n const appGroups = sections.flatMap((section): TopBarMenuEntry[] => [\n { kind: \"heading\", key: `section:${section.key}`, label: section.title },\n ...section.entries.map((entry): TopBarMenuEntry => ({ ...entry, key: `${section.key}:${entry.key}` })),\n ]);\n\n const entries = [...roleGroup, ...planGroup, ...appGroups];\n if (!entries.some(isRow)) return null;\n\n // The menu, then the role and the plan, as 0.42's two triggers named them: the bar\n // shows the flask only, so its name (and tooltip) is where they are read.\n const name = [\n words.menu,\n roleGroup.length > 0 && role !== undefined ? bar.role(role) : null,\n planGroup.length > 0 && plan !== undefined ? words.plan(plan) : null,\n ]\n .filter((part): part is string => part !== null)\n .join(\" · \");\n\n return (\n <TopBarActionMenu\n icon={flask}\n // The badge's words join the trigger's name, as the tours compass's NEW dot does.\n // `danger`, the badge's default: an amber dot would vanish into the amber flask.\n iconBadge={active ? { label: words.overridesOn } : null}\n aria-label={name}\n entries={entries}\n scrollable\n columns\n />\n );\n}\n"],"mappings":"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","names":[]}
@@ -106,7 +106,7 @@ export interface TopBarMenuHeader {
106
106
  * rather than widening it. A menu that can grow taller than a phone's glass passes
107
107
  * `scrollable` (0.51), since that clamp is sideways only.
108
108
  */
109
- export declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName, href, "aria-label": ariaLabel, heading, header, entries, leading, children, footer, footerLabel, align, panelClassName, scrollable, triggerAttributes, }: {
109
+ export declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName, href, "aria-label": ariaLabel, heading, header, entries, leading, children, footer, footerLabel, align, panelClassName, scrollable, columns, triggerAttributes, }: {
110
110
  /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */
111
111
  icon?: ReactNode;
112
112
  /**
@@ -190,6 +190,19 @@ export declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClas
190
190
  * header and the footer are inside the list and scroll with the rows.
191
191
  */
192
192
  scrollable?: boolean;
193
+ /**
194
+ * From `md` up, lay the heading groups side by side, one column per group (0.51.4,
195
+ * `DevMenu`; keksdose dev #640, Marcel: "Each group gets a column. That way it would be
196
+ * much better visible for desktop users"). Below `md`, the one column as before.
197
+ *
198
+ * A column is a group and the loose rows after it up to the next heading, so a row
199
+ * behind a rule at the end of a group (DevMenu's "Back to {role}") stays in that
200
+ * group's column. Loose rows before the first heading, the header, the menu-wide
201
+ * `heading` and the footer span the panel. The DOM order is unchanged, so ↓/↑ walk the
202
+ * rows down one column and on into the next, as a reader reads them. `leading` and
203
+ * `children` rows take a cell each; add `md:col-span-full` to one that should span.
204
+ */
205
+ columns?: boolean;
193
206
  /** `data-*` attributes for the trigger itself — a tour's anchor (`data-tour`), a test
194
207
  * hook. The parts built on this menu (`TourMenu`) pass theirs through here. */
195
208
  triggerAttributes?: {
@@ -26,6 +26,21 @@ function toBlocks(entries) {
26
26
  }
27
27
  return blocks.filter((block) => block.kind === "loose" || block.rows.length > 0);
28
28
  }
29
+ function toColumns(blocks) {
30
+ const lead = [];
31
+ const columns = [];
32
+ blocks.forEach((block, index) => {
33
+ if (block.kind === "group") columns.push({ group: block, after: [], index });
34
+ else if (columns.length === 0) lead.push(block);
35
+ else columns[columns.length - 1].after.push(block.entry);
36
+ });
37
+ for (const column of columns) {
38
+ while (column.after.at(-1)?.kind === "divider") column.after.pop();
39
+ }
40
+ return { lead, columns };
41
+ }
42
+ const COLUMNS_PANEL = "md:grid md:grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] md:items-start md:gap-y-2 md:w-[min(calc(100vw-2rem),var(--menu-columns-width))]";
43
+ const SPAN_ALL = "md:col-span-full";
29
44
  const SECTION_CONTROLS = "a[href]:not([role]),button:not([role])";
30
45
  const TABBABLE = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]";
31
46
  const SCROLLABLE_PANEL = "max-h-[calc(100dvh-4rem)] overflow-y-auto overscroll-contain";
@@ -68,9 +83,13 @@ function TopBarActionMenu({
68
83
  align,
69
84
  panelClassName,
70
85
  scrollable = false,
86
+ columns = false,
71
87
  triggerAttributes
72
88
  }) {
73
89
  const uid = useId();
90
+ const blocks = toBlocks(entries);
91
+ const grouped = columns ? toColumns(blocks) : null;
92
+ const laidOut = grouped && grouped.columns.length > 0 ? grouped : null;
74
93
  return /* @__PURE__ */ jsx(
75
94
  HoverMenu,
76
95
  {
@@ -109,7 +128,7 @@ function TopBarActionMenu({
109
128
  const footerBody = typeof footer === "function" ? footer(close) : footer;
110
129
  const headerExtra = typeof header?.extra === "function" ? header.extra(close) : header?.extra;
111
130
  const footerClass = "flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted [&_a:hover]:text-primary [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-focus [&_a:focus-visible]:ring-brand";
112
- const row = (entry) => /* @__PURE__ */ jsx("li", { children: entry.kind === "link" ? /* @__PURE__ */ jsx(
131
+ const row = (entry, className) => /* @__PURE__ */ jsx("li", { className, children: entry.kind === "link" ? /* @__PURE__ */ jsx(
113
132
  MenuItem,
114
133
  {
115
134
  href: entry.to,
@@ -138,46 +157,78 @@ function TopBarActionMenu({
138
157
  children: entry.label
139
158
  }
140
159
  ) }, entry.key);
160
+ const loose = (block, span) => {
161
+ const entry = block.entry;
162
+ if (entry.kind === "divider") {
163
+ return /* @__PURE__ */ jsx("li", { className: cn("my-1 border-t border-subtle", span && SPAN_ALL) }, entry.key);
164
+ }
165
+ return row(entry, span ? SPAN_ALL : void 0);
166
+ };
167
+ const group = (block, i, tight, inColumn = false) => {
168
+ const headingId = `${uid}-group-${i}`;
169
+ return /* @__PURE__ */ jsxs("li", { role: "none", children: [
170
+ /* @__PURE__ */ jsx(
171
+ "div",
172
+ {
173
+ id: headingId,
174
+ role: "presentation",
175
+ className: cn(
176
+ "px-3 pb-0.5 text-caption font-semibold uppercase tracking-wide text-muted [overflow-wrap:anywhere]",
177
+ // A column's heading lines up with its neighbours' at the top of the
178
+ // grid row; under `md` the columns stack, and it spaces like the list's.
179
+ tight ? "pt-1" : cn("pt-2.5", inColumn && "md:pt-1")
180
+ ),
181
+ children: block.heading.label
182
+ }
183
+ ),
184
+ /* @__PURE__ */ jsx("ul", { role: "group", "aria-labelledby": headingId, children: block.rows.map((entry) => row(entry)) })
185
+ ] }, block.heading.key);
186
+ };
141
187
  return (
142
188
  // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel
143
- /* @__PURE__ */ jsxs("ul", { className: cn("py-1", scrollable && SCROLLABLE_PANEL, panelClassName), onKeyDown: keepTabInsidePanel, children: [
144
- header && /* @__PURE__ */ jsxs(MenuSection, { className: "mb-1 border-b border-subtle px-3 py-2", children: [
145
- /* @__PURE__ */ jsx("div", { className: "truncate text-sm font-medium text-primary", children: header.title }),
146
- header.subtitle != null && /* @__PURE__ */ jsx("div", { className: "truncate text-xs text-muted", children: header.subtitle }),
147
- headerExtra != null && headerExtra !== false && /* @__PURE__ */ jsx("div", { className: "mt-1", children: headerExtra })
148
- ] }),
149
- heading && /* @__PURE__ */ jsx("li", { className: "px-3 py-1 text-micro font-semibold uppercase tracking-wide text-placeholder", children: heading }),
150
- leading?.(close),
151
- toBlocks(entries).map((block, i, blocks) => {
152
- if (block.kind === "loose") {
153
- const entry = block.entry;
154
- if (entry.kind === "divider") {
155
- return /* @__PURE__ */ jsx("li", { className: "my-1 border-t border-subtle" }, entry.key);
156
- }
157
- return row(entry);
158
- }
159
- const headingId = `${uid}-group-${i}`;
160
- const before = blocks[i - 1];
161
- const tight = !before || before.kind === "loose" && before.entry.kind === "divider";
162
- return /* @__PURE__ */ jsxs("li", { role: "none", children: [
163
- /* @__PURE__ */ jsx(
164
- "div",
189
+ /* @__PURE__ */ jsxs(
190
+ "ul",
191
+ {
192
+ className: cn("py-1", scrollable && SCROLLABLE_PANEL, laidOut && COLUMNS_PANEL, panelClassName),
193
+ style: laidOut ? { "--menu-columns-width": `${laidOut.columns.length * 14}rem` } : void 0,
194
+ onKeyDown: keepTabInsidePanel,
195
+ children: [
196
+ header && /* @__PURE__ */ jsxs(MenuSection, { className: cn("mb-1 border-b border-subtle px-3 py-2", laidOut && SPAN_ALL), children: [
197
+ /* @__PURE__ */ jsx("div", { className: "truncate text-sm font-medium text-primary", children: header.title }),
198
+ header.subtitle != null && /* @__PURE__ */ jsx("div", { className: "truncate text-xs text-muted", children: header.subtitle }),
199
+ headerExtra != null && headerExtra !== false && /* @__PURE__ */ jsx("div", { className: "mt-1", children: headerExtra })
200
+ ] }),
201
+ heading && /* @__PURE__ */ jsx(
202
+ "li",
165
203
  {
166
- id: headingId,
167
- role: "presentation",
168
204
  className: cn(
169
- "px-3 pb-0.5 text-caption font-semibold uppercase tracking-wide text-muted [overflow-wrap:anywhere]",
170
- tight ? "pt-1" : "pt-2.5"
205
+ "px-3 py-1 text-micro font-semibold uppercase tracking-wide text-placeholder",
206
+ laidOut && SPAN_ALL
171
207
  ),
172
- children: block.heading.label
208
+ children: heading
173
209
  }
174
210
  ),
175
- /* @__PURE__ */ jsx("ul", { role: "group", "aria-labelledby": headingId, children: block.rows.map(row) })
176
- ] }, block.heading.key);
177
- }),
178
- children?.(close),
179
- footerBody != null && footerBody !== false && /* @__PURE__ */ jsx(MenuSection, { className: "mt-1 border-t border-subtle px-3 py-2", children: footerLabel ? /* @__PURE__ */ jsx("nav", { "aria-label": footerLabel, className: footerClass, children: footerBody }) : /* @__PURE__ */ jsx("div", { className: footerClass, children: footerBody }) })
180
- ] })
211
+ leading?.(close),
212
+ laidOut ? /* @__PURE__ */ jsxs(Fragment, { children: [
213
+ laidOut.lead.map((block) => loose(block, true)),
214
+ laidOut.columns.map((column, c) => (
215
+ // A column of the grid; under `md` a group as in the list, its rows
216
+ // following.
217
+ /* @__PURE__ */ jsx("li", { role: "none", "data-menu-column": "", className: "min-w-0", children: /* @__PURE__ */ jsxs("ul", { role: "none", children: [
218
+ group(column.group, column.index, c === 0 && laidOut.lead.length === 0, true),
219
+ column.after.map((entry) => loose({ kind: "loose", entry }, false))
220
+ ] }) }, column.group.heading.key)
221
+ ))
222
+ ] }) : blocks.map((block, i) => {
223
+ if (block.kind === "loose") return loose(block, false);
224
+ const before = blocks[i - 1];
225
+ return group(block, i, !before || before.kind === "loose" && before.entry.kind === "divider");
226
+ }),
227
+ children?.(close),
228
+ footerBody != null && footerBody !== false && /* @__PURE__ */ jsx(MenuSection, { className: cn("mt-1 border-t border-subtle px-3 py-2", laidOut && SPAN_ALL), children: footerLabel ? /* @__PURE__ */ jsx("nav", { "aria-label": footerLabel, className: footerClass, children: footerBody }) : /* @__PURE__ */ jsx("div", { className: footerClass, children: footerBody }) })
229
+ ]
230
+ }
231
+ )
181
232
  );
182
233
  }
183
234
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/topbar-action-menu.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useRef } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { Link } from \"react-router\";\nimport { cn } from \"../lib/cn\";\nimport { HoverMenu, type HoverMenuProps } from \"../components/hover-menu\";\nimport { MenuItem } from \"../components/menu-item\";\nimport type { MenuItemTone } from \"../components/menu-item\";\nimport { StatusDot } from \"../components/status-dot\";\nimport { Tooltip } from \"../components/tooltip\";\nimport type { UserAvatarBadge } from \"../components/user-avatar\";\nimport { TOPBAR_TRIGGER_CLASS } from \"./topbar-controls\";\n\n/**\n * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a\n * `link` navigates via react-router, a `divider` draws a rule, and a `heading` names\n * the rows after it. Action and link rows close the menu automatically when chosen.\n * `kind` may be omitted for actions, keeping the common case terse.\n *\n * The rows are {@link MenuItem}s, so they take its options: `tone: \"danger\"` for the\n * log-out row, `checked` (+ `checkable`) for a choice, `current` for the link to the\n * page already open, `disabled`.\n */\nexport type TopBarMenuEntry =\n | {\n kind?: \"action\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n onSelect: () => void;\n tone?: MenuItemTone;\n /** Makes the row a choice (`menuitemradio`, or `menuitemcheckbox` with\n * `checkable: \"checkbox\"`) and says whether it is chosen. */\n checked?: boolean;\n checkable?: \"radio\" | \"checkbox\";\n disabled?: boolean;\n }\n | {\n kind: \"link\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n to: string;\n tone?: MenuItemTone;\n /** The link to the page already open: `aria-current=\"page\"` and a check mark. */\n current?: boolean;\n disabled?: boolean;\n }\n | { kind: \"divider\"; key: string }\n | {\n /**\n * A group heading (0.23, keksdose's guided-tours menu): the rows after it, up to\n * the next heading, the next `divider` or the end of `entries`, are ONE group\n * named by `label`. keksdose files its tours by topic and separated the blocks\n * with bare dividers — \"the kit's menu has no heading row to name them with\"\n * (tour-menu.tsx) — so a sighted reader had to guess what each block was and a\n * screen reader was told nothing at all.\n *\n * Not a row: it takes no focus, the arrow keys (and Home/End) pass over it, and it\n * never closes the menu. It is announced the way the kit's other grouped lists\n * announce theirs (`CommandPalette`): the group's rows sit in a `role=\"group\"`\n * whose `aria-labelledby` is this heading, so a reader says \"On this page, group\"\n * when the focus enters the first row, and nothing between rows.\n *\n * A divider ENDS the group, so a closing row such as \"All tours…\" behind a rule\n * belongs to no topic. A heading with no rows under it is not drawn — an empty\n * group names nothing; build the list from data and it may well be empty.\n *\n * The menu-wide `heading` prop is the panel's title, drawn once above everything;\n * with group headings it is usually redundant (two stacked caps lines read as one),\n * and the trigger's `aria-label` already names the menu.\n */\n kind: \"heading\";\n key: string;\n /** The group's name, shown above its rows and announced as the group's name. Keep\n * it short: it is set in small caps and wraps on a narrow panel. */\n label: ReactNode;\n };\n\ntype HeadingEntry = Extract<TopBarMenuEntry, { kind: \"heading\" }>;\ntype DividerEntry = Extract<TopBarMenuEntry, { kind: \"divider\" }>;\ntype RowEntry = Exclude<TopBarMenuEntry, HeadingEntry | DividerEntry>;\n\n/** What the panel draws from `entries`: loose rows and rules, and headed groups. */\ntype Block = { kind: \"loose\"; entry: RowEntry | DividerEntry } | { kind: \"group\"; heading: HeadingEntry; rows: RowEntry[] };\n\n/**\n * Cut `entries` into blocks: a heading opens a group that takes the rows after it until\n * the next heading or divider; everything else stands alone, as it always has.\n */\nfunction toBlocks(entries: readonly TopBarMenuEntry[]): Block[] {\n const blocks: Block[] = [];\n let group: Extract<Block, { kind: \"group\" }> | null = null;\n for (const entry of entries) {\n if (entry.kind === \"heading\") {\n group = { kind: \"group\", heading: entry, rows: [] };\n blocks.push(group);\n } else if (entry.kind === \"divider\") {\n group = null;\n blocks.push({ kind: \"loose\", entry });\n } else if (group) {\n group.rows.push(entry);\n } else {\n blocks.push({ kind: \"loose\", entry });\n }\n }\n // A heading with nothing under it names nothing: not drawn (see the `heading` kind).\n return blocks.filter((block) => block.kind === \"loose\" || block.rows.length > 0);\n}\n\n/**\n * The non-interactive identity block at the top of an account menu — kastlan's name +\n * role, keksdose's display name + email + role chip. It is text, not a `menuitem`: the\n * arrow keys pass over it.\n */\nexport interface TopBarMenuHeader {\n /** The first line — the user's name. */\n title: ReactNode;\n /** The muted second line — an email, a role. */\n subtitle?: ReactNode;\n /**\n * Anything below — a role chip. A link or button here stays out of the arrow keys\n * and in the Tab order, like the footer's.\n *\n * Pass a function to get `close`, as `footer` does — keksdose F7: its admin role\n * chip links to the admin area, and a router link does not unmount the menu, so\n * without `close` the panel stayed open over the page it had just navigated to.\n */\n extra?: ReactNode | ((close: () => void) => ReactNode);\n}\n\n/** What a control is, as far as `HoverMenu` is concerned, when it lives in the header\n * or the footer: not a menu row. */\nconst SECTION_CONTROLS = \"a[href]:not([role]),button:not([role])\";\nconst TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]';\n\n/**\n * `scrollable`'s cap (0.51, DevMenu): the glass under the bar. Under `TopBar` the panel\n * starts about 50px down — the `size-9` trigger centred in the `h-12` bar ends at 42px,\n * and `HoverMenu` hangs the panel `pt-2` below it — so 4rem (64px) leaves some 14px clear\n * under the last row. `dvh`, not `vh`: on a phone `100vh` is the viewport with the\n * browser's toolbars put away, taller than the glass while they show.\n * `overscroll-contain`, so a fling past the last row does not scroll the page behind.\n */\nconst SCROLLABLE_PANEL = \"max-h-[calc(100dvh-4rem)] overflow-y-auto overscroll-contain\";\n\n/**\n * A header or footer inside the menu panel.\n *\n * `HoverMenu` promotes EVERY role-less button and link in its panel to a `menuitem` out\n * of the Tab order, and turns every list into `role=\"none\"` — right for the rows, wrong\n * for a footer of legal links, which would join the arrow-key cycle and drop out of Tab.\n * So before it looks (a child's layout effect runs before its parent's), the controls\n * in here get their own role spelled out, which `HoverMenu` leaves alone, and any list\n * keeps its list semantics.\n */\nfunction MenuSection({ className, children }: { className: string; children: ReactNode }) {\n const ref = useRef<HTMLLIElement>(null);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n for (const c of el.querySelectorAll<HTMLElement>(SECTION_CONTROLS)) {\n c.setAttribute(\"role\", c.tagName === \"A\" ? \"link\" : \"button\");\n }\n for (const l of el.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role])\")) l.setAttribute(\"role\", \"list\");\n for (const l of el.querySelectorAll<HTMLElement>(\"li:not([role])\")) l.setAttribute(\"role\", \"listitem\");\n });\n return (\n <li ref={ref} role=\"none\" className={className}>\n {children}\n </li>\n );\n}\n\n/**\n * `HoverMenu` closes on any Tab inside the panel — right when Tab leaves it, wrong when\n * Tab is on its way from a row to a footer link, or from one footer link to the next:\n * the panel would unmount under the focus. So a Tab whose next stop is still inside the\n * panel stops here and the browser moves focus; one that leaves reaches `HoverMenu`,\n * which closes.\n */\nfunction keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {\n if (e.key !== \"Tab\") return;\n const from = e.target as HTMLElement;\n const stops = Array.from(e.currentTarget.querySelectorAll<HTMLElement>(TABBABLE)).filter(\n (el) => el.getAttribute(\"tabindex\") !== \"-1\",\n );\n const way = e.shiftKey ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING;\n if (stops.some((el) => el !== from && from.compareDocumentPosition(el) & way)) e.stopPropagation();\n}\n\n/**\n * A top-bar icon button that opens a {@link HoverMenu} of actions and/or links,\n * with an optional uppercase heading and divider rules. Generic glue over the\n * shared top-bar chrome — e.g. the feedback launcher or the guided-tours menu —\n * so those call sites describe their rows as data instead of rebuilding the\n * trigger + list markup. For a checkmarked value-switcher use\n * {@link OptionSwitcherMenu} instead.\n *\n * It is also the ACCOUNT menu kastlan and keksdose each hand-build on `HoverMenu`: a\n * custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a\n * free-content `footer` (the legal links).\n *\n * A long menu is grouped by topic with `{ kind: \"heading\" }` entries (0.23) — see\n * {@link TopBarMenuEntry}. There is no separate phone layout: the panel is the same\n * hover panel at 390px, kept inside the viewport by `HoverMenu`, and a heading wraps\n * rather than widening it. A menu that can grow taller than a phone's glass passes\n * `scrollable` (0.51), since that clamp is sideways only.\n */\nexport function TopBarActionMenu({\n icon,\n iconBadge,\n trigger,\n triggerClassName,\n href,\n \"aria-label\": ariaLabel,\n heading,\n header,\n entries,\n leading,\n children,\n footer,\n footerLabel,\n align,\n panelClassName,\n scrollable = false,\n triggerAttributes,\n}: {\n /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */\n icon?: ReactNode;\n /**\n * A status dot in the top-end corner of `icon` — keksdose's unread-support dot on the\n * feedback trigger. Like `UserAvatar`'s `badge`, its `label` joins the trigger's name\n * (\"Send feedback 2 unread\"), so the dot is not something only the eye is told about.\n * Ignored with `trigger`: give the face its own badge there.\n */\n iconBadge?: UserAvatarBadge | null;\n /**\n * A face of your own for the trigger — the account menu's `UserAvatar`. It is drawn\n * INSIDE the kit's button, which keeps the wiring: `aria-haspopup`, `aria-expanded`,\n * the toggle, the keyboard. `open` is there for a chevron that turns.\n *\n * The button is named by `aria-label` followed by any readable text in the face — a\n * `UserAvatar` `badge` label reaches a screen reader that way (\"Account menu 3\n * unread\"), where an `aria-label` on the button would have hidden it. A\n * `UserAvatar`'s initials are `aria-hidden`, so the person's name is only in the\n * name if `aria-label` carries it (\"Marcel Eifert, account menu\").\n */\n trigger?: (state: { open: boolean }) => ReactNode;\n /** Extra classes for the trigger button — `rounded-full` round an avatar. */\n triggerClassName?: string;\n /**\n * Make the trigger a LINK to this route (keksdose dev #585, Marcel: \"clicking on the\n * guided tours icon shall lead to the tours page like the all tours element does\").\n * A click or Enter follows it and shuts the panel; hover still opens the panel after\n * the usual delay, and ↓/↑ on the focused trigger still open it onto the first/last\n * row, so the rows stay reachable by keyboard. A tap on a touch screen follows the\n * link — so use it where the destination offers everything the menu does. Rendered as\n * an `<a>` (the router's `Link`) with `aria-haspopup=\"menu\"` and `aria-expanded`,\n * named by `aria-label`: a link inside the kit's button would be interactive content\n * inside interactive content.\n */\n href?: string;\n /** Names the trigger and the menu. Required: the trigger is an icon (refactor audit\n * 2026-10, API-1 — the `ariaLabel` spelling went in 0.35). */\n \"aria-label\": string;\n heading?: string;\n /** A non-interactive identity block above the rows. */\n header?: TopBarMenuHeader;\n entries: TopBarMenuEntry[];\n /**\n * Rows the entry data cannot describe (a `Disclosure` sub-list, an app's own\n * section), after `entries`. Render `<li>`s holding `MenuItem`s or plain controls;\n * `close` shuts the menu.\n */\n children?: (close: () => void) => ReactNode;\n /**\n * Rows like {@link children}, but BEFORE `entries`, under the header — the account\n * menu's `AccountMenuLanguage`, which goes first after the app's context rows (§6.5)\n * in a menu whose other rows are all entries (kastlan, build review B1). Render `<li>`s;\n * `close` shuts the menu.\n */\n leading?: (close: () => void) => ReactNode;\n /**\n * Free content below the rows, behind a rule — the legal links. Its links and\n * buttons are NOT menu rows: the arrow keys skip them and Tab reaches them. Pass a\n * function to get `close` for the links' `onClick`.\n */\n footer?: ReactNode | ((close: () => void) => ReactNode);\n /** Wraps the footer in a `<nav>` with this name (\"Legal\"). Without it, a plain block. */\n footerLabel?: string;\n /** Which edge of the trigger the panel lines up with, `start` or `end` in the reading\n * direction (see `HoverMenu`, whose type this is). Default `end`. */\n align?: HoverMenuProps[\"align\"];\n panelClassName?: string;\n /**\n * Cap the panel at the glass under the top bar and scroll the rows inside it (0.51,\n * `DevMenu`). `HoverMenu` keeps a panel inside the viewport sideways only, so a tall\n * one ran off the bottom of the screen: DevMenu's ~25 rows on a 390×844 phone, with\n * the last ones out of reach — the page cannot scroll them up, the panel hangs from\n * the sticky bar.\n *\n * Off by default: a menu that fits keeps its natural height and has no scroll\n * container (`TourMenu` keeps its own `max-h-[70vh]`). The keyboard needs nothing\n * extra: `HoverMenu`'s roving focus is a plain `focus()`, which the browser follows by\n * scrolling the panel to the row, so ↓, ↑, Home and End reach a row below the cap. The\n * header and the footer are inside the list and scroll with the rows.\n */\n scrollable?: boolean;\n /** `data-*` attributes for the trigger itself — a tour's anchor (`data-tour`), a test\n * hook. The parts built on this menu (`TourMenu`) pass theirs through here. */\n triggerAttributes?: { [key: `data-${string}`]: string | number | boolean | undefined };\n}) {\n // Names the groups' headings for `aria-labelledby`. Positional rather than from the\n // entry's `key`, which is the caller's text and may hold a space — and a space in\n // `aria-labelledby` is a list separator.\n const uid = useId();\n return (\n <HoverMenu\n aria-label={ariaLabel}\n align={align}\n trigger={({ open, toggle, close }) => {\n // One element for both kinds: the router's link when `href` is set, else the\n // kit's button. Same classes and face either way.\n //\n // Its name in the kit's Tooltip on hover and focus (0.48, keksdose dev#610): an\n // icon alone told a sighted user nothing the screen reader was told. In a\n // fragment, as IconButton does it, so the bubble is not the trigger's\n // `aria-describedby` — the name it repeats is the trigger's own, and a reader\n // would hear it twice. Portalled, like the top bar's other triggers.\n const control = (className: string, children: ReactNode, label?: string) => (\n <Tooltip label={ariaLabel} portal>\n <>\n {href ? (\n <Link {...triggerAttributes} to={href} onClick={close} aria-label={label} className={className}>\n {children}\n </Link>\n ) : (\n <button {...triggerAttributes} type=\"button\" onClick={toggle} aria-label={label} className={className}>\n {children}\n </button>\n )}\n </>\n </Tooltip>\n );\n if (trigger) {\n return control(\n cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName),\n <>\n <span className=\"sr-only\">{ariaLabel}</span>\n {/* The space keeps the name from running into the face's text (\"Account\n menu3 unread\"); as a flex item it takes no room. */}\n {\" \"}\n {trigger({ open })}\n </>,\n );\n }\n if (iconBadge) {\n // Named by its content, like the custom-trigger branch, so the badge label is\n // part of the name; an `aria-label` would have hidden it.\n return control(\n cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName),\n <>\n <span className=\"sr-only\">{ariaLabel}</span>{\" \"}\n <span aria-hidden className=\"relative inline-flex\">\n {icon}\n <StatusDot ring tone={iconBadge.tone ?? \"danger\"} className=\"absolute -end-0.5 -top-0.5\" />\n </span>{\" \"}\n <span className=\"sr-only\">{iconBadge.label}</span>\n </>,\n );\n }\n return control(cn(TOPBAR_TRIGGER_CLASS, triggerClassName), icon, ariaLabel);\n }}\n >\n {(close) => {\n const footerBody = typeof footer === \"function\" ? footer(close) : footer;\n const headerExtra = typeof header?.extra === \"function\" ? header.extra(close) : header?.extra;\n const footerClass =\n \"flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted [&_a:hover]:text-primary [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-focus [&_a:focus-visible]:ring-brand\";\n /** One action or link row — loose, or inside a heading's group. */\n const row = (entry: RowEntry) => (\n <li key={entry.key}>\n {entry.kind === \"link\" ? (\n <MenuItem\n href={entry.to}\n renderLink={({ href, ...p }) => <Link to={href} {...p} />}\n onClick={close}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n current={entry.current}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n ) : (\n <MenuItem\n onClick={() => {\n entry.onSelect();\n close();\n }}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n checked={entry.checked}\n checkable={entry.checkable}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n )}\n </li>\n );\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel\n <ul className={cn(\"py-1\", scrollable && SCROLLABLE_PANEL, panelClassName)} onKeyDown={keepTabInsidePanel}>\n {header && (\n <MenuSection className=\"mb-1 border-b border-subtle px-3 py-2\">\n <div className=\"truncate text-sm font-medium text-primary\">{header.title}</div>\n {header.subtitle != null && (\n <div className=\"truncate text-xs text-muted\">{header.subtitle}</div>\n )}\n {headerExtra != null && headerExtra !== false && <div className=\"mt-1\">{headerExtra}</div>}\n </MenuSection>\n )}\n {heading && (\n <li className=\"px-3 py-1 text-micro font-semibold uppercase tracking-wide text-placeholder\">\n {heading}\n </li>\n )}\n {leading?.(close)}\n {toBlocks(entries).map((block, i, blocks) => {\n if (block.kind === \"loose\") {\n const entry = block.entry;\n if (entry.kind === \"divider\") {\n return <li key={entry.key} className=\"my-1 border-t border-subtle\" />;\n }\n return row(entry);\n }\n const headingId = `${uid}-group-${i}`;\n const before = blocks[i - 1];\n // Under a rule (or at the top of the rows) the rule's margin is the gap;\n // straight under another group's last row the heading makes its own.\n const tight = !before || (before.kind === \"loose\" && before.entry.kind === \"divider\");\n return (\n <li key={block.heading.key} role=\"none\">\n <div\n id={headingId}\n role=\"presentation\"\n className={cn(\n \"px-3 pb-0.5 text-caption font-semibold uppercase tracking-wide text-muted [overflow-wrap:anywhere]\",\n tight ? \"pt-1\" : \"pt-2.5\",\n )}\n >\n {block.heading.label}\n </div>\n <ul role=\"group\" aria-labelledby={headingId}>\n {block.rows.map(row)}\n </ul>\n </li>\n );\n })}\n {children?.(close)}\n {footerBody != null && footerBody !== false && (\n <MenuSection className=\"mt-1 border-t border-subtle px-3 py-2\">\n {footerLabel ? (\n <nav aria-label={footerLabel} className={footerClass}>\n {footerBody}\n </nav>\n ) : (\n <div className={footerClass}>{footerBody}</div>\n )}\n </MenuSection>\n )}\n </ul>\n );\n }}\n </HoverMenu>\n );\n}\n"],"mappings":"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","names":["children","href"]}
1
+ {"version":3,"sources":["../../src/shell/topbar-action-menu.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useRef } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { Link } from \"react-router\";\nimport { cn } from \"../lib/cn\";\nimport { HoverMenu, type HoverMenuProps } from \"../components/hover-menu\";\nimport { MenuItem } from \"../components/menu-item\";\nimport type { MenuItemTone } from \"../components/menu-item\";\nimport { StatusDot } from \"../components/status-dot\";\nimport { Tooltip } from \"../components/tooltip\";\nimport type { UserAvatarBadge } from \"../components/user-avatar\";\nimport { TOPBAR_TRIGGER_CLASS } from \"./topbar-controls\";\n\n/**\n * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a\n * `link` navigates via react-router, a `divider` draws a rule, and a `heading` names\n * the rows after it. Action and link rows close the menu automatically when chosen.\n * `kind` may be omitted for actions, keeping the common case terse.\n *\n * The rows are {@link MenuItem}s, so they take its options: `tone: \"danger\"` for the\n * log-out row, `checked` (+ `checkable`) for a choice, `current` for the link to the\n * page already open, `disabled`.\n */\nexport type TopBarMenuEntry =\n | {\n kind?: \"action\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n onSelect: () => void;\n tone?: MenuItemTone;\n /** Makes the row a choice (`menuitemradio`, or `menuitemcheckbox` with\n * `checkable: \"checkbox\"`) and says whether it is chosen. */\n checked?: boolean;\n checkable?: \"radio\" | \"checkbox\";\n disabled?: boolean;\n }\n | {\n kind: \"link\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n to: string;\n tone?: MenuItemTone;\n /** The link to the page already open: `aria-current=\"page\"` and a check mark. */\n current?: boolean;\n disabled?: boolean;\n }\n | { kind: \"divider\"; key: string }\n | {\n /**\n * A group heading (0.23, keksdose's guided-tours menu): the rows after it, up to\n * the next heading, the next `divider` or the end of `entries`, are ONE group\n * named by `label`. keksdose files its tours by topic and separated the blocks\n * with bare dividers — \"the kit's menu has no heading row to name them with\"\n * (tour-menu.tsx) — so a sighted reader had to guess what each block was and a\n * screen reader was told nothing at all.\n *\n * Not a row: it takes no focus, the arrow keys (and Home/End) pass over it, and it\n * never closes the menu. It is announced the way the kit's other grouped lists\n * announce theirs (`CommandPalette`): the group's rows sit in a `role=\"group\"`\n * whose `aria-labelledby` is this heading, so a reader says \"On this page, group\"\n * when the focus enters the first row, and nothing between rows.\n *\n * A divider ENDS the group, so a closing row such as \"All tours…\" behind a rule\n * belongs to no topic. A heading with no rows under it is not drawn — an empty\n * group names nothing; build the list from data and it may well be empty.\n *\n * The menu-wide `heading` prop is the panel's title, drawn once above everything;\n * with group headings it is usually redundant (two stacked caps lines read as one),\n * and the trigger's `aria-label` already names the menu.\n */\n kind: \"heading\";\n key: string;\n /** The group's name, shown above its rows and announced as the group's name. Keep\n * it short: it is set in small caps and wraps on a narrow panel. */\n label: ReactNode;\n };\n\ntype HeadingEntry = Extract<TopBarMenuEntry, { kind: \"heading\" }>;\ntype DividerEntry = Extract<TopBarMenuEntry, { kind: \"divider\" }>;\ntype RowEntry = Exclude<TopBarMenuEntry, HeadingEntry | DividerEntry>;\n\n/** What the panel draws from `entries`: loose rows and rules, and headed groups. */\ntype Block = { kind: \"loose\"; entry: RowEntry | DividerEntry } | { kind: \"group\"; heading: HeadingEntry; rows: RowEntry[] };\n\n/**\n * Cut `entries` into blocks: a heading opens a group that takes the rows after it until\n * the next heading or divider; everything else stands alone, as it always has.\n */\nfunction toBlocks(entries: readonly TopBarMenuEntry[]): Block[] {\n const blocks: Block[] = [];\n let group: Extract<Block, { kind: \"group\" }> | null = null;\n for (const entry of entries) {\n if (entry.kind === \"heading\") {\n group = { kind: \"group\", heading: entry, rows: [] };\n blocks.push(group);\n } else if (entry.kind === \"divider\") {\n group = null;\n blocks.push({ kind: \"loose\", entry });\n } else if (group) {\n group.rows.push(entry);\n } else {\n blocks.push({ kind: \"loose\", entry });\n }\n }\n // A heading with nothing under it names nothing: not drawn (see the `heading` kind).\n return blocks.filter((block) => block.kind === \"loose\" || block.rows.length > 0);\n}\n\n/** One column of `columns` (0.51.4): a group and the loose entries after it, up to the\n * next group — so a group's own way out behind a rule (DevMenu's \"Back to {role}\") stays\n * under it instead of running across the panel. */\ntype LooseBlock = Extract<Block, { kind: \"loose\" }>;\ntype Column = { group: Extract<Block, { kind: \"group\" }>; after: Array<RowEntry | DividerEntry>; index: number };\n\n/** `blocks` as the columns layout draws them: what comes before the first group, then a\n * column per group. A rule that would end a column is dropped: the column's edge is the\n * separation there. */\nfunction toColumns(blocks: readonly Block[]): { lead: LooseBlock[]; columns: Column[] } {\n const lead: LooseBlock[] = [];\n const columns: Column[] = [];\n blocks.forEach((block, index) => {\n if (block.kind === \"group\") columns.push({ group: block, after: [], index });\n else if (columns.length === 0) lead.push(block);\n else columns[columns.length - 1]!.after.push(block.entry);\n });\n for (const column of columns) {\n while (column.after.at(-1)?.kind === \"divider\") column.after.pop();\n }\n return { lead, columns };\n}\n\n/**\n * `columns`' panel: each column 14rem, the panel as wide as its columns (a CSS variable\n * the list sets) but never wider than the glass, and from `md` up only. The grid places\n * as many 14rem columns as that width holds, so a narrow desktop wraps the groups onto a\n * second row instead of scrolling sideways. The header, the title and the footer span\n * the row.\n */\nconst COLUMNS_PANEL =\n \"md:grid md:grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] md:items-start md:gap-y-2 md:w-[min(calc(100vw-2rem),var(--menu-columns-width))]\";\nconst SPAN_ALL = \"md:col-span-full\";\n\n/**\n * The non-interactive identity block at the top of an account menu — kastlan's name +\n * role, keksdose's display name + email + role chip. It is text, not a `menuitem`: the\n * arrow keys pass over it.\n */\nexport interface TopBarMenuHeader {\n /** The first line — the user's name. */\n title: ReactNode;\n /** The muted second line — an email, a role. */\n subtitle?: ReactNode;\n /**\n * Anything below — a role chip. A link or button here stays out of the arrow keys\n * and in the Tab order, like the footer's.\n *\n * Pass a function to get `close`, as `footer` does — keksdose F7: its admin role\n * chip links to the admin area, and a router link does not unmount the menu, so\n * without `close` the panel stayed open over the page it had just navigated to.\n */\n extra?: ReactNode | ((close: () => void) => ReactNode);\n}\n\n/** What a control is, as far as `HoverMenu` is concerned, when it lives in the header\n * or the footer: not a menu row. */\nconst SECTION_CONTROLS = \"a[href]:not([role]),button:not([role])\";\nconst TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]';\n\n/**\n * `scrollable`'s cap (0.51, DevMenu): the glass under the bar. Under `TopBar` the panel\n * starts about 50px down — the `size-9` trigger centred in the `h-12` bar ends at 42px,\n * and `HoverMenu` hangs the panel `pt-2` below it — so 4rem (64px) leaves some 14px clear\n * under the last row. `dvh`, not `vh`: on a phone `100vh` is the viewport with the\n * browser's toolbars put away, taller than the glass while they show.\n * `overscroll-contain`, so a fling past the last row does not scroll the page behind.\n */\nconst SCROLLABLE_PANEL = \"max-h-[calc(100dvh-4rem)] overflow-y-auto overscroll-contain\";\n\n/**\n * A header or footer inside the menu panel.\n *\n * `HoverMenu` promotes EVERY role-less button and link in its panel to a `menuitem` out\n * of the Tab order, and turns every list into `role=\"none\"` — right for the rows, wrong\n * for a footer of legal links, which would join the arrow-key cycle and drop out of Tab.\n * So before it looks (a child's layout effect runs before its parent's), the controls\n * in here get their own role spelled out, which `HoverMenu` leaves alone, and any list\n * keeps its list semantics.\n */\nfunction MenuSection({ className, children }: { className: string; children: ReactNode }) {\n const ref = useRef<HTMLLIElement>(null);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n for (const c of el.querySelectorAll<HTMLElement>(SECTION_CONTROLS)) {\n c.setAttribute(\"role\", c.tagName === \"A\" ? \"link\" : \"button\");\n }\n for (const l of el.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role])\")) l.setAttribute(\"role\", \"list\");\n for (const l of el.querySelectorAll<HTMLElement>(\"li:not([role])\")) l.setAttribute(\"role\", \"listitem\");\n });\n return (\n <li ref={ref} role=\"none\" className={className}>\n {children}\n </li>\n );\n}\n\n/**\n * `HoverMenu` closes on any Tab inside the panel — right when Tab leaves it, wrong when\n * Tab is on its way from a row to a footer link, or from one footer link to the next:\n * the panel would unmount under the focus. So a Tab whose next stop is still inside the\n * panel stops here and the browser moves focus; one that leaves reaches `HoverMenu`,\n * which closes.\n */\nfunction keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {\n if (e.key !== \"Tab\") return;\n const from = e.target as HTMLElement;\n const stops = Array.from(e.currentTarget.querySelectorAll<HTMLElement>(TABBABLE)).filter(\n (el) => el.getAttribute(\"tabindex\") !== \"-1\",\n );\n const way = e.shiftKey ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING;\n if (stops.some((el) => el !== from && from.compareDocumentPosition(el) & way)) e.stopPropagation();\n}\n\n/**\n * A top-bar icon button that opens a {@link HoverMenu} of actions and/or links,\n * with an optional uppercase heading and divider rules. Generic glue over the\n * shared top-bar chrome — e.g. the feedback launcher or the guided-tours menu —\n * so those call sites describe their rows as data instead of rebuilding the\n * trigger + list markup. For a checkmarked value-switcher use\n * {@link OptionSwitcherMenu} instead.\n *\n * It is also the ACCOUNT menu kastlan and keksdose each hand-build on `HoverMenu`: a\n * custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a\n * free-content `footer` (the legal links).\n *\n * A long menu is grouped by topic with `{ kind: \"heading\" }` entries (0.23) — see\n * {@link TopBarMenuEntry}. There is no separate phone layout: the panel is the same\n * hover panel at 390px, kept inside the viewport by `HoverMenu`, and a heading wraps\n * rather than widening it. A menu that can grow taller than a phone's glass passes\n * `scrollable` (0.51), since that clamp is sideways only.\n */\nexport function TopBarActionMenu({\n icon,\n iconBadge,\n trigger,\n triggerClassName,\n href,\n \"aria-label\": ariaLabel,\n heading,\n header,\n entries,\n leading,\n children,\n footer,\n footerLabel,\n align,\n panelClassName,\n scrollable = false,\n columns = false,\n triggerAttributes,\n}: {\n /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */\n icon?: ReactNode;\n /**\n * A status dot in the top-end corner of `icon` — keksdose's unread-support dot on the\n * feedback trigger. Like `UserAvatar`'s `badge`, its `label` joins the trigger's name\n * (\"Send feedback 2 unread\"), so the dot is not something only the eye is told about.\n * Ignored with `trigger`: give the face its own badge there.\n */\n iconBadge?: UserAvatarBadge | null;\n /**\n * A face of your own for the trigger — the account menu's `UserAvatar`. It is drawn\n * INSIDE the kit's button, which keeps the wiring: `aria-haspopup`, `aria-expanded`,\n * the toggle, the keyboard. `open` is there for a chevron that turns.\n *\n * The button is named by `aria-label` followed by any readable text in the face — a\n * `UserAvatar` `badge` label reaches a screen reader that way (\"Account menu 3\n * unread\"), where an `aria-label` on the button would have hidden it. A\n * `UserAvatar`'s initials are `aria-hidden`, so the person's name is only in the\n * name if `aria-label` carries it (\"Marcel Eifert, account menu\").\n */\n trigger?: (state: { open: boolean }) => ReactNode;\n /** Extra classes for the trigger button — `rounded-full` round an avatar. */\n triggerClassName?: string;\n /**\n * Make the trigger a LINK to this route (keksdose dev #585, Marcel: \"clicking on the\n * guided tours icon shall lead to the tours page like the all tours element does\").\n * A click or Enter follows it and shuts the panel; hover still opens the panel after\n * the usual delay, and ↓/↑ on the focused trigger still open it onto the first/last\n * row, so the rows stay reachable by keyboard. A tap on a touch screen follows the\n * link — so use it where the destination offers everything the menu does. Rendered as\n * an `<a>` (the router's `Link`) with `aria-haspopup=\"menu\"` and `aria-expanded`,\n * named by `aria-label`: a link inside the kit's button would be interactive content\n * inside interactive content.\n */\n href?: string;\n /** Names the trigger and the menu. Required: the trigger is an icon (refactor audit\n * 2026-10, API-1 — the `ariaLabel` spelling went in 0.35). */\n \"aria-label\": string;\n heading?: string;\n /** A non-interactive identity block above the rows. */\n header?: TopBarMenuHeader;\n entries: TopBarMenuEntry[];\n /**\n * Rows the entry data cannot describe (a `Disclosure` sub-list, an app's own\n * section), after `entries`. Render `<li>`s holding `MenuItem`s or plain controls;\n * `close` shuts the menu.\n */\n children?: (close: () => void) => ReactNode;\n /**\n * Rows like {@link children}, but BEFORE `entries`, under the header — the account\n * menu's `AccountMenuLanguage`, which goes first after the app's context rows (§6.5)\n * in a menu whose other rows are all entries (kastlan, build review B1). Render `<li>`s;\n * `close` shuts the menu.\n */\n leading?: (close: () => void) => ReactNode;\n /**\n * Free content below the rows, behind a rule — the legal links. Its links and\n * buttons are NOT menu rows: the arrow keys skip them and Tab reaches them. Pass a\n * function to get `close` for the links' `onClick`.\n */\n footer?: ReactNode | ((close: () => void) => ReactNode);\n /** Wraps the footer in a `<nav>` with this name (\"Legal\"). Without it, a plain block. */\n footerLabel?: string;\n /** Which edge of the trigger the panel lines up with, `start` or `end` in the reading\n * direction (see `HoverMenu`, whose type this is). Default `end`. */\n align?: HoverMenuProps[\"align\"];\n panelClassName?: string;\n /**\n * Cap the panel at the glass under the top bar and scroll the rows inside it (0.51,\n * `DevMenu`). `HoverMenu` keeps a panel inside the viewport sideways only, so a tall\n * one ran off the bottom of the screen: DevMenu's ~25 rows on a 390×844 phone, with\n * the last ones out of reach — the page cannot scroll them up, the panel hangs from\n * the sticky bar.\n *\n * Off by default: a menu that fits keeps its natural height and has no scroll\n * container (`TourMenu` keeps its own `max-h-[70vh]`). The keyboard needs nothing\n * extra: `HoverMenu`'s roving focus is a plain `focus()`, which the browser follows by\n * scrolling the panel to the row, so ↓, ↑, Home and End reach a row below the cap. The\n * header and the footer are inside the list and scroll with the rows.\n */\n scrollable?: boolean;\n /**\n * From `md` up, lay the heading groups side by side, one column per group (0.51.4,\n * `DevMenu`; keksdose dev #640, Marcel: \"Each group gets a column. That way it would be\n * much better visible for desktop users\"). Below `md`, the one column as before.\n *\n * A column is a group and the loose rows after it up to the next heading, so a row\n * behind a rule at the end of a group (DevMenu's \"Back to {role}\") stays in that\n * group's column. Loose rows before the first heading, the header, the menu-wide\n * `heading` and the footer span the panel. The DOM order is unchanged, so ↓/↑ walk the\n * rows down one column and on into the next, as a reader reads them. `leading` and\n * `children` rows take a cell each; add `md:col-span-full` to one that should span.\n */\n columns?: boolean;\n /** `data-*` attributes for the trigger itself — a tour's anchor (`data-tour`), a test\n * hook. The parts built on this menu (`TourMenu`) pass theirs through here. */\n triggerAttributes?: { [key: `data-${string}`]: string | number | boolean | undefined };\n}) {\n // Names the groups' headings for `aria-labelledby`. Positional rather than from the\n // entry's `key`, which is the caller's text and may hold a space — and a space in\n // `aria-labelledby` is a list separator.\n const uid = useId();\n const blocks = toBlocks(entries);\n const grouped = columns ? toColumns(blocks) : null;\n // A menu with no group has nothing to put side by side: the list as always.\n const laidOut = grouped && grouped.columns.length > 0 ? grouped : null;\n return (\n <HoverMenu\n aria-label={ariaLabel}\n align={align}\n trigger={({ open, toggle, close }) => {\n // One element for both kinds: the router's link when `href` is set, else the\n // kit's button. Same classes and face either way.\n //\n // Its name in the kit's Tooltip on hover and focus (0.48, keksdose dev#610): an\n // icon alone told a sighted user nothing the screen reader was told. In a\n // fragment, as IconButton does it, so the bubble is not the trigger's\n // `aria-describedby` — the name it repeats is the trigger's own, and a reader\n // would hear it twice. Portalled, like the top bar's other triggers.\n const control = (className: string, children: ReactNode, label?: string) => (\n <Tooltip label={ariaLabel} portal>\n <>\n {href ? (\n <Link {...triggerAttributes} to={href} onClick={close} aria-label={label} className={className}>\n {children}\n </Link>\n ) : (\n <button {...triggerAttributes} type=\"button\" onClick={toggle} aria-label={label} className={className}>\n {children}\n </button>\n )}\n </>\n </Tooltip>\n );\n if (trigger) {\n return control(\n cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName),\n <>\n <span className=\"sr-only\">{ariaLabel}</span>\n {/* The space keeps the name from running into the face's text (\"Account\n menu3 unread\"); as a flex item it takes no room. */}\n {\" \"}\n {trigger({ open })}\n </>,\n );\n }\n if (iconBadge) {\n // Named by its content, like the custom-trigger branch, so the badge label is\n // part of the name; an `aria-label` would have hidden it.\n return control(\n cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName),\n <>\n <span className=\"sr-only\">{ariaLabel}</span>{\" \"}\n <span aria-hidden className=\"relative inline-flex\">\n {icon}\n <StatusDot ring tone={iconBadge.tone ?? \"danger\"} className=\"absolute -end-0.5 -top-0.5\" />\n </span>{\" \"}\n <span className=\"sr-only\">{iconBadge.label}</span>\n </>,\n );\n }\n return control(cn(TOPBAR_TRIGGER_CLASS, triggerClassName), icon, ariaLabel);\n }}\n >\n {(close) => {\n const footerBody = typeof footer === \"function\" ? footer(close) : footer;\n const headerExtra = typeof header?.extra === \"function\" ? header.extra(close) : header?.extra;\n const footerClass =\n \"flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted [&_a:hover]:text-primary [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-focus [&_a:focus-visible]:ring-brand\";\n /** One action or link row — loose, or inside a heading's group. */\n const row = (entry: RowEntry, className?: string) => (\n <li key={entry.key} className={className}>\n {entry.kind === \"link\" ? (\n <MenuItem\n href={entry.to}\n renderLink={({ href, ...p }) => <Link to={href} {...p} />}\n onClick={close}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n current={entry.current}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n ) : (\n <MenuItem\n onClick={() => {\n entry.onSelect();\n close();\n }}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n checked={entry.checked}\n checkable={entry.checkable}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n )}\n </li>\n );\n /** A rule or a row outside any group; `span` stretches it across the columns. */\n const loose = (block: LooseBlock, span: boolean) => {\n const entry = block.entry;\n if (entry.kind === \"divider\") {\n return <li key={entry.key} className={cn(\"my-1 border-t border-subtle\", span && SPAN_ALL)} />;\n }\n return row(entry, span ? SPAN_ALL : undefined);\n };\n /** A heading's group: its name, then its rows in a named `role=\"group\"`. */\n const group = (block: Extract<Block, { kind: \"group\" }>, i: number, tight: boolean, inColumn = false) => {\n const headingId = `${uid}-group-${i}`;\n return (\n <li key={block.heading.key} role=\"none\">\n <div\n id={headingId}\n role=\"presentation\"\n className={cn(\n \"px-3 pb-0.5 text-caption font-semibold uppercase tracking-wide text-muted [overflow-wrap:anywhere]\",\n // A column's heading lines up with its neighbours' at the top of the\n // grid row; under `md` the columns stack, and it spaces like the list's.\n tight ? \"pt-1\" : cn(\"pt-2.5\", inColumn && \"md:pt-1\"),\n )}\n >\n {block.heading.label}\n </div>\n <ul role=\"group\" aria-labelledby={headingId}>\n {block.rows.map((entry) => row(entry))}\n </ul>\n </li>\n );\n };\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel\n <ul\n className={cn(\"py-1\", scrollable && SCROLLABLE_PANEL, laidOut && COLUMNS_PANEL, panelClassName)}\n style={laidOut ? ({ \"--menu-columns-width\": `${laidOut.columns.length * 14}rem` } as CSSProperties) : undefined}\n onKeyDown={keepTabInsidePanel}\n >\n {header && (\n <MenuSection className={cn(\"mb-1 border-b border-subtle px-3 py-2\", laidOut && SPAN_ALL)}>\n <div className=\"truncate text-sm font-medium text-primary\">{header.title}</div>\n {header.subtitle != null && (\n <div className=\"truncate text-xs text-muted\">{header.subtitle}</div>\n )}\n {headerExtra != null && headerExtra !== false && <div className=\"mt-1\">{headerExtra}</div>}\n </MenuSection>\n )}\n {heading && (\n <li\n className={cn(\n \"px-3 py-1 text-micro font-semibold uppercase tracking-wide text-placeholder\",\n laidOut && SPAN_ALL,\n )}\n >\n {heading}\n </li>\n )}\n {leading?.(close)}\n {laidOut ? (\n <>\n {laidOut.lead.map((block) => loose(block, true))}\n {laidOut.columns.map((column, c) => (\n // A column of the grid; under `md` a group as in the list, its rows\n // following.\n <li key={column.group.heading.key} role=\"none\" data-menu-column=\"\" className=\"min-w-0\">\n <ul role=\"none\">\n {group(column.group, column.index, c === 0 && laidOut.lead.length === 0, true)}\n {column.after.map((entry) => loose({ kind: \"loose\", entry }, false))}\n </ul>\n </li>\n ))}\n </>\n ) : (\n blocks.map((block, i) => {\n if (block.kind === \"loose\") return loose(block, false);\n const before = blocks[i - 1];\n // Under a rule (or at the top of the rows) the rule's margin is the gap;\n // straight under another group's last row the heading makes its own.\n return group(block, i, !before || (before.kind === \"loose\" && before.entry.kind === \"divider\"));\n })\n )}\n {children?.(close)}\n {footerBody != null && footerBody !== false && (\n <MenuSection className={cn(\"mt-1 border-t border-subtle px-3 py-2\", laidOut && SPAN_ALL)}>\n {footerLabel ? (\n <nav aria-label={footerLabel} className={footerClass}>\n {footerBody}\n </nav>\n ) : (\n <div className={footerClass}>{footerBody}</div>\n )}\n </MenuSection>\n )}\n </ul>\n );\n }}\n </HoverMenu>\n );\n}\n"],"mappings":"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","names":["children","href"]}
package/dist/shell.d.ts CHANGED
@@ -18,7 +18,7 @@ export { SettingsLayout, SETTINGS_SEARCH_THRESHOLD } from "./settings/settings-l
18
18
  export type { SettingsLayoutProps, SettingsLayoutWidth, SettingsDocumentTitleParts } from "./settings/settings-layout.js";
19
19
  export { useSettingsRoute, resolveSettingsLocation, settingsFromListState, SETTINGS_FROM_LIST_STATE, } from "./settings/use-settings-route.js";
20
20
  export type { UseSettingsRouteOptions, SettingsRoute, SettingsLayoutMode, SettingsLocation, SettingsLocationRules, SettingsLocationResult, } from "./settings/use-settings-route.js";
21
- export { useSettingsFocus, SETTINGS_FOCUS_RING, SETTINGS_FOCUS_MS } from "./settings/use-settings-focus.js";
21
+ export { useSettingsFocus, useFocusParam, SETTINGS_FOCUS_RING, SETTINGS_FOCUS_MS } from "./settings/use-settings-focus.js";
22
22
  export { SettingsSection } from "./settings/settings-section.js";
23
23
  export type { SettingsSectionProps } from "./settings/settings-section.js";
24
24
  export { useSettingsLayout } from "./settings/settings-context.js";
package/dist/shell.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  settingsFromListState,
19
19
  SETTINGS_FROM_LIST_STATE
20
20
  } from "./settings/use-settings-route.js";
21
- import { useSettingsFocus, SETTINGS_FOCUS_RING, SETTINGS_FOCUS_MS } from "./settings/use-settings-focus.js";
21
+ import { useSettingsFocus, useFocusParam, SETTINGS_FOCUS_RING, SETTINGS_FOCUS_MS } from "./settings/use-settings-focus.js";
22
22
  import { SettingsSection } from "./settings/settings-section.js";
23
23
  import { useSettingsLayout } from "./settings/settings-context.js";
24
24
  import { SettingsHeadingLevel, useSettingsHeadingLevel } from "./settings/settings-heading.js";
@@ -84,6 +84,7 @@ export {
84
84
  settingsSearchEntries,
85
85
  syncState,
86
86
  useAdminMenuEntry,
87
+ useFocusParam,
87
88
  useLastVisitedPage,
88
89
  useSettingsFocus,
89
90
  useSettingsHeadingLevel,