@eifi1/ui-kit 0.14.1 → 0.15.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 (156) hide show
  1. package/README.md +4 -3
  2. package/dist/components/alert-banner.js +5 -2
  3. package/dist/components/alert-banner.js.map +1 -1
  4. package/dist/components/amount-input.d.ts +9 -1
  5. package/dist/components/amount-input.js +10 -4
  6. package/dist/components/amount-input.js.map +1 -1
  7. package/dist/components/autocomplete.js +9 -2
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/bulk-action-bar.js +6 -3
  10. package/dist/components/bulk-action-bar.js.map +1 -1
  11. package/dist/components/button-group.d.ts +1 -1
  12. package/dist/components/calculator.d.ts +1 -1
  13. package/dist/components/calendar-heatmap.js +5 -5
  14. package/dist/components/calendar-heatmap.js.map +1 -1
  15. package/dist/components/copy-button.d.ts +1 -1
  16. package/dist/components/data-table-cells.d.ts +1 -1
  17. package/dist/components/data-table-filter-popover.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +3 -3
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +12 -4
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/error-boundary.js +2 -1
  25. package/dist/components/error-boundary.js.map +1 -1
  26. package/dist/components/field.js +4 -1
  27. package/dist/components/field.js.map +1 -1
  28. package/dist/components/file-button.d.ts +1 -1
  29. package/dist/components/file-dropzone.d.ts +1 -1
  30. package/dist/components/form-actions.d.ts +1 -1
  31. package/dist/components/image-grid.d.ts +8 -1
  32. package/dist/components/image-grid.js +16 -3
  33. package/dist/components/image-grid.js.map +1 -1
  34. package/dist/components/measured-grid.js +2 -2
  35. package/dist/components/measured-grid.js.map +1 -1
  36. package/dist/components/money-field.d.ts +92 -0
  37. package/dist/components/money-field.js +40 -0
  38. package/dist/components/money-field.js.map +1 -0
  39. package/dist/components/multi-select.js +3 -0
  40. package/dist/components/multi-select.js.map +1 -1
  41. package/dist/components/number-field.d.ts +13 -2
  42. package/dist/components/number-field.js +4 -0
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +5 -2
  45. package/dist/components/number-input.js +11 -3
  46. package/dist/components/number-input.js.map +1 -1
  47. package/dist/components/numpad-sheet.d.ts +1 -1
  48. package/dist/components/page-header.js +29 -3
  49. package/dist/components/page-header.js.map +1 -1
  50. package/dist/components/series-chart.js +37 -8
  51. package/dist/components/series-chart.js.map +1 -1
  52. package/dist/components/settings-fields.d.ts +1 -1
  53. package/dist/components/stat-tile.js +3 -3
  54. package/dist/components/stat-tile.js.map +1 -1
  55. package/dist/components/text-link.d.ts +1 -1
  56. package/dist/components/text-link.js +2 -1
  57. package/dist/components/text-link.js.map +1 -1
  58. package/dist/components/time-input.d.ts +1 -1
  59. package/dist/components/treemap.d.ts +3 -2
  60. package/dist/components/treemap.js +14 -3
  61. package/dist/components/treemap.js.map +1 -1
  62. package/dist/components/ui.d.ts +1 -1
  63. package/dist/components/ui.js +44 -10
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/components/use-table-state.d.ts +1 -1
  66. package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
  67. package/dist/data-table.d.ts +1 -1
  68. package/dist/feedback/feedback-dialog.d.ts +10 -1
  69. package/dist/feedback/feedback-dialog.js +13 -3
  70. package/dist/feedback/feedback-dialog.js.map +1 -1
  71. package/dist/feedback/feedback-thread.js +31 -12
  72. package/dist/feedback/feedback-thread.js.map +1 -1
  73. package/dist/hooks/use-file-drop.d.ts +1 -1
  74. package/dist/i18n/defaults.d.ts +1 -1
  75. package/dist/i18n/kit-labels.d.ts +1 -1
  76. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  77. package/dist/i18n/locales/de-CH.d.ts +1 -1
  78. package/dist/i18n/locales/de-informal.d.ts +1 -1
  79. package/dist/i18n/locales/de.d.ts +1 -1
  80. package/dist/i18n/locales/de.js +1 -0
  81. package/dist/i18n/locales/de.js.map +1 -1
  82. package/dist/i18n/locales/es.d.ts +1 -1
  83. package/dist/i18n/locales/es.js +1 -0
  84. package/dist/i18n/locales/es.js.map +1 -1
  85. package/dist/i18n/locales/fr.d.ts +1 -1
  86. package/dist/i18n/locales/fr.js +1 -0
  87. package/dist/i18n/locales/fr.js.map +1 -1
  88. package/dist/i18n/locales/hu.d.ts +1 -1
  89. package/dist/i18n/locales/hu.js +1 -0
  90. package/dist/i18n/locales/hu.js.map +1 -1
  91. package/dist/i18n/locales/it.d.ts +1 -1
  92. package/dist/i18n/locales/it.js +1 -0
  93. package/dist/i18n/locales/it.js.map +1 -1
  94. package/dist/i18n/locales/zh.d.ts +1 -1
  95. package/dist/i18n/locales/zh.js +1 -0
  96. package/dist/i18n/locales/zh.js.map +1 -1
  97. package/dist/index.d.ts +5 -4
  98. package/dist/index.js +1 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
  101. package/dist/lib/document-navigation.d.ts +15 -0
  102. package/dist/lib/document-navigation.js +13 -0
  103. package/dist/lib/document-navigation.js.map +1 -0
  104. package/dist/lib/strip-fade.d.ts +31 -0
  105. package/dist/lib/strip-fade.js +54 -0
  106. package/dist/lib/strip-fade.js.map +1 -0
  107. package/dist/rhf/fields.d.ts +1 -1
  108. package/dist/rhf/form.d.ts +1 -1
  109. package/dist/rhf/form.js +1 -1
  110. package/dist/rhf/form.js.map +1 -1
  111. package/dist/rhf.d.ts +1 -1
  112. package/dist/shell/app-shell.d.ts +11 -2
  113. package/dist/shell/app-shell.js +20 -4
  114. package/dist/shell/app-shell.js.map +1 -1
  115. package/dist/shell/top-bar-brand.d.ts +1 -1
  116. package/dist/shell.d.ts +1 -1
  117. package/dist/wizard/stepper-nav.d.ts +1 -1
  118. package/dist/wizard/stepper-nav.js +14 -2
  119. package/dist/wizard/stepper-nav.js.map +1 -1
  120. package/dist/wizard.d.ts +1 -1
  121. package/package.json +7 -4
  122. package/src/components/alert-banner.tsx +10 -2
  123. package/src/components/amount-input.tsx +18 -4
  124. package/src/components/autocomplete.tsx +10 -1
  125. package/src/components/bulk-action-bar.tsx +12 -3
  126. package/src/components/calendar-heatmap.tsx +10 -5
  127. package/src/components/data-table-filter-popover.tsx +6 -3
  128. package/src/components/data-table.tsx +21 -7
  129. package/src/components/error-boundary.tsx +2 -1
  130. package/src/components/field.tsx +4 -1
  131. package/src/components/image-grid.tsx +30 -2
  132. package/src/components/measured-grid.tsx +3 -2
  133. package/src/components/money-field.tsx +75 -0
  134. package/src/components/multi-select.tsx +3 -0
  135. package/src/components/number-field.tsx +14 -0
  136. package/src/components/number-input.tsx +23 -4
  137. package/src/components/page-header.tsx +38 -3
  138. package/src/components/series-chart.tsx +56 -8
  139. package/src/components/stat-tile.tsx +8 -3
  140. package/src/components/text-link.tsx +2 -1
  141. package/src/components/treemap.tsx +30 -5
  142. package/src/components/ui.tsx +91 -11
  143. package/src/feedback/feedback-dialog.tsx +20 -2
  144. package/src/feedback/feedback-thread.tsx +36 -13
  145. package/src/i18n/locales/de.ts +1 -0
  146. package/src/i18n/locales/es.ts +1 -0
  147. package/src/i18n/locales/fr.ts +1 -0
  148. package/src/i18n/locales/hu.ts +1 -0
  149. package/src/i18n/locales/it.ts +1 -0
  150. package/src/i18n/locales/zh.ts +1 -0
  151. package/src/index.ts +1 -0
  152. package/src/lib/document-navigation.ts +17 -0
  153. package/src/lib/strip-fade.ts +82 -0
  154. package/src/rhf/form.tsx +3 -1
  155. package/src/shell/app-shell.tsx +34 -7
  156. package/src/wizard/stepper-nav.tsx +23 -2
