@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
@@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
6
6
  import type { KitLinkComponent } from "../i18n/kit-labels";
7
+ import { documentNavigation } from "../lib/document-navigation";
7
8
 
8
9
  /* ── Choosing what draws a link ──────────────────────────────────────────── */
9
10
 
@@ -98,7 +99,7 @@ export function replacingClick(
98
99
  onClick?.(event);
99
100
  if (!isPlainNavigationClick(event)) return;
100
101
  event.preventDefault();
101
- window.location.replace(href);
102
+ documentNavigation.replace(href);
102
103
  };
103
104
  }
104
105
 
@@ -36,8 +36,9 @@ export interface TreemapNode {
36
36
  value: number;
37
37
  /** Overrides the by-index palette colour for this tile — e.g. to paint every child
38
38
  * in its parent group's colour, so the groups stay legible in one picture. A
39
- * concrete `#rrggbb` keeps the label ink measured; anything else (a CSS var) gets
40
- * `currentColor` ink, because there is nothing to measure — set `labelColor` then. */
39
+ * concrete `#rrggbb` or a bare `var(--chart-N)` keeps the label ink measured;
40
+ * anything else (another var, a `color-mix()`) gets `currentColor` ink, because
41
+ * there is nothing to measure — set `labelColor` then. */
41
42
  fill?: string;
42
43
  /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):
43
44
  * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,
@@ -146,6 +147,22 @@ function useChartTokenHex(): string[] {
146
147
  return joined.split(",");
147
148
  }
148
149
 
150
+ const CHART_TOKEN_REF = /^var\(\s*--chart-(\d+)\s*\)$/;
151
+
152
+ /**
153
+ * The hex a tile fill measures as: the fill itself when it is hex, the live token when
154
+ * it is a bare `var(--chart-N)` (what `paletteFor` returns), otherwise null. Without
155
+ * the token case a hand-built recharts map coloured with the kit's own `paletteFor`
156
+ * got `currentColor` ink — dark page text on dark indigo in the light theme.
157
+ */
158
+ function measurableFill(fill: string, tokens: readonly string[]): string | null {
159
+ if (parseHex(fill)) return fill;
160
+ const m = CHART_TOKEN_REF.exec(fill.trim());
161
+ if (!m) return null;
162
+ const hex = tokens[Number(m[1]) - 1];
163
+ return hex && parseHex(hex) ? hex : null;
164
+ }
165
+
149
166
  // ── Tile ──────────────────────────────────────────────────────────────────
150
167
 
151
168
  const LABEL_FONT_SIZE = 12;
