@eifi1/ui-kit 0.10.0 → 0.11.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 (162) hide show
  1. package/README.md +20 -16
  2. package/dist/components/alert-banner.d.ts +26 -1
  3. package/dist/components/alert-banner.js +10 -7
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -0
  6. package/dist/components/bulk-action-bar.d.ts +51 -5
  7. package/dist/components/bulk-action-bar.js +88 -41
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +58 -3
  10. package/dist/components/button-group.js +56 -5
  11. package/dist/components/button-group.js.map +1 -1
  12. package/dist/components/calculator.d.ts +2 -0
  13. package/dist/components/calendar-heatmap.d.ts +124 -0
  14. package/dist/components/calendar-heatmap.js +295 -0
  15. package/dist/components/calendar-heatmap.js.map +1 -0
  16. package/dist/components/choice-card.d.ts +34 -2
  17. package/dist/components/choice-card.js +49 -0
  18. package/dist/components/choice-card.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +1 -0
  20. package/dist/components/data-table.js +2 -0
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/field.d.ts +43 -0
  23. package/dist/components/field.js +52 -0
  24. package/dist/components/field.js.map +1 -0
  25. package/dist/components/file-button.d.ts +1 -0
  26. package/dist/components/file-dropzone.d.ts +1 -0
  27. package/dist/components/floating-panel.d.ts +147 -4
  28. package/dist/components/floating-panel.js +188 -28
  29. package/dist/components/floating-panel.js.map +1 -1
  30. package/dist/components/list.d.ts +14 -1
  31. package/dist/components/list.js +7 -1
  32. package/dist/components/list.js.map +1 -1
  33. package/dist/components/menu-item.d.ts +11 -1
  34. package/dist/components/menu-item.js +6 -1
  35. package/dist/components/menu-item.js.map +1 -1
  36. package/dist/components/mini-calendar.d.ts +70 -5
  37. package/dist/components/mini-calendar.js +166 -62
  38. package/dist/components/mini-calendar.js.map +1 -1
  39. package/dist/components/nav-pills.d.ts +66 -0
  40. package/dist/components/nav-pills.js +65 -0
  41. package/dist/components/nav-pills.js.map +1 -0
  42. package/dist/components/number-field.d.ts +2 -0
  43. package/dist/components/number-input.d.ts +2 -0
  44. package/dist/components/numpad-sheet.d.ts +2 -0
  45. package/dist/components/progress-bar.d.ts +31 -2
  46. package/dist/components/progress-bar.js +73 -7
  47. package/dist/components/progress-bar.js.map +1 -1
  48. package/dist/components/series-chart.d.ts +16 -0
  49. package/dist/components/series-chart.js +4 -1
  50. package/dist/components/series-chart.js.map +1 -1
  51. package/dist/components/settings-fields.d.ts +1 -0
  52. package/dist/components/status-dot.d.ts +6 -2
  53. package/dist/components/status-dot.js +6 -1
  54. package/dist/components/status-dot.js.map +1 -1
  55. package/dist/components/table.d.ts +40 -8
  56. package/dist/components/table.js +41 -7
  57. package/dist/components/table.js.map +1 -1
  58. package/dist/components/text.d.ts +8 -1
  59. package/dist/components/text.js +1 -0
  60. package/dist/components/text.js.map +1 -1
  61. package/dist/components/time-input.d.ts +1 -0
  62. package/dist/components/toggle-group.d.ts +15 -0
  63. package/dist/components/toggle-group.js +23 -3
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/tooltip.d.ts +11 -3
  66. package/dist/components/tooltip.js +2 -0
  67. package/dist/components/tooltip.js.map +1 -1
  68. package/dist/components/ui.d.ts +55 -4
  69. package/dist/components/ui.js +84 -7
  70. package/dist/components/ui.js.map +1 -1
  71. package/dist/components/user-avatar.d.ts +25 -3
  72. package/dist/components/user-avatar.js +32 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +1 -0
  75. package/dist/i18n/defaults.d.ts +2 -0
  76. package/dist/i18n/defaults.js +2 -0
  77. package/dist/i18n/defaults.js.map +1 -1
  78. package/dist/i18n/kit-labels.d.ts +3 -0
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  81. package/dist/i18n/locales/de-CH.d.ts +2 -0
  82. package/dist/i18n/locales/de-informal.d.ts +2 -0
  83. package/dist/i18n/locales/de.d.ts +2 -0
  84. package/dist/i18n/locales/de.js +9 -1
  85. package/dist/i18n/locales/de.js.map +1 -1
  86. package/dist/i18n/locales/es.d.ts +2 -0
  87. package/dist/i18n/locales/es.js +9 -1
  88. package/dist/i18n/locales/es.js.map +1 -1
  89. package/dist/i18n/locales/fr.d.ts +2 -0
  90. package/dist/i18n/locales/fr.js +9 -1
  91. package/dist/i18n/locales/fr.js.map +1 -1
  92. package/dist/i18n/locales/hu.d.ts +2 -0
  93. package/dist/i18n/locales/hu.js +9 -1
  94. package/dist/i18n/locales/hu.js.map +1 -1
  95. package/dist/i18n/locales/it.d.ts +2 -0
  96. package/dist/i18n/locales/it.js +9 -1
  97. package/dist/i18n/locales/it.js.map +1 -1
  98. package/dist/i18n/locales/zh.d.ts +2 -0
  99. package/dist/i18n/locales/zh.js +9 -1
  100. package/dist/i18n/locales/zh.js.map +1 -1
  101. package/dist/index.d.ts +14 -10
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/lib/clipping.d.ts +21 -5
  105. package/dist/lib/clipping.js +3 -0
  106. package/dist/lib/clipping.js.map +1 -1
  107. package/dist/lib/dates.d.ts +17 -1
  108. package/dist/lib/dates.js +19 -0
  109. package/dist/lib/dates.js.map +1 -1
  110. package/dist/rhf/form.d.ts +1 -0
  111. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  112. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  113. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  114. package/dist/rhf.d.ts +2 -0
  115. package/dist/rhf.js +1 -0
  116. package/dist/rhf.js.map +1 -1
  117. package/dist/shell/topbar-action-menu.d.ts +73 -3
  118. package/dist/shell/topbar-action-menu.js +100 -27
  119. package/dist/shell/topbar-action-menu.js.map +1 -1
  120. package/dist/shell.d.ts +2 -1
  121. package/dist/wizard/stepper-nav.d.ts +1 -0
  122. package/dist/wizard/wizard-context.d.ts +7 -1
  123. package/dist/wizard/wizard-context.js +4 -0
  124. package/dist/wizard/wizard-context.js.map +1 -1
  125. package/dist/wizard.d.ts +2 -1
  126. package/package.json +1 -1
  127. package/src/components/alert-banner.tsx +36 -7
  128. package/src/components/bulk-action-bar.tsx +163 -57
  129. package/src/components/button-group.tsx +124 -4
  130. package/src/components/calendar-heatmap.tsx +504 -0
  131. package/src/components/choice-card.tsx +97 -1
  132. package/src/components/data-table.tsx +7 -0
  133. package/src/components/field.tsx +137 -0
  134. package/src/components/floating-panel.tsx +366 -15
  135. package/src/components/list.tsx +28 -2
  136. package/src/components/menu-item.tsx +22 -1
  137. package/src/components/mini-calendar.tsx +245 -54
  138. package/src/components/nav-pills.tsx +145 -0
  139. package/src/components/progress-bar.tsx +129 -11
  140. package/src/components/series-chart.tsx +21 -0
  141. package/src/components/status-dot.tsx +10 -2
  142. package/src/components/table.tsx +85 -11
  143. package/src/components/text.tsx +9 -1
  144. package/src/components/toggle-group.tsx +48 -3
  145. package/src/components/tooltip.tsx +17 -3
  146. package/src/components/ui.tsx +149 -11
  147. package/src/components/user-avatar.tsx +57 -3
  148. package/src/i18n/defaults.ts +2 -0
  149. package/src/i18n/kit-labels.tsx +2 -0
  150. package/src/i18n/locales/de.ts +9 -0
  151. package/src/i18n/locales/es.ts +9 -0
  152. package/src/i18n/locales/fr.ts +9 -0
  153. package/src/i18n/locales/hu.ts +9 -0
  154. package/src/i18n/locales/it.ts +9 -0
  155. package/src/i18n/locales/zh.ts +9 -0
  156. package/src/index.ts +5 -0
  157. package/src/lib/clipping.ts +22 -4
  158. package/src/lib/dates.ts +37 -0
  159. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  160. package/src/rhf.ts +2 -0
  161. package/src/shell/topbar-action-menu.tsx +239 -49
  162. package/src/wizard/wizard-context.tsx +9 -0
