@eifi1/ui-kit 0.12.0 → 0.13.1

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 +23 -1
  3. package/dist/components/amount-input.js +78 -13
  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 +225 -21
  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 +10 -2
  46. package/dist/components/numpad-sheet.js +6 -4
  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 +4 -2
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +59 -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 +165 -12
  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 +470 -30
  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 +17 -3
  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 +47 -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 +32 -7
  190. package/src/rhf/fields.tsx +99 -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
@@ -146,35 +146,40 @@ export function useFieldSync<T>({
146
146
  };
147
147
  }, []);
148
148
 
149
- const flush = useCallback(async () => {
150
- if (inFlightRef.current) return; // the settle path below picks the new draft up
151
- const next = draftRef.current;
152
- if (equalsRef.current(next, savedRef.current)) return;
153
-
154
- inFlightRef.current = true;
155
- setState("pending");
156
- setError(null);
157
- try {
158
- await onSaveRef.current(next);
159
- savedRef.current = next;
160
- inFlightRef.current = false;
161
- if (!mountedRef.current) return;
162
- // The draft may have moved while this was in the air. Compare against what is
163
- // on screen NOW, not against what was sent.
164
- if (equalsRef.current(draftRef.current, next)) {
165
- setState("synced");
166
- } else {
167
- setState("edited");
168
- void flush();
149
+ // A named inner function so the settle path can start the next round by calling it
150
+ // again; a `useCallback` cannot refer to its own binding while it is being built.
151
+ const flush = useCallback(() => {
152
+ const run = async (): Promise<void> => {
153
+ if (inFlightRef.current) return; // the settle path below picks the new draft up
154
+ const next = draftRef.current;
155
+ if (equalsRef.current(next, savedRef.current)) return;
156
+
157
+ inFlightRef.current = true;
158
+ setState("pending");
159
+ setError(null);
160
+ try {
161
+ await onSaveRef.current(next);
162
+ savedRef.current = next;
163
+ inFlightRef.current = false;
164
+ if (!mountedRef.current) return;
165
+ // The draft may have moved while this was in the air. Compare against what is
166
+ // on screen NOW, not against what was sent.
167
+ if (equalsRef.current(draftRef.current, next)) {
168
+ setState("synced");
169
+ } else {
170
+ setState("edited");
171
+ void run();
172
+ }
173
+ } catch (cause) {
174
+ inFlightRef.current = false;
175
+ const err = toError(cause);
176
+ onErrorRef.current?.(err);
177
+ if (!mountedRef.current) return;
178
+ setState("error");
179
+ setError(err);
169
180
  }
170
- } catch (cause) {
171
- inFlightRef.current = false;
172
- const err = toError(cause);
173
- onErrorRef.current?.(err);
174
- if (!mountedRef.current) return;
175
- setState("error");
176
- setError(err);
177
- }
181
+ };
182
+ return run();
178
183
  }, []);
179
184
 
180
185
  const setValue = useCallback(
@@ -396,23 +401,26 @@ export function FieldSyncRow<T>({
396
401
  const retry = onRetry ?? sync.retry;
397
402
 
398
403
  // The saved confirmation: on only for the moment after a save lands. Keyed on the
399
- // TRANSITION pending → synced, so a field that starts synced shows nothing.
400
- const previous = useRef(state);
404
+ // TRANSITION pending → synced, so a field that starts synced shows nothing. The
405
+ // transition is seen while rendering, against the previous state; the effect only
406
+ // owns the timer that turns it off again.
407
+ const [previous, setPrevious] = useState(state);
401
408
  const [justSaved, setJustSaved] = useState(false);
409
+ if (state !== previous) {
410
+ setPrevious(state);
411
+ setJustSaved(state === "synced" && previous === "pending");
412
+ }
402
413
  useEffect(() => {
403
- const was = previous.current;
404
- previous.current = state;
405
- if (state === "synced" && was === "pending") {
406
- // eslint-disable-next-line react-hooks/set-state-in-effect -- reacts to the save landing
407
- setJustSaved(true);
408
- const t = setTimeout(() => setJustSaved(false), savedMs);
409
- return () => clearTimeout(t);
410
- }
411
- if (state !== "synced") setJustSaved(false);
412
- }, [state, savedMs]);
414
+ if (!justSaved) return;
415
+ const t = setTimeout(() => setJustSaved(false), savedMs);
416
+ return () => clearTimeout(t);
417
+ }, [justSaved, savedMs]);
413
418
  const shown: FieldSyncState | null = state === "synced" ? (justSaved ? "synced" : null) : state;
414
419
 
415
420
  return (
421
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:
422
+ `onBlur` listens for focus leaving the row's own controls (which are the
423
+ interactive elements) to save; the wrapper itself is never focused. */
416
424
  <div
417
425
  data-field-sync={state}
418
426
  className={cn(
@@ -169,6 +169,10 @@ export function FullBleedDialog({
169
169
  if (!open || typeof document === "undefined") return null;
170
170
 
171
171
  return createPortal(
172
+ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog
173
+ container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever
174
+ inside it holds focus, and the mouse handlers are the backdrop press (Escape and the
175
+ close button are its keyboard path). */
172
176
  <div
173
177
  // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):
174
178
  // the backdrop fades, the panel rises from the bottom edge it is anchored to.
@@ -103,22 +103,24 @@ export function HoverMenu({
103
103
  // close what was just opened (feedback #19). Toggling closed on click stays
104
104
  // for keyboard/touch activation, which reaches "open" without going through
105
105
  // the hover path.
106
- const openedByHoverRef = useRef(false);
106
+ //
107
+ // State rather than a ref: `close` is handed to the caller's render prop, and a
108
+ // function that writes a ref must not be reachable from render.
109
+ const [openedByHover, setOpenedByHover] = useState(false);
107
110
 
108
111
  const close = useCallback(() => {
109
- openedByHoverRef.current = false;
112
+ setOpenedByHover(false);
110
113
  setPhase("closed");
111
114
  }, []);
112
115
 
113
- const toggle = useCallback(
114
- () =>
115
- setPhase((p) => {
116
- if (p === "open" || p === "closing") return openedByHoverRef.current ? p : "closed";
117
- openedByHoverRef.current = false;
118
- return "open";
119
- }),
120
- [],
121
- );
116
+ const toggle = useCallback(() => {
117
+ if (open) {
118
+ if (!openedByHover) setPhase("closed");
119
+ return;
120
+ }
121
+ setOpenedByHover(false);
122
+ setPhase("open");
123
+ }, [open, openedByHover]);
122
124
 
123
125
  const handleMouseEnter = useCallback(
124
126
  () => setPhase((p) => (p === "closing" ? "open" : p === "closed" ? "opening" : p)),
@@ -134,7 +136,7 @@ export function HoverMenu({
134
136
  useEffect(() => {
135
137
  if (phase === "opening") {
136
138
  const id = setTimeout(() => {
137
- openedByHoverRef.current = true;
139
+ setOpenedByHover(true);
138
140
  setPhase("open");
139
141
  }, OPEN_DELAY_MS);
140
142
  return () => clearTimeout(id);
@@ -217,7 +219,7 @@ export function HoverMenu({
217
219
  (edge === "last" ? list[list.length - 1] : list[0])?.focus();
218
220
  } else {
219
221
  focusOnOpenRef.current = edge;
220
- openedByHoverRef.current = false;
222
+ setOpenedByHover(false);
221
223
  setPhase("open");
222
224
  }
223
225
  }
@@ -247,11 +249,14 @@ export function HoverMenu({
247
249
  // subtracted first so the measurement is against the natural position.
248
250
  const panelRef = useRef<HTMLDivElement>(null);
249
251
  const [shiftX, setShiftX] = useState(0);
252
+ // Back to 0 on close, adjusted while rendering against the previous `open`.
253
+ const [wasOpen, setWasOpen] = useState(open);
254
+ if (open !== wasOpen) {
255
+ setWasOpen(open);
256
+ if (!open) setShiftX(0);
257
+ }
250
258
  useLayoutEffect(() => {
251
- if (!open) {
252
- setShiftX(0);
253
- return;
254
- }
259
+ if (!open) return;
255
260
  const el = panelRef.current;
256
261
  if (!el) return;
257
262
  const rect = el.getBoundingClientRect();
@@ -267,6 +272,10 @@ export function HoverMenu({
267
272
  }, [open]);
268
273
 
269
274
  return (
275
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
276
+ itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`
277
+ catches keys bubbling from the caller's trigger button and the menuitems, which
278
+ are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */
270
279
  <div
271
280
  // `...rest` first: the hover handlers below are the whole component, and a caller
272
281
  // passing one of its own must not silently replace them.
@@ -106,8 +106,20 @@ const TARGET_RING =
106
106
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
107
107
 
108
108
  interface ListItemBaseProps {
109
- /** The row's name: one line, truncated. */
109
+ /** The row's name: one line, truncated — unless {@link titleLines} says otherwise. */
110
110
  title: ReactNode;
111
+ /**
112
+ * How many lines the title may take. `1` (default) truncates with an ellipsis, which
113
+ * is right for a name the app chose — a unit number, a user, a file — where the
114
+ * cut-off end is still recognisable and every row keeps one height.
115
+ *
116
+ * `2` clamps at two lines; `"all"` shows every line, breaking inside a long word
117
+ * rather than overflowing. For rows whose only name is free text a person typed
118
+ * (kastlan 44): a defect or a note titled by its description, where the first 30
119
+ * characters on a phone say nothing and there is no other field to tell two rows
120
+ * apart. Pair it with `align="start"` so the icon stays by the first line.
121
+ */
122
+ titleLines?: 1 | 2 | "all";
111
123
  /** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */
112
124
  subtitle?: ReactNode;
113
125
  /** `2` for a line that is a body rather than a label (keksdose's notification text). */
@@ -256,7 +268,7 @@ export type ListItemProps = ListItemBaseProps &
256
268
 
257
269
  /**
258
270
  * One row of a {@link List}: leading icon or avatar, title and subtitle (both
259
- * truncating), a trailing slot, and — beside the row, not inside it — its actions.
271
+ * truncating by default), a trailing slot, and — beside the row, not inside it — its actions.
260
272
  *
261
273
  * All three apps draw this row by hand, a dozen times: kastlan's units
262
274
  * (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity
@@ -269,6 +281,7 @@ export type ListItemProps = ListItemBaseProps &
269
281
  export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
270
282
  {
271
283
  title,
284
+ titleLines = 1,
272
285
  subtitle,
273
286
  subtitleLines = 1,
274
287
  meta,
@@ -329,7 +342,8 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
329
342
  // Names the body's group; harmless without one.
330
343
  id={hasBody ? titleId : undefined}
331
344
  className={cn(
332
- "block truncate text-sm text-[var(--text-primary)]",
345
+ "block text-sm text-[var(--text-primary)]",
346
+ titleLines === 1 ? "truncate" : titleLines === 2 ? "line-clamp-2 break-words" : "break-words",
333
347
  unread ? "font-semibold" : "font-medium",
334
348
  )}
335
349
  >
@@ -370,6 +370,10 @@ function ModalPanel({
370
370
  )}
371
371
  {...backdropClose}
372
372
  >
373
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- always
374
+ role="dialog" or "alertdialog" (the ternary hides that from the rule): `onKeyDown`
375
+ is Escape-to-close per the APG dialog pattern, and the drag handlers are
376
+ swipe-to-dismiss, whose keyboard path is that same Escape. */}
373
377
  <div
374
378
  // `...rest` FIRST, everything the dialog needs to be a dialog after it. A
375
379
  // wrapper that spreads its own props through to this one cannot then take away
@@ -127,7 +127,7 @@ export function MultiEntityCombobox<V extends string | number>({
127
127
  create: createLabel,
128
128
  });
129
129
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
130
- const { open, results, resolve, setOpen } = core;
130
+ const { open, results, resolve, setOpen, query, triggerRef } = core;
131
131
  // One id per instance, generated here rather than in the core: `aria-controls` on
132
132
  // the trigger has to name the list while the list is still closed, so the id
133
133
  // belongs to whoever renders both ends of it.
@@ -154,13 +154,13 @@ export function MultiEntityCombobox<V extends string | number>({
154
154
  : labels.selectedCount(value.length);
155
155
  })();
156
156
 
157
- const q = core.query.trim();
157
+ const q = query.trim();
158
158
  const showCreate =
159
159
  Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
160
160
  const showClear = Boolean(clearable && value.length > 0 && !disabled);
161
161
 
162
162
  const toggle = (o: ComboOption<V>) => {
163
- core.cacheRef.current.set(o.value, o);
163
+ core.rememberOption(o);
164
164
  if (valueSet.has(o.value)) onChange(value.filter((v) => v !== o.value));
165
165
  else onChange([...value, o.value]);
166
166
  // Stay open — multi-select keeps adding.
@@ -174,7 +174,7 @@ export function MultiEntityCombobox<V extends string | number>({
174
174
  <div {...rest} className={cn("relative", className)}>
175
175
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
176
176
  <button
177
- ref={core.triggerRef}
177
+ ref={triggerRef}
178
178
  id={id}
179
179
  type="button"
180
180
  // A combobox, not a button. The distinction is not pedantry: this control
@@ -234,6 +234,10 @@ export function MultiEntityCombobox<V extends string | number>({
234
234
  {summary}
235
235
  </span>
236
236
  {showClear ? (
237
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
238
+ the trigger <button>, so it never holds focus and a key handler here could
239
+ never fire. A pointer shortcut; the keyboard path is unticking the options
240
+ in the open list. */
237
241
  <span
238
242
  role="button"
239
243
  tabIndex={-1}
@@ -232,6 +232,7 @@ export function NumberInput({
232
232
  // keeps focus/caret); desktop keeps the native decimal keypad.
233
233
  inputMode={isMobile ? "none" : "decimal"}
234
234
  autoComplete="off"
235
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
235
236
  autoFocus={autoFocus}
236
237
  disabled={disabled}
237
238
  // Display mode has no floating label to feed the blank-placeholder trick,
@@ -99,11 +99,21 @@ export function NumberPadSheet({
99
99
  onDone,
100
100
  label,
101
101
  labels,
102
+ decimalMark = ".",
103
+ onResult,
102
104
  }: {
103
105
  value: string;
104
106
  /** Fired with the raw (sanitised) field text on every key — same contract as
105
107
  * the host field's own `onChange`. */
106
108
  onChange: (value: string) => void;
109
+ /** The glyph on the decimal key — "," where the host shows a comma (AmountInput
110
+ * in fr-CH, kastlan 40). With "," the key inserts a comma and the text is handed
111
+ * back unsanitized, for the host to read by its locale (keksdose G1). */
112
+ decimalMark?: "." | ",";
113
+ /** "=": the evaluated result, dot-decimal. Default `onChange`. A host that reads
114
+ * typed text by the locale's marks takes it apart from keystrokes, since a result
115
+ * like "1.234" is a decimal and never a grouped thousand. */
116
+ onResult?: (value: string) => void;
107
117
  /** Fired on "Done": the host blurs the input, which commits (evaluates) and
108
118
  * unmounts the sheet via its existing blur handler. */
109
119
  onDone: () => void;
@@ -180,11 +190,15 @@ export function NumberPadSheet({
180
190
  const result = evaluateExpression(value);
181
191
  const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
182
192
 
183
- const insert = (ch: string) => onChange(sanitizeLive(value + ch));
193
+ // With a "," mark the host keeps the typist's own text (AmountInput, keksdose G1):
194
+ // the key inserts the comma it shows, and the host, not this pad, decides which mark
195
+ // is the decimal one — sanitizing here would fold a grouping "." into a decimal.
196
+ const insert = (ch: string) =>
197
+ onChange(decimalMark === "," ? value + (ch === "." ? "," : ch) : sanitizeLive(value + ch));
184
198
  const backspace = () => onChange(value.slice(0, -1));
185
199
  const clearAll = () => onChange("");
186
200
  const equals = () => {
187
- if (result !== null) onChange(formatResult(result));
201
+ if (result !== null) (onResult ?? onChange)(formatResult(result));
188
202
  };
189
203
 
190
204
  const sheet = (
@@ -231,7 +245,7 @@ export function NumberPadSheet({
231
245
  onClick={() => insert(key.ins)}
232
246
  className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}
233
247
  >
234
- {key.label}
248
+ {key.kind === "ins" && key.name === "decimal" ? decimalMark : key.label}
235
249
  </button>
236
250
  ),
237
251
  )}
@@ -2,21 +2,44 @@ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
  import { SECTION_LABEL_CLASS } from "./text";
4
4
 
5
- /** `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
6
- * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx). */
7
- export type PageHeaderSize = "sm" | "md";
5
+ /**
6
+ * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
7
+ * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).
8
+ *
9
+ * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at
10
+ * EVERY width — budgets, rules, accounts, transactions and budget pages all write
11
+ * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to
12
+ * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's
13
+ * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is
14
+ * one: the letters are taken in order, and there is none between `sm` and `md` — an
15
+ * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is
16
+ * not.
17
+ */
18
+ export type PageHeaderSize = "sm" | "compact" | "md";
8
19
 
9
20
  const TITLE: Record<PageHeaderSize, string> = {
10
21
  sm: "text-lg font-semibold",
22
+ compact: "text-xl font-semibold",
11
23
  md: "text-xl font-semibold tracking-tight sm:text-2xl",
12
24
  };
13
25
 
26
+ /**
27
+ * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both
28
+ * layouts are one row: title block at the start, actions at the end.
29
+ */
30
+ export type PageHeaderMobileLayout = "stacked" | "inline";
31
+
32
+ const ROW: Record<PageHeaderMobileLayout, string> = {
33
+ stacked: "flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4",
34
+ inline: "flex-row items-center justify-between gap-2 sm:gap-4",
35
+ };
36
+
14
37
  export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
15
38
  title: ReactNode;
16
39
  /** A sentence under the title. */
17
40
  description?: ReactNode;
18
41
  /** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
19
- * wrapping, on a phone. */
42
+ * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */
20
43
  actions?: ReactNode;
21
44
  /** A `Breadcrumbs` trail, drawn above everything else. */
22
45
  breadcrumbs?: ReactNode;
@@ -27,6 +50,19 @@ export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "
27
50
  * header inside a page that already has its `h1` (a tab's own header). */
28
51
  as?: "h1" | "h2" | "h3";
29
52
  size?: PageHeaderSize;
53
+ /**
54
+ * `stacked` (default): on a phone the actions drop under the title and wrap, so a
55
+ * row of three buttons never pushes the title off the screen.
56
+ *
57
+ * `inline`: the actions stay beside the title on a phone too, vertically centred on
58
+ * it — keksdose's headers (transactions-page.tsx:515, `flex items-center
59
+ * justify-between gap-2`), which carry one or two icon-sized controls and were tuned
60
+ * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the
61
+ * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is
62
+ * for a header whose actions are known to fit beside a phone-width title (an icon
63
+ * button, a month picker); a long title wraps to make room for them instead.
64
+ */
65
+ mobileLayout?: PageHeaderMobileLayout;
30
66
  }
31
67
 
32
68
  /**
@@ -48,6 +84,7 @@ export function PageHeader({
48
84
  eyebrow,
49
85
  as = "h1",
50
86
  size = "md",
87
+ mobileLayout = "stacked",
51
88
  className,
52
89
  ...rest
53
90
  }: PageHeaderProps) {
@@ -55,7 +92,7 @@ export function PageHeader({
55
92
  return (
56
93
  <div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
57
94
  {breadcrumbs}
58
- <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
95
+ <div className={cn("flex min-w-0", ROW[mobileLayout])}>
59
96
  <div className="min-w-0 flex-1">
60
97
  {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
61
98
  <Heading className={cn("break-words text-[var(--text-primary)]", TITLE[size])}>{title}</Heading>
@@ -154,6 +154,9 @@ export function PickerSheet({
154
154
 
155
155
  if (!open || typeof document === "undefined") return null;
156
156
  return createPortal(
157
+ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog
158
+ container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever
159
+ inside it holds focus, and `onMouseDown` only stops propagation (live #328). */
157
160
  <div
158
161
  // Spread FIRST, and every attribute below it deliberately wins: `style`,
159
162
  // `onMouseDown` and `onKeyDown` here are not styling choices, they are live
@@ -169,6 +169,9 @@ export function Popover({
169
169
  {trigger({ open, toggle, ref: triggerRef })}
170
170
  {open && pos &&
171
171
  createPortal(
172
+ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the
173
+ dialog container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for
174
+ whatever inside it holds focus. */
172
175
  <div
173
176
  // `...rest` first: the role, the id the trigger's `aria-controls` points at,
174
177
  // the `tabIndex` the trap needs and the measured position are what make this
@@ -223,7 +223,8 @@ export interface DeltaProps extends SignedFormatProps, Omit<ComponentPropsWithou
223
223
  goodDirection?: GoodDirection;
224
224
  /** What it is measured against, after it: "vs last month". */
225
225
  label?: ReactNode;
226
- /** Hide the arrow (shown by default, as on a StatTile). */
226
+ /** Hide the arrow (shown by default, as on a StatTile). At zero the arrow is a flat
227
+ * dash BESIDE the figure ("– 0.00 %"), never in place of it. */
227
228
  arrow?: boolean;
228
229
  sensitive?: boolean;
229
230
  /** The `statTile` namespace's direction words — a delta says the same as a tile's. */
@@ -276,8 +277,16 @@ export function Delta({
276
277
  >
277
278
  <span data-private={sensitive ? "" : undefined} className={cn("inline-flex items-center gap-0.5 font-medium", tone)}>
278
279
  {arrow && <Icon className="size-[1em] shrink-0" aria-hidden />}
279
- {/* Flat with an arrow is the dash alone, as on a tile; without one, the zero. */}
280
- <span aria-hidden>{dir === "flat" && arrow ? null : amount}</span>
280
+ {/* The figure at zero too, beside the flat dash (kastlan feedback #48). Before
281
+ this a flat delta with an arrow drew the dash ALONE, as a StatTile does — so
282
+ a rent-change column that printed "0.00 %" for an unchanged lease showed a
283
+ bare "–" in its place, and a bare dash is what a table prints for a MISSING
284
+ value: "unchanged" and "not known" looked the same. The Minus icon is the
285
+ neutral indicator, the muted tone the neutral colour, and the figure says
286
+ how much (none) in the column's own digits. A value that is missing never
287
+ reaches here — a non-finite one renders nothing, above. Visible change for
288
+ every `<Delta value={0}>` with the default arrow: "–" became "– 0 %". */}
289
+ <span aria-hidden>{amount}</span>
281
290
  <span className="sr-only">{spoken}</span>
282
291
  </span>
283
292
  {label != null && <span className="text-[var(--text-muted)]">{label}</span>}
@@ -379,7 +379,9 @@ export function StatTile({
379
379
  <div className="relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums">
380
380
  <span id={deltaId} data-private={priv} className={cn("inline-flex items-center gap-0.5 font-medium", deltaTone)}>
381
381
  <Icon className="size-3 shrink-0" aria-hidden />
382
- <span aria-hidden>{dir === "flat" ? null : amount}</span>
382
+ {/* The figure at zero too, like Delta (kastlan 48): "0 %" says the change was
383
+ measured; a bare dash reads like a missing value. */}
384
+ <span aria-hidden>{amount}</span>
383
385
  <span className="sr-only">{spoken}</span>
384
386
  </span>
385
387
  {d.label != null && <span className="text-[var(--text-muted)]">{d.label}</span>}
@@ -164,13 +164,13 @@ export function SwipeableRow({
164
164
  // painted the RIGHT action's colour into the gap. With no left action at all that
165
165
  // colour was the only thing the gesture ever showed.
166
166
  //
167
- // Written during render rather than in an effect: an effect would repaint a frame
168
- // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in
169
- // the consuming app. The value is derived from this render's own dx, so a
170
- // concurrent re-render recomputes it identically.
171
- const lastSide = useRef<"left" | "right">("right");
172
- if (dx !== 0) lastSide.current = dx < 0 ? "left" : "right";
173
- const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === "left";
167
+ // Adjusted during render rather than in an effect: an effect would repaint a frame
168
+ // later (visibly, at 150ms). State set while rendering re-renders this component
169
+ // before anything is committed, so the released frame already has the right side.
170
+ const [lastSide, setLastSide] = useState<"left" | "right">("right");
171
+ const side = dx === 0 ? null : dx < 0 ? "left" : "right";
172
+ if (side && side !== lastSide) setLastSide(side);
173
+ const draggingLeft = side ? side === "left" : lastSide === "left";
174
174
  const shown = draggingLeft
175
175
  ? leftActions[Math.max(0, swipe.armedLeftIndex)]
176
176
  : rightActions[Math.max(0, swipe.armedRightIndex)];