@@ -214,6 +231,8 @@ export function TreemapCell({
214
231
  nodeNote,
215
232
  dir = "ltr",
216
233
  }: TreemapCellProps) {
234
+ // Before the early return: a hook has to run on every render of the cell.
235
+ const tokenHex = useChartTokenHex();
217
236
  // Depth 0 is recharts' synthetic root, which spans the whole chart.
218
237
  if (!depth) return null;
219
238
  // Whole pixels: a rect on a half pixel renders its edge across two half-lit
@@ -223,7 +242,13 @@ export function TreemapCell({
223
242
  const pw = Math.round(width);
224
243
  const ph = Math.round(height);
225
244
  const tileFill = fill ?? "var(--chart-1)";
226
- const ink = labelColor ?? textOn(tileFill);
245
+ const measured = measurableFill(tileFill, tokenHex);
246
+ const ink = labelColor ?? (measured ? textOn(measured) : "currentColor");
247
+ // The halo only on an OPAQUE fill we could measure. A translucent fill (a
248
+ // `color-mix(… transparent)`) composited twice — once as the tile, once as the halo
249
+ // over it — draws a darker ring round every glyph, which read as doubled text on the
250
+ // faded tiles. `none` still overrides the inherited chart `stroke`.
251
+ const halo = measured ? tileFill : "none";
227
252
  // Below ~44x20 there is no room for a readable word, so no label at all rather than
228
253
  // a one-letter stub bleeding over the tile edge.
229
254
  const label = pw >= 44 && ph >= 20 ? fitLabel(name ?? "", pw, LABEL_FONT_SIZE) : null;
@@ -284,7 +309,7 @@ export function TreemapCell({
284
309
  y={py + 15}
285
310
  direction={dir}
286
311
  fill={ink}
287
- stroke={tileFill}
312
+ stroke={halo}
288
313
  strokeWidth={2.5}
289
314
  strokeLinejoin="round"
290
315
  paintOrder="stroke"
@@ -302,7 +327,7 @@ export function TreemapCell({
302
327
  direction={dir}
303
328
  fill={ink}
304
329
  fillOpacity={0.85}
305
- stroke={tileFill}
330
+ stroke={halo}
306
331
  strokeWidth={2.5}
307
332
  strokeLinejoin="round"
308
333
  paintOrder="stroke"
@@ -2,6 +2,7 @@ import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
3
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
+ import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
5
6
  import { horizontalStep } from "../lib/direction";
6
7
  import { useMediaQuery } from "../hooks/use-media-query";
7
8
  import { Tooltip, type TooltipSide } from "./tooltip";
@@ -1290,9 +1291,20 @@ export const FIELD_DISPLAY =
1290
1291
  // adjacent controls). Pair the labelled case with a static FieldLabel.
1291
1292
  // `relative` so the trigger can host an absolutely-centred FieldChevron / clear
1292
1293
  // button the way the native Select does.
1294
+ //
1295
+ // `[&>:first-child]:min-h-[1lh]` holds one line box open whatever the value is. A
1296
+ // trigger with nothing chosen and no placeholder renders an EMPTY value span, an
1297
+ // empty span has no line box, and the trigger collapsed to its padding — 18px
1298
+ // tall in a stack of 38px fields (an RhfCombobox without a placeholder; DatePicker
1299
+ // and MonthPicker had met the same thing as Keksdose live #294 and patched their
1300
+ // own span with a U+00A0). Here, on the shared class, it covers every trigger
1301
+ // built on it, including a `renderTrigger` one. `1lh` rather than a fixed height
1302
+ // so it tracks whatever type the trigger is set in; the first child is the value
1303
+ // in every trigger here (the chevron and the clear button are absolute).
1293
1304
  export const FIELD_TRIGGER = cn(
1294
1305
  FIELD_BASE,
1295
1306
  "relative flex items-center justify-between gap-2 text-start hover:bg-[var(--bg-hover)]",
1307
+ "[&>:first-child]:min-h-[1lh]",
1296
1308
  );
1297
1309
 
1298
1310
  /** An alias rather than an interface: the chevron adds nothing of its own to an
@@ -1871,7 +1883,11 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
1871
1883
  invalid?: boolean;
1872
1884
  /** See {@link Input}'s `error`. */
1873
1885
  error?: ReactNode;
1874
- /** A {@link FieldHint} for the label line — see {@link FloatingField}. */
1886
+ /** A {@link FieldHint} for the label line — see {@link FloatingField}. Plain
1887
+ * TEXT (a string or a number) is a caption instead, and goes UNDER the field
1888
+ * like every other field's hint, attached through `aria-describedby`: the label
1889
+ * line is 11px of strip shared with the label, and a sentence placed there was
1890
+ * set in the value's type on top of both the label and the value. */
1875
1891
  hint?: ReactNode;
1876
1892
  /**
1877
1893
  * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
@@ -1911,12 +1927,33 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1911
1927
  const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);
1912
1928
  const small = size === "sm" && label === undefined;
1913
1929
  const fieldId = id ?? generated;
1930
+ const hintId = useId();
1931
+ // Text is a caption under the field; anything else (a FieldHint) rides the label
1932
+ // line. See `hint` above. An empty string is no caption at all.
1933
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
1914
1934
  const { isInvalid, describedBy, errorEl } = useFieldError(
1915
1935
  error,
1916
1936
  invalid,
1917
- rest["aria-describedby"],
1937
+ // The caption goes in BEFORE the error — the standing advice first, the news
1938
+ // second, the order useFieldError keeps for a caller's own description.
1939
+ textHint
1940
+ ? rest["aria-describedby"]
1941
+ ? `${rest["aria-describedby"]} ${hintId}`
1942
+ : hintId
1943
+ : rest["aria-describedby"],
1918
1944
  rest["aria-invalid"],
1919
1945
  );
1946
+ const below =
1947
+ textHint || errorEl !== null ? (
1948
+ <>
1949
+ {textHint && (
1950
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
1951
+ {hint}
1952
+ </p>
1953
+ )}
1954
+ {errorEl}
1955
+ </>
1956
+ ) : null;
1920
1957
  // Custom chevron (native arrow hidden via appearance-none) so it sits a touch
1921
1958
  // in from the end border and matches both themes — feedback #223. A DISABLED
1922
1959
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
@@ -1927,10 +1964,17 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1927
1964
  <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
1928
1965
  );
1929
1966
  // `pe-9` is the room the chevron takes; a list box has none to make room for.
1930
- const dress = listBox ? "overflow-y-auto" : "appearance-none pe-9";
1967
+ //
1968
+ // A list box's height is `size` rows plus its padding, but its rows are clipped at
1969
+ // the PADDING edge, not the content edge — so FIELD_BASE's `pb-2` showed the next
1970
+ // row through the bottom padding: `size={4}` drew four and a half. The vertical room
1971
+ // moves onto the rows instead (option padding counts toward the row height the
1972
+ // browser sizes the box by), and the box keeps no bottom padding: exactly `size`
1973
+ // whole rows, at any width.
1974
+ const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
1931
1975
  if (label === undefined) {
1932
1976
  return (
1933
- <FieldGroup errorEl={errorEl}>
1977
+ <FieldGroup errorEl={below}>
1934
1978
  <div className={cn("relative", className)}>
1935
1979
  <select
1936
1980
  ref={ref}
@@ -1953,6 +1997,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1953
1997
  className={cn(
1954
1998
  FIELD_BASE,
1955
1999
  dress,
2000
+ listBox && "py-0",
1956
2001
  small && SELECT_SM,
1957
2002
  selectClassName,
1958
2003
  isInvalid && FIELD_INVALID,
@@ -1966,8 +2011,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1966
2011
  );
1967
2012
  }
1968
2013
  return (
1969
- <FieldGroup errorEl={errorEl}>
1970
- <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={hint}>
2014
+ <FieldGroup errorEl={below}>
2015
+ <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={textHint ? undefined : hint}>
1971
2016
  <select
1972
2017
  ref={ref}
1973
2018
  id={fieldId}
@@ -1978,8 +2023,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1978
2023
  className={cn(
1979
2024
  FIELD_BASE,
1980
2025
  // The floated label takes the top strip of a list box too, so its first
1981
- // row starts under the label rather than behind it.
1982
- listBox ? "pt-5 pb-1" : FIELD_FLOATING_PAD,
2026
+ // row starts under the label rather than behind it. No bottom padding — see
2027
+ // `dress`.
2028
+ listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
1983
2029
  "peer",
1984
2030
  dress,
1985
2031
  selectClassName,
@@ -2695,6 +2741,10 @@ const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-[var(--bo
2695
2741
  const TAB_ADD_CLASSES =
2696
2742
  "inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
2697
2743
  const TAB_ADD_WRAP_CLASSES = "px-2.5 text-xs md:px-3 md:text-sm";
2744
+ // `relative`: the phone's visually hidden label needs a positioned ancestor (see the
2745
+ // sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone "+".
2746
+ const TAB_ADD_PHONE_CLASSES = "relative max-md:px-2";
2747
+
2698
2748
 
2699
2749
  export function Tabs<T extends string>({
2700
2750
  tabs,
@@ -2719,6 +2769,18 @@ export function Tabs<T extends string>({
2719
2769
  const phone = useMediaQuery(PHONE_QUERY, false);
2720
2770
  const vertical = orientation === "vertical" && !phone;
2721
2771
  const stripRef = useRef<HTMLDivElement>(null);
2772
+ const fade = useStripFade(stripRef);
2773
+ // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
2774
+ // deep link to the seventh report. A selection behind the cut edge was the worst
2775
+ // case of the overflow above: the one tab the panel below belongs to, sliced to
2776
+ // "80 km," under the add button. Only the strip scrolls (`scrollBy` on it, not
2777
+ // `scrollIntoView`, which would scroll the page as well), and only as far as needed
2778
+ // to clear the fade.
2779
+ useLayoutEffect(() => {
2780
+ const strip = stripRef.current;
2781
+ const tab = strip?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
2782
+ if (strip && tab) scrollIntoStrip(strip, tab);
2783
+ }, [active]);
2722
2784
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
2723
2785
  // Whether Delete reaches this tab: every removable tab, or only the open one.
2724
2786
  const deletesOnKey = (tab: TabItem<T>) =>
@@ -2821,6 +2883,11 @@ export function Tabs<T extends string>({
2821
2883
  // Only when vertical: `horizontal` is the tablist's implicit value, and the
2822
2884
  // attribute on every existing strip would be noise in every snapshot of them.
2823
2885
  aria-orientation={vertical ? "vertical" : undefined}
2886
+ // Merged over a caller's own style rather than replacing it; see useStripFade.
2887
+ // `data-overflow` names the cut ends in reading-direction terms, for a caller's
2888
+ // own affordance (a scroll button) and for tests, which cannot read the mask.
2889
+ style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}
2890
+ data-overflow={fade.overflow}
2824
2891
  // With an add button the strip gains an outer box, and `className` goes there
2825
2892
  // — it is the box a caller's margin or width is meant for.
2826
2893
  className={cn(
@@ -2892,7 +2959,16 @@ export function Tabs<T extends string>({
2892
2959
  {/* A separator for the NAME only — whitespace between flex items is
2893
2960
  not drawn, and without it the two lines ran together into one
2894
2961
  word for a screen reader ("80 km/hloop 2"). */}{" "}
2895
- <span className="text-[11px] font-normal leading-tight text-[var(--text-muted)]">
2962
+ <span
2963
+ className={cn(
2964
+ "text-[11px] font-normal leading-tight text-[var(--text-muted)]",
2965
+ // On a wrapped strip's filled chip (below `md`) the muted grey
2966
+ // was dark text on the brand fill — "loop 2" all but vanished
2967
+ // under the one label that most needed reading. It takes the
2968
+ // chip's own ink instead, as the label and the × already do.
2969
+ wrap && isActive && !vertical && "text-[var(--brand-contrast)] md:text-[var(--text-muted)]",
2970
+ )}
2971
+ >
2896
2972
  {tab.detail}
2897
2973
  </span>
2898
2974
  </span>
@@ -3013,10 +3089,14 @@ export function Tabs<T extends string>({
3013
3089
  type="button"
3014
3090
  onClick={onAdd}
3015
3091
  disabled={busy}
3016
- className={cn(TAB_ADD_CLASSES, wrap && TAB_ADD_WRAP_CLASSES)}
3092
+ className={cn(TAB_ADD_CLASSES, wrap ? TAB_ADD_WRAP_CLASSES : TAB_ADD_PHONE_CLASSES)}
3017
3093
  >
3018
3094
  <Plus aria-hidden />
3019
- {addLabel ?? text.add}
3095
+ {/* Below md a scrolling strip keeps the room for tabs: the label is visually
3096
+ hidden and the "+" carries it as its name. Beside "+ Add loop" a 390px strip
3097
+ showed the open tab and a sliver of the next (showcase audit). A wrapping
3098
+ strip has its own row there, so it keeps the label. */}
3099
+ <span className={wrap ? undefined : "max-md:sr-only"}>{addLabel ?? text.add}</span>
3020
3100
  </button>
3021
3101
  </div>
3022
3102
  </div>
@@ -53,6 +53,9 @@ export interface FeedbackDialogTextLabels {
53
53
  category: string;
54
54
  subject: string;
55
55
  body: string;
56
+ /** The body's label when `requireBody={false}`: "What happened? (optional)".
57
+ * Optional, so a complete `UiKitLabels` typed before 0.14.2 still compiles. */
58
+ bodyOptional?: string;
56
59
  /** The heading over the attachment buttons. */
57
60
  attachment: string;
58
61
  /** The line beside the buttons naming the shortcut. */
@@ -66,6 +69,7 @@ export const DEFAULT_FEEDBACK_DIALOG_LABELS: FeedbackDialogTextLabels = {
66
69
  category: "Category",
67
70
  subject: "Subject",
68
71
  body: "What happened?",
72
+ bodyOptional: "What happened? (optional)",
69
73
  attachment: "Screenshot",
70
74
  submitHint: "Ctrl/⌘ + Enter to send",
71
75
  cancel: "Cancel",
@@ -108,6 +112,7 @@ export function FeedbackDialog({
108
112
  maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
109
113
  onAttachmentError,
110
114
  onCaptureScreenshot,
115
+ requireBody = true,
111
116
  }: {
112
117
  open: boolean;
113
118
  onClose: () => void;
@@ -129,6 +134,12 @@ export function FeedbackDialog({
129
134
  * attachment"; the returned file is fed through the same validation + preview.
130
135
  */
131
136
  onCaptureScreenshot?: () => Promise<File | null>;
137
+ /**
138
+ * `false`: a report with only a subject can be sent, and the body's label says it is
139
+ * optional (`bodyOptional`, unless `labels.body` is passed). Default `true`, the
140
+ * dialog's behaviour until 0.14.2 (keksdose K1: its backend takes title-only reports).
141
+ */
142
+ requireBody?: boolean;
132
143
  }) {
133
144
  const [title, setTitle] = useState("");
134
145
  const [body, setBody] = useState("");
@@ -151,7 +162,9 @@ export function FeedbackDialog({
151
162
  }
152
163
  }
153
164
 
154
- const canSubmit = !!title && !!body && !submitting;
165
+ // Trimmed: a subject or body of spaces is empty (keksdose's API answers a blank title
166
+ // with a 422, and a blank report is no report).
167
+ const canSubmit = !!title.trim() && (!requireBody || !!body.trim()) && !submitting;
155
168
  const trySubmit = () => {
156
169
  if (canSubmit) void onSubmit({ title, body, category, attachment });
157
170
  };
@@ -174,7 +187,12 @@ export function FeedbackDialog({
174
187
  <Input label={labels.subject} value={title} onChange={(e) => setTitle(e.target.value)} variant="display" />
175
188
  );
176
189
  const bodyField = (
177
- <Textarea rows={5} label={labels.body} value={body} onChange={(e) => setBody(e.target.value)} />
190
+ <Textarea
191
+ rows={5}
192
+ label={requireBody ? labels.body : (labelsProp?.body ?? labels.bodyOptional ?? labels.body)}
193
+ value={body}
194
+ onChange={(e) => setBody(e.target.value)}
195
+ />
178
196
  );
179
197
  const attachmentField = (
180
198
  <FeedbackAttachmentField
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import type { ReactNode, Ref } from "react";
3
- import { Paperclip, Send } from "lucide-react";
3
+ import { ImageIcon, ImageOff, Paperclip, Send } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { formatDate, formatRelativeTime } from "../lib/format";
6
6
  import { Button, EmptyState, Spinner, Textarea } from "../components/ui";
@@ -336,18 +336,7 @@ function DefaultAttachment({
336
336
  size: (bytes: number) => string;
337
337
  }) {
338
338
  const { name, url, type } = attachment;
339
- if (url && type?.startsWith("image/")) {
340
- return (
341
- <a href={url} target="_blank" rel="noopener noreferrer" className="block">
342
- <img
343
- src={url}
344
- alt={name}
345
- loading="lazy"
346
- className="h-20 w-20 rounded border border-[var(--border)] object-cover"
347
- />
348
- </a>
349
- );
350
- }
339
+ if (url && type?.startsWith("image/")) return <ImageThumbnail url={url} name={name} />;
351
340
  const content = (
352
341
  <>
353
342
  <Paperclip className="size-3.5 shrink-0" aria-hidden />
@@ -368,6 +357,40 @@ function DefaultAttachment({
368
357
  );
369
358
  }
370
359
 
360
+ /** An image attachment's thumbnail. The box is drawn before the picture arrives —
361
+ * `loading="lazy"` leaves it empty until it nears the viewport, and a URL that has
362
+ * expired never fills it — and an empty bordered square read as a broken layout, not
363
+ * as a picture. So the box carries an image glyph underneath until the picture
364
+ * covers it, and swaps it for a broken-image glyph when the load fails. */
365
+ function ImageThumbnail({ url, name }: { url: string; name: string }) {
366
+ // Keyed on the URL, so a message whose attachment is replaced tries the new one.
367
+ const [failedUrl, setFailedUrl] = useState<string | null>(null);
368
+ const failed = failedUrl === url;
369
+ const Glyph = failed ? ImageOff : ImageIcon;
370
+ return (
371
+ <a
372
+ href={url}
373
+ target="_blank"
374
+ rel="noopener noreferrer"
375
+ className="relative block h-20 w-20 overflow-hidden rounded border border-[var(--border)] bg-[var(--bg-surface-2)]"
376
+ >
377
+ <Glyph aria-hidden className="absolute inset-0 m-auto size-6 text-[var(--text-muted)]" />
378
+ {!failed && (
379
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- load/error are resource events, not interactions
380
+ <img
381
+ src={url}
382
+ alt={name}
383
+ loading="lazy"
384
+ onError={() => setFailedUrl(url)}
385
+ className="relative h-full w-full object-cover"
386
+ />
387
+ )}
388
+ {/* The <img> and its alt are gone once it failed; the link still needs a name. */}
389
+ {failed && <span className="sr-only">{name}</span>}
390
+ </a>
391
+ );
392
+ }
393
+
371
394
  /** The time, refreshed once a minute while `active`, so "2 minutes ago" does not stay
372
395
  * "just now" on a page left open. Read in state rather than in render, which must be
373
396
  * pure. */
@@ -26,6 +26,7 @@ export function uiKitLabelsDe(numberLocale = "de-DE"): UiKitLabels {
26
26
  category: "Kategorie",
27
27
  subject: "Betreff",
28
28
  body: "Was ist passiert?",
29
+ bodyOptional: "Was ist passiert? (optional)",
29
30
  attachment: "Screenshot",
30
31
  submitHint: "Strg/⌘ + Enter zum Senden",
31
32
  cancel: "Abbrechen",
@@ -27,6 +27,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
27
27
  category: "Categoría",
28
28
  subject: "Asunto",
29
29
  body: "¿Qué ha pasado?",
30
+ bodyOptional: "¿Qué ha pasado? (opcional)",
30
31
  attachment: "Captura de pantalla",
31
32
  submitHint: "Ctrl/⌘ + Intro para enviar",
32
33
  cancel: "Cancelar",
@@ -27,6 +27,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
27
27
  category: "Catégorie",
28
28
  subject: "Objet",
29
29
  body: "Que s’est-il passé\u202f?",
30
+ bodyOptional: "Que s’est-il passé\u202f? (facultatif)",
30
31
  attachment: "Capture d’écran",
31
32
  submitHint: "Ctrl/⌘ + Entrée pour envoyer",
32
33
  cancel: "Annuler",
@@ -26,6 +26,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
26
26
  category: "Kategória",
27
27
  subject: "Tárgy",
28
28
  body: "Mi történt?",
29
+ bodyOptional: "Mi történt? (nem kötelező)",
29
30
  attachment: "Képernyőkép",
30
31
  submitHint: "Ctrl/⌘ + Enter a küldéshez",
31
32
  cancel: "Mégse",
@@ -27,6 +27,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
27
27
  category: "Categoria",
28
28
  subject: "Oggetto",
29
29
  body: "Cosa è successo?",
30
+ bodyOptional: "Cosa è successo? (facoltativo)",
30
31
  attachment: "Screenshot",
31
32
  submitHint: "Ctrl/⌘ + Invio per inviare",
32
33
  cancel: "Annulla",
@@ -26,6 +26,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
26
26
  category: "类别",
27
27
  subject: "主题",
28
28
  body: "发生了什么?",
29
+ bodyOptional: "发生了什么?(可选)",
29
30
  attachment: "截图",
30
31
  submitHint: "Ctrl/⌘ + Enter 发送",
31
32
  cancel: "取消",
package/src/index.ts CHANGED
@@ -83,6 +83,7 @@ export * from "./components/numpad-sheet";
83
83
  export * from "./components/number-input";
84
84
  export * from "./components/currency-select";
85
85
  export * from "./components/amount-input";
86
+ export * from "./components/money-field";
86
87
  export * from "./components/combobox";
87
88
  export * from "./components/picker-sheet";
88
89
  export * from "./components/entity-combobox";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The full-document navigations the kit makes, behind one object the tests can spy on.
3
+ *
4
+ * jsdom implements neither `location.reload` nor `location.replace`, and its
5
+ * `window.location` is unforgeable: a test cannot stub it by redefining the property
6
+ * once the suite runs each file in its own VM context (`pool: "vmThreads"`, which took
7
+ * the suite from ~3 minutes to ~1). `vi.spyOn(documentNavigation, "reload")` works in
8
+ * every pool. Internal, not part of the package's surface.
9
+ */
10
+ export const documentNavigation = {
11
+ reload(): void {
12
+ window.location.reload();
13
+ },
14
+ replace(href: string): void {
15
+ window.location.replace(href);
16
+ },
17
+ };
@@ -0,0 +1,82 @@
1
+ import { useLayoutEffect, useState, type RefObject } from "react";
2
+
3
+ // How far the fade at a scrolled strip's cut edge reaches, and how much room a tab
4
+ // brought into view keeps from that edge so the fade never lies on top of it.
5
+ export const TAB_FADE_PX = 24;
6
+
7
+ /**
8
+ * Shared by Tabs' strip and AppShell's scrolling phone sub-nav; internal, not part of
9
+ * the package's surface.
10
+ *
11
+ * Which ends of a sideways-scrolling strip have tabs cut off behind them, as the
12
+ * `mask-image` that fades those ends out — or `undefined` while everything fits.
13
+ *
14
+ * A strip that overflowed used to stop dead at its edge: the last tab sliced through
15
+ * mid-word, and nothing on a phone (whose scrollbars are overlays that only show
16
+ * while you drag) said there was more to reach. A fade is the cue — the letters
17
+ * thin out rather than end — and it is measured, not assumed, so a strip that fits
18
+ * is painted exactly as before. Physical `left`/`right` in the gradient, because
19
+ * `mask-image` has no logical directions; the reading direction is read from the
20
+ * element, and `scrollLeft` is a magnitude here because an RTL strip counts it
21
+ * negative from the start edge.
22
+ */
23
+ export function useStripFade(
24
+ ref: RefObject<HTMLElement | null>,
25
+ ): { mask: string | undefined; overflow: "start" | "end" | "both" | undefined } {
26
+ const [edges, setEdges] = useState({ left: false, right: false, rtl: false });
27
+ useLayoutEffect(() => {
28
+ const el = ref.current;
29
+ if (!el) return;
30
+ const measure = () => {
31
+ const hidden = el.scrollWidth - el.clientWidth;
32
+ const scrolled = Math.abs(el.scrollLeft);
33
+ const rtl = getComputedStyle(el).direction === "rtl";
34
+ const atStart = scrolled > 1;
35
+ const atEnd = hidden - scrolled > 1;
36
+ const left = rtl ? atEnd : atStart;
37
+ const right = rtl ? atStart : atEnd;
38
+ setEdges((prev) =>
39
+ prev.left === left && prev.right === right && prev.rtl === rtl ? prev : { left, right, rtl },
40
+ );
41
+ };
42
+ measure();
43
+ el.addEventListener("scroll", measure, { passive: true });
44
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
45
+ observer?.observe(el);
46
+ // The tabs, too: a label or a badge that grows changes the overflow without
47
+ // resizing the strip's own box.
48
+ for (const child of Array.from(el.children)) observer?.observe(child);
49
+ return () => {
50
+ el.removeEventListener("scroll", measure);
51
+ observer?.disconnect();
52
+ };
53
+ });
54
+ if (!edges.left && !edges.right) return { mask: undefined, overflow: undefined };
55
+ const l = edges.left ? `transparent, #000 ${TAB_FADE_PX}px` : "#000";
56
+ const r = edges.right ? `#000 calc(100% - ${TAB_FADE_PX}px), transparent` : "#000";
57
+ const rtl = edges.rtl;
58
+ const start = rtl ? edges.right : edges.left;
59
+ const end = rtl ? edges.left : edges.right;
60
+ return {
61
+ mask: `linear-gradient(to right, ${l}, ${r})`,
62
+ overflow: start && end ? "both" : start ? "start" : "end",
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Scroll `item` into view inside `strip` — the strip only (`scrollBy` on it, never
68
+ * `scrollIntoView`, which would scroll the page too) — far enough to clear the fade.
69
+ * A no-op when the strip fits or the item is already clear of both edges.
70
+ */
71
+ export function scrollIntoStrip(strip: HTMLElement, item: HTMLElement): void {
72
+ if (strip.scrollWidth <= strip.clientWidth) return;
73
+ const s = strip.getBoundingClientRect();
74
+ const t = item.getBoundingClientRect();
75
+ const delta =
76
+ t.left < s.left + TAB_FADE_PX
77
+ ? t.left - s.left - TAB_FADE_PX
78
+ : t.right > s.right - TAB_FADE_PX
79
+ ? t.right - s.right + TAB_FADE_PX
80
+ : 0;
81
+ if (delta !== 0) strip.scrollBy({ left: delta });
82
+ }
package/src/rhf/form.tsx CHANGED
@@ -197,7 +197,9 @@ export function FormItem({ className, ...rest }: FormItemProps) {
197
197
  const value = useMemo(() => ({ id, mounted, mark }), [id, mounted, mark]);
198
198
  return (
199
199
  <FormItemContext.Provider value={value}>
200
- <div data-slot="form-item" {...rest} className={cn("grid gap-1", className)} />
200
+ {/* `content-start`: stretched to a taller neighbour's height in a grid row, the
201
+ item gave the extra to its rows and its control sat lower (showcase audit). */}
202
+ <div data-slot="form-item" {...rest} className={cn("grid content-start gap-1", className)} />
201
203
  </FormItemContext.Provider>
202
204
  );
203
205
  }