@eifi1/ui-kit 0.41.0 → 0.42.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 (143) hide show
  1. package/README.md +19 -6
  2. package/dist/admin/admin-area-labels.d.ts +55 -0
  3. package/dist/admin/admin-area-labels.js +33 -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 +93 -0
  9. package/dist/admin/admin-overview.js +69 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +63 -0
  12. package/dist/admin/admin-sections.js +64 -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 +25 -2
  18. package/dist/billing/billing-banner.js +5 -2
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +32 -0
  21. package/dist/billing/billing-labels.js +9 -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-subscription.d.ts +46 -0
  29. package/dist/billing/cancel-subscription.js +57 -0
  30. package/dist/billing/cancel-subscription.js.map +1 -0
  31. package/dist/billing/plan-card.d.ts +45 -1
  32. package/dist/billing/plan-card.js +53 -2
  33. package/dist/billing/plan-card.js.map +1 -1
  34. package/dist/billing/plan-switch.d.ts +10 -1
  35. package/dist/billing/plan-switch.js +22 -13
  36. package/dist/billing/plan-switch.js.map +1 -1
  37. package/dist/billing/subscription-actions.d.ts +37 -4
  38. package/dist/billing/subscription-actions.js +10 -3
  39. package/dist/billing/subscription-actions.js.map +1 -1
  40. package/dist/components/account-chips.d.ts +42 -1
  41. package/dist/components/account-chips.js +33 -0
  42. package/dist/components/account-chips.js.map +1 -1
  43. package/dist/components/empty-state.d.ts +2 -1
  44. package/dist/components/empty-state.js.map +1 -1
  45. package/dist/eslint/index.js +1 -1
  46. package/dist/i18n/defaults.js +5 -1
  47. package/dist/i18n/defaults.js.map +1 -1
  48. package/dist/i18n/german.js +79 -0
  49. package/dist/i18n/german.js.map +1 -1
  50. package/dist/i18n/kit-labels.d.ts +9 -0
  51. package/dist/i18n/kit-labels.js.map +1 -1
  52. package/dist/i18n/locales/en.js +93 -1
  53. package/dist/i18n/locales/en.js.map +3 -3
  54. package/dist/i18n/locales/es.js +76 -0
  55. package/dist/i18n/locales/es.js.map +1 -1
  56. package/dist/i18n/locales/fr.js +75 -0
  57. package/dist/i18n/locales/fr.js.map +1 -1
  58. package/dist/i18n/locales/hu.js +76 -0
  59. package/dist/i18n/locales/hu.js.map +1 -1
  60. package/dist/i18n/locales/it.js +76 -0
  61. package/dist/i18n/locales/it.js.map +1 -1
  62. package/dist/i18n/locales/zh.js +75 -0
  63. package/dist/i18n/locales/zh.js.map +1 -1
  64. package/dist/i18n/review.js +20 -1
  65. package/dist/i18n/review.js.map +1 -1
  66. package/dist/index.d.ts +18 -2
  67. package/dist/index.js +23 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/settings/settings-layout.d.ts +7 -1
  70. package/dist/settings/settings-layout.js +35 -27
  71. package/dist/settings/settings-layout.js.map +1 -1
  72. package/dist/shell/account-menu-language.d.ts +46 -0
  73. package/dist/shell/account-menu-language.js +64 -0
  74. package/dist/shell/account-menu-language.js.map +1 -0
  75. package/dist/shell/admin-menu.d.ts +46 -0
  76. package/dist/shell/admin-menu.js +60 -0
  77. package/dist/shell/admin-menu.js.map +1 -0
  78. package/dist/shell/app-shell.d.ts +10 -0
  79. package/dist/shell/app-shell.js +22 -4
  80. package/dist/shell/app-shell.js.map +1 -1
  81. package/dist/shell/dev-switchers.d.ts +53 -0
  82. package/dist/shell/dev-switchers.js +102 -0
  83. package/dist/shell/dev-switchers.js.map +1 -0
  84. package/dist/shell/sync-status-indicator.d.ts +86 -0
  85. package/dist/shell/sync-status-indicator.js +170 -0
  86. package/dist/shell/sync-status-indicator.js.map +1 -0
  87. package/dist/shell/top-bar-actions.d.ts +49 -0
  88. package/dist/shell/top-bar-actions.js +31 -0
  89. package/dist/shell/top-bar-actions.js.map +1 -0
  90. package/dist/shell/top-bar-parts-labels.d.ts +84 -0
  91. package/dist/shell/top-bar-parts-labels.js +48 -0
  92. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  93. package/dist/shell/topbar-action-menu.d.ts +13 -1
  94. package/dist/shell/topbar-action-menu.js +5 -2
  95. package/dist/shell/topbar-action-menu.js.map +1 -1
  96. package/dist/shell/topbar-controls.d.ts +16 -3
  97. package/dist/shell/topbar-controls.js +5 -1
  98. package/dist/shell/topbar-controls.js.map +1 -1
  99. package/dist/shell/tour-menu.d.ts +58 -0
  100. package/dist/shell/tour-menu.js +73 -0
  101. package/dist/shell/tour-menu.js.map +1 -0
  102. package/dist/shell.d.ts +14 -0
  103. package/dist/shell.js +19 -0
  104. package/dist/shell.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/admin/admin-area-labels.ts +85 -0
  107. package/src/admin/admin-forbidden.tsx +75 -0
  108. package/src/admin/admin-overview.tsx +166 -0
  109. package/src/admin/admin-sections.tsx +137 -0
  110. package/src/admin/admin-tool-links.tsx +84 -0
  111. package/src/billing/billing-banner.tsx +32 -4
  112. package/src/billing/billing-labels.ts +53 -0
  113. package/src/billing/billing-overview.ts +37 -0
  114. package/src/billing/billing-standing.ts +8 -0
  115. package/src/billing/cancel-subscription.tsx +102 -0
  116. package/src/billing/plan-card.tsx +97 -1
  117. package/src/billing/plan-switch.tsx +16 -0
  118. package/src/billing/subscription-actions.tsx +64 -15
  119. package/src/components/account-chips.tsx +81 -1
  120. package/src/components/empty-state.tsx +2 -1
  121. package/src/i18n/defaults.ts +4 -0
  122. package/src/i18n/german.ts +92 -0
  123. package/src/i18n/kit-labels.tsx +9 -0
  124. package/src/i18n/locales/en.ts +19 -0
  125. package/src/i18n/locales/es.ts +86 -0
  126. package/src/i18n/locales/fr.ts +86 -0
  127. package/src/i18n/locales/hu.ts +84 -0
  128. package/src/i18n/locales/it.ts +87 -0
  129. package/src/i18n/locales/zh.ts +79 -0
  130. package/src/i18n/review.ts +19 -0
  131. package/src/index.ts +30 -2
  132. package/src/settings/settings-layout.tsx +58 -34
  133. package/src/shell/account-menu-language.tsx +116 -0
  134. package/src/shell/admin-menu.tsx +117 -0
  135. package/src/shell/app-shell.tsx +44 -3
  136. package/src/shell/dev-switchers.tsx +171 -0
  137. package/src/shell/sync-status-indicator.tsx +301 -0
  138. package/src/shell/top-bar-actions.tsx +79 -0
  139. package/src/shell/top-bar-parts-labels.ts +133 -0
  140. package/src/shell/topbar-action-menu.tsx +15 -2
  141. package/src/shell/topbar-controls.tsx +20 -3
  142. package/src/shell/tour-menu.tsx +144 -0
  143. package/src/shell.ts +24 -0