@@ -251,8 +251,17 @@ function AutocompleteInner<V extends string | number = string>(
251
251
  <span
252
252
  aria-hidden
253
253
  className={cn(
254
- "pointer-events-none absolute top-1/2 flex -translate-y-1/2 text-[var(--text-muted)]",
254
+ "pointer-events-none absolute flex text-[var(--text-muted)]",
255
255
  small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
256
+ // Labelled, the icon sits on the VALUE's line — the one line box below
257
+ // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
258
+ // the box. Centred on the box it rose into the top strip and sat on the
259
+ // label's first letters, which float at the same start inset. On the
260
+ // value line it is beside what it describes, and the label keeps the
261
+ // start edge every other label in the form's column shares.
262
+ hasLabel
263
+ ? "top-[calc(1rem+1px)] h-5 items-center"
264
+ : "top-1/2 -translate-y-1/2",
256
265
  )}
257
266
  >
258
267
  {icon}
@@ -277,7 +277,8 @@ export function BulkActionBar({
277
277
  <X />
278
278
  </IconButton>
279
279
  ) : (
280
- <Button type="button" variant="secondary" size="sm" onClick={onClear}>
280
+ // `ms-auto`: when the actions wrap, the way out still sits at the bar's end.
281
+ <Button type="button" variant="secondary" size="sm" onClick={onClear} className="ms-auto">
281
282
  {labels.clear}
282
283
  </Button>
283
284
  );
@@ -296,7 +297,11 @@ export function BulkActionBar({
296
297
  "sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur",
297
298
  shown ? "animate-overlay" : "animate-overlay-out",
298
299
  );
299
- const rowClass = cn("flex items-center", floating ? "gap-1 px-2 py-1" : "gap-2 px-3 py-2");
300
+ // `flex-wrap`: on a phone the count, three actions and a text "Clear selection" are
301
+ // wider than the screen. Without wrapping the count (the one shrinkable item)
302
+ // collapsed to a sliver and the rest ran out of the bar; wrapped, the actions that do
303
+ // not fit move to a second line and every label stays whole.
304
+ const rowClass = cn("flex flex-wrap items-center", floating ? "gap-1 px-2 py-1" : "gap-2 px-3 py-2");
300
305
  const surfaceStyle = floating ? { ...FLOATING_STYLE, ...style } : style;
301
306
 
302
307
  const toolbar = (
@@ -324,7 +329,11 @@ export function BulkActionBar({
324
329
  className={hasPanel ? rowClass : cn(rowClass, surfaceClass, className)}
325
330
  >
326
331
  {floating && clear}
327
- <span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--text-primary)]">
332
+ {/* `flex-auto`, not `flex-1`: a zero basis told the wrapping that the count needs
333
+ no room, so it was the item squeezed to nothing instead of a line breaking.
334
+ Its own width is its basis now; `truncate` is left for a label too long for a
335
+ line on its own. */}
336
+ <span className="min-w-0 flex-auto truncate text-sm font-medium text-[var(--text-primary)]">
328
337
  {labels.selected(shownCount)}
329
338
  </span>
330
339
  {/* `contents`, so the caller's controls are flex items of the bar itself. */}
@@ -354,14 +354,19 @@ export function CalendarHeatmap({
354
354
 
355
355
  const weekdayLabels = useMemo(() => {
356
356
  const ref = new Date(2024, 0, 7 + weekStart); // 7 January 2024 was a Sunday
357
- return Array.from({ length: 7 }, (_, i) => {
357
+ const week = Array.from({ length: 7 }, (_, i) => {
358
358
  const d = new Date(ref);
359
359
  d.setDate(ref.getDate() + i);
360
- return {
361
- short: d.toLocaleDateString(locale, { weekday: "short" }),
362
- long: d.toLocaleDateString(locale, { weekday: "long" }),
363
- };
360
+ return d;
364
361
  });
362
+ const name = (d: Date, weekday: "short" | "narrow" | "long") => d.toLocaleDateString(locale, { weekday });
363
+ // The label column is 2rem at 10px type, room for about four characters. Most
364
+ // locales' "short" is three or four ("Thu", "jeu.", "czw."), but some have no
365
+ // abbreviation at all — Arabic's short Thursday IS "الخميس", which overran the box
366
+ // and was cut. Such a locale takes the "narrow" form ("خ") for ALL seven, so the
367
+ // column never mixes two forms; the full name is on the aria-label either way.
368
+ const tooLong = week.some((d) => [...name(d, "short")].length > 4);
369
+ return week.map((d) => ({ short: name(d, tooLong ? "narrow" : "short"), long: name(d, "long") }));
365
370
  }, [locale, weekStart]);
366
371
 
367
372
  const lead = days.length ? weekdayIndex(days[0], weekStart) : 0;
@@ -163,9 +163,12 @@ export function FilterPopover<T>({
163
163
  const set = (from: string, to: string) => onChange({ type: "date", from, to });
164
164
  const presets = dateRangePresets();
165
165
  const isActivePreset = (p: { from: string; to: string }) => v.from === p.from && v.to === p.to;
166
+ // Side by side, the 7rem preset column leaves a phone's calendar and its two
167
+ // native date inputs ~180px, which clips "mm/dd/yyyy" mid-placeholder. Below `sm`
168
+ // the presets wrap as a row above the calendar, which then gets the panel's width.
166
169
  return (
167
- <div className="flex gap-3">
168
- <ul className="flex w-28 shrink-0 flex-col gap-0.5 text-xs">
170
+ <div className="flex flex-col gap-3 sm:flex-row">
171
+ <ul className="flex flex-wrap gap-0.5 text-xs sm:w-28 sm:shrink-0 sm:flex-col sm:flex-nowrap">
169
172
  {presets.map((p) => {
170
173
  const active = isActivePreset(p);
171
174
  return (
@@ -174,7 +177,7 @@ export function FilterPopover<T>({
174
177
  type="button"
175
178
  onClick={() => set(p.from, p.to)}
176
179
  className={cn(
177
- "block w-full rounded px-2 py-1 text-left transition-colors",
180
+ "block rounded px-2 py-1 text-start transition-colors sm:w-full",
178
181
  active
179
182
  ? "bg-[var(--brand-bg)] text-[var(--brand-muted)]"
180
183
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
@@ -247,9 +247,10 @@ export interface DataTableProps<T> {
247
247
  */
248
248
  urlSync?: TableUrlSync;
249
249
  /**
250
- * When true (desktop only), the table fills its parent's height and scrolls
251
- * INTERNALLY, with the header pinned — so the page itself doesn't add a second,
252
- * outer scrollbar. The parent must give it a bounded height (e.g. a flex
250
+ * When true, the table fills its parent's height and scrolls INTERNALLY, with the
251
+ * header pinned — so the page itself doesn't add a second, outer scrollbar. On a
252
+ * phone the card list is the scroller instead, with the filter button pinned above
253
+ * it; an unbounded parent simply lets either layout grow to its content. The parent must give it a bounded height (e.g. a flex
253
254
  * column inside the viewport-locked app shell). See feedback #207.
254
255
  */
255
256
  fillHeight?: boolean;
@@ -1634,7 +1635,10 @@ export function DataTable<T>({
1634
1635
  className={cn(
1635
1636
  "group/table",
1636
1637
  frame ? "overflow-clip" : "min-w-0",
1637
- fillHeight && "md:flex md:flex-1 md:flex-col md:min-h-0",
1638
+ // Both layouts, not just the table: the phone card list overflowing a bounded
1639
+ // pane drew itself over whatever followed it, and in a viewport-locked shell
1640
+ // the rows below the fold could not be reached at all.
1641
+ fillHeight && "flex flex-1 flex-col min-h-0",
1638
1642
  className,
1639
1643
  )}
1640
1644
  >
@@ -1658,7 +1662,7 @@ export function DataTable<T>({
1658
1662
  </div>
1659
1663
  )}
1660
1664
  {!isMdUp && (
1661
- <div ref={mobileRootRef}>
1665
+ <div ref={mobileRootRef} className={cn(fillHeight && "flex min-h-0 flex-1 flex-col")}>
1662
1666
  {/* Mobile filter access (feedback #299): the per-column filter popovers
1663
1667
  live in the desktop header, which the card list doesn't render — so
1664
1668
  expose the same filters through a bottom sheet here. */}
@@ -1679,6 +1683,11 @@ export function DataTable<T>({
1679
1683
  <ul
1680
1684
  className={cn(
1681
1685
  "divide-y divide-[var(--border)]",
1686
+ // Under `fillHeight` the list is the scroller, as the table body is on a
1687
+ // wide screen: the filter button stays above it and a server pager below.
1688
+ // The endless-scroll sentinel still fires in here — an
1689
+ // IntersectionObserver on the viewport sees through the clipping ancestor.
1690
+ fillHeight && "min-h-0 flex-1 overflow-y-auto overscroll-contain",
1682
1691
  // Same busy treatment as the desktop body; see `isLoading` below.
1683
1692
  isLoading && mobileSlice.length > 0 && "opacity-60 transition-opacity",
1684
1693
  )}
@@ -1711,7 +1720,8 @@ export function DataTable<T>({
1711
1720
  ref={loadMoreRef}
1712
1721
  className="px-4 py-4 text-center text-xs text-[var(--text-placeholder)]"
1713
1722
  >
1714
- {labels.loading}
1723
+ {/* Isolated for the reason spelled out on LoadingText. */}
1724
+ <bdi>{labels.loading}</bdi>
1715
1725
  </li>
1716
1726
  )}
1717
1727
  </ul>
@@ -2287,10 +2297,14 @@ function PlainRoot(props: React.HTMLAttributes<HTMLDivElement>) {
2287
2297
 
2288
2298
  /** The loading row's content: a spinner beside the word, the word for everyone. */
2289
2299
  function LoadingText({ label }: { label: string }) {
2300
+ // `<bdi>`: the label takes its direction from its own letters, not the table's. The
2301
+ // English default inside a `dir="rtl"` table otherwise had its trailing "…" — a
2302
+ // neutral with no letter after it — resolved right-to-left and drawn in front of
2303
+ // the word: "…Loading". An Arabic or Hebrew label still reads right-to-left.
2290
2304
  return (
2291
2305
  <span className="inline-flex items-center gap-2">
2292
2306
  <Spinner label={null} className="size-4" />
2293
- {label}
2307
+ <bdi>{label}</bdi>
2294
2308
  </span>
2295
2309
  );
2296
2310
  }
@@ -5,6 +5,7 @@ import { useKitLabels } from "../i18n/kit-labels";
5
5
  import { Button, EmptyState } from "./ui";
6
6
  import { CopyButton } from "./copy-button";
7
7
  import { Disclosure } from "./disclosure";
8
+ import { documentNavigation } from "../lib/document-navigation";
8
9
 
9
10
  /** The words of the default fallback. */
10
11
  export interface ErrorBoundaryLabels {
@@ -277,7 +278,7 @@ function isOffline(): boolean {
277
278
 
278
279
  function reloadPage(): void {
279
280
  try {
280
- window.location.reload();
281
+ documentNavigation.reload();
281
282
  } catch {
282
283
  /* nothing left to do from here */
283
284
  }
@@ -150,7 +150,10 @@ export function Field({
150
150
  <div
151
151
  data-slot="field"
152
152
  className={cn(
153
- "grid gap-1.5",
153
+ // `content-start`: in a grid row next to a taller item (one with a hint), a
154
+ // stretched Field would hand the extra height to its rows — the label grew,
155
+ // and the control sat lower than its neighbour's (showcase audit, #/forms).
156
+ "grid content-start gap-1.5",
154
157
  // A hidden label is `position: absolute`; `relative` keeps its containing
155
158
  // block here rather than the page's (see sr-only-containment.test).
156
159
  labelVisibility !== "visible" && "relative",
@@ -70,6 +70,18 @@ const ASPECT_CLASS = {
70
70
  auto: "aspect-[4/3]",
71
71
  } as const;
72
72
 
73
+ /**
74
+ * A file name as stem and extension, so a tile can cut the stem and keep the
75
+ * extension whole: "floor-plan-level-2.p / df" (a break inside the extension) told the
76
+ * reader less than "floor-plan-le….pdf" does. A leading dot (".env") or an
77
+ * "extension" longer than five characters is not treated as one.
78
+ */
79
+ export function splitFileName(name: string): [stem: string, ext: string] {
80
+ const dot = name.lastIndexOf(".");
81
+ if (dot <= 0 || name.length - dot > 6) return [name, ""];
82
+ return [name.slice(0, dot), name.slice(dot)];
83
+ }
84
+
73
85
  /**
74
86
  * A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect
75
87
  * photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each
@@ -134,7 +146,7 @@ export function ImageGrid({
134
146
  {kind === "file" ? (
135
147
  <span className="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]">
136
148
  <FileText aria-hidden className="size-6 shrink-0 text-[var(--text-muted)]" />
137
- <span aria-hidden className="line-clamp-2 break-all">{item.fileName ?? item.alt}</span>
149
+ <FileNameLabel name={item.fileName ?? item.alt ?? ""} />
138
150
  </span>
139
151
  ) : (
140
152
  <AuthedImage
@@ -153,7 +165,12 @@ export function ImageGrid({
153
165
  <div
154
166
  role="group"
155
167
  aria-label={labels.actions(name)}
156
- className="absolute end-1 top-1 flex gap-1"
168
+ // A backdrop behind the whole group, in the inverse token the overlay
169
+ // buttons use: a translucent disc alone took most of its colour from
170
+ // the picture, and on a pale photo or the light file tile the glyphs
171
+ // washed out. Two layers of the inverse keep them readable over any
172
+ // picture, in both themes.
173
+ className="absolute end-1 top-1 flex gap-1 rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_40%,transparent)] p-0.5 backdrop-blur-sm"
157
174
  >
158
175
  {actions}
159
176
  </div>
@@ -179,3 +196,14 @@ export function ImageGrid({
179
196
  </>
180
197
  );
181
198
  }
199
+
200
+ /** One line: the stem truncates, the extension never does. */
201
+ function FileNameLabel({ name }: { name: string }) {
202
+ const [stem, ext] = splitFileName(name);
203
+ return (
204
+ <span aria-hidden className="flex max-w-full min-w-0">
205
+ <span className="min-w-0 truncate">{stem}</span>
206
+ {ext && <span className="shrink-0">{ext}</span>}
207
+ </span>
208
+ );
209
+ }
@@ -771,7 +771,8 @@ function CellGrid({
771
771
  <Eraser aria-hidden className="size-3.5" /> {labels.clear}
772
772
  </Button>
773
773
  <span className="text-[11px] tabular-nums text-[var(--text-muted)]">
774
- {labels.points(filled.length)}
774
+ {/* bdi: an LTR label ("3 points") in an RTL grid would reorder to "points 3". */}
775
+ <bdi>{labels.points(filled.length)}</bdi>
775
776
  </span>
776
777
  {problems > 0 && (
777
778
  <span className="text-[11px] text-[var(--danger)]">{labels.problems(problems)}</span>
@@ -845,7 +846,7 @@ function TextView({ columns, cells, onCells, disabled, mark, labels, labelledBy,
845
846
  <p className="mt-1 px-1 text-[11px] text-[var(--text-muted)]">
846
847
  {columns.map((column) => column.label).join(" · ")}
847
848
  {" — "}
848
- {labels.points(lines)}
849
+ <bdi>{labels.points(lines)}</bdi>
849
850
  </p>
850
851
  </div>
851
852
  );
@@ -0,0 +1,75 @@
1
+ import { useState, type ComponentProps, type Ref } from "react";
2
+ import { AmountInput } from "./amount-input";
3
+
4
+ type AmountInputProps = ComponentProps<typeof AmountInput>;
5
+
6
+ export interface MoneyFieldProps
7
+ extends Omit<AmountInputProps, "value" | "onChange" | "onCommit" | "negative" | "onNegativeChange" | "ref"> {
8
+ /** The amount, or `null` for an empty field. */
9
+ value: number | null;
10
+ /**
11
+ * The settled amount — on blur, on Enter, on the numpad's "=" and on a calculator
12
+ * result; never a half-typed "1200+". Rounded to the currency's minor unit (or
13
+ * `digits`) and clamped to `min` / `max`, like {@link AmountInput}. `null` when the
14
+ * field was emptied. Not fired when the settled amount equals `value`, so an
15
+ * Enter-then-blur saves once.
16
+ */
17
+ onCommit: (value: number | null) => void;
18
+ ref?: Ref<HTMLInputElement>;
19
+ }
20
+
21
+ /** The number a settled text reads as: `null` for empty, `undefined` for no number
22
+ * (a lone "-" or "."). */
23
+ function parseSettled(text: string): number | null | undefined {
24
+ const t = text.trim();
25
+ if (t === "") return null;
26
+ const n = Number(t);
27
+ return Number.isFinite(n) ? n : undefined;
28
+ }
29
+
30
+ /**
31
+ * {@link AmountInput} with a NUMBER in and a number out, for money edited outside a
32
+ * form — a budget row, a table cell (kastlan 52: new-budget-page edited a CHF amount per
33
+ * row with `NumberField` because AmountInput is string-valued and reports every
34
+ * keystroke, "1200+" included, so a number-state row needed its own parse-and-settle
35
+ * glue). This is that glue, the same one `RhfMoneyField` has: the text being typed
36
+ * lives here, and only a settled figure reaches `onCommit`.
37
+ *
38
+ * The currency chip, the calculator, the numpad, the locale's decimal mark, the
39
+ * rounding to the currency's minor unit and `min` / `max` are AmountInput's. A caller-
40
+ * owned sign (`negative`) is not offered: the figure here carries its own minus.
41
+ *
42
+ * ```tsx
43
+ * <MoneyField ariaLabel="Amount" currency="CHF" value={row.amount}
44
+ * onCommit={(amount) => updateRow(row.id, { amount })} />
45
+ * ```
46
+ */
47
+ export function MoneyField({ value, onCommit, ref, ...props }: MoneyFieldProps) {
48
+ const external = value === null ? "" : String(value);
49
+ // The draft is re-read from `value` only when `value` moves away from what the draft
50
+ // already says (a reset, another row's save), so a commit's own echo keeps the
51
+ // typist's spelling ("12.50" stays "12.50" when 12.5 comes back).
52
+ const [draft, setDraft] = useState(external);
53
+ const [seen, setSeen] = useState(value);
54
+ if (seen !== value) {
55
+ setSeen(value);
56
+ if (parseSettled(draft) !== value) setDraft(external);
57
+ }
58
+ return (
59
+ <AmountInput
60
+ {...props}
61
+ ref={ref}
62
+ value={draft}
63
+ onChange={setDraft}
64
+ onCommit={(text) => {
65
+ const next = parseSettled(text);
66
+ // No number ("-" alone): back to the stored amount rather than a guess.
67
+ if (next === undefined) {
68
+ setDraft(external);
69
+ return;
70
+ }
71
+ if (next !== value) onCommit(next);
72
+ }}
73
+ />
74
+ );
75
+ }
@@ -210,6 +210,9 @@ export function MultiSelect({
210
210
  label !== undefined && FIELD_FLOATING_PAD,
211
211
  // FIELD_TRIGGER's hover would still light a trigger nothing can open.
212
212
  "disabled:hover:bg-[var(--bg-surface-2)]",
213
+ // Dimmed like EntityCombobox's and MultiEntityCombobox's disabled triggers;
214
+ // only the label was, so a disabled MultiSelect read as live (showcase audit).
215
+ "disabled:cursor-not-allowed disabled:opacity-50",
213
216
  field.isInvalid && FIELD_INVALID,
214
217
  )}
215
218
  >
@@ -304,3 +304,17 @@ export function NumberField({
304
304
  </div>
305
305
  );
306
306
  }
307
+
308
+ /**
309
+ * A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as
310
+ * defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a
311
+ * form-bound field, for the plain one (kastlan 51: a building's construction year in a
312
+ * wizard's local state, an entity id in the document upload spelled the two props out
313
+ * by hand).
314
+ *
315
+ * `??` rather than a spread over the defaults, so a wrapper forwarding its own optional
316
+ * `digits={props.digits}` still gets the preset.
317
+ */
318
+ export function IntegerField(props: NumberFieldProps) {
319
+ return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;
320
+ }
@@ -53,7 +53,10 @@ interface NumberInputProps {
53
53
  /** A {@link FieldHint} "?" on the label's own line, beside the label rather
54
54
  * than at the far end of the strip — the far end is where the calculator
55
55
  * lives, and a hint placed there landed on top of it (steering-design
56
- * feedback #48). */
56
+ * feedback #48). Plain TEXT (a string or a number) is a caption instead, UNDER
57
+ * the field and attached through `aria-describedby` after the caller's own —
58
+ * the same rule as {@link Select}'s `hint`: the label line has no room for a
59
+ * sentence, and one placed there ran over the label and into the value. */
57
60
  hint?: ReactNode;
58
61
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
59
62
  invalid?: boolean;
@@ -176,6 +179,10 @@ export function NumberInput({
176
179
  }: NumberInputProps) {
177
180
  const generatedId = useId();
178
181
  const fieldId = id ?? generatedId;
182
+ const hintId = useId();
183
+ // Text is a caption under the field; a FieldHint rides the label line. See `hint`.
184
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
185
+ const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;
179
186
  const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
180
187
  const labelled = label !== undefined;
181
188
  // On phones we suppress the OS keyboard (inputMode="none" below) for our own
@@ -215,13 +222,13 @@ export function NumberInput({
215
222
  stepBy(count);
216
223
  };
217
224
 
218
- return (
225
+ const field = (
219
226
  <FloatingField
220
227
  className={cn("w-full", className)}
221
228
  htmlFor={fieldId}
222
229
  label={label}
223
230
  srOnlyLabel={asDisplay}
224
- hint={hint}
231
+ hint={textHint ? undefined : hint}
225
232
  >
226
233
  <input
227
234
  ref={inputRef}
@@ -248,7 +255,7 @@ export function NumberInput({
248
255
  }}
249
256
  onKeyDown={onKeyDown}
250
257
  aria-invalid={invalid || undefined}
251
- aria-describedby={ariaDescribedBy}
258
+ aria-describedby={describedBy}
252
259
  aria-required={ariaRequired}
253
260
  // The end padding sits last so it always wins over an inputClassName that
254
261
  // sets its own px. Both trailing controls can be on at once, so the room
@@ -301,4 +308,16 @@ export function NumberInput({
301
308
  )}
302
309
  </FloatingField>
303
310
  );
311
+ if (!textHint) return field;
312
+ // Outside the field's own `relative` box, as Select's caption is: the calculator
313
+ // and the unit are `inset-y-0` in it and would stretch down over a second line.
314
+ // `className` stays on the field, so adding a caption cannot change what it styles.
315
+ return (
316
+ <div>
317
+ {field}
318
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
319
+ {hint}
320
+ </p>
321
+ </div>
322
+ );
304
323
  }
@@ -1,3 +1,4 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
1
2
  import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
3
  import { cn } from "../lib/cn";
3
4
  import { SECTION_LABEL_CLASS } from "./text";
@@ -168,6 +169,37 @@ export function PageHeader({
168
169
  // wraps instead: actions that do not fit go to a line of their own, as the app's old
169
170
  // `flex-wrap` header did.
170
171
  const keepsWords = mobileLayout === "inline" && !truncateTitle;
172
+ // keksdose, after H1: once the actions have wrapped below the title, the phone's
173
+ // header was THREE rows — title / actions / secondary — where the app's old header
174
+ // shared the second row between the month nav and the toggles (~48px of phone
175
+ // height). So the secondary group's full-width basis (G6b: a row of its own) is
176
+ // dropped exactly when the actions already sit below the title; then it joins their
177
+ // row if it fits, and wraps under them if not. While the actions share the title's
178
+ // row, nothing changes: the secondary group never climbs onto the title's row.
179
+ //
180
+ // CSS cannot see "the previous item wrapped", so it is measured — by a
181
+ // ResizeObserver, whose first callback runs on `observe`. It is stable: the
182
+ // secondary group comes after the actions, so its basis cannot move them. No
183
+ // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.
184
+ const measuresWrap = keepsWords && hasSecondary && actions != null;
185
+ const titleRef = useRef<HTMLDivElement>(null);
186
+ const actionsRef = useRef<HTMLDivElement>(null);
187
+ const [actionsWrapped, setActionsWrapped] = useState(false);
188
+ useLayoutEffect(() => {
189
+ const titleBox = titleRef.current;
190
+ const actionsBox = actionsRef.current;
191
+ if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === "undefined") return;
192
+ const observer = new ResizeObserver(() => {
193
+ const t = titleBox.getBoundingClientRect();
194
+ const a = actionsBox.getBoundingClientRect();
195
+ // Unmeasured (display: none, no layout): keep the full-width basis.
196
+ setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);
197
+ });
198
+ observer.observe(titleBox);
199
+ observer.observe(actionsBox);
200
+ if (titleBox.parentElement) observer.observe(titleBox.parentElement);
201
+ return () => observer.disconnect();
202
+ }, [measuresWrap]);
171
203
  return (
172
204
  <div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
173
205
  {breadcrumbs}
@@ -184,7 +216,7 @@ export function PageHeader({
184
216
  align,
185
217
  )}
186
218
  >
187
- <div className={cn("flex-1", keepsWords ? "min-w-min" : "min-w-0")}>
219
+ <div ref={titleRef} className={cn("flex-1", keepsWords ? "min-w-min" : "min-w-0")}>
188
220
  {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
189
221
  <Heading
190
222
  className={cn(
@@ -198,7 +230,10 @@ export function PageHeader({
198
230
  {description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
199
231
  </div>
200
232
  {actions != null && (
201
- <div className={cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2")}>
233
+ <div
234
+ ref={actionsRef}
235
+ className={cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2")}
236
+ >
202
237
  {actions}
203
238
  </div>
204
239
  )}
@@ -206,7 +241,7 @@ export function PageHeader({
206
241
  <div
207
242
  className={cn(
208
243
  "flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0",
209
- wrapsSecondary && "basis-full sm:basis-auto",
244
+ wrapsSecondary && (measuresWrap && actionsWrapped ? "sm:basis-auto" : "basis-full sm:basis-auto"),
210
245
  )}
211
246
  >
212
247
  {secondaryActions}
@@ -26,7 +26,7 @@
26
26
  // "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
27
27
  // invent. What each of those does to the zoom is decided in one place,
28
28
  // `defaultZoomAxes` in `chart-zoom.tsx`.
29
- import { useMemo, useRef, useState } from "react";
29
+ import { useLayoutEffect, useMemo, useRef, useState } from "react";
30
30
  import type { KeyboardEvent, ReactNode, RefObject } from "react";
31
31
  import {
32
32
  Area,
@@ -884,6 +884,55 @@ function focusedBarShape(index: number) {
884
884
  };
885
885
  }
886
886
 
887
+ /** A marker label's size and weight — the one place both the text and its width
888
+ * estimate read them from. */
889
+ const MARKER_LABEL_SIZE = 11;
890
+
891
+ /**
892
+ * A marker's label, centred over its point but clamped inside the plot. recharts'
893
+ * `position: "top"` centres on the point no matter where it is, so a marker near the
894
+ * plot's end (the last sample, a projection's end point) had half its label cut off by
895
+ * the card — "over the c" on a phone. The width is measured once drawn; the first
896
+ * frame (and a DOM without layout) uses a 0.6em-per-character estimate, which errs wide.
897
+ */
898
+ function MarkerLabel({
899
+ viewBox,
900
+ value,
901
+ }: {
902
+ viewBox?: { x?: number; y?: number; width?: number };
903
+ value?: ReactNode;
904
+ }) {
905
+ const plot = usePlotArea();
906
+ const ref = useRef<SVGTextElement>(null);
907
+ const [measured, setMeasured] = useState<number>();
908
+ const text = value == null ? "" : String(value);
909
+ useLayoutEffect(() => {
910
+ const el = ref.current;
911
+ if (!el || typeof el.getComputedTextLength !== "function") return;
912
+ const w = el.getComputedTextLength();
913
+ if (w > 0 && w !== measured) setMeasured(w);
914
+ }, [text, measured]);
915
+ if (!viewBox || viewBox.x == null || viewBox.y == null || text === "") return null;
916
+ const cx = viewBox.x + (viewBox.width ?? 0) / 2;
917
+ const half = (measured ?? text.length * MARKER_LABEL_SIZE * 0.6) / 2;
918
+ // The start clamp applied last: a label wider than the plot keeps its start on screen.
919
+ const x = plot ? Math.max(plot.x + half, Math.min(cx, plot.x + plot.width - half)) : cx;
920
+ return (
921
+ <text
922
+ ref={ref}
923
+ x={x}
924
+ y={viewBox.y - 5}
925
+ textAnchor="middle"
926
+ fontSize={MARKER_LABEL_SIZE}
927
+ fontWeight={600}
928
+ fill="var(--text-primary)"
929
+ className="recharts-label"
930
+ >
931
+ {text}
932
+ </text>
933
+ );
934
+ }
935
+
887
936
  /**
888
937
  * The keyboard's way onto a clickable chart (`onPointClick`): one focusable `<rect>` per
889
938
  * bar, or per slot when there are no bars, laid over the plot INSIDE the recharts chart,
@@ -1350,6 +1399,11 @@ function SeriesPlot({
1350
1399
  tickLine={false}
1351
1400
  axisLine={false}
1352
1401
  minTickGap={32}
1402
+ // Where the ticks crowd, drop every Nth rather than recharts' default
1403
+ // `preserveEnd`, which drops whichever single tick collides: a phone's whole-week
1404
+ // axis came out 2 4 6 8 _ 12, a doubled gap that reads as a missing week. Every
1405
+ // other tick, keeping the last, gives 4 8 12 — still an even ruler.
1406
+ interval="equidistantPreserveEnd"
1353
1407
  tick={x.ticks === false ? false : undefined}
1354
1408
  {...(tickAngle
1355
1409
  ? { angle: tickAngle, textAnchor: tickAngle < 0 ? "end" : "start" }
@@ -1563,13 +1617,7 @@ function SeriesPlot({
1563
1617
  ifOverflow="discard"
1564
1618
  label={
1565
1619
  marker.label
1566
- ? {
1567
- value: marker.label,
1568
- position: "top",
1569
- fontSize: 11,
1570
- fontWeight: 600,
1571
- fill: "var(--text-primary)",
1572
- }
1620
+ ? { value: marker.label, position: "top", content: <MarkerLabel /> }
1573
1621
  : undefined
1574
1622
  }
1575
1623
  />
@@ -516,12 +516,17 @@ function FigureRow({
516
516
  }) {
517
517
  const own = figure.tone ?? (tileTone === "neutral" ? undefined : tileTone);
518
518
  const svTone = own === "none" ? undefined : own;
519
+ // The LABEL gives way, never the figure. This row used to wrap, so on a narrow tile a
520
+ // long label ("AUG (SIGNED)") dropped its amount onto a line of its own while the
521
+ // next row stayed on one — two layouts in one list, and a figure no longer beside
522
+ // the period it belongs to. Now the label breaks within itself and the amount stays
523
+ // on the label's first line, end-aligned like every other row.
519
524
  return (
520
- <div className="flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]">
521
- <dt className="uppercase tracking-wide">{figure.label}</dt>
525
+ <div className="flex items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]">
526
+ <dt className="min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]">{figure.label}</dt>
522
527
  <dd
523
528
  data-private={priv}
524
- className={cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
529
+ className={cn("ms-auto shrink-0 whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
525
530
  >
526
531
  {show(figure.value)}
527
532
  </dd>