@eifi1/ui-kit 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +26 -7
  2. package/dist/admin/admin-area-labels.d.ts +59 -0
  3. package/dist/admin/admin-area-labels.js +35 -0
  4. package/dist/admin/admin-area-labels.js.map +1 -0
  5. package/dist/admin/admin-forbidden.d.ts +35 -0
  6. package/dist/admin/admin-forbidden.js +36 -0
  7. package/dist/admin/admin-forbidden.js.map +1 -0
  8. package/dist/admin/admin-overview.d.ts +110 -0
  9. package/dist/admin/admin-overview.js +92 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +94 -0
  12. package/dist/admin/admin-sections.js +81 -0
  13. package/dist/admin/admin-sections.js.map +1 -0
  14. package/dist/admin/admin-tool-links.d.ts +48 -0
  15. package/dist/admin/admin-tool-links.js +31 -0
  16. package/dist/admin/admin-tool-links.js.map +1 -0
  17. package/dist/billing/billing-banner.d.ts +33 -2
  18. package/dist/billing/billing-banner.js +29 -3
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +36 -0
  21. package/dist/billing/billing-labels.js +10 -0
  22. package/dist/billing/billing-labels.js.map +1 -1
  23. package/dist/billing/billing-overview.d.ts +33 -0
  24. package/dist/billing/billing-overview.js +6 -0
  25. package/dist/billing/billing-overview.js.map +1 -1
  26. package/dist/billing/billing-standing.d.ts +8 -0
  27. package/dist/billing/billing-standing.js.map +1 -1
  28. package/dist/billing/cancel-request.d.ts +53 -0
  29. package/dist/billing/cancel-request.js +21 -0
  30. package/dist/billing/cancel-request.js.map +1 -0
  31. package/dist/billing/cancel-subscription.d.ts +46 -0
  32. package/dist/billing/cancel-subscription.js +57 -0
  33. package/dist/billing/cancel-subscription.js.map +1 -0
  34. package/dist/billing/plan-card.d.ts +45 -1
  35. package/dist/billing/plan-card.js +53 -2
  36. package/dist/billing/plan-card.js.map +1 -1
  37. package/dist/billing/plan-switch.d.ts +10 -1
  38. package/dist/billing/plan-switch.js +22 -13
  39. package/dist/billing/plan-switch.js.map +1 -1
  40. package/dist/billing/provider-page.d.ts +40 -0
  41. package/dist/billing/provider-page.js +33 -0
  42. package/dist/billing/provider-page.js.map +1 -0
  43. package/dist/billing/subscription-actions.d.ts +68 -4
  44. package/dist/billing/subscription-actions.js +65 -5
  45. package/dist/billing/subscription-actions.js.map +1 -1
  46. package/dist/components/account-chips.d.ts +50 -2
  47. package/dist/components/account-chips.js +33 -0
  48. package/dist/components/account-chips.js.map +1 -1
  49. package/dist/components/empty-state.d.ts +2 -1
  50. package/dist/components/empty-state.js.map +1 -1
  51. package/dist/eslint/index.js +1 -1
  52. package/dist/feedback/feedback-menu.d.ts +7 -4
  53. package/dist/feedback/feedback-menu.js +4 -2
  54. package/dist/feedback/feedback-menu.js.map +1 -1
  55. package/dist/hooks/use-search-param-state.js +4 -1
  56. package/dist/hooks/use-search-param-state.js.map +1 -1
  57. package/dist/i18n/defaults.js +7 -1
  58. package/dist/i18n/defaults.js.map +1 -1
  59. package/dist/i18n/german.js +113 -0
  60. package/dist/i18n/german.js.map +1 -1
  61. package/dist/i18n/kit-labels.d.ts +12 -0
  62. package/dist/i18n/kit-labels.js.map +1 -1
  63. package/dist/i18n/locales/en.js +142 -1
  64. package/dist/i18n/locales/en.js.map +3 -3
  65. package/dist/i18n/locales/es.js +110 -0
  66. package/dist/i18n/locales/es.js.map +1 -1
  67. package/dist/i18n/locales/fr.js +109 -0
  68. package/dist/i18n/locales/fr.js.map +1 -1
  69. package/dist/i18n/locales/hu.js +110 -0
  70. package/dist/i18n/locales/hu.js.map +1 -1
  71. package/dist/i18n/locales/it.js +110 -0
  72. package/dist/i18n/locales/it.js.map +1 -1
  73. package/dist/i18n/locales/zh.js +109 -0
  74. package/dist/i18n/locales/zh.js.map +1 -1
  75. package/dist/i18n/review.js +34 -1
  76. package/dist/i18n/review.js.map +1 -1
  77. package/dist/index.d.ts +24 -2
  78. package/dist/index.js +29 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/search/global-search.d.ts +3 -0
  81. package/dist/search/global-search.js +3 -1
  82. package/dist/search/global-search.js.map +1 -1
  83. package/dist/settings/settings-catalogue.d.ts +9 -1
  84. package/dist/settings/settings-catalogue.js +1 -1
  85. package/dist/settings/settings-catalogue.js.map +1 -1
  86. package/dist/settings/settings-layout.d.ts +7 -1
  87. package/dist/settings/settings-layout.js +35 -27
  88. package/dist/settings/settings-layout.js.map +1 -1
  89. package/dist/shell/account-menu-language.d.ts +46 -0
  90. package/dist/shell/account-menu-language.js +64 -0
  91. package/dist/shell/account-menu-language.js.map +1 -0
  92. package/dist/shell/admin-menu.d.ts +69 -0
  93. package/dist/shell/admin-menu.js +64 -0
  94. package/dist/shell/admin-menu.js.map +1 -0
  95. package/dist/shell/app-shell.d.ts +10 -0
  96. package/dist/shell/app-shell.js +22 -4
  97. package/dist/shell/app-shell.js.map +1 -1
  98. package/dist/shell/dev-switchers.d.ts +53 -0
  99. package/dist/shell/dev-switchers.js +107 -0
  100. package/dist/shell/dev-switchers.js.map +1 -0
  101. package/dist/shell/sync-status-indicator.d.ts +86 -0
  102. package/dist/shell/sync-status-indicator.js +170 -0
  103. package/dist/shell/sync-status-indicator.js.map +1 -0
  104. package/dist/shell/top-bar-actions.d.ts +49 -0
  105. package/dist/shell/top-bar-actions.js +31 -0
  106. package/dist/shell/top-bar-actions.js.map +1 -0
  107. package/dist/shell/top-bar-parts-labels.d.ts +90 -0
  108. package/dist/shell/top-bar-parts-labels.js +51 -0
  109. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  110. package/dist/shell/topbar-action-menu.d.ts +13 -1
  111. package/dist/shell/topbar-action-menu.js +5 -2
  112. package/dist/shell/topbar-action-menu.js.map +1 -1
  113. package/dist/shell/topbar-controls.d.ts +16 -3
  114. package/dist/shell/topbar-controls.js +5 -1
  115. package/dist/shell/topbar-controls.js.map +1 -1
  116. package/dist/shell/tour-menu.d.ts +67 -0
  117. package/dist/shell/tour-menu.js +87 -0
  118. package/dist/shell/tour-menu.js.map +1 -0
  119. package/dist/shell.d.ts +16 -0
  120. package/dist/shell.js +21 -0
  121. package/dist/shell.js.map +1 -1
  122. package/dist/tour/tour-catalogue-labels.d.ts +44 -0
  123. package/dist/tour/tour-catalogue-labels.js +32 -0
  124. package/dist/tour/tour-catalogue-labels.js.map +1 -0
  125. package/dist/tour/tour-catalogue.d.ts +68 -0
  126. package/dist/tour/tour-catalogue.js +189 -0
  127. package/dist/tour/tour-catalogue.js.map +1 -0
  128. package/dist/tour/tour-info.d.ts +112 -0
  129. package/dist/tour/tour-info.js +36 -0
  130. package/dist/tour/tour-info.js.map +1 -0
  131. package/dist/tour/use-tour-progress.d.ts +61 -0
  132. package/dist/tour/use-tour-progress.js +116 -0
  133. package/dist/tour/use-tour-progress.js.map +1 -0
  134. package/dist/tour/use-tour-url-sync.d.ts +58 -0
  135. package/dist/tour/use-tour-url-sync.js +110 -0
  136. package/dist/tour/use-tour-url-sync.js.map +1 -0
  137. package/dist/tour.d.ts +8 -0
  138. package/dist/tour.js +18 -0
  139. package/dist/tour.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/admin/admin-area-labels.ts +91 -0
  142. package/src/admin/admin-forbidden.tsx +75 -0
  143. package/src/admin/admin-overview.tsx +217 -0
  144. package/src/admin/admin-sections.tsx +198 -0
  145. package/src/admin/admin-tool-links.tsx +84 -0
  146. package/src/billing/billing-banner.tsx +57 -6
  147. package/src/billing/billing-labels.ts +59 -0
  148. package/src/billing/billing-overview.ts +37 -0
  149. package/src/billing/billing-standing.ts +8 -0
  150. package/src/billing/cancel-request.ts +75 -0
  151. package/src/billing/cancel-subscription.tsx +102 -0
  152. package/src/billing/plan-card.tsx +97 -1
  153. package/src/billing/plan-switch.tsx +16 -0
  154. package/src/billing/provider-page.ts +75 -0
  155. package/src/billing/subscription-actions.tsx +155 -20
  156. package/src/components/account-chips.tsx +89 -2
  157. package/src/components/empty-state.tsx +2 -1
  158. package/src/feedback/feedback-menu.tsx +8 -3
  159. package/src/hooks/use-search-param-state.ts +6 -1
  160. package/src/i18n/defaults.ts +6 -0
  161. package/src/i18n/german.ts +127 -0
  162. package/src/i18n/kit-labels.tsx +12 -0
  163. package/src/i18n/locales/en.ts +32 -0
  164. package/src/i18n/locales/es.ts +121 -0
  165. package/src/i18n/locales/fr.ts +121 -0
  166. package/src/i18n/locales/hu.ts +119 -0
  167. package/src/i18n/locales/it.ts +122 -0
  168. package/src/i18n/locales/zh.ts +114 -0
  169. package/src/i18n/review.ts +33 -0
  170. package/src/index.ts +38 -2
  171. package/src/search/global-search.tsx +5 -0
  172. package/src/settings/settings-catalogue.ts +10 -2
  173. package/src/settings/settings-layout.tsx +58 -34
  174. package/src/shell/account-menu-language.tsx +116 -0
  175. package/src/shell/admin-menu.tsx +146 -0
  176. package/src/shell/app-shell.tsx +44 -3
  177. package/src/shell/dev-switchers.tsx +179 -0
  178. package/src/shell/sync-status-indicator.tsx +301 -0
  179. package/src/shell/top-bar-actions.tsx +79 -0
  180. package/src/shell/top-bar-parts-labels.ts +142 -0
  181. package/src/shell/topbar-action-menu.tsx +15 -2
  182. package/src/shell/topbar-controls.tsx +20 -3
  183. package/src/shell/tour-menu.tsx +179 -0
  184. package/src/shell.ts +27 -0
  185. package/src/tour/tour-catalogue-labels.ts +75 -0
  186. package/src/tour/tour-catalogue.tsx +316 -0
  187. package/src/tour/tour-info.ts +144 -0
  188. package/src/tour/use-tour-progress.ts +198 -0
  189. package/src/tour/use-tour-url-sync.ts +221 -0
  190. package/src/tour.ts +11 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/settings/settings-catalogue.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport type { SearchEntry } from \"../search/search-index\";\n\n/**\n * The settings catalogue (docs/settings-harmonization.md §4.1), lifted from keksdose's\n * `settings-index.ts`: the groups a settings page is split into, and one entry per\n * SETTING — not per card — so a search for \"quiet hours\" finds the field inside the\n * notifications card. One list feeds the sidebar, the phone list, the in-page search and\n * ⌘K ({@link settingsSearchEntries}); keksdose's page and its palette drifted when they\n * had one each.\n *\n * The kit resolves no words: `title`, `help` and `keywords` arrive translated (an app's\n * own, or the core groups' from `DEFAULT_SETTINGS_LABELS.groups`).\n */\n\n/** One group of the page: a sidebar row on a desktop, a list row on a phone (§3.2, §3.3). */\nexport interface SettingsGroup<G extends string = string> {\n /** The path segment, `/settings/<id>`: lowercase, a–z and \"-\". */\n id: G;\n /** The row's icon — a lucide icon at the row's size is sized by the row. */\n icon: ReactNode;\n /** The group's name: the sidebar row, the heading over its cards, the phone list. */\n title: string;\n /** One line under the heading and in the phone list, where it is cut to one line. */\n help: string;\n /**\n * `false` hides the group: no row, no hits, and its path acts as an unknown one — it\n * lands on the default group (desktop) or the list (phone), never on a 404 (§3.1).\n * kastlan's `/admin/billing` while billing is off. Default: visible.\n */\n visible?: boolean;\n}\n\n/** One setting, and the card that holds it (§4.1). */\nexport interface SettingsEntry<G extends string = string> {\n /** Unique within the catalogue. */\n id: string;\n /** The group whose page shows the card. */\n group: G;\n /** The card's DOM id, unique on the page — the `?focus=` target. Several entries may\n * share one: a card that holds four settings has four entries. */\n anchor: string;\n /** What a search hit says. */\n title: string;\n /** Localized, space-separated words people type that the title does not contain\n * (\"dark night\" for the theme, \"2fa totp\" for two-factor). Never shown. */\n keywords?: string;\n /**\n * `false` drops the entry from the page's search and ⌘K. A group whose entries are\n * ALL hidden is hidden with them (§4.1): a demo session marks every card it is refused\n * (passkeys, 2FA, export, deletion …) and so never sees a page of cards that each\n * answer 403. Default: visible.\n */\n visible?: boolean;\n}\n\n/**\n * The groups that show, in their order: not `visible: false`, and — when `entries` is\n * given — holding at least one visible entry (§4.1: Kurvenschmiede's notifications group\n * stays hidden until it has a card). Without `entries` only the groups' own flag counts.\n */\nexport function visibleSettingsGroups<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries?: readonly SettingsEntry<G>[],\n): SettingsGroup<G>[] {\n if (!entries) return groups.filter((g) => g.visible !== false);\n const filled = new Set<G>();\n for (const e of entries) if (e.visible !== false) filled.add(e.group);\n return groups.filter((g) => g.visible !== false && filled.has(g.id));\n}\n\n/** The visible entries of the visible groups — what search may find. */\nexport function visibleSettingsEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n): SettingsEntry<G>[] {\n const shown = new Set(visibleSettingsGroups(groups, entries).map((g) => g.id));\n return entries.filter((e) => e.visible !== false && shown.has(e.group));\n}\n\n/** `basePath` without a trailing slash: \"/settings/\" and \"/settings\" are one page. */\nexport function normalizeSettingsBase(basePath: string): string {\n const trimmed = basePath.replace(/\\/+$/, \"\");\n return trimmed.startsWith(\"/\") || trimmed === \"\" ? trimmed : `/${trimmed}`;\n}\n\n/** `/settings/<group>`, and `?focus=<anchor>` when a card is named (§3.4). */\nexport function settingsHref(basePath: string, group: string, anchor?: string): string {\n const path = `${normalizeSettingsBase(basePath)}/${group}`;\n return anchor ? `${path}?focus=${encodeURIComponent(anchor)}` : path;\n}\n\n/** \"dark night\" → [\"dark\", \"night\"]. */\nexport function settingsKeywords(keywords: string | undefined): string[] {\n return keywords ? keywords.split(/\\s+/).filter(Boolean) : [];\n}\n\nexport interface SettingsSearchEntriesOptions {\n /** `/settings` or `/admin` — the hits link to `<basePath>/<group>?focus=<anchor>`. */\n basePath: string;\n}\n\n/**\n * The catalogue as {@link GlobalSearch} entries (§3.4): each visible setting, under its\n * group's name as the palette's section, linking to `/settings/<group>?focus=<anchor>` —\n * the same link the page's own search hit is, so ⌘K, a tour and a mail all land the\n * same way (§3.5). The group's name is a keyword too, as on the page: \"security\" lists\n * the security group's settings.\n *\n * Ids are prefixed with the base path (\"settings:theme\", \"admin:users\") so a palette\n * that also holds pages and actions keeps them unique.\n */\nexport function settingsSearchEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n options: SettingsSearchEntriesOptions,\n): SearchEntry[] {\n const base = normalizeSettingsBase(options.basePath);\n const prefix = base.replace(/^\\//, \"\").replace(/\\//g, \":\") || \"settings\";\n const byId = new Map(visibleSettingsGroups(groups, entries).map((g) => [g.id, g]));\n return visibleSettingsEntries(groups, entries).map((e) => {\n const group = byId.get(e.group)!;\n return {\n id: `${prefix}:${e.id}`,\n title: e.title,\n keywords: [group.title, ...settingsKeywords(e.keywords)],\n group: group.title,\n icon: group.icon,\n href: settingsHref(base, e.group, e.anchor),\n };\n });\n}\n"],"mappings":";AA6DO,SAAS,sBACd,QACA,SACoB;AACpB,MAAI,CAAC,QAAS,QAAO,OAAO,OAAO,CAAC,MAAM,EAAE,YAAY,KAAK;AAC7D,QAAM,SAAS,oBAAI,IAAO;AAC1B,aAAW,KAAK,QAAS,KAAI,EAAE,YAAY,MAAO,QAAO,IAAI,EAAE,KAAK;AACpE,SAAO,OAAO,OAAO,CAAC,MAAM,EAAE,YAAY,SAAS,OAAO,IAAI,EAAE,EAAE,CAAC;AACrE;AAGO,SAAS,uBACd,QACA,SACoB;AACpB,QAAM,QAAQ,IAAI,IAAI,sBAAsB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC7E,SAAO,QAAQ,OAAO,CAAC,MAAM,EAAE,YAAY,SAAS,MAAM,IAAI,EAAE,KAAK,CAAC;AACxE;AAGO,SAAS,sBAAsB,UAA0B;AAC9D,QAAM,UAAU,SAAS,QAAQ,QAAQ,EAAE;AAC3C,SAAO,QAAQ,WAAW,GAAG,KAAK,YAAY,KAAK,UAAU,IAAI,OAAO;AAC1E;AAGO,SAAS,aAAa,UAAkB,OAAe,QAAyB;AACrF,QAAM,OAAO,GAAG,sBAAsB,QAAQ,CAAC,IAAI,KAAK;AACxD,SAAO,SAAS,GAAG,IAAI,UAAU,mBAAmB,MAAM,CAAC,KAAK;AAClE;AAGO,SAAS,iBAAiB,UAAwC;AACvE,SAAO,WAAW,SAAS,MAAM,KAAK,EAAE,OAAO,OAAO,IAAI,CAAC;AAC7D;AAiBO,SAAS,sBACd,QACA,SACA,SACe;AACf,QAAM,OAAO,sBAAsB,QAAQ,QAAQ;AACnD,QAAM,SAAS,KAAK,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,GAAG,KAAK;AAC9D,QAAM,OAAO,IAAI,IAAI,sBAAsB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AACjF,SAAO,uBAAuB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM;AACxD,UAAM,QAAQ,KAAK,IAAI,EAAE,KAAK;AAC9B,WAAO;AAAA,MACL,IAAI,GAAG,MAAM,IAAI,EAAE,EAAE;AAAA,MACrB,OAAO,EAAE;AAAA,MACT,UAAU,CAAC,MAAM,OAAO,GAAG,iBAAiB,EAAE,QAAQ,CAAC;AAAA,MACvD,OAAO,MAAM;AAAA,MACb,MAAM,MAAM;AAAA,MACZ,MAAM,aAAa,MAAM,EAAE,OAAO,EAAE,MAAM;AAAA,IAC5C;AAAA,EACF,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../src/settings/settings-catalogue.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport type { SearchEntry } from \"../search/search-index\";\n\n/**\n * The settings catalogue (docs/settings-harmonization.md §4.1), lifted from keksdose's\n * `settings-index.ts`: the groups a settings page is split into, and one entry per\n * SETTING — not per card — so a search for \"quiet hours\" finds the field inside the\n * notifications card. One list feeds the sidebar, the phone list, the in-page search and\n * ⌘K ({@link settingsSearchEntries}); keksdose's page and its palette drifted when they\n * had one each.\n *\n * The kit resolves no words: `title`, `help` and `keywords` arrive translated (an app's\n * own, or the core groups' from `DEFAULT_SETTINGS_LABELS.groups`).\n */\n\n/** One group of the page: a sidebar row on a desktop, a list row on a phone (§3.2, §3.3). */\nexport interface SettingsGroup<G extends string = string> {\n /** The path segment, `/settings/<id>`: lowercase, a–z and \"-\". */\n id: G;\n /** The row's icon — a lucide icon at the row's size is sized by the row. */\n icon: ReactNode;\n /** The group's name: the sidebar row, the heading over its cards, the phone list. */\n title: string;\n /** One line under the heading and in the phone list, where it is cut to one line. */\n help: string;\n /**\n * `false` hides the group: no row, no hits, and its path acts as an unknown one — it\n * lands on the default group (desktop) or the list (phone), never on a 404 (§3.1).\n * kastlan's `/admin/billing` while billing is off. Default: visible.\n */\n visible?: boolean;\n /**\n * 0.43: the group shows with no visible entry — a page of its own rather than of setting\n * cards (the admin area's Overview, whose tiles are no settings; `adminSections` sets\n * it). `visible: false` still hides it. Default: a group shows only with an entry once\n * `entries` are given (§4.1).\n */\n standalone?: boolean;\n}\n\n/** One setting, and the card that holds it (§4.1). */\nexport interface SettingsEntry<G extends string = string> {\n /** Unique within the catalogue. */\n id: string;\n /** The group whose page shows the card. */\n group: G;\n /** The card's DOM id, unique on the page — the `?focus=` target. Several entries may\n * share one: a card that holds four settings has four entries. */\n anchor: string;\n /** What a search hit says. */\n title: string;\n /** Localized, space-separated words people type that the title does not contain\n * (\"dark night\" for the theme, \"2fa totp\" for two-factor). Never shown. */\n keywords?: string;\n /**\n * `false` drops the entry from the page's search and ⌘K. A group whose entries are\n * ALL hidden is hidden with them (§4.1): a demo session marks every card it is refused\n * (passkeys, 2FA, export, deletion …) and so never sees a page of cards that each\n * answer 403. Default: visible.\n */\n visible?: boolean;\n}\n\n/**\n * The groups that show, in their order: not `visible: false`, and — when `entries` is\n * given — holding at least one visible entry (§4.1: Kurvenschmiede's notifications group\n * stays hidden until it has a card) or `standalone` (0.43). Without `entries` only the\n * groups' own flag counts.\n */\nexport function visibleSettingsGroups<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries?: readonly SettingsEntry<G>[],\n): SettingsGroup<G>[] {\n if (!entries) return groups.filter((g) => g.visible !== false);\n const filled = new Set<G>();\n for (const e of entries) if (e.visible !== false) filled.add(e.group);\n return groups.filter((g) => g.visible !== false && (g.standalone === true || filled.has(g.id)));\n}\n\n/** The visible entries of the visible groups — what search may find. */\nexport function visibleSettingsEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n): SettingsEntry<G>[] {\n const shown = new Set(visibleSettingsGroups(groups, entries).map((g) => g.id));\n return entries.filter((e) => e.visible !== false && shown.has(e.group));\n}\n\n/** `basePath` without a trailing slash: \"/settings/\" and \"/settings\" are one page. */\nexport function normalizeSettingsBase(basePath: string): string {\n const trimmed = basePath.replace(/\\/+$/, \"\");\n return trimmed.startsWith(\"/\") || trimmed === \"\" ? trimmed : `/${trimmed}`;\n}\n\n/** `/settings/<group>`, and `?focus=<anchor>` when a card is named (§3.4). */\nexport function settingsHref(basePath: string, group: string, anchor?: string): string {\n const path = `${normalizeSettingsBase(basePath)}/${group}`;\n return anchor ? `${path}?focus=${encodeURIComponent(anchor)}` : path;\n}\n\n/** \"dark night\" → [\"dark\", \"night\"]. */\nexport function settingsKeywords(keywords: string | undefined): string[] {\n return keywords ? keywords.split(/\\s+/).filter(Boolean) : [];\n}\n\nexport interface SettingsSearchEntriesOptions {\n /** `/settings` or `/admin` — the hits link to `<basePath>/<group>?focus=<anchor>`. */\n basePath: string;\n}\n\n/**\n * The catalogue as {@link GlobalSearch} entries (§3.4): each visible setting, under its\n * group's name as the palette's section, linking to `/settings/<group>?focus=<anchor>` —\n * the same link the page's own search hit is, so ⌘K, a tour and a mail all land the\n * same way (§3.5). The group's name is a keyword too, as on the page: \"security\" lists\n * the security group's settings.\n *\n * Ids are prefixed with the base path (\"settings:theme\", \"admin:users\") so a palette\n * that also holds pages and actions keeps them unique.\n */\nexport function settingsSearchEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n options: SettingsSearchEntriesOptions,\n): SearchEntry[] {\n const base = normalizeSettingsBase(options.basePath);\n const prefix = base.replace(/^\\//, \"\").replace(/\\//g, \":\") || \"settings\";\n const byId = new Map(visibleSettingsGroups(groups, entries).map((g) => [g.id, g]));\n return visibleSettingsEntries(groups, entries).map((e) => {\n const group = byId.get(e.group)!;\n return {\n id: `${prefix}:${e.id}`,\n title: e.title,\n keywords: [group.title, ...settingsKeywords(e.keywords)],\n group: group.title,\n icon: group.icon,\n href: settingsHref(base, e.group, e.anchor),\n };\n });\n}\n"],"mappings":";AAqEO,SAAS,sBACd,QACA,SACoB;AACpB,MAAI,CAAC,QAAS,QAAO,OAAO,OAAO,CAAC,MAAM,EAAE,YAAY,KAAK;AAC7D,QAAM,SAAS,oBAAI,IAAO;AAC1B,aAAW,KAAK,QAAS,KAAI,EAAE,YAAY,MAAO,QAAO,IAAI,EAAE,KAAK;AACpE,SAAO,OAAO,OAAO,CAAC,MAAM,EAAE,YAAY,UAAU,EAAE,eAAe,QAAQ,OAAO,IAAI,EAAE,EAAE,EAAE;AAChG;AAGO,SAAS,uBACd,QACA,SACoB;AACpB,QAAM,QAAQ,IAAI,IAAI,sBAAsB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC7E,SAAO,QAAQ,OAAO,CAAC,MAAM,EAAE,YAAY,SAAS,MAAM,IAAI,EAAE,KAAK,CAAC;AACxE;AAGO,SAAS,sBAAsB,UAA0B;AAC9D,QAAM,UAAU,SAAS,QAAQ,QAAQ,EAAE;AAC3C,SAAO,QAAQ,WAAW,GAAG,KAAK,YAAY,KAAK,UAAU,IAAI,OAAO;AAC1E;AAGO,SAAS,aAAa,UAAkB,OAAe,QAAyB;AACrF,QAAM,OAAO,GAAG,sBAAsB,QAAQ,CAAC,IAAI,KAAK;AACxD,SAAO,SAAS,GAAG,IAAI,UAAU,mBAAmB,MAAM,CAAC,KAAK;AAClE;AAGO,SAAS,iBAAiB,UAAwC;AACvE,SAAO,WAAW,SAAS,MAAM,KAAK,EAAE,OAAO,OAAO,IAAI,CAAC;AAC7D;AAiBO,SAAS,sBACd,QACA,SACA,SACe;AACf,QAAM,OAAO,sBAAsB,QAAQ,QAAQ;AACnD,QAAM,SAAS,KAAK,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,GAAG,KAAK;AAC9D,QAAM,OAAO,IAAI,IAAI,sBAAsB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AACjF,SAAO,uBAAuB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM;AACxD,UAAM,QAAQ,KAAK,IAAI,EAAE,KAAK;AAC9B,WAAO;AAAA,MACL,IAAI,GAAG,MAAM,IAAI,EAAE,EAAE;AAAA,MACrB,OAAO,EAAE;AAAA,MACT,UAAU,CAAC,MAAM,OAAO,GAAG,iBAAiB,EAAE,QAAQ,CAAC;AAAA,MACvD,OAAO,MAAM;AAAA,MACb,MAAM,MAAM;AAAA,MACZ,MAAM,aAAa,MAAM,EAAE,OAAO,EAAE,MAAM;AAAA,IAC5C;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -41,6 +41,12 @@ export interface SettingsLayoutProps<G extends string> {