@@ -1613,6 +1613,9 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1613
1613
  processing: "您的付款正在处理中。确认后您的套餐即会生效;此页面会自动更新。",
1614
1614
  choosePlan: "选择套餐",
1615
1615
  updatePayment: "更新付款方式",
1616
+ // 0.42 (§16.7 A): for an admin who isn't the buyer.
1617
+ paymentFailedAskBuyer: (buyer) =>
1618
+ buyer ? `付款未成功。${buyer} 可以更新付款方式。` : "付款未成功。购买该订阅的管理员可以更新付款方式。",
1616
1619
  checkAgain: "再次检查",
1617
1620
  lockReason: "您的套餐已结束。选择套餐后即可再次进行修改。",
1618
1621
  // As `characterCount.limitReached` ("已达到字符数上限").
@@ -1643,6 +1646,18 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1643
1646
  switchCredited: "抵扣后续扣款",
1644
1647
  switchNextCharge: (date) => `下次扣款日期:${date}`,
1645
1648
  switchConfirm: "更改套餐",
1649
+ // 0.42 (§16): the portal for the buyer only, the in-app cancel, the grant warning.
1650
+ buyerHolds: (buyer) =>
1651
+ buyer
1652
+ ? `付款方式和发票由购买该订阅的 ${buyer} 管理。`
1653
+ : "付款方式和发票由购买该订阅的管理员管理。",
1654
+ cancelConfirmTitle: "取消订阅",
1655
+ cancelConfirmBody: (date) => `您的订阅将于 ${date} 结束。在此之前,一切照常使用。`,
1656
+ cancelConfirmAction: "立即取消订阅",
1657
+ keepSubscription: "保留订阅",
1658
+ grantWarningTitle: "您的免费使用权仍然有效",
1659
+ grantWarningBody: "您的免费使用权没有结束日期。在此期间,购买不会改变您的套餐:您将为一个不生效的套餐付费。",
1660
+ grantWarningAction: "仍然购买",
1646
1661
  waitingChanges: (count) => `${n(count)} 项修改正在等待套餐`,
1647
1662
  // "放弃", as `wizard.cancelConfirmLabel`.
1648
1663
  discardWaiting: "放弃等待中的修改",
@@ -1708,6 +1723,70 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1708
1723
  more: "更多",
1709
1724
  moreTitle: "更多页面",
1710
1725
  },