package/src/lib/dates.ts CHANGED
@@ -149,6 +149,43 @@ export function addDaysIso(iso: string, days: number): string {
149
149
  return toLocalIso(d);
150
150
  }
151
151
 
152
+ /**
153
+ * `d` moved by whole months, keeping the day of the month where the target has one.
154
+ * `setMonth` alone ROLLS OVER — 31 January + 1 month is 3 March — which would skip
155
+ * February entirely for anyone paging through the year with PageDown. Returns a new
156
+ * Date; `d` is not touched.
157
+ */
158
+ export function addMonthsClamped(d: Date, months: number): Date {
159
+ const day = d.getDate();
160
+ const out = new Date(d.getFullYear(), d.getMonth() + months, 1);
161
+ const lastOfTarget = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate();
162
+ out.setDate(Math.min(day, lastOfTarget));
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * The first day of the week in `locale`, as a `Date#getDay` index (0 = Sunday).
168
+ *
169
+ * `Intl.Locale#getWeekInfo()` is the standard spelling; V8 shipped it first as the
170
+ * `weekInfo` accessor, and Firefox has neither yet — hence both reads and a Monday
171
+ * fallback, which is ISO 8601. Its `firstDay` counts 1 = Monday … 7 = Sunday, so `% 7`
172
+ * maps it onto `getDay`. `undefined` asks for the runtime's default locale.
173
+ */
174
+ export function localeWeekStart(locale: string | undefined): 0 | 1 | 2 | 3 | 4 | 5 | 6 {
175
+ try {
176
+ const tag = locale ?? new Intl.DateTimeFormat().resolvedOptions().locale;
177
+ const loc = new Intl.Locale(tag) as Intl.Locale & {
178
+ getWeekInfo?: () => { firstDay: number };
179
+ weekInfo?: { firstDay: number };
180
+ };
181
+ const firstDay = (loc.getWeekInfo?.() ?? loc.weekInfo)?.firstDay;
182
+ if (typeof firstDay === "number") return (firstDay % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
183
+ } catch {
184
+ // A malformed tag throws from `Intl.Locale`; a calendar still has to render.
185
+ }
186
+ return 1;
187
+ }
188
+
152
189
  /** Whether two Dates fall on the same local calendar day. */
153
190
  export function sameYmd(a: Date, b: Date): boolean {
154
191
  return (
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The bridge between a react-hook-form step and the wizard's Next gate.
3
+ * `@eifi1/ui-kit/rhf`.
4
+ *
5
+ * ```tsx
6
+ * function AddressStep({ onUpdate }: { onUpdate: (v: Address) => void }) {
7
+ * const form = useForm<Address>({ defaultValues });
8
+ * useRhfWizardStep(form, { onValid: onUpdate });
9
+ * return <Form {...form}>…</Form>;
10
+ * }
11
+ * ```
12
+ *
13
+ * Inside a `<StepperNav>` the hook registers the step's validator with the wizard, so
14
+ * Next (and Finish on a form-bearing last step) runs it: on success the values go to
15
+ * `onValid` and the wizard advances; on failure react-hook-form shows its own inline
16
+ * `<FormMessage>`s, focuses the first field in error, and the wizard stays put — and,
17
+ * since the result carries no per-field errors, says its "fill in the required
18
+ * fields" line so the button did not seem to do nothing. It also RETURNS the
19
+ * validator, for a nav of your own outside `<StepperNav>` (where there is no wizard
20
+ * context to register with, so nothing is registered).
21
+ *
22
+ * Two modes:
23
+ *
24
+ * - **The whole form** (no `fields`): `form.handleSubmit`, exactly as kastlan's
25
+ * `use-rhf-wizard-step.ts` — every rule and the resolver run, `onValid` receives
26
+ * the TRANSFORMED values (a zod `.transform()` included), and focus follows the
27
+ * form's own `shouldFocusError` (on by default).
28
+ * - **Some fields** (`fields: ["iban", "bic"]`): `form.trigger(fields)`, for a wizard
29
+ * that keeps one form across several steps, each of which validates only what it
30
+ * shows. The first of those fields in error is focused (`shouldFocus`; the control
31
+ * has to take `field.ref`), errors on fields of later steps are not raised, and
32
+ * `onValid` receives `form.getValues()` — the untransformed values, because a
33
+ * resolver's output is only produced for the whole form.
34
+ *
35
+ * Both callbacks are read through refs, so the validator is registered once per form
36
+ * however the caller writes them (an inline `(v) => onUpdate(v)` is the usual case).
37
+ *
38
+ * The positional `useRhfWizardStep(form, onValid)` of the hook kastlan kept is the
39
+ * whole-form mode, and still accepted.
40
+ *
41
+ * Why it lives here and not in `@eifi1/ui-kit/wizard`: it imports react-hook-form,
42
+ * which only this entry may (see packaging-contract).
43
+ */
44
+ import { useCallback, useEffect, useRef } from "react";
45
+ import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
46
+ import { useOptionalWizardContext } from "../wizard/wizard-context";
47
+
48
+ export type RhfWizardStepOptions<
49
+ TFieldValues extends FieldValues,
50
+ TTransformed = TFieldValues,
51
+ > =
52
+ | {
53
+ /** Validate only these fields — the step's share of a form that spans steps. */
54
+ fields: readonly FieldPath<TFieldValues>[];
55
+ /** Called with `form.getValues()` when the fields pass, before the wizard
56
+ * advances. */
57
+ onValid?: (values: TFieldValues) => void;
58
+ }
59
+ | {
60
+ /** Omitted: validate the whole form through `handleSubmit`. */
61
+ fields?: undefined;
62
+ /** Called with the form's (transformed) values when it passes, before the
63
+ * wizard advances. */
64
+ onValid?: (values: TTransformed) => void;
65
+ };
66
+
67
+ /**
68
+ * Connects `form` to the wizard's step validation and returns the step's validator.
69
+ * See the module note for the two modes.
70
+ */
71
+ export function useRhfWizardStep<
72
+ TFieldValues extends FieldValues,
73
+ TContext = unknown,
74
+ TTransformed = TFieldValues,
75
+ >(
76
+ form: UseFormReturn<TFieldValues, TContext, TTransformed>,
77
+ options?:
78
+ | RhfWizardStepOptions<TFieldValues, TTransformed>
79
+ | ((values: TTransformed) => void),
80
+ ): () => Promise<boolean> {
81
+ const opts = typeof options === "function" ? { onValid: options } : (options ?? {});
82
+ const optsRef = useRef(opts);
83
+ useEffect(() => {
84
+ optsRef.current = opts;
85
+ });
86
+
87
+ const validate = useCallback(async (): Promise<boolean> => {
88
+ const current = optsRef.current;
89
+ if (current.fields) {
90
+ const ok = await form.trigger([...current.fields], { shouldFocus: true });
91
+ if (ok) current.onValid?.(form.getValues());
92
+ return ok;
93
+ }
94
+ const onValid = current.onValid as ((values: TTransformed) => void) | undefined;
95
+ let ok = false;
96
+ await form.handleSubmit(
97
+ (values) => {
98
+ ok = true;
99
+ onValid?.(values);
100
+ },
101
+ () => {
102
+ ok = false;
103
+ },
104
+ )();
105
+ return ok;
106
+ }, [form]);
107
+
108
+ const wizard = useOptionalWizardContext();
109
+ const register = wizard?.registerStepValidate;
110
+ useEffect(() => register?.(validate), [register, validate]);
111
+
112
+ return validate;
113
+ }
package/src/rhf.ts CHANGED
@@ -7,3 +7,5 @@
7
7
  // the barrel dropped its last react-hook-form import (see the wizard section of
8
8
  // src/index.ts). The packaging contract test holds that line.
9
9
  export * from "./rhf/form";
10
+ // The react-hook-form bridge to the wizard's Next gate (useWizard lives in /wizard).
11
+ export * from "./rhf/use-rhf-wizard-step";
@@ -1,20 +1,116 @@
1
- import type { ReactNode } from "react";
1
+ import { useLayoutEffect, useRef } from "react";
2
+ import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
2
3
  import { Link } from "react-router";
3
4
  import { cn } from "../lib/cn";
4
5
  import { HoverMenu } from "../components/hover-menu";
5
- import { TOPBAR_MENU_ITEM_CLASS, TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
6
+ import { MenuItem } from "../components/menu-item";
7
+ import type { MenuItemTone } from "../components/menu-item";
8
+ import { TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
6
9
 
7
10
  /**
8
11
  * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a
9
12
  * `link` navigates via react-router, and a `divider` draws a rule. Non-divider
10
13
  * rows close the menu automatically when chosen. `kind` may be omitted for
11
14
  * actions, keeping the common case terse.
15
+ *
16
+ * The rows are {@link MenuItem}s, so they take its options: `tone: "danger"` for the
17
+ * log-out row, `checked` (+ `checkable`) for a choice, `current` for the link to the
18
+ * page already open, `disabled`.
12
19
  */
13
20
  export type TopBarMenuEntry =
14
- | { kind?: "action"; key: string; icon?: ReactNode; label: ReactNode; trailing?: ReactNode; onSelect: () => void }
15
- | { kind: "link"; key: string; icon?: ReactNode; label: ReactNode; trailing?: ReactNode; to: string }
21
+ | {
22
+ kind?: "action";
23
+ key: string;
24
+ icon?: ReactNode;
25
+ label: ReactNode;
26
+ trailing?: ReactNode;
27
+ onSelect: () => void;
28
+ tone?: MenuItemTone;
29
+ /** Makes the row a choice (`menuitemradio`, or `menuitemcheckbox` with
30
+ * `checkable: "checkbox"`) and says whether it is chosen. */
31
+ checked?: boolean;
32
+ checkable?: "radio" | "checkbox";
33
+ disabled?: boolean;
34
+ }
35
+ | {
36
+ kind: "link";
37
+ key: string;
38
+ icon?: ReactNode;
39
+ label: ReactNode;
40
+ trailing?: ReactNode;
41
+ to: string;
42
+ tone?: MenuItemTone;
43
+ /** The link to the page already open: `aria-current="page"` and a check mark. */
44
+ current?: boolean;
45
+ disabled?: boolean;
46
+ }
16
47
  | { kind: "divider"; key: string };
17
48
 
49
+ /**
50
+ * The non-interactive identity block at the top of an account menu — kastlan's name +
51
+ * role, keksdose's display name + email + role chip. It is text, not a `menuitem`: the
52
+ * arrow keys pass over it.
53
+ */
54
+ export interface TopBarMenuHeader {
55
+ /** The first line — the user's name. */
56
+ title: ReactNode;
57
+ /** The muted second line — an email, a role. */
58
+ subtitle?: ReactNode;
59
+ /** Anything below — a role chip. A link or button here stays out of the arrow keys
60
+ * and in the Tab order, like the footer's. */
61
+ extra?: ReactNode;
62
+ }
63
+
64
+ /** What a control is, as far as `HoverMenu` is concerned, when it lives in the header
65
+ * or the footer: not a menu row. */
66
+ const SECTION_CONTROLS = "a[href]:not([role]),button:not([role])";
67
+ const TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]';
68
+
69
+ /**
70
+ * A header or footer inside the menu panel.
71
+ *
72
+ * `HoverMenu` promotes EVERY role-less button and link in its panel to a `menuitem` out
73
+ * of the Tab order, and turns every list into `role="none"` — right for the rows, wrong
74
+ * for a footer of legal links, which would join the arrow-key cycle and drop out of Tab.
75
+ * So before it looks (a child's layout effect runs before its parent's), the controls
76
+ * in here get their own role spelled out, which `HoverMenu` leaves alone, and any list
77
+ * keeps its list semantics.
78
+ */
79
+ function MenuSection({ className, children }: { className: string; children: ReactNode }) {
80
+ const ref = useRef<HTMLLIElement>(null);
81
+ useLayoutEffect(() => {
82
+ const el = ref.current;
83
+ if (!el) return;
84
+ for (const c of el.querySelectorAll<HTMLElement>(SECTION_CONTROLS)) {
85
+ c.setAttribute("role", c.tagName === "A" ? "link" : "button");
86
+ }
87
+ for (const l of el.querySelectorAll<HTMLElement>("ul:not([role]),ol:not([role])")) l.setAttribute("role", "list");
88
+ for (const l of el.querySelectorAll<HTMLElement>("li:not([role])")) l.setAttribute("role", "listitem");
89
+ });
90
+ return (
91
+ <li ref={ref} role="none" className={className}>
92
+ {children}
93
+ </li>
94
+ );
95
+ }
96
+
97
+ /**
98
+ * `HoverMenu` closes on any Tab inside the panel — right when Tab leaves it, wrong when
99
+ * Tab is on its way from a row to a footer link, or from one footer link to the next:
100
+ * the panel would unmount under the focus. So a Tab whose next stop is still inside the
101
+ * panel stops here and the browser moves focus; one that leaves reaches `HoverMenu`,
102
+ * which closes.
103
+ */
104
+ function keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {
105
+ if (e.key !== "Tab") return;
106
+ const from = e.target as HTMLElement;
107
+ const stops = Array.from(e.currentTarget.querySelectorAll<HTMLElement>(TABBABLE)).filter(
108
+ (el) => el.getAttribute("tabindex") !== "-1",
109
+ );
110
+ const way = e.shiftKey ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING;
111
+ if (stops.some((el) => el !== from && from.compareDocumentPosition(el) & way)) e.stopPropagation();
112
+ }
113
+
18
114
  /**
19
115
  * A top-bar icon button that opens a {@link HoverMenu} of actions and/or links,
20
116
  * with an optional uppercase heading and divider rules. Generic glue over the
@@ -22,70 +118,164 @@ export type TopBarMenuEntry =
22
118
  * so those call sites describe their rows as data instead of rebuilding the
23
119
  * trigger + list markup. For a checkmarked value-switcher use
24
120
  * {@link OptionSwitcherMenu} instead.
121
+ *
122
+ * It is also the ACCOUNT menu kastlan and keksdose each hand-build on `HoverMenu`: a
123
+ * custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a
124
+ * free-content `footer` (the legal links).
25
125
  */
26
126
  export function TopBarActionMenu({
27
127
  icon,
128
+ trigger,
129
+ triggerClassName,
28
130
  ariaLabel,
29
131
  heading,
132
+ header,
30
133
  entries,
134
+ children,
135
+ footer,
136
+ footerLabel,
137
+ align,
31
138
  panelClassName,
32
139
  }: {
33
- icon: ReactNode;
140
+ /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */
141
+ icon?: ReactNode;
142
+ /**
143
+ * A face of your own for the trigger — the account menu's `UserAvatar`. It is drawn
144
+ * INSIDE the kit's button, which keeps the wiring: `aria-haspopup`, `aria-expanded`,
145
+ * the toggle, the keyboard. `open` is there for a chevron that turns.
146
+ *
147
+ * The button is named by `ariaLabel` followed by any readable text in the face — a
148
+ * `UserAvatar` `badge` label reaches a screen reader that way ("Account menu 3
149
+ * unread"), where an `aria-label` on the button would have hidden it. A
150
+ * `UserAvatar`'s initials are `aria-hidden`, so the person's name is only in the
151
+ * name if `ariaLabel` carries it ("Marcel Eifert, account menu").
152
+ */
153
+ trigger?: (state: { open: boolean }) => ReactNode;
154
+ /** Extra classes for the trigger button — `rounded-full` round an avatar. */
155
+ triggerClassName?: string;
156
+ /** Names the trigger and the menu. */
34
157
  ariaLabel: string;
35
158
  heading?: string;
159
+ /** A non-interactive identity block above the rows. */
160
+ header?: TopBarMenuHeader;
36
161
  entries: TopBarMenuEntry[];
162
+ /**
163
+ * Rows the entry data cannot describe (a `Disclosure` sub-list, an app's own
164
+ * section), after `entries`. Render `<li>`s holding `MenuItem`s or plain controls;
165
+ * `close` shuts the menu.
166
+ */
167
+ children?: (close: () => void) => ReactNode;
168
+ /**
169
+ * Free content below the rows, behind a rule — the legal links. Its links and
170
+ * buttons are NOT menu rows: the arrow keys skip them and Tab reaches them. Pass a
171
+ * function to get `close` for the links' `onClick`.
172
+ */
173
+ footer?: ReactNode | ((close: () => void) => ReactNode);
174
+ /** Wraps the footer in a `<nav>` with this name ("Legal"). Without it, a plain block. */
175
+ footerLabel?: string;
176
+ /** Which edge of the trigger the panel lines up with (see `HoverMenu`). Default `end`. */
177
+ align?: "start" | "end" | "left" | "right";
37
178
  panelClassName?: string;
38
179
  }) {
39
180
  return (
40
181
  <HoverMenu
41
182
  ariaLabel={ariaLabel}
42
- trigger={({ toggle }) => (
43
- <button type="button" onClick={toggle} aria-label={ariaLabel} className={TOPBAR_TRIGGER_CLASS}>
44
- {icon}
45
- </button>
46
- )}
183
+ align={align}
184
+ trigger={({ open, toggle }) =>
185
+ trigger ? (
186
+ <button type="button" onClick={toggle} className={cn(TOPBAR_TRIGGER_CLASS, "relative", triggerClassName)}>
187
+ <span className="sr-only">{ariaLabel}</span>
188
+ {/* The space keeps the name from running into the face's text ("Account
189
+ menu3 unread"); as a flex item it takes no room. */}
190
+ {" "}
191
+ {trigger({ open })}
192
+ </button>
193
+ ) : (
194
+ <button
195
+ type="button"
196
+ onClick={toggle}
197
+ aria-label={ariaLabel}
198
+ className={cn(TOPBAR_TRIGGER_CLASS, triggerClassName)}
199
+ >
200
+ {icon}
201
+ </button>
202
+ )
203
+ }
47
204
  >
48
- {(close) => (
49
- <ul className={cn("py-1", panelClassName)}>
50
- {heading && (
51
- <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
52
- {heading}
53
- </li>
54
- )}
55
- {entries.map((entry) => {
56
- if (entry.kind === "divider") {
57
- return <li key={entry.key} className="my-1 border-t border-[var(--border)]" />;
58
- }
59
- const content = (
60
- <span className="flex w-full items-center gap-2">
61
- {entry.icon}
62
- {entry.label}
63
- {entry.trailing && <span className="ms-auto ps-2">{entry.trailing}</span>}
64
- </span>
65
- );
66
- return (
67
- <li key={entry.key}>
68
- {entry.kind === "link" ? (
69
- <Link to={entry.to} onClick={close} className={TOPBAR_MENU_ITEM_CLASS}>
70
- {content}
71
- </Link>
72
- ) : (
73
- <button
74
- type="button"
75
- onClick={() => {
76
- entry.onSelect();
77
- close();
78
- }}
79
- className={TOPBAR_MENU_ITEM_CLASS}
80
- >
81
- {content}
82
- </button>
205
+ {(close) => {
206
+ const footerBody = typeof footer === "function" ? footer(close) : footer;
207
+ const footerClass =
208
+ "flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--text-muted)] [&_a:hover]:text-[var(--text-primary)] [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-[var(--brand)]";
209
+ return (
210
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel
211
+ <ul className={cn("py-1", panelClassName)} onKeyDown={keepTabInsidePanel}>
212
+ {header && (
213
+ <MenuSection className="mb-1 border-b border-[var(--border)] px-3 py-2">
214
+ <div className="truncate text-sm font-medium text-[var(--text-primary)]">{header.title}</div>
215
+ {header.subtitle != null && (
216
+ <div className="truncate text-xs text-[var(--text-muted)]">{header.subtitle}</div>
83
217
  )}
218
+ {header.extra != null && <div className="mt-1">{header.extra}</div>}
219
+ </MenuSection>
220
+ )}
221
+ {heading && (
222
+ <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
223
+ {heading}
84
224
  </li>
85
- );
86
- })}
87
- </ul>
88
- )}
225
+ )}
226
+ {entries.map((entry) => {
227
+ if (entry.kind === "divider") {
228
+ return <li key={entry.key} className="my-1 border-t border-[var(--border)]" />;
229
+ }
230
+ return (
231
+ <li key={entry.key}>
232
+ {entry.kind === "link" ? (
233
+ <MenuItem
234
+ href={entry.to}
235
+ renderLink={({ href, ...p }) => <Link to={href} {...p} />}
236
+ onClick={close}
237
+ leading={entry.icon}
238
+ trailing={entry.trailing}
239
+ tone={entry.tone}
240
+ current={entry.current}
241
+ disabled={entry.disabled}
242
+ >
243
+ {entry.label}
244
+ </MenuItem>
245
+ ) : (
246
+ <MenuItem
247
+ onClick={() => {
248
+ entry.onSelect();
249
+ close();
250
+ }}
251
+ leading={entry.icon}
252
+ trailing={entry.trailing}
253
+ tone={entry.tone}
254
+ checked={entry.checked}
255
+ checkable={entry.checkable}
256
+ disabled={entry.disabled}
257
+ >
258
+ {entry.label}
259
+ </MenuItem>
260
+ )}
261
+ </li>
262
+ );
263
+ })}
264
+ {children?.(close)}
265
+ {footerBody != null && footerBody !== false && (
266
+ <MenuSection className="mt-1 border-t border-[var(--border)] px-3 py-2">
267
+ {footerLabel ? (
268
+ <nav aria-label={footerLabel} className={footerClass}>
269
+ {footerBody}
270
+ </nav>
271
+ ) : (
272
+ <div className={footerClass}>{footerBody}</div>
273
+ )}
274
+ </MenuSection>
275
+ )}
276
+ </ul>
277
+ );
278
+ }}
89
279
  </HoverMenu>
90
280
  );
91
281
  }
@@ -27,3 +27,12 @@ export function useWizardContext(): WizardContextValue {
27
27
  }
28
28
  return ctx;
29
29
  }
30
+
31
+ /**
32
+ * The wizard context, or `null` outside a `<StepperNav>`. For a hook that registers
33
+ * with the wizard when there is one and works without it — `useRhfWizardStep` in
34
+ * `@eifi1/ui-kit/rhf`, whose returned `validate` a custom nav can call itself.
35
+ */
36
+ export function useOptionalWizardContext(): WizardContextValue | null {
37
+ return useContext(WizardContext);
38
+ }