@eifi1/ui-kit 0.12.0 → 0.13.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 (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +17 -1
  3. package/dist/components/amount-input.js +46 -11
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +211 -20
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +6 -2
  46. package/dist/components/numpad-sheet.js +3 -2
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +3 -1
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +57 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +97 -10
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +453 -29
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +6 -1
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +46 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +29 -6
  190. package/src/rhf/fields.tsx +97 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -60,6 +60,58 @@ export function pickLinkRenderer<P>(
60
60
  return kitLink as unknown as (props: P) => ReactElement;
61
61
  }
62
62
 
63
+ /** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /
64
+ * Shift / Alt (new tab, new window, download), and not one a handler already took. */
65
+ function isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {
66
+ return (
67
+ !event.defaultPrevented &&
68
+ event.button === 0 &&
69
+ !event.metaKey &&
70
+ !event.ctrlKey &&
71
+ !event.shiftKey &&
72
+ !event.altKey
73
+ );
74
+ }
75
+
76
+ /**
77
+ * The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).
78
+ *
79
+ * A router link replaces the history entry itself (it is handed `replace`, see
80
+ * {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the
81
+ * browser's own navigation always pushes. So a plain click is taken over — the caller's
82
+ * `onClick` runs first and may still `preventDefault` — and becomes
83
+ * `location.replace(href)`: the same document load, with Back skipping the one-shot URL.
84
+ * That is the case of `replace` together with `reloadDocument`, or an app with no
85
+ * `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left
86
+ * to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes
87
+ * back untouched.
88
+ * @internal
89
+ */
90
+ export function replacingClick(
91
+ onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,
92
+ href: string,
93
+ replace: boolean | undefined,
94
+ target?: string,
95
+ ): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {
96
+ if (!replace || (target !== undefined && target !== "" && target !== "_self")) return onClick;
97
+ return (event) => {
98
+ onClick?.(event);
99
+ if (!isPlainNavigationClick(event)) return;
100
+ event.preventDefault();
101
+ window.location.replace(href);
102
+ };
103
+ }
104
+
105
+ /**
106
+ * What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else
107
+ * nothing at all — so a provider link that spreads its props onto a DOM `<a>` never
108
+ * receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).
109
+ * @internal
110
+ */
111
+ export function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {
112
+ return replace ? { replace: true } : {};
113
+ }
114
+
63
115
  /**
64
116
  * Draws `props` through `render` as a component of its own, so a router link's hooks
65
117
  * belong to it and not to the kit component calling it — for the kit components that
@@ -72,14 +124,33 @@ export function RenderedKitLink<P>({ render, props }: { render: (props: P) => Re
72
124
 
73
125
  /* ── TextLink ─────────────────────────────────────────────────────────────── */
74
126
 
75
- /** `brand` (default) for a link in running text, `muted` for footer and meta links
76
- * that should not compete with the content, `danger` for a destructive one ("Leave
77
- * group"), `inherit` for a link that takes its surroundings' colour. */
78
- export type TextLinkTone = "brand" | "muted" | "danger" | "inherit";
127
+ /**
128
+ * `brand` (default) for a link in running text, `muted` for footer and meta links
129
+ * that should not compete with the content, `danger` for a destructive one ("Leave
130
+ * group"), `inherit` for a link that takes its surroundings' colour.
131
+ *
132
+ * `primary` and `secondary` are the body and the secondary text colour, UNDERLINED
133
+ * (keksdose F3): a link that must read as part of the text around it — a name in a
134
+ * list row, a reference in a card's meta line — where `brand` repaints it in the accent
135
+ * and `muted` is a step too light to read as content. `secondary` darkens to the body
136
+ * colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its
137
+ * underline thickens instead.
138
+ *
139
+ * `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside
140
+ * a `<Tone tone="warning">` sentence or a warning banner, which `brand` would paint in a
141
+ * second hue and `inherit` would leave indistinguishable from the sentence. Like
142
+ * `primary`, it keeps its colour on hover and thickens the underline.
143
+ *
144
+ * The three colours that sit IN the text default to `underline="always"`: with the
145
+ * colour of their surroundings, the underline is the only thing that says "link"
146
+ * (WCAG 1.4.1). An explicit `underline` still wins.
147
+ */
148
+ export type TextLinkTone = "brand" | "muted" | "danger" | "inherit" | "primary" | "secondary" | "warning";
79
149
 
80
- /** `hover` (default): underlined under the pointer — the idiom all three apps draw.
81
- * `always` for a link inside prose, where colour alone must not be the only mark
82
- * (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
150
+ /** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under
151
+ * the pointer — the idiom all three apps draw. `always` (the default for `primary`,
152
+ * `secondary`, `warning`) for a link inside prose, where colour alone must not be the
153
+ * only mark (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
83
154
  export type TextLinkUnderline = "hover" | "always" | "none";
84
155
 
85
156
  /** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */
@@ -98,6 +169,9 @@ export interface TextLinkRenderProps {
98
169
  "aria-current"?: TextLinkCurrent;
99
170
  id?: string;
100
171
  title?: string;
172
+ /** Present, as `true`, only when the link was given `replace` — see
173
+ * `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */
174
+ replace?: boolean;
101
175
  [key: `aria-${string}`]: string | boolean | number | undefined;
102
176
  [key: `data-${string}`]: unknown;
103
177
  }
@@ -108,8 +182,24 @@ export interface TextLinkProps
108
182
  children: ReactNode;
109
183
  /** Default `brand`. */
110
184
  tone?: TextLinkTone;
111
- /** Default `hover`. */
185
+ /** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */
112
186
  underline?: TextLinkUnderline;
187
+ /**
188
+ * Navigate by replacing the current history entry, so Back does not return to this
189
+ * URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed
190
+ * to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a
191
+ * plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes
192
+ * `location.replace(href)`. Ignored on an `external` link, which opens a new tab.
193
+ */
194
+ replace?: boolean;
195
+ /**
196
+ * A plain `<a>` for an in-app `href`: the browser loads the whole document instead of
197
+ * the router swapping the view — keksdose F1, a route served outside the SPA (a file
198
+ * download, a server-rendered page, a logout that must drop every in-memory cache).
199
+ * Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying
200
+ * "not the router" for this one link.
201
+ */
202
+ reloadDocument?: boolean;
113
203
  /**
114
204
  * The link leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`
115
205
  * (never the router's), an external-link mark after the text (mirrored in RTL), and
@@ -141,8 +231,15 @@ const TONE: Record<TextLinkTone, string> = {
141
231
  muted: "text-[var(--text-muted)] hover:text-[var(--text-primary)]",
142
232
  danger: "text-[var(--danger)] hover:text-[var(--danger-hover)]",
143
233
  inherit: "",
234
+ primary: "text-[var(--text-primary)] hover:decoration-2",
235
+ secondary: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
236
+ warning: "text-[var(--warning)] hover:decoration-2",
144
237
  };
145
238
 
239
+ /** The tones whose colour is their surroundings' — underlined by default; see
240
+ * {@link TextLinkTone}. */
241
+ const UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>(["primary", "secondary", "warning"]);
242
+
146
243
  const UNDERLINE: Record<TextLinkUnderline, string> = {
147
244
  hover: "no-underline hover:underline",
148
245
  always: "underline",
@@ -168,8 +265,10 @@ export function TextLink({
168
265
  href,
169
266
  children,
170
267
  tone = "brand",
171
- underline = "hover",
268
+ underline: underlineProp,
172
269
  external = false,
270
+ replace,
271
+ reloadDocument = false,
173
272
  stopPropagation = false,
174
273
  current,
175
274
  icon: Icon,
@@ -182,6 +281,7 @@ export function TextLink({
182
281
  }: TextLinkProps) {
183
282
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
184
283
  const kitLink = useKitLink();
284
+ const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? "always" : "hover");
185
285
  const ariaCurrent: TextLinkCurrent | undefined =
186
286
  current === true ? "page" : current === false ? undefined : current;
187
287
 
@@ -208,18 +308,19 @@ export function TextLink({
208
308
  </>
209
309
  );
210
310
 
311
+ const handleClick: TextLinkRenderProps["onClick"] = stopPropagation
312
+ ? (event) => {
313
+ event.stopPropagation();
314
+ onClick?.(event);
315
+ }
316
+ : onClick;
211
317
  const props: TextLinkRenderProps = {
212
318
  ...(rest as Partial<TextLinkRenderProps>),
213
319
  ref,
214
320
  href,
215
321
  className: look,
216
322
  "aria-current": ariaCurrent,
217
- onClick: stopPropagation
218
- ? (event) => {
219
- event.stopPropagation();
220
- onClick?.(event);
221
- }
222
- : onClick,
323
+ onClick: handleClick,
223
324
  onKeyDown: stopPropagation
224
325
  ? (event) => {
225
326
  if (event.key === "Enter") event.stopPropagation();
@@ -229,11 +330,18 @@ export function TextLink({
229
330
  children: body,
230
331
  };
231
332
 
232
- const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);
233
- if (render) return <RenderedTextLink render={render} {...props} />;
333
+ const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);
334
+ if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;
335
+ // A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.
336
+ const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);
234
337
  const { children: content, ...anchor } = props;
235
338
  return (
236
- <a {...anchor} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
339
+ <a
340
+ {...anchor}
341
+ href={href}
342
+ onClick={plainClick}
343
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}
344
+ >
237
345
  {content}
238
346
  </a>
239
347
  );
@@ -16,6 +16,14 @@ import { cn } from "../lib/cn";
16
16
  * (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a
17
17
  * figure that size, 12px competed with it. Named `md` rather than slotted in order
18
18
  * because `sm` was already the default and renaming it would move every caller.
19
+ *
20
+ * `md` is also the heading over a CHART COLUMN (lenkbank P9): the small label centred
21
+ * over each of a pair of facing charts, or over each column of a small-multiples grid.
22
+ * There the label names a plot, not a section of the page, so `sm` (12px, the page's
23
+ * section heading) out-ranks the card title above it, and `xs` (10px, a legend's
24
+ * title) sinks below the axis ticks it sits beside. Rule of thumb: `xs` inside a
25
+ * control (legend, menu), `md` over a figure or a plot, `sm` over a block of page
26
+ * content.
19
27
  */
20
28
  export type SectionLabelSize = "xs" | "md" | "sm";
21
29
 
@@ -34,6 +42,31 @@ export const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {
34
42
  sm: "text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]",
35
43
  };
36
44
 
45
+ /**
46
+ * `plain` (default): the label alone, as above.
47
+ *
48
+ * `band` (0.13, keksdose F5): the label on a full-width `surface-2` bar with a bottom
49
+ * border — the day-group header of keksdose's mobile transaction list
50
+ * (mobile-transaction-list.tsx:171), which separates one day's rows from the next.
51
+ * Its type is kept as keksdose drew it — `font-medium` in the SECONDARY text colour,
52
+ * not the plain label's semibold muted — so adopting the kit does not restyle a live
53
+ * list: the bar already sets the label apart, and needs no extra weight. The padding (`px-4 py-2`) lines the text up with a
54
+ * `ListItem density="comfortable"` row below it; override it with `className` for a
55
+ * list at another density. A sticky header is the caller's: add `sticky top-0`.
56
+ */
57
+ export type SectionLabelVariant = "plain" | "band";
58
+
59
+ /** The band's own type and box, per size — spelled out rather than merged over
60
+ * {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling
61
+ * a font-size `text-[…]` from a colour `text-[var(…)]`. */
62
+ const SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {
63
+ xs: "text-[10px]",
64
+ md: "text-[11px]",
65
+ sm: "text-xs",
66
+ };
67
+ const BAND =
68
+ "block border-b border-[var(--border)] bg-[var(--bg-surface-2)] px-4 py-2 font-medium uppercase tracking-wide text-[var(--text-secondary)]";
69
+
37
70
  type SectionLabelElement = "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div" | "legend";
38
71
 
39
72
  export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
@@ -45,6 +78,9 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
45
78
  */
46
79
  as?: SectionLabelElement;
47
80
  size?: SectionLabelSize;
81
+ /** `band` draws the label on a `surface-2` bar with a bottom border — a list's group
82
+ * header. See {@link SectionLabelVariant}. */
83
+ variant?: SectionLabelVariant;
48
84
  children: ReactNode;
49
85
  }
50
86
 
@@ -55,10 +91,18 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
55
91
  * 10px version on a menu row. One component, the token colour, and the level chosen
56
92
  * where the outline is known.
57
93
  */
58
- export function SectionLabel({ as = "h3", size = "sm", className, children, ...rest }: SectionLabelProps) {
94
+ export function SectionLabel({
95
+ as = "h3",
96
+ size = "sm",
97
+ variant = "plain",
98
+ className,
99
+ children,
100
+ ...rest
101
+ }: SectionLabelProps) {
59
102
  const Tag = as as ElementType;
103
+ const type = variant === "band" ? cn(SECTION_LABEL_BAND_CLASS[size], BAND) : SECTION_LABEL_CLASS[size];
60
104
  return (
61
- <Tag {...rest} className={cn(SECTION_LABEL_CLASS[size], className)}>
105
+ <Tag {...rest} className={cn(type, className)}>
62
106
  {children}
63
107
  </Tag>
64
108
  );
@@ -104,6 +104,25 @@ type ToggleGroupCaption<V> = ReactNode | ((value: V) => ReactNode);
104
104
  /** The group as it has always been: one option is always the answer. */
105
105
  export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
106
106
  allowEmpty?: false;
107
+ /**
108
+ * What the options ARE to a screen reader, separately from whether the group can be
109
+ * emptied (kastlan feedback #47).
110
+ *
111
+ * - `"radio"` (default) — a `radiogroup` of radios: one Tab stop, arrow keys MOVE the
112
+ * choice. Right for a choice between answers ("Monthly / Yearly").
113
+ * - `"pressed"` — a `group` of toggle buttons (`aria-pressed`), each its own Tab stop,
114
+ * Space/Enter to press; still one option always pressed. Right for a row that reads
115
+ * as a set of switches — a view mode, a toolbar-like filter — where arrows moving the
116
+ * selection on focus would be a surprise.
117
+ *
118
+ * Before this the only way to `aria-pressed` was `allowEmpty`, which also let a second
119
+ * press clear the choice — so kastlan took the clearable shape and threw the `null`
120
+ * away in `onChange` (`(v) => v && setMode(v)`), a group announcing that a press would
121
+ * unpress what it would not. Here a press on the pressed option re-sends its value, as
122
+ * the radio shape does, and nothing is ever unpressed. A clearable group
123
+ * (`allowEmpty`) is always `"pressed"`: a radio cannot be unchecked by activating it.
124
+ */
125
+ semantics?: "radio" | "pressed";
107
126
  value: T;
108
127
  onChange: (value: T) => void;
109
128
  /** See {@link ToggleGroupCaption}. */
@@ -122,6 +141,9 @@ export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupB
122
141
  */
123
142
  export interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {
124
143
  allowEmpty: true;
144
+ /** Always toggle buttons — see {@link ToggleGroupRequiredProps.semantics}. Accepted so
145
+ * a wrapper can forward one `semantics` to either shape. */
146
+ semantics?: "pressed";
125
147
  value: T | null;
126
148
  onChange: (value: T | null) => void;
127
149
  /** See {@link ToggleGroupCaption}. `null` while nothing is chosen. */
@@ -171,7 +193,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
171
193
  const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
172
194
  // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
173
195
  // what lets the `onChange` below be called with `null` only in the mode that allows it.
174
- const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
196
+ const { allowEmpty: _allowEmpty, onChange: _onChange, caption, semantics: _semantics, ...rest } = restWithMode;
175
197
  // Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the
176
198
  // border has to say what the attribute says.
177
199
  const outsideInvalid = rest["aria-invalid"] === true || rest["aria-invalid"] === "true";
@@ -183,11 +205,14 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
183
205
  if (props.allowEmpty) props.onChange(next === value ? null : next);
184
206
  else props.onChange(next);
185
207
  };
186
- const clearable = props.allowEmpty === true;
208
+ // `pressed`: toggle buttons. Every clearable group is; a required one is when it asks
209
+ // (kastlan #47) — the role, and not whether a second press clears, is what this flag
210
+ // decides from here on, so `choose` above still keys off `allowEmpty` alone.
211
+ const pressed = props.allowEmpty === true || props.semantics === "pressed";
187
212
  // A radio group is ONE tab stop (the checked radio, else the first) and arrows move
188
213
  // the choice — the pattern `role="radiogroup"` promises a screen-reader user. Before
189
- // 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a
190
- // row of toggle buttons, where separate tab stops are the pattern.
214
+ // 0.7.0 each segment was its own tab stop with no arrow keys. The toggle-button shape
215
+ // is a row of buttons, where separate tab stops are the pattern.
191
216
  const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
192
217
  const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
193
218
  const last = options.length - 1;
@@ -218,7 +243,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
218
243
  // radiogroup role or the disabled state by accident. `className` is destructured
219
244
  // out entirely and merged through `cn`, so it is never in here.
220
245
  {...rest}
221
- role={clearable ? "group" : "radiogroup"}
246
+ role={pressed ? "group" : "radiogroup"}
222
247
  // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
223
248
  // have not moved yet.
224
249
  aria-label={ariaLabelAttr ?? ariaLabel}
@@ -263,12 +288,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
263
288
  <button
264
289
  key={opt.value}
265
290
  type="button"
266
- role={clearable ? undefined : "radio"}
267
- aria-checked={clearable ? undefined : active}
268
- aria-pressed={clearable ? active : undefined}
291
+ role={pressed ? undefined : "radio"}
292
+ aria-checked={pressed ? undefined : active}
293
+ aria-pressed={pressed ? active : undefined}
269
294
  disabled={disabled}
270
- tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}
271
- onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}
295
+ tabIndex={pressed ? undefined : opt.value === tabStop ? 0 : -1}
296
+ onKeyDown={pressed ? undefined : (e) => onRadioKey(e, index)}
272
297
  onClick={() => choose(opt.value)}
273
298
  className={cn(
274
299
  // `truncate` (which carries whitespace-nowrap) rather than letting a
@@ -83,6 +83,23 @@ export interface LegendEntry {
83
83
  * "today"). Decorative: it is wrapped `aria-hidden`, and the label is the name.
84
84
  */
85
85
  icon?: ReactNode;
86
+ /**
87
+ * `"end"`: push this entry to the legend's inline END — the far right in LTR, the far
88
+ * left in RTL — with everything before it kept at the start. kastlan's lease gantt
89
+ * (feedback #49) keys its bars at the start and its "today" hairline at the end, over
90
+ * the time axis whose end "today" is nearest, and wrote it as
91
+ * `className="[&>li:last-child]:ms-auto"` — a selector that breaks the moment the
92
+ * entries are reordered and that reaches into the legend's markup.
93
+ *
94
+ * An auto inline-start margin, so it is logical (RTL mirrors it) and it takes only the
95
+ * row's LEFTOVER space: on a narrow screen where the row wraps, the entry wraps with it
96
+ * and sits at the end of its own line rather than overflowing. Give it to the LAST
97
+ * entry — auto margins push everything after them too, so an end entry in the middle
98
+ * drags the rest over with it (which is also how to end-align a GROUP: mark its first).
99
+ * In a vertical legend it sits at the column's inline end. Honoured by both
100
+ * {@link StaticLegend} and {@link ToggleLegend}, which share this entry type.
101
+ */
102
+ align?: "start" | "end";
86
103
  }
87
104
 
88
105
  export interface ToggleLegendProps {
@@ -159,6 +176,7 @@ export function ToggleLegend({
159
176
  "flex items-center gap-1.5 rounded text-start text-[11px] text-[var(--text-secondary)] transition-opacity hover:opacity-80",
160
177
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
161
178
  off && "opacity-35",
179
+ entry.align === "end" && "ms-auto",
162
180
  )}
163
181
  >
164
182
  <LegendMark entry={entry} off={off} />
@@ -219,7 +237,10 @@ export function StaticLegend({
219
237
  {entries.map((entry) => (
220
238
  <li
221
239
  key={entry.key}
222
- className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]"
240
+ className={cn(
241
+ "flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]",
242
+ entry.align === "end" && "ms-auto",
243
+ )}
223
244
  >
224
245
  <LegendMark entry={entry} off={false} />
225
246
  {entry.label}
@@ -87,6 +87,19 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
87
87
  * what they were before the default existed.
88
88
  */
89
89
  portal?: boolean;
90
+ /**
91
+ * Mount the IN-PLACE bubble only while it is up (hovered or focused, not dismissed),
92
+ * instead of always. It still sits next to its trigger and is still placed by CSS
93
+ * alone; what changes is that, while closed, it is not in the DOM — so it is not in
94
+ * `getAllByRole("tooltip")`, not in an ancestor's `textContent` and not in an
95
+ * ancestor's accessible name. The trigger is described (`aria-describedby`) while
96
+ * the bubble is up, exactly as a portalled one is. See "Lazy in place" on
97
+ * {@link Tooltip} for why this exists (keksdose F6) and why it is not the default.
98
+ *
99
+ * Has no effect on a bubble that is portalled (`portal`, or auto-portalled inside a
100
+ * clipping container) — that one is already mounted only while up.
101
+ */
102
+ lazy?: boolean;
90
103
  /** Tag the bubble `data-private`, for a label that repeats the user's own data. */
91
104
  redact?: boolean;
92
105
  children: ReactNode;
@@ -94,7 +107,7 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
94
107
 
95
108
  /** What each variant below takes: the resolved `side`, and every span attribute the
96
109
  * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
97
- type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: TooltipSide };
110
+ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { side: TooltipSide };
98
111
 
99
112
  /**
100
113
  * Hover/focus label for a control.
@@ -170,12 +183,50 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: Toolt
170
183
  * Why not simply portal everything? Because the in-place bubble is the one existing
171
184
  * app tests rely on (it is in the DOM without a hover), and because it follows its
172
185
  * trigger through a scroll or an animation for free — the portalled one re-measures.
186
+ *
187
+ * ⚠️ **Lazy in place: `lazy` keeps the bubble next to its trigger but out of the DOM
188
+ * until it is up (keksdose F6).** `data-clips` cured the doubled text for tooltips
189
+ * inside a table; it did nothing for the ones outside any clipping container, where the
190
+ * always-mounted bubble is still a real `role="tooltip"` node full of text. About seven
191
+ * keksdose sites — column-header tooltips, a direction toggle, an fx-estimate row, a
192
+ * FlagBadge, a toast inside a `<button>` — still pinned `portal` for that alone: their
193
+ * tests found two tooltips with `getAllByRole("tooltip")`, read the label twice in a
194
+ * `textContent`, or got a button whose accessible name had the bubble's sentence glued
195
+ * on. `portal` fixed the test and cost the browser the measured, re-positioning bubble
196
+ * for no reason. `lazy` is the in-place bubble with the portalled one's lifetime: it is
197
+ * rendered only while hovered or focused (and not dismissed), in the same slot, with the
198
+ * same classes, so it looks and sits exactly as the default one does when it is up.
199
+ *
200
+ * Why unmount rather than hide? Hiding was the other option — keep the bubble mounted
201
+ * with `hidden` (or `aria-hidden` plus `display: none` until `:hover` / `:focus-within`)
202
+ * and let `aria-describedby` go on pointing at it, which the accname algorithm allows:
203
+ * a node referenced directly by `aria-describedby` contributes its text even while
204
+ * hidden. That would take the bubble out of the role queries and out of an ancestor's
205
+ * accessible name — but NOT out of `textContent`, which is plain DOM and counts hidden
206
+ * text too, and `textContent` is one of the three things F6 lists. Only a bubble that
207
+ * is not there solves all three. The cost is the portalled variant's: the trigger is
208
+ * described while the bubble is up rather than always. Focusing the trigger IS what
209
+ * puts it up, and React commits the `aria-describedby` in the same task as that focus
210
+ * event, so a screen reader landing on the control still hears it; what goes is the description
211
+ * of a trigger that is read in browse mode without ever being focused.
212
+ *
213
+ * Escape still dismisses it — here by unmounting it, which takes it off the screen and
214
+ * out of the accessibility tree at once — and the next hover or focus brings it back.
215
+ * There is no fade to lose: neither in-place bubble has ever had a transition, only the
216
+ * `opacity` switch, so appearing on a state change looks the same as appearing on
217
+ * `:hover`.
218
+ *
219
+ * Why opt-in rather than the new default? Because the always-mounted bubble is the one
220
+ * existing app tests find without a hover (see above): flipping it would turn every
221
+ * `getByRole("tooltip")` written against 0.12 into a failure in all three apps at once.
222
+ * Reach for `lazy` wherever a `portal` was pinned only to keep a test's DOM clean.
173
223
  */
174
224
  export function Tooltip({
175
225
  label,
176
226
  side = "top",
177
227
  className,
178
228
  portal,
229
+ lazy = false,
179
230
  redact = false,
180
231
  children,
181
232
  ...rest
@@ -208,6 +259,7 @@ export function Tooltip({
208
259
  className={className}
209
260
  redact={redact}
210
261
  detect={portal === undefined}
262
+ lazy={lazy}
211
263
  {...rest}
212
264
  >
213
265
  {children}
@@ -235,9 +287,10 @@ function InPlaceTooltip({
235
287
  className,
236
288
  redact,
237
289
  detect,
290
+ lazy,
238
291
  children,
239
292
  ...rest
240
- }: TooltipVariantProps & { detect: boolean }) {
293
+ }: TooltipVariantProps & { detect: boolean; lazy: boolean }) {
241
294
  const id = useId();
242
295
  const triggerRef = useRef<HTMLSpanElement | null>(null);
243
296
  const [clipped, setClipped] = useState(false);
@@ -263,6 +316,9 @@ function InPlaceTooltip({
263
316
  setClipped(hasClippingAncestor(el));
264
317
  };
265
318
  return (
319
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers track
320
+ whether the bubble is up and activate nothing; the caller's child is the
321
+ interactive element, keeps its own handlers, and its focus shows the bubble too. */
266
322
  <span
267
323
  // `...rest` first: the four handlers below are what decides whether a bubble is
268
324
  // up, and a caller passing an `onFocus` of its own must not replace them.
@@ -272,10 +328,6 @@ function InPlaceTooltip({
272
328
  // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing
273
329
  // here that can be activated is the caller's child, which keeps every handler it
274
330
  // arrived with — so this wrapper needs no role and no key handling of its own.
275
- // `jsx-a11y/no-static-element-interactions` warns about it all the same, as it
276
- // already does about the portal variant's identical trigger below; both are left
277
- // visible rather than silenced, because a rule this package ratchets should be
278
- // argued with in the backlog and not in a disable comment.
279
331
  onMouseEnter={(e) => {
280
332
  setHovered(true);
281
333
  arm(e.currentTarget);
@@ -287,12 +339,28 @@ function InPlaceTooltip({
287
339
  }}
288
340
  onBlur={() => setFocused(false)}
289
341
  >
290
- {/* In place the bubble is always there to point at; portalled, only while up. */}
291
- {describedBy(children, clipped ? (open ? id : undefined) : dismissed ? undefined : id)}
342
+ {/* In place the bubble is always there to point at; portalled or lazy, only while up. */}
343
+ {describedBy(children, clipped || lazy ? (open ? id : undefined) : dismissed ? undefined : id)}
292
344
  {clipped ? (
293
345
  open && (
294
346
  <PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />
295
347
  )
348
+ ) : lazy ? (
349
+ // keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only
350
+ // while up, so it is visible whenever it exists — `opacity-100` outright rather
351
+ // than the `group-hover` switch, which would also be right but would make the
352
+ // bubble's visibility depend on two sources (the state that mounted it and the
353
+ // CSS that shows it) that can disagree for a frame after Escape.
354
+ open && (
355
+ <span
356
+ id={id}
357
+ role="tooltip"
358
+ data-private={redact ? "" : undefined}
359
+ className={cn(TOOLTIP_SURFACE, "pointer-events-none absolute z-50 opacity-100", sidePositionClass[side])}
360
+ >
361
+ {label}
362
+ </span>
363
+ )
296
364
  ) : (
297
365
  <span
298
366
  id={id}
@@ -506,6 +574,9 @@ function PortalTooltip({
506
574
 
507
575
  return (
508
576
  <>
577
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- as in
578
+ `InPlaceTooltip`: the handlers only show and hide the bubble; the caller's child
579
+ is the interactive element, and focusing it shows the bubble. */}
509
580
  <span
510
581
  // As in `InPlaceTooltip`: the caller's attributes first, the four handlers that
511
582
  // run this component after them. The BUBBLE is deliberately not given them — it