41
41
  * own navigation carries them.
42
42
  */
43
43
  phoneFooter?: ReactNode;
44
+ /**
45
+ * 0.42.0 (docs/admin-harmonization.md §2.5 decision 16): content under the section
46
+ * list on a desktop, in the same sticky column — the admin area's Tools list
47
+ * (`AdminToolLinks`). Not shown on a phone, where `phoneFooter` carries the same links.
48
+ */
49
+ sidebarFooter?: ReactNode;
44
50
  /** Prop > `<UiKitProvider labels={{ settings }}>` > English. */
45
51
  labels?: LabelOverride<SettingsLabels>;
46
52
  /** See {@link SettingsLayoutMode}. Default `"auto"`: the screen decides. */
@@ -97,4 +103,4 @@ export interface SettingsDocumentTitleParts {
97
103
  * The cards inside read {@link useSettingsLayout} for `selectSub`, and their titles are
98
104
  * headings under the group's ({@link SettingsHeadingLevel}).
99
105
  */
100
- export declare function SettingsLayout<G extends string>({ title, groups, entries, basePath, defaultGroup, aliases, search, width, renderGroup, phoneFooter, labels: labelsProp, layout, appName, documentTitle, className, }: SettingsLayoutProps<G>): ReactElement;
106
+ export declare function SettingsLayout<G extends string>({ title, groups, entries, basePath, defaultGroup, aliases, search, width, renderGroup, phoneFooter, sidebarFooter, labels: labelsProp, layout, appName, documentTitle, className, }: SettingsLayoutProps<G>): ReactElement;
@@ -27,6 +27,7 @@ import { DEFAULT_SETTINGS_LABELS } from "./settings-labels.js";
27
27
  import { settingsFromListState, useSettingsRoute } from "./use-settings-route.js";
28
28
  import { useSettingsFocus } from "./use-settings-focus.js";
29
29
  const SETTINGS_SEARCH_THRESHOLD = 8;
30
+ const SIDEBAR_COLUMN = "mb-4 md:sticky md:top-6 md:mb-0 md:self-start";
30
31
  function defaultDocumentTitle({ group, page, appName }) {
31
32
  return [group, page, appName].filter((part) => Boolean(part)).join(" \xB7 ");
32
33
  }
@@ -41,6 +42,7 @@ function SettingsLayout({
41
42
  width = "6xl",
42
43
  renderGroup,
43
44
  phoneFooter,
45
+ sidebarFooter,
44
46
  labels: labelsProp,
45
47
  layout = "auto",
46
48
  appName,
@@ -206,38 +208,44 @@ function SettingsLayout({
206
208
  ] });
207
209
  } else {
208
210
  const open = group === null ? void 0 : groupById.get(group);
211
+ const withSidebarFooter = (tabs) => sidebarFooter == null ? tabs : /* @__PURE__ */ jsxs("div", { className: cn(SIDEBAR_COLUMN, "space-y-6"), children: [
212
+ tabs,
213
+ /* @__PURE__ */ jsx("div", { "data-slot": "settings-sidebar-footer", children: sidebarFooter })
214
+ ] });
209
215
  body = /* @__PURE__ */ jsxs("div", { className: pageClass, "data-settings-view": "desktop", children: [
210
216
  /* @__PURE__ */ jsx(PageHeader, { size: "compact", title: pageTitle }),
211
217
  searchField,
212
218
  /* @__PURE__ */ jsxs("div", { className: "md:grid md:grid-cols-[14rem_minmax(0,1fr)] md:gap-6", children: [
213
- /* @__PURE__ */ jsx(
214
- Tabs,
215
- {
216
- orientation: "vertical",
217
- "aria-label": labels.sections,
218
- className: "mb-4 md:sticky md:top-6 md:mb-0 md:self-start",
219
- panelId: open && !searching ? panelId : void 0,
220
- tabs: route.groups.map((g) => {
221
- const count = hitsByGroup.get(g.id) ?? 0;
222
- const dim = searching && count === 0;
223
- return {
224
- id: g.id,
225
- href: `${root}${settingsHref(base, g.id)}`,
226
- name: g.title,
227
- label: /* @__PURE__ */ jsx("span", { className: cn(dim && "opacity-40"), children: g.title }),
228
- icon: /* @__PURE__ */ jsx("span", { className: cn("inline-flex [&_svg]:size-4", dim && "opacity-40"), children: g.icon }),
229
- badge: searching && count > 0 ? /* @__PURE__ */ jsxs(Chip, { size: "sm", tone: "brand", children: [
230
- /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: count }),
231
- /* @__PURE__ */ jsx("span", { className: "sr-only-fixed", children: labels.matchCount(count) })
232
- ] }) : void 0
233
- };
234
- }),
235
- active: open?.id ?? route.groups[0]?.id,
236
- onChange: (id) => {
237
- setQuery("");
238
- select(id);
219
+ withSidebarFooter(
220
+ /* @__PURE__ */ jsx(
221
+ Tabs,
222
+ {
223
+ orientation: "vertical",
224
+ "aria-label": labels.sections,
225
+ className: sidebarFooter == null ? SIDEBAR_COLUMN : void 0,
226
+ panelId: open && !searching ? panelId : void 0,
227
+ tabs: route.groups.map((g) => {
228
+ const count = hitsByGroup.get(g.id) ?? 0;
229
+ const dim = searching && count === 0;
230
+ return {
231
+ id: g.id,
232
+ href: `${root}${settingsHref(base, g.id)}`,
233
+ name: g.title,
234
+ label: /* @__PURE__ */ jsx("span", { className: cn(dim && "opacity-40"), children: g.title }),
235
+ icon: /* @__PURE__ */ jsx("span", { className: cn("inline-flex [&_svg]:size-4", dim && "opacity-40"), children: g.icon }),
236
+ badge: searching && count > 0 ? /* @__PURE__ */ jsxs(Chip, { size: "sm", tone: "brand", children: [
237
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: count }),
238
+ /* @__PURE__ */ jsx("span", { className: "sr-only-fixed", children: labels.matchCount(count) })
239
+ ] }) : void 0
240
+ };
241
+ }),
242
+ active: open?.id ?? route.groups[0]?.id,
243
+ onChange: (id) => {
244
+ setQuery("");
245
+ select(id);
246
+ }
239
247
  }
240
- }
248
+ )
241
249
  ),
242
250
  /* @__PURE__ */ jsx("div", { className: "min-w-0 space-y-4", children: searching ? results : open ? /* @__PURE__ */ jsxs(
243
251
  "div",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/settings/settings-layout.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Link, useHref, useLocation } from \"react-router\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { Tabs } from \"../components/tabs\";\nimport { FOCUS_RING } from \"../components/focus-ring\";\nimport { Chip } from \"../components/chip\";\nimport { List, ListItem } from \"../components/list\";\nimport type { ListItemLinkProps } from \"../components/list\";\nimport { PageHeader } from \"../components/page-header\";\nimport { SearchField } from \"../components/search-field\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { createSearchIndex } from \"../search/search-index\";\nimport type { SearchEntry } from \"../search/search-index\";\nimport {\n normalizeSettingsBase,\n settingsHref,\n settingsKeywords,\n visibleSettingsEntries,\n} from \"./settings-catalogue\";\nimport type { SettingsEntry, SettingsGroup } from \"./settings-catalogue\";\nimport { SettingsLayoutContext } from \"./settings-context\";\nimport type { SettingsLayoutContextValue } from \"./settings-context\";\nimport { SettingsHeadingLevel } from \"./settings-heading\";\nimport { DEFAULT_SETTINGS_LABELS } from \"./settings-labels\";\nimport type { SettingsLabels } from \"./settings-labels\";\nimport { settingsFromListState, useSettingsRoute } from \"./use-settings-route\";\nimport type { SettingsLayoutMode } from \"./use-settings-route\";\nimport { useSettingsFocus } from \"./use-settings-focus\";\n\n/** The page's width cap (§3.2): `6xl` for settings, `7xl` for keksdose's admin, whose\n * user table scrolled sideways at 6xl (dev#488). */\nexport type SettingsLayoutWidth = \"6xl\" | \"7xl\";\n\n/** More entries than this and the search field shows by itself (§3.4): Kurvenschmiede has\n * about twelve, keksdose 26; a page of six needs none. */\nexport const SETTINGS_SEARCH_THRESHOLD = 8;\n\nexport interface SettingsLayoutProps<G extends string> {\n /** The page's `h1`: \"Settings\", \"Administration\". Default: `labels.title`. */\n title?: ReactNode;\n /** The groups, in order (§4.2). */\n groups: readonly SettingsGroup<G>[];\n /** One entry per setting (§4.1). Hidden entries leave search; a group left without a\n * visible one is hidden. */\n entries: readonly SettingsEntry<G>[];\n /** `/settings` or `/admin`. The layout owns every path below it. */\n basePath: string;\n /** The group `/settings` opens on a desktop. Default: the first visible one. */\n defaultGroup?: G;\n /** Retired group ids and their successors, replaced in the address (§3.1). */\n aliases?: Readonly<Partial<Record<string, G>>>;\n /**\n * The search field. Left out, it shows once more than {@link SETTINGS_SEARCH_THRESHOLD}\n * entries are visible; `true` or `false` wins over the count — keksdose's admin keeps\n * none (§3.4, §10.3). ⌘K gets the entries either way ({@link settingsSearchEntries}).\n */\n search?: boolean;\n /** See {@link SettingsLayoutWidth}. Default `\"6xl\"`. */\n width?: SettingsLayoutWidth;\n /** The group's cards, in one column. `sub` is the path segment after the group, which\n * a card owns (`useSettingsLayout().selectSub`). */\n renderGroup: (group: G, sub: string | null) => ReactNode;\n /**\n * Rows under the phone's group list that LEAVE settings — kastlan's \"Administration\",\n * its import page (§3.3, §5). `ListItem`s with an `href`; the layout puts them in a list\n * of their own, so they never read as a group. Not shown on a desktop, where the app's\n * own navigation carries them.\n */\n phoneFooter?: ReactNode;\n /** Prop > `<UiKitProvider labels={{ settings }}>` > English. */\n labels?: LabelOverride<SettingsLabels>;\n /** See {@link SettingsLayoutMode}. Default `\"auto\"`: the screen decides. */\n layout?: SettingsLayoutMode;\n /**\n * The app's name, the last part of the window's title: \"Security · Settings ·\n * Kastlan\" (§3.1). Left out, the title ends at the page: \"Security · Settings\".\n */\n appName?: string;\n /**\n * The window's title (`document.title`, §3.1) — round 0.34 C8, KS U7. By default the\n * layout writes \"<group> · <page> · <appName>\" while a group is open and\n * \"<page> · <appName>\" on the phone's list, and puts back the title it found when it\n * unmounts. `<page>` is `title` when that is a string, else `labels.title`.\n *\n * A function writes another order or another separator from the same parts; `false`\n * leaves `document.title` to the app. Kastlan's `SettingsChrome` and Kurvenschmiede's\n * `GroupTitle` were this effect written by hand, and Kurvenschmiede's admin page, on\n * the same layout, had none — every admin section was titled \"Kurvenschmiede\".\n */\n documentTitle?: false | ((parts: SettingsDocumentTitleParts) => string);\n className?: string;\n}\n\n/** What {@link SettingsLayoutProps.documentTitle} is given to write the window's title. */\nexport interface SettingsDocumentTitleParts {\n /** The open group's name, or `null` on the phone's list of groups. */\n group: string | null;\n /** The page: `title` when it is a string, else `labels.title` (\"Settings\"). */\n page: string;\n /** The `appName` prop, or `undefined`. */\n appName: string | undefined;\n}\n\n/** \"Security · Settings · Kastlan\" (§3.1); a part that is missing is left out. */\nfunction defaultDocumentTitle({ group, page, appName }: SettingsDocumentTitleParts): string {\n return [group, page, appName].filter((part): part is string => Boolean(part)).join(\" · \");\n}\n\ninterface Searchable<G extends string> extends SearchEntry {\n anchor: string;\n settingsGroup: G;\n}\n\n/**\n * The settings page (docs/settings-harmonization.md §3), and the admin page built the\n * same way (§5). keksdose's page is the reference for the desktop; the phone pattern and\n * the path segments are new for all three apps.\n *\n * **Desktop** (§3.2): the page's `PageHeader`, the search field, then a 14rem sidebar —\n * the kit's vertical `Tabs`, a link per group, sticky so a long group keeps the list in\n * view — beside the open group: its heading (`h2`), its help line and its cards in ONE\n * column, never a masonry (keksdose dev#490/#493).\n *\n * **Phone** (§3.3, below `md`): a drill-down. `/settings` is a list of the groups (icon,\n * name, help line, chevron) with `phoneFooter` under it; a group is a page of its own with\n * a back link, its name as the `h1` and the cards under it. It replaces keksdose's chip\n * strip.\n *\n * **Routes, history and `?focus=`** are {@link useSettingsRoute}'s and\n * {@link useSettingsFocus}'s. **Search** (§3.4) runs the kit's matcher over the catalogue:\n * accents folded, ranked, over the title, the group's name and the keywords. On a desktop\n * the sidebar dims the groups without hits and counts the others' while the hits replace\n * the group; on a phone they replace the list. A hit is a link to its card.\n *\n * The cards inside read {@link useSettingsLayout} for `selectSub`, and their titles are\n * headings under the group's ({@link SettingsHeadingLevel}).\n */\nexport function SettingsLayout<G extends string>({\n title,\n groups,\n entries,\n basePath,\n defaultGroup,\n aliases,\n search,\n width = \"6xl\",\n renderGroup,\n phoneFooter,\n labels: labelsProp,\n layout = \"auto\",\n appName,\n documentTitle,\n className,\n}: SettingsLayoutProps<G>): ReactElement {\n const labels = useKitLabels(\"settings\", DEFAULT_SETTINGS_LABELS, labelsProp);\n const base = normalizeSettingsBase(basePath);\n const route = useSettingsRoute({ groups, entries, basePath: base, defaultGroup, aliases, layout });\n const location = useLocation();\n // The browser URL of an app path, for the sidebar's real links (a middle click opens a\n // tab): `#/settings/…` under a hash router, the basename in front under a browser one.\n const root = useHref(\"/\").replace(/\\/$/, \"\");\n const panelId = useId();\n const searchRef = useRef<HTMLInputElement>(null);\n const [query, setQuery] = useState(\"\");\n\n const visibleEntries = useMemo(() => visibleSettingsEntries(groups, entries), [groups, entries]);\n const showSearch = search ?? visibleEntries.length > SETTINGS_SEARCH_THRESHOLD;\n const trimmed = query.trim();\n const searching = showSearch && trimmed !== \"\";\n\n const groupById = useMemo(() => new Map(route.groups.map((g) => [g.id, g])), [route.groups]);\n const index = useMemo(\n () =>\n createSearchIndex<Searchable<G>>(\n visibleEntries.map((e) => ({\n id: e.id,\n title: e.title,\n keywords: [groupById.get(e.group)?.title ?? e.group, ...settingsKeywords(e.keywords)],\n group: e.group,\n anchor: e.anchor,\n settingsGroup: e.group,\n })),\n // Every hit: the list is the page's whole answer, not a palette's top few.\n { groupLimit: Infinity },\n ),\n [visibleEntries, groupById],\n );\n const hits = useMemo(() => (searching ? index.search(trimmed).map((h) => h.entry) : []), [searching, index, trimmed]);\n const hitsByGroup = useMemo(() => {\n const counts = new Map<G, number>();\n for (const hit of hits) counts.set(hit.settingsGroup, (counts.get(hit.settingsGroup) ?? 0) + 1);\n return counts;\n }, [hits]);\n\n const anchors = useMemo(() => new Set(entries.map((e) => e.anchor)), [entries]);\n const { phone, group, sub, selectSub, select } = route;\n const headingLevel = phone ? \"h2\" : \"h3\";\n const context = useMemo<SettingsLayoutContextValue>(\n () => ({\n basePath: base,\n group,\n sub,\n selectSub,\n headingLevel,\n hasAnchor: (anchor) => anchors.has(anchor),\n }),\n [base, group, sub, selectSub, headingLevel, anchors],\n );\n\n // Not while the address is about to be replaced (its removal of `focus` would undo the\n // replace), not on the list, and not while the hits stand where the cards would be (a\n // phone's group page has no search: a query left from its list does not count there).\n const hitsShown = searching && (!phone || group === null);\n useSettingsFocus(route.redirecting || group === null || hitsShown ? null : route.focus);\n\n // A link inside the page. On a phone it opens a page of its own — a push that marks the\n // list as the entry before it, so the group page's back is a real back. On a desktop\n // it switches the group in place — a replace.\n const { state } = location;\n const renderLink = useCallback(\n ({ href, ...props }: ListItemLinkProps) => (\n <Link to={href} replace={!phone} state={phone ? settingsFromListState() : state} {...props} />\n ),\n [phone, state],\n );\n\n const clearSearch = () => {\n setQuery(\"\");\n // The button that was pressed goes with the empty state; the field is where the\n // next query is typed.\n searchRef.current?.focus();\n };\n\n const pageTitle = title ?? labels.title;\n\n // The window's title (§3.1). Computed to a string during render so the effect below\n // runs when the words change, not whenever an inline `documentTitle` function is new.\n // Each run puts back the title it found before writing its own, so a group switch\n // ends where it began and the unmount leaves the title the page had before settings.\n const windowTitle =\n documentTitle === false\n ? null\n : (documentTitle ?? defaultDocumentTitle)({\n group: group === null ? null : (groupById.get(group)?.title ?? null),\n page: typeof title === \"string\" ? title : labels.title,\n appName,\n });\n useEffect(() => {\n if (windowTitle === null) return;\n const before = document.title;\n document.title = windowTitle;\n return () => {\n document.title = before;\n };\n }, [windowTitle]);\n\n const pageClass = cn(\"mx-auto w-full space-y-4 p-4 md:p-6\", width === \"7xl\" ? \"max-w-7xl\" : \"max-w-6xl\", className);\n\n const searchField = showSearch ? (\n <SearchField\n ref={searchRef}\n className=\"max-w-md\"\n value={query}\n onChange={setQuery}\n aria-label={labels.search}\n placeholder={labels.searchPlaceholder}\n />\n ) : null;\n\n const results =\n hits.length === 0 ? (\n <EmptyState\n title={labels.noMatches(trimmed)}\n action={\n <Button variant=\"secondary\" size=\"sm\" onClick={clearSearch}>\n {labels.clearSearch}\n </Button>\n }\n />\n ) : (\n <Card className=\"p-1\">\n <List separator=\"divider\" density=\"comfortable\" aria-label={labels.results}>\n {hits.map((hit) => (\n <ListItem\n key={hit.id}\n title={hit.title}\n trailing={groupById.get(hit.settingsGroup)?.title}\n href={settingsHref(base, hit.settingsGroup, hit.anchor)}\n renderLink={renderLink}\n // Leave search on the way: the hit's card is what the user asked for.\n onClick={() => setQuery(\"\")}\n />\n ))}\n </List>\n </Card>\n );\n\n const cards = (open: G) => (\n <SettingsHeadingLevel level={headingLevel}>\n <div className=\"space-y-4\">{renderGroup(open, sub)}</div>\n </SettingsHeadingLevel>\n );\n\n let body: ReactNode;\n if (phone) {\n const open = group === null ? undefined : groupById.get(group);\n body = open ? (\n <div className={pageClass} data-settings-view=\"group\">\n <PageHeader\n size=\"compact\"\n title={open.title}\n description={open.help}\n breadcrumbs={\n <Link\n to={base || \"/\"}\n aria-label={labels.back}\n onClick={(e) => {\n // A real link for a middle click; a plain click is the page's own back,\n // which is `navigate(-1)` when the list is the entry before this one.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n route.back();\n }}\n className={cn(\"-ms-1 inline-flex items-center gap-0.5 self-start rounded-md pe-1 text-sm font-medium text-muted hover:text-primary\", FOCUS_RING)}\n >\n <ChevronLeft aria-hidden className=\"size-4 rtl:-scale-x-100\" />\n {pageTitle}\n </Link>\n }\n />\n {cards(open.id)}\n </div>\n ) : (\n <div className={pageClass} data-settings-view=\"list\">\n <PageHeader size=\"compact\" title={pageTitle} />\n {searchField}\n {searching ? (\n results\n ) : (\n <Card className=\"p-1\">\n <List separator=\"divider\" density=\"comfortable\" aria-label={labels.sections}>\n {route.groups.map((g) => (\n <ListItem\n key={g.id}\n href={settingsHref(base, g.id)}\n renderLink={renderLink}\n leading={\n <span\n aria-hidden\n className=\"flex size-8 shrink-0 items-center justify-center rounded-md bg-surface-2 text-secondary [&_svg]:size-4\"\n >\n {g.icon}\n </span>\n }\n title={g.title}\n subtitle={g.help}\n trailing={<ChevronRight aria-hidden className=\"size-4 rtl:-scale-x-100\" />}\n />\n ))}\n </List>\n </Card>\n )}\n {!searching && phoneFooter != null && (\n <Card className=\"p-1\">\n <List separator=\"divider\" density=\"comfortable\">\n {phoneFooter}\n </List>\n </Card>\n )}\n </div>\n );\n } else {\n const open = group === null ? undefined : groupById.get(group);\n body = (\n <div className={pageClass} data-settings-view=\"desktop\">\n <PageHeader size=\"compact\" title={pageTitle} />\n {searchField}\n <div className=\"md:grid md:grid-cols-[14rem_minmax(0,1fr)] md:gap-6\">\n <Tabs<G>\n orientation=\"vertical\"\n aria-label={labels.sections}\n // Sticky (new in the harmonisation, §3.2): keksdose's notifications group\n // scrolls for screens, and the list should stay in view beside it.\n className=\"mb-4 md:sticky md:top-6 md:mb-0 md:self-start\"\n panelId={open && !searching ? panelId : undefined}\n tabs={route.groups.map((g) => {\n const count = hitsByGroup.get(g.id) ?? 0;\n // While searching, the groups with nothing to show step back (keksdose).\n const dim = searching && count === 0;\n return {\n id: g.id,\n href: `${root}${settingsHref(base, g.id)}`,\n name: g.title,\n label: <span className={cn(dim && \"opacity-40\")}>{g.title}</span>,\n icon: <span className={cn(\"inline-flex [&_svg]:size-4\", dim && \"opacity-40\")}>{g.icon}</span>,\n badge:\n searching && count > 0 ? (\n <Chip size=\"sm\" tone=\"brand\">\n <span aria-hidden>{count}</span>\n <span className=\"sr-only-fixed\">{labels.matchCount(count)}</span>\n </Chip>\n ) : undefined,\n };\n })}\n active={(open?.id ?? route.groups[0]?.id) as G}\n onChange={(id) => {\n // While searching the hits stand where the group would; a click on a group\n // means \"go there\", so the search makes way (keksdose).\n setQuery(\"\");\n select(id);\n }}\n />\n <div className=\"min-w-0 space-y-4\">\n {searching ? (\n results\n ) : open ? (\n <div\n id={panelId}\n role=\"tabpanel\"\n aria-labelledby={`${panelId}-tab`}\n className=\"space-y-4\"\n >\n <PageHeader as=\"h2\" size=\"sm\" title={open.title} description={open.help} />\n {cards(open.id)}\n </div>\n ) : null}\n </div>\n </div>\n </div>\n );\n }\n\n return <SettingsLayoutContext.Provider value={context}>{body}</SettingsLayoutContext.Provider>;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/settings/settings-layout.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Link, useHref, useLocation } from \"react-router\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { Tabs } from \"../components/tabs\";\nimport { FOCUS_RING } from \"../components/focus-ring\";\nimport { Chip } from \"../components/chip\";\nimport { List, ListItem } from \"../components/list\";\nimport type { ListItemLinkProps } from \"../components/list\";\nimport { PageHeader } from \"../components/page-header\";\nimport { SearchField } from \"../components/search-field\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { createSearchIndex } from \"../search/search-index\";\nimport type { SearchEntry } from \"../search/search-index\";\nimport {\n normalizeSettingsBase,\n settingsHref,\n settingsKeywords,\n visibleSettingsEntries,\n} from \"./settings-catalogue\";\nimport type { SettingsEntry, SettingsGroup } from \"./settings-catalogue\";\nimport { SettingsLayoutContext } from \"./settings-context\";\nimport type { SettingsLayoutContextValue } from \"./settings-context\";\nimport { SettingsHeadingLevel } from \"./settings-heading\";\nimport { DEFAULT_SETTINGS_LABELS } from \"./settings-labels\";\nimport type { SettingsLabels } from \"./settings-labels\";\nimport { settingsFromListState, useSettingsRoute } from \"./use-settings-route\";\nimport type { SettingsLayoutMode } from \"./use-settings-route\";\nimport { useSettingsFocus } from \"./use-settings-focus\";\n\n/** The page's width cap (§3.2): `6xl` for settings, `7xl` for keksdose's admin, whose\n * user table scrolled sideways at 6xl (dev#488). */\nexport type SettingsLayoutWidth = \"6xl\" | \"7xl\";\n\n/** More entries than this and the search field shows by itself (§3.4): Kurvenschmiede has\n * about twelve, keksdose 26; a page of six needs none. */\nexport const SETTINGS_SEARCH_THRESHOLD = 8;\n\n/** The desktop sidebar's column: sticky beside a long group (§3.2). */\nconst SIDEBAR_COLUMN = \"mb-4 md:sticky md:top-6 md:mb-0 md:self-start\";\n\nexport interface SettingsLayoutProps<G extends string> {\n /** The page's `h1`: \"Settings\", \"Administration\". Default: `labels.title`. */\n title?: ReactNode;\n /** The groups, in order (§4.2). */\n groups: readonly SettingsGroup<G>[];\n /** One entry per setting (§4.1). Hidden entries leave search; a group left without a\n * visible one is hidden. */\n entries: readonly SettingsEntry<G>[];\n /** `/settings` or `/admin`. The layout owns every path below it. */\n basePath: string;\n /** The group `/settings` opens on a desktop. Default: the first visible one. */\n defaultGroup?: G;\n /** Retired group ids and their successors, replaced in the address (§3.1). */\n aliases?: Readonly<Partial<Record<string, G>>>;\n /**\n * The search field. Left out, it shows once more than {@link SETTINGS_SEARCH_THRESHOLD}\n * entries are visible; `true` or `false` wins over the count — keksdose's admin keeps\n * none (§3.4, §10.3). ⌘K gets the entries either way ({@link settingsSearchEntries}).\n */\n search?: boolean;\n /** See {@link SettingsLayoutWidth}. Default `\"6xl\"`. */\n width?: SettingsLayoutWidth;\n /** The group's cards, in one column. `sub` is the path segment after the group, which\n * a card owns (`useSettingsLayout().selectSub`). */\n renderGroup: (group: G, sub: string | null) => ReactNode;\n /**\n * Rows under the phone's group list that LEAVE settings — kastlan's \"Administration\",\n * its import page (§3.3, §5). `ListItem`s with an `href`; the layout puts them in a list\n * of their own, so they never read as a group. Not shown on a desktop, where the app's\n * own navigation carries them.\n */\n phoneFooter?: ReactNode;\n /**\n * 0.42.0 (docs/admin-harmonization.md §2.5 decision 16): content under the section\n * list on a desktop, in the same sticky column — the admin area's Tools list\n * (`AdminToolLinks`). Not shown on a phone, where `phoneFooter` carries the same links.\n */\n sidebarFooter?: ReactNode;\n /** Prop > `<UiKitProvider labels={{ settings }}>` > English. */\n labels?: LabelOverride<SettingsLabels>;\n /** See {@link SettingsLayoutMode}. Default `\"auto\"`: the screen decides. */\n layout?: SettingsLayoutMode;\n /**\n * The app's name, the last part of the window's title: \"Security · Settings ·\n * Kastlan\" (§3.1). Left out, the title ends at the page: \"Security · Settings\".\n */\n appName?: string;\n /**\n * The window's title (`document.title`, §3.1) — round 0.34 C8, KS U7. By default the\n * layout writes \"<group> · <page> · <appName>\" while a group is open and\n * \"<page> · <appName>\" on the phone's list, and puts back the title it found when it\n * unmounts. `<page>` is `title` when that is a string, else `labels.title`.\n *\n * A function writes another order or another separator from the same parts; `false`\n * leaves `document.title` to the app. Kastlan's `SettingsChrome` and Kurvenschmiede's\n * `GroupTitle` were this effect written by hand, and Kurvenschmiede's admin page, on\n * the same layout, had none — every admin section was titled \"Kurvenschmiede\".\n */\n documentTitle?: false | ((parts: SettingsDocumentTitleParts) => string);\n className?: string;\n}\n\n/** What {@link SettingsLayoutProps.documentTitle} is given to write the window's title. */\nexport interface SettingsDocumentTitleParts {\n /** The open group's name, or `null` on the phone's list of groups. */\n group: string | null;\n /** The page: `title` when it is a string, else `labels.title` (\"Settings\"). */\n page: string;\n /** The `appName` prop, or `undefined`. */\n appName: string | undefined;\n}\n\n/** \"Security · Settings · Kastlan\" (§3.1); a part that is missing is left out. */\nfunction defaultDocumentTitle({ group, page, appName }: SettingsDocumentTitleParts): string {\n return [group, page, appName].filter((part): part is string => Boolean(part)).join(\" · \");\n}\n\ninterface Searchable<G extends string> extends SearchEntry {\n anchor: string;\n settingsGroup: G;\n}\n\n/**\n * The settings page (docs/settings-harmonization.md §3), and the admin page built the\n * same way (§5). keksdose's page is the reference for the desktop; the phone pattern and\n * the path segments are new for all three apps.\n *\n * **Desktop** (§3.2): the page's `PageHeader`, the search field, then a 14rem sidebar —\n * the kit's vertical `Tabs`, a link per group, sticky so a long group keeps the list in\n * view — beside the open group: its heading (`h2`), its help line and its cards in ONE\n * column, never a masonry (keksdose dev#490/#493).\n *\n * **Phone** (§3.3, below `md`): a drill-down. `/settings` is a list of the groups (icon,\n * name, help line, chevron) with `phoneFooter` under it; a group is a page of its own with\n * a back link, its name as the `h1` and the cards under it. It replaces keksdose's chip\n * strip.\n *\n * **Routes, history and `?focus=`** are {@link useSettingsRoute}'s and\n * {@link useSettingsFocus}'s. **Search** (§3.4) runs the kit's matcher over the catalogue:\n * accents folded, ranked, over the title, the group's name and the keywords. On a desktop\n * the sidebar dims the groups without hits and counts the others' while the hits replace\n * the group; on a phone they replace the list. A hit is a link to its card.\n *\n * The cards inside read {@link useSettingsLayout} for `selectSub`, and their titles are\n * headings under the group's ({@link SettingsHeadingLevel}).\n */\nexport function SettingsLayout<G extends string>({\n title,\n groups,\n entries,\n basePath,\n defaultGroup,\n aliases,\n search,\n width = \"6xl\",\n renderGroup,\n phoneFooter,\n sidebarFooter,\n labels: labelsProp,\n layout = \"auto\",\n appName,\n documentTitle,\n className,\n}: SettingsLayoutProps<G>): ReactElement {\n const labels = useKitLabels(\"settings\", DEFAULT_SETTINGS_LABELS, labelsProp);\n const base = normalizeSettingsBase(basePath);\n const route = useSettingsRoute({ groups, entries, basePath: base, defaultGroup, aliases, layout });\n const location = useLocation();\n // The browser URL of an app path, for the sidebar's real links (a middle click opens a\n // tab): `#/settings/…` under a hash router, the basename in front under a browser one.\n const root = useHref(\"/\").replace(/\\/$/, \"\");\n const panelId = useId();\n const searchRef = useRef<HTMLInputElement>(null);\n const [query, setQuery] = useState(\"\");\n\n const visibleEntries = useMemo(() => visibleSettingsEntries(groups, entries), [groups, entries]);\n const showSearch = search ?? visibleEntries.length > SETTINGS_SEARCH_THRESHOLD;\n const trimmed = query.trim();\n const searching = showSearch && trimmed !== \"\";\n\n const groupById = useMemo(() => new Map(route.groups.map((g) => [g.id, g])), [route.groups]);\n const index = useMemo(\n () =>\n createSearchIndex<Searchable<G>>(\n visibleEntries.map((e) => ({\n id: e.id,\n title: e.title,\n keywords: [groupById.get(e.group)?.title ?? e.group, ...settingsKeywords(e.keywords)],\n group: e.group,\n anchor: e.anchor,\n settingsGroup: e.group,\n })),\n // Every hit: the list is the page's whole answer, not a palette's top few.\n { groupLimit: Infinity },\n ),\n [visibleEntries, groupById],\n );\n const hits = useMemo(() => (searching ? index.search(trimmed).map((h) => h.entry) : []), [searching, index, trimmed]);\n const hitsByGroup = useMemo(() => {\n const counts = new Map<G, number>();\n for (const hit of hits) counts.set(hit.settingsGroup, (counts.get(hit.settingsGroup) ?? 0) + 1);\n return counts;\n }, [hits]);\n\n const anchors = useMemo(() => new Set(entries.map((e) => e.anchor)), [entries]);\n const { phone, group, sub, selectSub, select } = route;\n const headingLevel = phone ? \"h2\" : \"h3\";\n const context = useMemo<SettingsLayoutContextValue>(\n () => ({\n basePath: base,\n group,\n sub,\n selectSub,\n headingLevel,\n hasAnchor: (anchor) => anchors.has(anchor),\n }),\n [base, group, sub, selectSub, headingLevel, anchors],\n );\n\n // Not while the address is about to be replaced (its removal of `focus` would undo the\n // replace), not on the list, and not while the hits stand where the cards would be (a\n // phone's group page has no search: a query left from its list does not count there).\n const hitsShown = searching && (!phone || group === null);\n useSettingsFocus(route.redirecting || group === null || hitsShown ? null : route.focus);\n\n // A link inside the page. On a phone it opens a page of its own — a push that marks the\n // list as the entry before it, so the group page's back is a real back. On a desktop\n // it switches the group in place — a replace.\n const { state } = location;\n const renderLink = useCallback(\n ({ href, ...props }: ListItemLinkProps) => (\n <Link to={href} replace={!phone} state={phone ? settingsFromListState() : state} {...props} />\n ),\n [phone, state],\n );\n\n const clearSearch = () => {\n setQuery(\"\");\n // The button that was pressed goes with the empty state; the field is where the\n // next query is typed.\n searchRef.current?.focus();\n };\n\n const pageTitle = title ?? labels.title;\n\n // The window's title (§3.1). Computed to a string during render so the effect below\n // runs when the words change, not whenever an inline `documentTitle` function is new.\n // Each run puts back the title it found before writing its own, so a group switch\n // ends where it began and the unmount leaves the title the page had before settings.\n const windowTitle =\n documentTitle === false\n ? null\n : (documentTitle ?? defaultDocumentTitle)({\n group: group === null ? null : (groupById.get(group)?.title ?? null),\n page: typeof title === \"string\" ? title : labels.title,\n appName,\n });\n useEffect(() => {\n if (windowTitle === null) return;\n const before = document.title;\n document.title = windowTitle;\n return () => {\n document.title = before;\n };\n }, [windowTitle]);\n\n const pageClass = cn(\"mx-auto w-full space-y-4 p-4 md:p-6\", width === \"7xl\" ? \"max-w-7xl\" : \"max-w-6xl\", className);\n\n const searchField = showSearch ? (\n <SearchField\n ref={searchRef}\n className=\"max-w-md\"\n value={query}\n onChange={setQuery}\n aria-label={labels.search}\n placeholder={labels.searchPlaceholder}\n />\n ) : null;\n\n const results =\n hits.length === 0 ? (\n <EmptyState\n title={labels.noMatches(trimmed)}\n action={\n <Button variant=\"secondary\" size=\"sm\" onClick={clearSearch}>\n {labels.clearSearch}\n </Button>\n }\n />\n ) : (\n <Card className=\"p-1\">\n <List separator=\"divider\" density=\"comfortable\" aria-label={labels.results}>\n {hits.map((hit) => (\n <ListItem\n key={hit.id}\n title={hit.title}\n trailing={groupById.get(hit.settingsGroup)?.title}\n href={settingsHref(base, hit.settingsGroup, hit.anchor)}\n renderLink={renderLink}\n // Leave search on the way: the hit's card is what the user asked for.\n onClick={() => setQuery(\"\")}\n />\n ))}\n </List>\n </Card>\n );\n\n const cards = (open: G) => (\n <SettingsHeadingLevel level={headingLevel}>\n <div className=\"space-y-4\">{renderGroup(open, sub)}</div>\n </SettingsHeadingLevel>\n );\n\n let body: ReactNode;\n if (phone) {\n const open = group === null ? undefined : groupById.get(group);\n body = open ? (\n <div className={pageClass} data-settings-view=\"group\">\n <PageHeader\n size=\"compact\"\n title={open.title}\n description={open.help}\n breadcrumbs={\n <Link\n to={base || \"/\"}\n aria-label={labels.back}\n onClick={(e) => {\n // A real link for a middle click; a plain click is the page's own back,\n // which is `navigate(-1)` when the list is the entry before this one.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n route.back();\n }}\n className={cn(\"-ms-1 inline-flex items-center gap-0.5 self-start rounded-md pe-1 text-sm font-medium text-muted hover:text-primary\", FOCUS_RING)}\n >\n <ChevronLeft aria-hidden className=\"size-4 rtl:-scale-x-100\" />\n {pageTitle}\n </Link>\n }\n />\n {cards(open.id)}\n </div>\n ) : (\n <div className={pageClass} data-settings-view=\"list\">\n <PageHeader size=\"compact\" title={pageTitle} />\n {searchField}\n {searching ? (\n results\n ) : (\n <Card className=\"p-1\">\n <List separator=\"divider\" density=\"comfortable\" aria-label={labels.sections}>\n {route.groups.map((g) => (\n <ListItem\n key={g.id}\n href={settingsHref(base, g.id)}\n renderLink={renderLink}\n leading={\n <span\n aria-hidden\n className=\"flex size-8 shrink-0 items-center justify-center rounded-md bg-surface-2 text-secondary [&_svg]:size-4\"\n >\n {g.icon}\n </span>\n }\n title={g.title}\n subtitle={g.help}\n trailing={<ChevronRight aria-hidden className=\"size-4 rtl:-scale-x-100\" />}\n />\n ))}\n </List>\n </Card>\n )}\n {!searching && phoneFooter != null && (\n <Card className=\"p-1\">\n <List separator=\"divider\" density=\"comfortable\">\n {phoneFooter}\n </List>\n </Card>\n )}\n </div>\n );\n } else {\n const open = group === null ? undefined : groupById.get(group);\n // The section list alone, or (0.42) with the app's `sidebarFooter` under it in one\n // sticky column.\n const withSidebarFooter = (tabs: ReactNode) =>\n sidebarFooter == null ? (\n tabs\n ) : (\n <div className={cn(SIDEBAR_COLUMN, \"space-y-6\")}>\n {tabs}\n <div data-slot=\"settings-sidebar-footer\">{sidebarFooter}</div>\n </div>\n );\n body = (\n <div className={pageClass} data-settings-view=\"desktop\">\n <PageHeader size=\"compact\" title={pageTitle} />\n {searchField}\n <div className=\"md:grid md:grid-cols-[14rem_minmax(0,1fr)] md:gap-6\">\n {withSidebarFooter(\n <Tabs<G>\n orientation=\"vertical\"\n aria-label={labels.sections}\n // Sticky (new in the harmonisation, §3.2): keksdose's notifications group\n // scrolls for screens, and the list should stay in view beside it. With a\n // `sidebarFooter` the column round both is the sticky one.\n className={sidebarFooter == null ? SIDEBAR_COLUMN : undefined}\n panelId={open && !searching ? panelId : undefined}\n tabs={route.groups.map((g) => {\n const count = hitsByGroup.get(g.id) ?? 0;\n // While searching, the groups with nothing to show step back (keksdose).\n const dim = searching && count === 0;\n return {\n id: g.id,\n href: `${root}${settingsHref(base, g.id)}`,\n name: g.title,\n label: <span className={cn(dim && \"opacity-40\")}>{g.title}</span>,\n icon: <span className={cn(\"inline-flex [&_svg]:size-4\", dim && \"opacity-40\")}>{g.icon}</span>,\n badge:\n searching && count > 0 ? (\n <Chip size=\"sm\" tone=\"brand\">\n <span aria-hidden>{count}</span>\n <span className=\"sr-only-fixed\">{labels.matchCount(count)}</span>\n </Chip>\n ) : undefined,\n };\n })}\n active={(open?.id ?? route.groups[0]?.id) as G}\n onChange={(id) => {\n // While searching the hits stand where the group would; a click on a group\n // means \"go there\", so the search makes way (keksdose).\n setQuery(\"\");\n select(id);\n }}\n />,\n )}\n <div className=\"min-w-0 space-y-4\">\n {searching ? (\n results\n ) : open ? (\n <div\n id={panelId}\n role=\"tabpanel\"\n aria-labelledby={`${panelId}-tab`}\n className=\"space-y-4\"\n >\n <PageHeader as=\"h2\" size=\"sm\" title={open.title} description={open.help} />\n {cards(open.id)}\n </div>\n ) : null}\n </div>\n </div>\n </div>\n );\n }\n\n return <SettingsLayoutContext.Provider value={context}>{body}</SettingsLayoutContext.Provider>;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from "react";
2
+ /** One offered language in the account menu's sub-list. */
3
+ export interface AccountMenuLanguageOption {
4
+ code: string;
5
+ /** Its name, in its own language ("Deutsch", "Français"). */
6
+ label: ReactNode;
7
+ /**
8
+ * A flag before the name, as the app draws it — keksdose's flag-icons span
9
+ * (`<span aria-hidden className="fi fi-ch …" />`). A node rather than a country code, so
10
+ * the kit ships no flag set; decorative, so give it `aria-hidden`.
11
+ */
12
+ flag?: ReactNode;
13
+ /**
14
+ * Or the ISO 3166-1 alpha-2 country (lowercase) whose flag-icons flag to draw, as
15
+ * `LanguageMenu`'s `LanguageOption` takes it — so one language list feeds both
16
+ * (Kurvenschmiede's `LANGUAGES`). Needs the app's flag-icons CSS, as `LanguageMenu`
17
+ * does. `flag` wins when both are given.
18
+ */
19
+ country?: string;
20
+ }
21
+ export interface AccountMenuLanguageProps {
22
+ languages: readonly AccountMenuLanguageOption[];
23
+ /** The active language's code. */
24
+ value: string | undefined;
25
+ /** A pick: the app sets the device's language and, signed in, the account's
26
+ * (settings §6.2). */
27
+ onPick: (code: string) => void;
28
+ /** The account menu's `close` (from `TopBarActionMenu`'s `children`): a pick shuts it. */
29
+ close?: () => void;
30
+ /** The row's name. Default: `topBar.language` ("Language"). */
31
+ label?: string;
32
+ /** Open the sub-list at first. Default `false`. */
33
+ defaultOpen?: boolean;
34
+ }
35
+ /**
36
+ * The language choice INSIDE the account menu (docs/top-bar-harmonization.md §2.3),
37
+ * keksdose's sub-list: a `Disclosure variant="menu"` row — the globe, "Language", the
38
+ * active language's flag and the chevron — that opens in place onto one checked
39
+ * `MenuItem` per language. Inline rather than a nested popover: a popover inside a
40
+ * popover fights the outside click and has nowhere to go on a phone (keksdose #140).
41
+ *
42
+ * It renders its own `<li>`: drop it into `TopBarActionMenu`'s `children` first after the
43
+ * app's context rows (§6.5). Signed out there is no account menu; `LanguageMenu` stays
44
+ * the bar's button (§2.3).
45
+ */
46
+ export declare function AccountMenuLanguage({ languages, value, onPick, close, label, defaultOpen, }: AccountMenuLanguageProps): import("react").JSX.Element;
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Globe } from "lucide-react";
5
+ import { Disclosure } from "../components/disclosure.js";
6
+ import { MenuItem } from "../components/menu-item.js";
7
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels.js";
8
+ function flagOf(lang) {
9
+ if (lang.flag !== void 0) return lang.flag;
10
+ if (!lang.country) return void 0;
11
+ return /* @__PURE__ */ jsx(
12
+ "span",
13
+ {
14
+ "aria-hidden": true,
15
+ "data-country": lang.country,
16
+ className: `fi fi-${lang.country} inline-block h-[0.9375rem] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]`
17
+ }
18
+ );
19
+ }
20
+ function AccountMenuLanguage({
21
+ languages,
22
+ value,
23
+ onPick,
24
+ close,
25
+ label,
26
+ defaultOpen = false
27
+ }) {
28
+ const words = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
29
+ const [open, setOpen] = useState(defaultOpen);
30
+ const active = languages.find((l) => l.code === value);
31
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
32
+ Disclosure,
33
+ {
34
+ variant: "menu",
35
+ open,
36
+ onOpenChange: setOpen,
37
+ title: /* @__PURE__ */ jsxs("span", { className: "flex items-center justify-between gap-3", children: [
38
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
39
+ /* @__PURE__ */ jsx(Globe, { "aria-hidden": true, className: "size-4" }),
40
+ label ?? words.language
41
+ ] }),
42
+ active && flagOf(active)
43
+ ] }),
44
+ children: /* @__PURE__ */ jsx("ul", { children: languages.map((lang) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
45
+ MenuItem,
46
+ {
47
+ checked: lang.code === value,
48
+ onClick: () => {
49
+ onPick(lang.code);
50
+ setOpen(false);
51
+ close?.();
52
+ },
53
+ leading: flagOf(lang),
54
+ className: "ps-9",
55
+ children: lang.label
56
+ }
57
+ ) }, lang.code)) })
58
+ }
59
+ ) });
60
+ }
61
+ export {
62
+ AccountMenuLanguage
63
+ };
64
+ //# sourceMappingURL=account-menu-language.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/account-menu-language.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Globe } from \"lucide-react\";\nimport { Disclosure } from \"../components/disclosure\";\nimport { MenuItem } from \"../components/menu-item\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/** One offered language in the account menu's sub-list. */\nexport interface AccountMenuLanguageOption {\n code: string;\n /** Its name, in its own language (\"Deutsch\", \"Français\"). */\n label: ReactNode;\n /**\n * A flag before the name, as the app draws it — keksdose's flag-icons span\n * (`<span aria-hidden className=\"fi fi-ch …\" />`). A node rather than a country code, so\n * the kit ships no flag set; decorative, so give it `aria-hidden`.\n */\n flag?: ReactNode;\n /**\n * Or the ISO 3166-1 alpha-2 country (lowercase) whose flag-icons flag to draw, as\n * `LanguageMenu`'s `LanguageOption` takes it — so one language list feeds both\n * (Kurvenschmiede's `LANGUAGES`). Needs the app's flag-icons CSS, as `LanguageMenu`\n * does. `flag` wins when both are given.\n */\n country?: string;\n}\n\n/** The option's flag: its own node, else the flag-icons flag of its country. */\nfunction flagOf(lang: AccountMenuLanguageOption): ReactNode {\n if (lang.flag !== undefined) return lang.flag;\n if (!lang.country) return undefined;\n return (\n <span\n aria-hidden\n data-country={lang.country}\n className={`fi fi-${lang.country} inline-block h-[0.9375rem] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]`}\n />\n );\n}\n\nexport interface AccountMenuLanguageProps {\n languages: readonly AccountMenuLanguageOption[];\n /** The active language's code. */\n value: string | undefined;\n /** A pick: the app sets the device's language and, signed in, the account's\n * (settings §6.2). */\n onPick: (code: string) => void;\n /** The account menu's `close` (from `TopBarActionMenu`'s `children`): a pick shuts it. */\n close?: () => void;\n /** The row's name. Default: `topBar.language` (\"Language\"). */\n label?: string;\n /** Open the sub-list at first. Default `false`. */\n defaultOpen?: boolean;\n}\n\n/**\n * The language choice INSIDE the account menu (docs/top-bar-harmonization.md §2.3),\n * keksdose's sub-list: a `Disclosure variant=\"menu\"` row — the globe, \"Language\", the\n * active language's flag and the chevron — that opens in place onto one checked\n * `MenuItem` per language. Inline rather than a nested popover: a popover inside a\n * popover fights the outside click and has nowhere to go on a phone (keksdose #140).\n *\n * It renders its own `<li>`: drop it into `TopBarActionMenu`'s `children` first after the\n * app's context rows (§6.5). Signed out there is no account menu; `LanguageMenu` stays\n * the bar's button (§2.3).\n */\nexport function AccountMenuLanguage({\n languages,\n value,\n onPick,\n close,\n label,\n defaultOpen = false,\n}: AccountMenuLanguageProps) {\n const words = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const [open, setOpen] = useState(defaultOpen);\n const active = languages.find((l) => l.code === value);\n return (\n <li>\n <Disclosure\n variant=\"menu\"\n open={open}\n onOpenChange={setOpen}\n title={\n <span className=\"flex items-center justify-between gap-3\">\n <span className=\"flex items-center gap-2\">\n <Globe aria-hidden className=\"size-4\" />\n {label ?? words.language}\n </span>\n {active && flagOf(active)}\n </span>\n }\n >\n <ul>\n {languages.map((lang) => (\n <li key={lang.code}>\n <MenuItem\n checked={lang.code === value}\n onClick={() => {\n onPick(lang.code);\n setOpen(false);\n close?.();\n }}\n leading={flagOf(lang)}\n // Under the row's own text, past the globe.\n className=\"ps-9\"\n >\n {lang.label}\n </MenuItem>\n </li>\n ))}\n </ul>\n </Disclosure>\n </li>\n );\n}\n"],"mappings":";AAgCI,cAqDQ,YArDR;AAhCJ,SAAS,gBAAgB;AAEzB,SAAS,aAAa;AACtB,SAAS,kBAAkB;AAC3B,SAAS,gBAAgB;AACzB,SAAS,wBAAwB,oBAAoB;AAuBrD,SAAS,OAAO,MAA4C;AAC1D,MAAI,KAAK,SAAS,OAAW,QAAO,KAAK;AACzC,MAAI,CAAC,KAAK,QAAS,QAAO;AAC1B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,eAAW;AAAA,MACX,gBAAc,KAAK;AAAA,MACnB,WAAW,SAAS,KAAK,OAAO;AAAA;AAAA,EAClC;AAEJ;AA4BO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAChB,GAA6B;AAC3B,QAAM,QAAQ,aAAa,UAAU,sBAAsB;AAC3D,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,WAAW;AAC5C,QAAM,SAAS,UAAU,KAAK,CAAC,MAAM,EAAE,SAAS,KAAK;AACrD,SACE,oBAAC,QACC;AAAA,IAAC;AAAA;AAAA,MACC,SAAQ;AAAA,MACR;AAAA,MACA,cAAc;AAAA,MACd,OACE,qBAAC,UAAK,WAAU,2CACd;AAAA,6BAAC,UAAK,WAAU,2BACd;AAAA,8BAAC,SAAM,eAAW,MAAC,WAAU,UAAS;AAAA,UACrC,SAAS,MAAM;AAAA,WAClB;AAAA,QACC,UAAU,OAAO,MAAM;AAAA,SAC1B;AAAA,MAGF,8BAAC,QACE,oBAAU,IAAI,CAAC,SACd,oBAAC,QACC;AAAA,QAAC;AAAA;AAAA,UACC,SAAS,KAAK,SAAS;AAAA,UACvB,SAAS,MAAM;AACb,mBAAO,KAAK,IAAI;AAChB,oBAAQ,KAAK;AACb,oBAAQ;AAAA,UACV;AAAA,UACA,SAAS,OAAO,IAAI;AAAA,UAEpB,WAAU;AAAA,UAET,eAAK;AAAA;AAAA,MACR,KAbO,KAAK,IAcd,CACD,GACH;AAAA;AAAA,EACF,GACF;AAEJ;","names":[]}
@@ -0,0 +1,69 @@
1
+ import type { LabelOverride } from "../i18n/kit-labels.js";
2
+ import type { AdminAreaLabels } from "../admin/admin-area-labels.js";
3
+ import type { TopBarMenuEntry } from "./topbar-action-menu.js";
4
+ export interface AdminMenuEntryOptions {
5
+ /** The admin area. Default `/admin`. */
6
+ href?: string;
7
+ /**
8
+ * The waiting count (§2.4 decision 15, §6.3): only work that waits on this admin —
9
+ * `adminWaitingCount(attention, […])`. `0`, `null` or none shows no badge.
10
+ */
11
+ badge?: number | null;
12
+ /**
13
+ * The resolved `adminArea` words — pass `useAdminAreaLabels()`, or the row is ENGLISH
14
+ * in every language: a plain function reads no provider (kastlan, 0.42 adoption). Builds
15
+ * no hook, so the entry can be made in the list of entries. {@link useAdminMenuEntry}
16
+ * (0.43) is the same entry with the provider's words read for you.
17
+ */
18
+ labels?: AdminAreaLabels;
19
+ /** The entry's `key`. Default `"administration"`. */
20
+ key?: string;
21
+ /** The page already open: `aria-current` and a check mark. */
22
+ current?: boolean;
23
+ }
24
+ /**
25
+ * The "Administration" {@link TopBarMenuEntry}: a link row with the `ShieldCheck` icon,
26
+ * the kit's word and the waiting count as its badge. Put it in the entries for an admin
27
+ * only — the menu closes on the way, as any link row:
28
+ *
29
+ * const labels = useAdminAreaLabels();
30
+ * entries={[…, ...(isAdmin ? [adminMenuEntry({ badge: waiting, labels })] : []), …]}
31
+ *
32
+ * A function, not a hook: it reads NO provider, so pass `labels` (`useAdminAreaLabels()`)
33
+ * or the row says "Administration" in every language — or call {@link useAdminMenuEntry}
34
+ * at the component's top.
35
+ */
36
+ export declare function adminMenuEntry({ href, badge, labels, key, current, }?: AdminMenuEntryOptions): TopBarMenuEntry;
37
+ export interface UseAdminMenuEntryOptions extends Omit<AdminMenuEntryOptions, "labels"> {
38
+ /** Over the provider's `adminArea` words: prop > `<UiKitProvider labels={{ adminArea }}>`
39
+ * > English. */
40
+ labels?: LabelOverride<AdminAreaLabels>;
41
+ }
42
+ /**
43
+ * {@link adminMenuEntry} with the provider's words (0.43, kastlan): the hook reads
44
+ * `useAdminAreaLabels()` itself, so the row is in the app's language without a `labels`
45
+ * to remember. Call it at the component's top, then put it in the entries:
46
+ *
47
+ * const admin = useAdminMenuEntry({ badge: waiting });
48
+ * entries={[…, ...(isAdmin ? [admin] : []), …]}
49
+ */
50
+ export declare function useAdminMenuEntry({ labels: labelsProp, ...options }?: UseAdminMenuEntryOptions): TopBarMenuEntry;
51
+ export interface AdminMenuRowProps {
52
+ /** The admin area. Default `/admin`. */
53
+ href?: string;
54
+ /** As {@link AdminMenuEntryOptions.badge}. */
55
+ badge?: number | null;
56
+ /** Runs on the way — `HoverMenu`'s `close`. */
57
+ onNavigate?: () => void;
58
+ current?: boolean;
59
+ /** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */
60
+ labels?: LabelOverride<AdminAreaLabels>;
61
+ }
62
+ /**
63
+ * The same row as {@link adminMenuEntry} for a menu drawn from `HoverMenu` children: the
64
+ * `<li>` holding the router-link {@link MenuItem} `TopBarActionMenu` draws for a link
65
+ * entry, so the two menus look alike.
66
+ *
67
+ * {(close) => <ul>… {isAdmin && <AdminMenuRow badge={waiting} onNavigate={close} />} …</ul>}
68
+ */
69
+ export declare function AdminMenuRow({ href, badge, onNavigate, current, labels: labelsProp }: AdminMenuRowProps): import("react").JSX.Element;
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Link } from "react-router";
4
+ import { ShieldCheck } from "lucide-react";
5
+ import { Chip } from "../components/chip.js";
6
+ import { MenuItem } from "../components/menu-item.js";
7
+ import { DEFAULT_ADMIN_AREA_LABELS, useAdminAreaLabels } from "../admin/admin-area-labels.js";
8
+ function badgeText(badge) {
9
+ if (typeof badge !== "number" || !(badge > 0)) return null;
10
+ return badge > 99 ? "99+" : String(Math.floor(badge));
11
+ }
12
+ function WaitingBadge({ badge, labels }) {
13
+ const text = badgeText(badge);
14
+ if (text === null) return null;
15
+ return /* @__PURE__ */ jsxs(Chip, { size: "sm", variant: "solid", tone: "warning", "data-slot": "admin-badge", children: [
16
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: text }),
17
+ /* @__PURE__ */ jsxs("span", { className: "sr-only-fixed", children: [
18
+ ", ",
19
+ labels.waiting(badge)
20
+ ] })
21
+ ] });
22
+ }
23
+ function adminMenuEntry({
24
+ href = "/admin",
25
+ badge,
26
+ labels = DEFAULT_ADMIN_AREA_LABELS,
27
+ key = "administration",
28
+ current
29
+ } = {}) {
30
+ return {
31
+ kind: "link",
32
+ key,
33
+ to: href,
34
+ icon: /* @__PURE__ */ jsx(ShieldCheck, { className: "size-4", "aria-hidden": true }),
35
+ label: labels.title,
36
+ trailing: badgeText(badge) === null ? void 0 : /* @__PURE__ */ jsx(WaitingBadge, { badge, labels }),
37
+ current
38
+ };
39
+ }
40
+ function useAdminMenuEntry({ labels: labelsProp, ...options } = {}) {
41
+ const labels = useAdminAreaLabels(labelsProp);
42
+ return adminMenuEntry({ ...options, labels });
43
+ }
44
+ function AdminMenuRow({ href = "/admin", badge, onNavigate, current, labels: labelsProp }) {
45
+ const labels = useAdminAreaLabels(labelsProp);
46
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
47
+ MenuItem,
48
+ {
49
+ href,
50
+ renderLink: ({ href: to, ...p }) => /* @__PURE__ */ jsx(Link, { to, ...p }),
51
+ onClick: onNavigate ? () => onNavigate() : void 0,
52
+ leading: /* @__PURE__ */ jsx(ShieldCheck, { className: "size-4", "aria-hidden": true }),
53
+ trailing: badgeText(badge) === null ? void 0 : /* @__PURE__ */ jsx(WaitingBadge, { badge, labels }),
54
+ current,
55
+ children: labels.title
56
+ }
57
+ ) });
58
+ }
59
+ export {
60
+ AdminMenuRow,
61
+ adminMenuEntry,
62
+ useAdminMenuEntry
63
+ };
64
+ //# sourceMappingURL=admin-menu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/admin-menu.tsx"],"sourcesContent":["import { Link } from \"react-router\";\nimport { ShieldCheck } from \"lucide-react\";\nimport { Chip } from \"../components/chip\";\nimport { MenuItem } from \"../components/menu-item\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_ADMIN_AREA_LABELS, useAdminAreaLabels } from \"../admin/admin-area-labels\";\nimport type { AdminAreaLabels } from \"../admin/admin-area-labels\";\nimport type { TopBarMenuEntry } from \"./topbar-action-menu\";\n\n/**\n * The account menu's \"Administration\" row (0.42.0, docs/admin-harmonization.md §2.1, §6.6,\n * §7): the way into the admin area everyone finds, in every app, for an admin only. Two\n * forms of one row — {@link adminMenuEntry} for `TopBarActionMenu`'s `entries`, and\n * {@link AdminMenuRow} for a menu built from `HoverMenu` children (Kurvenschmiede's).\n */\n\n/** The badge's text: nothing for none or 0, \"99+\" past 99. */\nfunction badgeText(badge: number | null | undefined): string | null {\n if (typeof badge !== \"number\" || !(badge > 0)) return null;\n return badge > 99 ? \"99+\" : String(Math.floor(badge));\n}\n\n/**\n * The waiting count at the row's end: the `solid` count pill of {@link Chip}, in the\n * pending count's tone, with the number for the eye and `adminArea.waiting` for a reader\n * (\"Administration, 3 waiting\"). `null` for no count.\n */\nfunction WaitingBadge({ badge, labels }: { badge: number | null | undefined; labels: AdminAreaLabels }) {\n const text = badgeText(badge);\n if (text === null) return null;\n return (\n <Chip size=\"sm\" variant=\"solid\" tone=\"warning\" data-slot=\"admin-badge\">\n <span aria-hidden>{text}</span>\n {/* The comma parts it from the row's label in the name: \"Administration, 3 waiting\". */}\n <span className=\"sr-only-fixed\">, {labels.waiting(badge as number)}</span>\n </Chip>\n );\n}\n\nexport interface AdminMenuEntryOptions {\n /** The admin area. Default `/admin`. */\n href?: string;\n /**\n * The waiting count (§2.4 decision 15, §6.3): only work that waits on this admin —\n * `adminWaitingCount(attention, […])`. `0`, `null` or none shows no badge.\n */\n badge?: number | null;\n /**\n * The resolved `adminArea` words — pass `useAdminAreaLabels()`, or the row is ENGLISH\n * in every language: a plain function reads no provider (kastlan, 0.42 adoption). Builds\n * no hook, so the entry can be made in the list of entries. {@link useAdminMenuEntry}\n * (0.43) is the same entry with the provider's words read for you.\n */\n labels?: AdminAreaLabels;\n /** The entry's `key`. Default `\"administration\"`. */\n key?: string;\n /** The page already open: `aria-current` and a check mark. */\n current?: boolean;\n}\n\n/**\n * The \"Administration\" {@link TopBarMenuEntry}: a link row with the `ShieldCheck` icon,\n * the kit's word and the waiting count as its badge. Put it in the entries for an admin\n * only — the menu closes on the way, as any link row:\n *\n * const labels = useAdminAreaLabels();\n * entries={[…, ...(isAdmin ? [adminMenuEntry({ badge: waiting, labels })] : []), …]}\n *\n * A function, not a hook: it reads NO provider, so pass `labels` (`useAdminAreaLabels()`)\n * or the row says \"Administration\" in every language — or call {@link useAdminMenuEntry}\n * at the component's top.\n */\nexport function adminMenuEntry({\n href = \"/admin\",\n badge,\n labels = DEFAULT_ADMIN_AREA_LABELS,\n key = \"administration\",\n current,\n}: AdminMenuEntryOptions = {}): TopBarMenuEntry {\n return {\n kind: \"link\",\n key,\n to: href,\n icon: <ShieldCheck className=\"size-4\" aria-hidden />,\n label: labels.title,\n trailing: badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />,\n current,\n };\n}\n\nexport interface UseAdminMenuEntryOptions extends Omit<AdminMenuEntryOptions, \"labels\"> {\n /** Over the provider's `adminArea` words: prop > `<UiKitProvider labels={{ adminArea }}>`\n * > English. */\n labels?: LabelOverride<AdminAreaLabels>;\n}\n\n/**\n * {@link adminMenuEntry} with the provider's words (0.43, kastlan): the hook reads\n * `useAdminAreaLabels()` itself, so the row is in the app's language without a `labels`\n * to remember. Call it at the component's top, then put it in the entries:\n *\n * const admin = useAdminMenuEntry({ badge: waiting });\n * entries={[…, ...(isAdmin ? [admin] : []), …]}\n */\nexport function useAdminMenuEntry({ labels: labelsProp, ...options }: UseAdminMenuEntryOptions = {}): TopBarMenuEntry {\n const labels = useAdminAreaLabels(labelsProp);\n return adminMenuEntry({ ...options, labels });\n}\n\nexport interface AdminMenuRowProps {\n /** The admin area. Default `/admin`. */\n href?: string;\n /** As {@link AdminMenuEntryOptions.badge}. */\n badge?: number | null;\n /** Runs on the way — `HoverMenu`'s `close`. */\n onNavigate?: () => void;\n current?: boolean;\n /** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */\n labels?: LabelOverride<AdminAreaLabels>;\n}\n\n/**\n * The same row as {@link adminMenuEntry} for a menu drawn from `HoverMenu` children: the\n * `<li>` holding the router-link {@link MenuItem} `TopBarActionMenu` draws for a link\n * entry, so the two menus look alike.\n *\n * {(close) => <ul>… {isAdmin && <AdminMenuRow badge={waiting} onNavigate={close} />} …</ul>}\n */\nexport function AdminMenuRow({ href = \"/admin\", badge, onNavigate, current, labels: labelsProp }: AdminMenuRowProps) {\n // The provider's words, as every kit part (0.43: through the namespace's own hook).\n const labels = useAdminAreaLabels(labelsProp);\n return (\n <li>\n <MenuItem\n href={href}\n renderLink={({ href: to, ...p }) => <Link to={to} {...p} />}\n onClick={onNavigate ? () => onNavigate() : undefined}\n leading={<ShieldCheck className=\"size-4\" aria-hidden />}\n trailing={badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />}\n current={current}\n >\n {labels.title}\n </MenuItem>\n </li>\n );\n}\n"],"mappings":";AAgCM,cAEA,YAFA;AAhCN,SAAS,YAAY;AACrB,SAAS,mBAAmB;AAC5B,SAAS,YAAY;AACrB,SAAS,gBAAgB;AAEzB,SAAS,2BAA2B,0BAA0B;AAY9D,SAAS,UAAU,OAAiD;AAClE,MAAI,OAAO,UAAU,YAAY,EAAE,QAAQ,GAAI,QAAO;AACtD,SAAO,QAAQ,KAAK,QAAQ,OAAO,KAAK,MAAM,KAAK,CAAC;AACtD;AAOA,SAAS,aAAa,EAAE,OAAO,OAAO,GAAkE;AACtG,QAAM,OAAO,UAAU,KAAK;AAC5B,MAAI,SAAS,KAAM,QAAO;AAC1B,SACE,qBAAC,QAAK,MAAK,MAAK,SAAQ,SAAQ,MAAK,WAAU,aAAU,eACvD;AAAA,wBAAC,UAAK,eAAW,MAAE,gBAAK;AAAA,IAExB,qBAAC,UAAK,WAAU,iBAAgB;AAAA;AAAA,MAAG,OAAO,QAAQ,KAAe;AAAA,OAAE;AAAA,KACrE;AAEJ;AAmCO,SAAS,eAAe;AAAA,EAC7B,OAAO;AAAA,EACP;AAAA,EACA,SAAS;AAAA,EACT,MAAM;AAAA,EACN;AACF,IAA2B,CAAC,GAAoB;AAC9C,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,IAAI;AAAA,IACJ,MAAM,oBAAC,eAAY,WAAU,UAAS,eAAW,MAAC;AAAA,IAClD,OAAO,OAAO;AAAA,IACd,UAAU,UAAU,KAAK,MAAM,OAAO,SAAY,oBAAC,gBAAa,OAAc,QAAgB;AAAA,IAC9F;AAAA,EACF;AACF;AAgBO,SAAS,kBAAkB,EAAE,QAAQ,YAAY,GAAG,QAAQ,IAA8B,CAAC,GAAoB;AACpH,QAAM,SAAS,mBAAmB,UAAU;AAC5C,SAAO,eAAe,EAAE,GAAG,SAAS,OAAO,CAAC;AAC9C;AAqBO,SAAS,aAAa,EAAE,OAAO,UAAU,OAAO,YAAY,SAAS,QAAQ,WAAW,GAAsB;AAEnH,QAAM,SAAS,mBAAmB,UAAU;AAC5C,SACE,oBAAC,QACC;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,YAAY,CAAC,EAAE,MAAM,IAAI,GAAG,EAAE,MAAM,oBAAC,QAAK,IAAS,GAAG,GAAG;AAAA,MACzD,SAAS,aAAa,MAAM,WAAW,IAAI;AAAA,MAC3C,SAAS,oBAAC,eAAY,WAAU,UAAS,eAAW,MAAC;AAAA,MACrD,UAAU,UAAU,KAAK,MAAM,OAAO,SAAY,oBAAC,gBAAa,OAAc,QAAgB;AAAA,MAC9F;AAAA,MAEC,iBAAO;AAAA;AAAA,EACV,GACF;AAEJ;","names":[]}
@@ -67,6 +67,16 @@ export type AppShellSidebarFooterItem = {
67
67
  hint?: string;
68
68
  /** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`. */
69
69
  external?: boolean;
70
+ /**
71
+ * 0.42.0 (docs/admin-harmonization.md §6.9): a count of what waits behind the link —
72
+ * kastlan's "Administration" with its To-triage count. A pill at the end of the
73
+ * expanded link, a corner dot on the collapsed icon; `0`, `null` or none shows
74
+ * nothing, and above 99 it reads "99+". The count joins the link's name, in
75
+ * `badgeLabel`'s words when given ("Administration, 3 waiting").
76
+ */
77
+ badge?: number | null;
78
+ /** The badge's words for a reader — `adminArea.waiting(count)`. Default: the number. */
79
+ badgeLabel?: string;
70
80
  } | {
71
81
  kind: "links";
72
82
  key: string;