1726
+ // 0.42.0 — docs/admin-harmonization.md §3.1: the admin area's shared words.
1727
+ adminArea: {
1728
+ title: "管理",
1729
+ waiting: (count) => `${n(count)} 项待处理`,
1730
+ forbiddenTitle: "仅限管理员",
1731
+ forbiddenBody: (appName) => `管理员在这里管理 ${appName}。您的账户无法打开此页面。`,
1732
+ forbiddenHome: "前往首页",
1733
+ forbiddenReviewer: "审校翻译",
1734
+ sections: {
1735
+ overview: { title: "概览", help: "一眼看清待处理的事项。" },
1736
+ users: { title: "用户", help: "谁拥有账户,以及每个人可以做什么。" },
1737
+ invitations: { title: "邀请", help: "让新成员加入的链接。" },
1738
+ billing: { title: "账单", help: "套餐、付款和发票。" },
1739
+ activity: { title: "活动", help: "管理员的操作记录,最新的在前。" },
1740
+ },
1741
+ tiles: {
1742
+ feedbackWaiting: "待分类",
1743
+ deletionRequests: "删除请求",
1744
+ invitationsPending: "待接受的邀请",
1745
+ users: "用户",
1746
+ usersNew: (count) => `近 7 天新增 ${n(count)} 人`,
1747
+ },
1748
+ tools: "工具",
1749
+ },
1750
+ // 0.42.0 — docs/top-bar-harmonization.md §3.1: the top bar's shared parts; "套餐" as
1751
+ // `billing`.
1752
+ topBarParts: {
1753
+ tours: {
1754
+ menu: "导览",
1755
+ newDot: "有新导览",
1756
+ thisPage: "本页",
1757
+ newTours: "新导览",
1758
+ allTours: "全部导览…",
1759
+ badgeNew: "新",
1760
+ completed: "已完成",
1761
+ },
1762
+ sync: {
1763
+ blocked: (count) => `${n(count)} 项修改无法从此设备发送`,
1764
+ offline: "离线——修改已保存在此设备上,重新连接后会同步",
1765
+ refused: "服务器拒绝了上一次同步",
1766
+ syncing: "正在同步…",
1767
+ pending: (count) => `${n(count)} 项修改等待同步`,
1768
+ syncedAt: (time) => `已于 ${time} 同步`,
1769
+ idle: "已是最新",
1770
+ syncHint: "点按立即同步",
1771
+ offlineHint: "离线时无可同步内容",
1772
+ withHint: (state, hint) => `${state}——${hint}`,
1773
+ scoped: (scope, state) => `${scope}:${state}`,
1774
+ offlineShort: "离线",
1775
+ syncingShort: "正在同步…",
1776
+ waitingShort: (count) => `${n(count)} 项等待中`,
1777
+ refusedShort: "未同步",
1778
+ offlineUnsaved: "离线——重新连接前无法保存修改",
1779
+ saving: "正在保存…",
1780
+ },
1781
+ dev: {
1782
+ roleHeading: "开发 · 角色",
1783
+ switchPlan: "切换套餐",
1784
+ plan: (value) => `套餐:${value}`,
1785
+ planHeading: "开发 · 套餐",
1786
+ backTo: (role) => `返回 ${role}`,
1787
+ billingOff: "本地已关闭计费:切换套餐不会改变任何内容",
1788
+ },
1789
+ },
1711
1790
  };
1712
1791
  }
1713
1792
 
@@ -393,9 +393,28 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
393
393
  // 0.39: PlanSwitchConfirm's two lines with figures (§15.2.10).
394
394
  "billing.switchFromTo": [["{{from}}", "{{to}}"]],
395
395
  "billing.switchNextCharge": [["{{date}}"]],
396
+ // 0.42 (§16): the buyer's name, or none once they are gone; the cancel's period end.
397
+ "billing.buyerHolds": [["{{buyer}}"], [undefined]],
398
+ "billing.paymentFailedAskBuyer": [["{{buyer}}"], [undefined]],
399
+ "billing.cancelConfirmBody": [["{{date}}"]],
396
400
  "planChange.current": [["{{plan}}"]],
397
401
  "planChange.changed": [["{{from}}", "{{to}}"]],
398
402
  "planChange.set": [["{{to}}"]],
399
403
  "planChange.untilDone": [["{{date}}"]],
400
404
  "planChange.usageOf": [["{{used}}", "{{limit}}", "{{dimension}}"]],
405
+ // 0.42.0 (docs/admin-harmonization.md §3.1): the menu row's count, the 403 page's app,
406
+ // the users tile's new accounts.
407
+ "adminArea.waiting": one,
408
+ "adminArea.forbiddenBody": [["{{app}}"]],
409
+ "adminArea.tiles.usersNew": one,
410
+ // 0.42.0 (docs/top-bar-harmonization.md §3.1): the sync indicator's counts, its time, the
411
+ // state joined to its hint or to the app's scope; the dev plan and the role to go back to.
412
+ "topBarParts.sync.blocked": one,
413
+ "topBarParts.sync.pending": one,
414
+ "topBarParts.sync.syncedAt": [["{{time}}"]],
415
+ "topBarParts.sync.withHint": [["{{state}}", "{{hint}}"]],
416
+ "topBarParts.sync.scoped": [["{{scope}}", "{{state}}"]],
417
+ "topBarParts.sync.waitingShort": one,
418
+ "topBarParts.dev.plan": [["{{plan}}"]],
419
+ "topBarParts.dev.backTo": [["{{role}}"]],
401
420
  };
package/src/index.ts CHANGED
@@ -412,6 +412,10 @@ export type { WriteLock, WriteLockLabels, WriteLockProviderProps, WriteLockKind,
412
412
  // 0.18: the parts both apps' admin rosters repeat (Kurvenschmiede 6).
413
413
  export { RoleChip, AccountStateChip, DateMark, dateColumn, DEFAULT_ACCOUNT_STATE_LABELS, ACCOUNT_STATE_TONES } from "./components/account-chips";
414
414
  export type { RoleChipProps, RoleDefinition, RoleVocabulary, AccountState, AccountStateLabels, AccountStateChipProps, DateMarkProps, DateColumnOptions } from "./components/account-chips";
415
+ // 0.42 (docs/admin-harmonization.md §2.1): the role in the account menu's header, a link
416
+ // to the role's tool (`RoleDefinition.href`).
417
+ export { AccountRoleChip } from "./components/account-chips";
418
+ export type { AccountRoleChipProps } from "./components/account-chips";
415
419
  // 0.18: the cold-start notice keksdose built (#199), for every app: the corner notice
416
420
  // that explains the wait (its framework-free watchdog is lib/server-wake).
417
421
  export { ServerWakeNotice, useServerWakeStage, DEFAULT_SERVER_WAKE_LABELS } from "./components/server-wake";
@@ -785,6 +789,25 @@ export type {
785
789
  InvitationsPanelProps,
786
790
  } from "./admin/invitations-panel";
787
791
  export type { AdminPerson } from "./admin/admin-parts";
792
+ // 0.42.0 (docs/admin-harmonization.md §3.1): the admin area's template — its words, the
793
+ // shared sections and the catalogue helper, the 403 page, the overview and its tiles fed
794
+ // by `GET /admin/attention`, and the Tools list. The menu row is `./shell`'s.
795
+ export { DEFAULT_ADMIN_AREA_LABELS, useAdminAreaLabels } from "./admin/admin-area-labels";
796
+ export type { AdminAreaLabels, AdminTileLabels, AdminSharedSection } from "./admin/admin-area-labels";
797
+ export { ADMIN_SECTIONS, adminSections } from "./admin/admin-sections";
798
+ export type { AdminSectionDefinition, AdminOwnSection, AdminSectionsOptions } from "./admin/admin-sections";
799
+ export { AdminForbidden } from "./admin/admin-forbidden";
800
+ export type { AdminForbiddenProps } from "./admin/admin-forbidden";
801
+ export { AdminOverview, AttentionTile, adminWaitingCount, DEFAULT_ADMIN_ATTENTION_HREFS } from "./admin/admin-overview";
802
+ export type {
803
+ AdminAttention,
804
+ AdminAttentionTile,
805
+ AdminExtraTile,
806
+ AdminOverviewProps,
807
+ AttentionTileProps,
808
+ } from "./admin/admin-overview";
809
+ export { AdminToolLinks } from "./admin/admin-tool-links";
810
+ export type { AdminToolLink, AdminToolLinksProps } from "./admin/admin-tool-links";
788
811
  // 0.33.0: the operator's plan parts (docs/billing-harmonization.md §14.12).
789
812
  export {
790
813
  planColumn,
@@ -811,8 +834,8 @@ export { DEFAULT_BILLING_LABELS, useBillingLabels, SUBSCRIPTION_STATUSES } from
811
834
  export type { BillingLabels, SubscriptionStatus } from "./billing/billing-labels";
812
835
  export { BILLING_INTERVALS, planPrice, minorToMajor, formatPlanPrice, billingCurrencyFor } from "./billing/plan-price";
813
836
  export type { BillingInterval, BillingCurrency, PlanIntervalPrices, PlanPrices, PlanPriceAnswer } from "./billing/plan-price";
814
- export { PlanCard, PlanPicker, planRelation } from "./billing/plan-card";
815
- export type { BillingPlan, PlanRelation, PlanCardProps, PlanPickerProps, PlanChoice } from "./billing/plan-card";
837
+ export { PlanCard, PlanPicker, GrantWarningConfirm, planRelation } from "./billing/plan-card";
838
+ export type { BillingPlan, PlanRelation, PlanCardProps, PlanPickerProps, PlanChoice, GrantWarningConfirmProps } from "./billing/plan-card";
816
839
  export { SubscriptionStatusChip, SUBSCRIPTION_STATUS_TONES } from "./billing/subscription-status-chip";
817
840
  export type { SubscriptionStatusChipProps } from "./billing/subscription-status-chip";
818
841
  export { BillingBanner } from "./billing/billing-banner";
@@ -855,6 +878,11 @@ export type { PlanLimitToastOptions } from "./billing/plan-limit-toast";
855
878
  export { PlanSwitchConfirm } from "./billing/plan-switch";
856
879
  export type { PlanSwitchConfirmProps, PlanSwitchSide } from "./billing/plan-switch";
857
880
  export type { PaidPrice, PlanSwitchRequest, PlanSwitchPreview } from "./billing/billing-overview";
881
+ // 0.42 (§16): the portal for the buyer only, the in-app cancel, the permanent-grant warning.
882
+ export { isPermanentGrant } from "./billing/billing-overview";
883
+ export type { BillingPortalAccess } from "./billing/billing-overview";
884
+ export { CancelSubscriptionConfirm } from "./billing/cancel-subscription";
885
+ export type { CancelSubscriptionConfirmProps } from "./billing/cancel-subscription";
858
886
  // The operator's plan parts (§14.12) are admin/plan-change, in the admin section.
859
887
 
860
888
  // ── settings ─────────────────────────────────────────────────────────────────
@@ -40,6 +40,9 @@ export type SettingsLayoutWidth = "6xl" | "7xl";
40
40
  * about twelve, keksdose 26; a page of six needs none. */
41
41
  export const SETTINGS_SEARCH_THRESHOLD = 8;
42
42
 
43
+ /** The desktop sidebar's column: sticky beside a long group (§3.2). */
44
+ const SIDEBAR_COLUMN = "mb-4 md:sticky md:top-6 md:mb-0 md:self-start";
45
+
43
46
  export interface SettingsLayoutProps<G extends string> {
44
47
  /** The page's `h1`: "Settings", "Administration". Default: `labels.title`. */
45
48
  title?: ReactNode;
@@ -72,6 +75,12 @@ export interface SettingsLayoutProps<G extends string> {
72
75
  * own navigation carries them.
73
76
  */
74
77
  phoneFooter?: ReactNode;
78
+ /**
79
+ * 0.42.0 (docs/admin-harmonization.md §2.5 decision 16): content under the section
80
+ * list on a desktop, in the same sticky column — the admin area's Tools list
81
+ * (`AdminToolLinks`). Not shown on a phone, where `phoneFooter` carries the same links.
82
+ */
83
+ sidebarFooter?: ReactNode;
75
84
  /** Prop > `<UiKitProvider labels={{ settings }}>` > English. */
76
85
  labels?: LabelOverride<SettingsLabels>;
77
86
  /** See {@link SettingsLayoutMode}. Default `"auto"`: the screen decides. */
@@ -151,6 +160,7 @@ export function SettingsLayout<G extends string>({
151
160
  width = "6xl",
152
161
  renderGroup,
153
162
  phoneFooter,
163
+ sidebarFooter,
154
164
  labels: labelsProp,
155
165
  layout = "auto",
156
166
  appName,
@@ -376,45 +386,59 @@ export function SettingsLayout<G extends string>({
376
386
  );
377
387
  } else {
378
388
  const open = group === null ? undefined : groupById.get(group);
389
+ // The section list alone, or (0.42) with the app's `sidebarFooter` under it in one
390
+ // sticky column.
391
+ const withSidebarFooter = (tabs: ReactNode) =>
392
+ sidebarFooter == null ? (
393
+ tabs
394
+ ) : (
395
+ <div className={cn(SIDEBAR_COLUMN, "space-y-6")}>
396
+ {tabs}
397
+ <div data-slot="settings-sidebar-footer">{sidebarFooter}</div>
398
+ </div>
399
+ );
379
400
  body = (
380
401
  <div className={pageClass} data-settings-view="desktop">
381
402
  <PageHeader size="compact" title={pageTitle} />
382
403
  {searchField}
383
404
  <div className="md:grid md:grid-cols-[14rem_minmax(0,1fr)] md:gap-6">
384
- <Tabs<G>
385
- orientation="vertical"
386
- aria-label={labels.sections}
387
- // Sticky (new in the harmonisation, §3.2): keksdose's notifications group
388
- // scrolls for screens, and the list should stay in view beside it.
389
- className="mb-4 md:sticky md:top-6 md:mb-0 md:self-start"
390
- panelId={open && !searching ? panelId : undefined}
391
- tabs={route.groups.map((g) => {
392
- const count = hitsByGroup.get(g.id) ?? 0;
393
- // While searching, the groups with nothing to show step back (keksdose).
394
- const dim = searching && count === 0;
395
- return {
396
- id: g.id,
397
- href: `${root}${settingsHref(base, g.id)}`,
398
- name: g.title,
399
- label: <span className={cn(dim && "opacity-40")}>{g.title}</span>,
400
- icon: <span className={cn("inline-flex [&_svg]:size-4", dim && "opacity-40")}>{g.icon}</span>,
401
- badge:
402
- searching && count > 0 ? (
403
- <Chip size="sm" tone="brand">
404
- <span aria-hidden>{count}</span>
405
- <span className="sr-only-fixed">{labels.matchCount(count)}</span>
406
- </Chip>
407
- ) : undefined,
408
- };
409
- })}
410
- active={(open?.id ?? route.groups[0]?.id) as G}
411
- onChange={(id) => {
412
- // While searching the hits stand where the group would; a click on a group
413
- // means "go there", so the search makes way (keksdose).
414
- setQuery("");
415
- select(id);
416
- }}
417
- />
405
+ {withSidebarFooter(
406
+ <Tabs<G>
407
+ orientation="vertical"
408
+ aria-label={labels.sections}
409
+ // Sticky (new in the harmonisation, §3.2): keksdose's notifications group
410
+ // scrolls for screens, and the list should stay in view beside it. With a
411
+ // `sidebarFooter` the column round both is the sticky one.
412
+ className={sidebarFooter == null ? SIDEBAR_COLUMN : undefined}
413
+ panelId={open && !searching ? panelId : undefined}
414
+ tabs={route.groups.map((g) => {
415
+ const count = hitsByGroup.get(g.id) ?? 0;
416
+ // While searching, the groups with nothing to show step back (keksdose).
417
+ const dim = searching && count === 0;
418
+ return {
419
+ id: g.id,
420
+ href: `${root}${settingsHref(base, g.id)}`,
421
+ name: g.title,
422
+ label: <span className={cn(dim && "opacity-40")}>{g.title}</span>,
423
+ icon: <span className={cn("inline-flex [&_svg]:size-4", dim && "opacity-40")}>{g.icon}</span>,
424
+ badge:
425
+ searching && count > 0 ? (
426
+ <Chip size="sm" tone="brand">
427
+ <span aria-hidden>{count}</span>
428
+ <span className="sr-only-fixed">{labels.matchCount(count)}</span>
429
+ </Chip>
430
+ ) : undefined,
431
+ };
432
+ })}
433
+ active={(open?.id ?? route.groups[0]?.id) as G}
434
+ onChange={(id) => {
435
+ // While searching the hits stand where the group would; a click on a group
436
+ // means "go there", so the search makes way (keksdose).
437
+ setQuery("");
438
+ select(id);
439
+ }}
440
+ />,
441
+ )}
418
442
  <div className="min-w-0 space-y-4">
419
443
  {searching ? (
420
444
  results
@@ -0,0 +1,116 @@
1
+ import { useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { Globe } from "lucide-react";
4
+ import { Disclosure } from "../components/disclosure";
5
+ import { MenuItem } from "../components/menu-item";
6
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels";
7
+
8
+ /** One offered language in the account menu's sub-list. */
9
+ export interface AccountMenuLanguageOption {
10
+ code: string;
11
+ /** Its name, in its own language ("Deutsch", "Français"). */
12
+ label: ReactNode;
13
+ /**
14
+ * A flag before the name, as the app draws it — keksdose's flag-icons span
15
+ * (`<span aria-hidden className="fi fi-ch …" />`). A node rather than a country code, so
16
+ * the kit ships no flag set; decorative, so give it `aria-hidden`.
17
+ */
18
+ flag?: ReactNode;
19
+ /**
20
+ * Or the ISO 3166-1 alpha-2 country (lowercase) whose flag-icons flag to draw, as
21
+ * `LanguageMenu`'s `LanguageOption` takes it — so one language list feeds both
22
+ * (Kurvenschmiede's `LANGUAGES`). Needs the app's flag-icons CSS, as `LanguageMenu`
23
+ * does. `flag` wins when both are given.
24
+ */
25
+ country?: string;
26
+ }
27
+
28
+ /** The option's flag: its own node, else the flag-icons flag of its country. */
29
+ function flagOf(lang: AccountMenuLanguageOption): ReactNode {
30
+ if (lang.flag !== undefined) return lang.flag;
31
+ if (!lang.country) return undefined;
32
+ return (
33
+ <span
34
+ aria-hidden
35
+ data-country={lang.country}
36
+ 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)]`}
37
+ />
38
+ );
39
+ }
40
+
41
+ export interface AccountMenuLanguageProps {
42
+ languages: readonly AccountMenuLanguageOption[];
43
+ /** The active language's code. */
44
+ value: string | undefined;
45
+ /** A pick: the app sets the device's language and, signed in, the account's
46
+ * (settings §6.2). */
47
+ onPick: (code: string) => void;
48
+ /** The account menu's `close` (from `TopBarActionMenu`'s `children`): a pick shuts it. */
49
+ close?: () => void;
50
+ /** The row's name. Default: `topBar.language` ("Language"). */
51
+ label?: string;
52
+ /** Open the sub-list at first. Default `false`. */
53
+ defaultOpen?: boolean;
54
+ }
55
+
56
+ /**
57
+ * The language choice INSIDE the account menu (docs/top-bar-harmonization.md §2.3),
58
+ * keksdose's sub-list: a `Disclosure variant="menu"` row — the globe, "Language", the
59
+ * active language's flag and the chevron — that opens in place onto one checked
60
+ * `MenuItem` per language. Inline rather than a nested popover: a popover inside a
61
+ * popover fights the outside click and has nowhere to go on a phone (keksdose #140).
62
+ *
63
+ * It renders its own `<li>`: drop it into `TopBarActionMenu`'s `children` first after the
64
+ * app's context rows (§6.5). Signed out there is no account menu; `LanguageMenu` stays
65
+ * the bar's button (§2.3).
66
+ */
67
+ export function AccountMenuLanguage({
68
+ languages,
69
+ value,
70
+ onPick,
71
+ close,
72
+ label,
73
+ defaultOpen = false,
74
+ }: AccountMenuLanguageProps) {
75
+ const words = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
76
+ const [open, setOpen] = useState(defaultOpen);
77
+ const active = languages.find((l) => l.code === value);
78
+ return (
79
+ <li>
80
+ <Disclosure
81
+ variant="menu"
82
+ open={open}
83
+ onOpenChange={setOpen}
84
+ title={
85
+ <span className="flex items-center justify-between gap-3">
86
+ <span className="flex items-center gap-2">
87
+ <Globe aria-hidden className="size-4" />
88
+ {label ?? words.language}
89
+ </span>
90
+ {active && flagOf(active)}
91
+ </span>
92
+ }
93
+ >
94
+ <ul>
95
+ {languages.map((lang) => (
96
+ <li key={lang.code}>
97
+ <MenuItem
98
+ checked={lang.code === value}
99
+ onClick={() => {
100
+ onPick(lang.code);
101
+ setOpen(false);
102
+ close?.();
103
+ }}
104
+ leading={flagOf(lang)}
105
+ // Under the row's own text, past the globe.
106
+ className="ps-9"
107
+ >
108
+ {lang.label}
109
+ </MenuItem>
110
+ </li>
111
+ ))}
112
+ </ul>
113
+ </Disclosure>
114
+ </li>
115
+ );
116
+ }
@@ -0,0 +1,117 @@
1
+ import { Link } from "react-router";
2
+ import { ShieldCheck } from "lucide-react";
3
+ import { Chip } from "../components/chip";
4
+ import { MenuItem } from "../components/menu-item";
5
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
6
+ import { DEFAULT_ADMIN_AREA_LABELS } from "../admin/admin-area-labels";
7
+ import type { AdminAreaLabels } from "../admin/admin-area-labels";
8
+ import type { TopBarMenuEntry } from "./topbar-action-menu";
9
+
10
+ /**
11
+ * The account menu's "Administration" row (0.42.0, docs/admin-harmonization.md §2.1, §6.6,
12
+ * §7): the way into the admin area everyone finds, in every app, for an admin only. Two
13
+ * forms of one row — {@link adminMenuEntry} for `TopBarActionMenu`'s `entries`, and
14
+ * {@link AdminMenuRow} for a menu built from `HoverMenu` children (Kurvenschmiede's).
15
+ */
16
+
17
+ /** The badge's text: nothing for none or 0, "99+" past 99. */
18
+ function badgeText(badge: number | null | undefined): string | null {
19
+ if (typeof badge !== "number" || !(badge > 0)) return null;
20
+ return badge > 99 ? "99+" : String(Math.floor(badge));
21
+ }
22
+
23
+ /**
24
+ * The waiting count at the row's end: the `solid` count pill of {@link Chip}, in the
25
+ * pending count's tone, with the number for the eye and `adminArea.waiting` for a reader
26
+ * ("Administration, 3 waiting"). `null` for no count.
27
+ */
28
+ function WaitingBadge({ badge, labels }: { badge: number | null | undefined; labels: AdminAreaLabels }) {
29
+ const text = badgeText(badge);
30
+ if (text === null) return null;
31
+ return (
32
+ <Chip size="sm" variant="solid" tone="warning" data-slot="admin-badge">
33
+ <span aria-hidden>{text}</span>
34
+ {/* The comma parts it from the row's label in the name: "Administration, 3 waiting". */}
35
+ <span className="sr-only-fixed">, {labels.waiting(badge as number)}</span>
36
+ </Chip>
37
+ );
38
+ }
39
+
40
+ export interface AdminMenuEntryOptions {
41
+ /** The admin area. Default `/admin`. */
42
+ href?: string;
43
+ /**
44
+ * The waiting count (§2.4 decision 15, §6.3): only work that waits on this admin —
45
+ * `adminWaitingCount(attention, […])`. `0`, `null` or none shows no badge.
46
+ */
47
+ badge?: number | null;
48
+ /** The resolved `adminArea` words (`useAdminAreaLabels()`); default English. Builds no
49
+ * hook, so the entry can be made in the list of entries. */
50
+ labels?: AdminAreaLabels;
51
+ /** The entry's `key`. Default `"administration"`. */
52
+ key?: string;
53
+ /** The page already open: `aria-current` and a check mark. */
54
+ current?: boolean;
55
+ }
56
+
57
+ /**
58
+ * The "Administration" {@link TopBarMenuEntry}: a link row with the `ShieldCheck` icon,
59
+ * the kit's word and the waiting count as its badge. Put it in the entries for an admin
60
+ * only — the menu closes on the way, as any link row:
61
+ *
62
+ * entries={[…, ...(isAdmin ? [adminMenuEntry({ badge: waiting, labels })] : []), …]}
63
+ */
64
+ export function adminMenuEntry({
65
+ href = "/admin",
66
+ badge,
67
+ labels = DEFAULT_ADMIN_AREA_LABELS,
68
+ key = "administration",
69
+ current,
70
+ }: AdminMenuEntryOptions = {}): TopBarMenuEntry {
71
+ return {
72
+ kind: "link",
73
+ key,
74
+ to: href,
75
+ icon: <ShieldCheck className="size-4" aria-hidden />,
76
+ label: labels.title,
77
+ trailing: badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />,
78
+ current,
79
+ };
80
+ }
81
+
82
+ export interface AdminMenuRowProps {
83
+ /** The admin area. Default `/admin`. */
84
+ href?: string;
85
+ /** As {@link AdminMenuEntryOptions.badge}. */
86
+ badge?: number | null;
87
+ /** Runs on the way — `HoverMenu`'s `close`. */
88
+ onNavigate?: () => void;
89
+ current?: boolean;
90
+ /** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */
91
+ labels?: LabelOverride<AdminAreaLabels>;
92
+ }
93
+
94
+ /**
95
+ * The same row as {@link adminMenuEntry} for a menu drawn from `HoverMenu` children: the
96
+ * `<li>` holding the router-link {@link MenuItem} `TopBarActionMenu` draws for a link
97
+ * entry, so the two menus look alike.
98
+ *
99
+ * {(close) => <ul>… {isAdmin && <AdminMenuRow badge={waiting} onNavigate={close} />} …</ul>}
100
+ */
101
+ export function AdminMenuRow({ href = "/admin", badge, onNavigate, current, labels: labelsProp }: AdminMenuRowProps) {
102
+ const labels = useKitLabels("adminArea", DEFAULT_ADMIN_AREA_LABELS, labelsProp);
103
+ return (
104
+ <li>
105
+ <MenuItem
106
+ href={href}
107
+ renderLink={({ href: to, ...p }) => <Link to={to} {...p} />}
108
+ onClick={onNavigate ? () => onNavigate() : undefined}
109
+ leading={<ShieldCheck className="size-4" aria-hidden />}
110
+ trailing={badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />}
111
+ current={current}
112
+ >
113
+ {labels.title}
114
+ </MenuItem>
115
+ </li>
116
+ );
117
+ }
@@ -91,6 +91,16 @@ export type AppShellSidebarFooterItem =
91
91
  hint?: string;
92
92
  /** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`. */
93
93
  external?: boolean;
94
+ /**
95
+ * 0.42.0 (docs/admin-harmonization.md §6.9): a count of what waits behind the link —
96
+ * kastlan's "Administration" with its To-triage count. A pill at the end of the
97
+ * expanded link, a corner dot on the collapsed icon; `0`, `null` or none shows
98
+ * nothing, and above 99 it reads "99+". The count joins the link's name, in
99
+ * `badgeLabel`'s words when given ("Administration, 3 waiting").
100
+ */
101
+ badge?: number | null;
102
+ /** The badge's words for a reader — `adminArea.waiting(count)`. Default: the number. */
103
+ badgeLabel?: string;
94
104
  }
95
105
  | {
96
106
  kind: "links";
@@ -589,6 +599,17 @@ const plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}
589
599
  const FOOTER_ICON_LINK =
590
600
  "flex min-h-8 items-center justify-center rounded-md text-muted hover:bg-hover hover:text-secondary";
591
601
 
602
+ /** A footer link's count as drawn: nothing for none, "99+" past 99. */
603
+ function footerBadgeText(badge: number | null | undefined): string | null {
604
+ if (typeof badge !== "number" || !(badge > 0)) return null;
605
+ return badge > 99 ? "99+" : String(Math.floor(badge));
606
+ }
607
+
608
+ /** The count's pill — the solid "count" look of `Chip` (warning, the pending count's
609
+ * tone, as `FloatingActionGroup`'s badge), sized for the footer's small text. */
610
+ const FOOTER_BADGE =
611
+ "inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-warning px-1 text-micro font-semibold leading-4 tabular-nums text-warning-contrast";
612
+
592
613
  /** Calls the link renderer as a component, so a router link's hooks are its own. */
593
614
  function RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
594
615
  return render(props);
@@ -627,12 +648,23 @@ function SidebarFooterItems({
627
648
  const to = item.kind === "links" ? (item.to ?? item.links[0]?.to) : item.to;
628
649
  if (!to) return null;
629
650
  const external = item.kind === "links" ? item.links.find((l) => l.to === to)?.external : item.external;
651
+ const count = item.kind === "links" ? null : footerBadgeText(item.badge);
652
+ const spoken = count === null || item.kind === "links" ? null : (item.badgeLabel ?? count);
630
653
  return (
631
654
  <Tooltip key={item.key} label={item.label} side="end" portal className="block">
632
655
  {linkTo(to, external, {
633
- "aria-label": item.label,
634
- className: FOOTER_ICON_LINK,
635
- children: <item.icon className="size-4" aria-hidden />,
656
+ "aria-label": spoken === null ? item.label : `${item.label}, ${spoken}`,
657
+ className: cn(FOOTER_ICON_LINK, count !== null && "relative"),
658
+ children: (
659
+ <>
660
+ <item.icon className="size-4" aria-hidden />
661
+ {count !== null && (
662
+ <span aria-hidden data-badge="" className={cn(FOOTER_BADGE, "absolute -top-0.5 end-0.5")}>
663
+ {count}
664
+ </span>
665
+ )}
666
+ </>
667
+ ),
636
668
  })}
637
669
  </Tooltip>
638
670
  );
@@ -655,6 +687,7 @@ function SidebarFooterItems({
655
687
  </nav>
656
688
  );
657
689
  }
690
+ const count = footerBadgeText(item.badge);
658
691
  const link = linkTo(item.to, item.external, {
659
692
  className: cn(FOOTER_ICON_LINK, "gap-1.5 text-xs font-medium"),
660
693
  children: (
@@ -663,6 +696,14 @@ function SidebarFooterItems({
663
696
  {/* Wraps from Large up (text size §10.17, round 0.34 T1): the link is
664
697
  `min-h-8`, so it grows a line rather than cut "Feedb…". */}
665
698
  <span className="min-w-0 truncate-until-large">{item.text ?? item.label}</span>
699
+ {count !== null && (
700
+ <>
701
+ <span aria-hidden data-badge="" className={FOOTER_BADGE}>
702
+ {count}
703
+ </span>
704
+ <span className="sr-only">, {item.badgeLabel ?? count}</span>
705
+ </>
706
+ )}
666
707
  </>
667
708
  ),
668
709
  });