@eifi1/ui-kit 0.14.2 → 0.15.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 (118) hide show
  1. package/dist/chart.d.ts +2 -1
  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 +1 -1
  5. package/dist/components/autocomplete.js +9 -2
  6. package/dist/components/autocomplete.js.map +1 -1
  7. package/dist/components/bulk-action-bar.js +6 -3
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +1 -1
  10. package/dist/components/calculator.d.ts +1 -1
  11. package/dist/components/calendar-heatmap.js +5 -5
  12. package/dist/components/calendar-heatmap.js.map +1 -1
  13. package/dist/components/copy-button.d.ts +1 -1
  14. package/dist/components/data-table-cells.d.ts +1 -1
  15. package/dist/components/data-table-filter-popover.d.ts +1 -1
  16. package/dist/components/data-table-filter-popover.js +3 -3
  17. package/dist/components/data-table-filter-popover.js.map +1 -1
  18. package/dist/components/data-table-filters.d.ts +1 -1
  19. package/dist/components/data-table.d.ts +1 -1
  20. package/dist/components/data-table.js +12 -4
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/facing-pair.d.ts +1 -0
  23. package/dist/components/field.js +4 -1
  24. package/dist/components/field.js.map +1 -1
  25. package/dist/components/file-button.d.ts +1 -1
  26. package/dist/components/file-dropzone.d.ts +1 -1
  27. package/dist/components/form-actions.d.ts +1 -1
  28. package/dist/components/image-grid.d.ts +8 -1
  29. package/dist/components/image-grid.js +16 -3
  30. package/dist/components/image-grid.js.map +1 -1
  31. package/dist/components/measured-grid.js +2 -2
  32. package/dist/components/measured-grid.js.map +1 -1
  33. package/dist/components/money-field.d.ts +1 -1
  34. package/dist/components/multi-select.js +3 -0
  35. package/dist/components/multi-select.js.map +1 -1
  36. package/dist/components/number-field.d.ts +1 -1
  37. package/dist/components/number-input.d.ts +5 -2
  38. package/dist/components/number-input.js +11 -3
  39. package/dist/components/number-input.js.map +1 -1
  40. package/dist/components/numpad-sheet.d.ts +1 -1
  41. package/dist/components/series-chart-budget.d.ts +60 -0
  42. package/dist/components/series-chart-budget.js +56 -0
  43. package/dist/components/series-chart-budget.js.map +1 -0
  44. package/dist/components/series-chart.d.ts +54 -2
  45. package/dist/components/series-chart.js +104 -20
  46. package/dist/components/series-chart.js.map +1 -1
  47. package/dist/components/settings-fields.d.ts +1 -1
  48. package/dist/components/slider.js +6 -2
  49. package/dist/components/slider.js.map +1 -1
  50. package/dist/components/stat-tile.js +3 -3
  51. package/dist/components/stat-tile.js.map +1 -1
  52. package/dist/components/text-link.d.ts +1 -1
  53. package/dist/components/time-input.d.ts +1 -1
  54. package/dist/components/treemap.d.ts +3 -2
  55. package/dist/components/treemap.js +14 -3
  56. package/dist/components/treemap.js.map +1 -1
  57. package/dist/components/ui.d.ts +1 -1
  58. package/dist/components/ui.js +44 -10
  59. package/dist/components/ui.js.map +1 -1
  60. package/dist/components/use-table-state.d.ts +1 -1
  61. package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
  62. package/dist/data-table.d.ts +1 -1
  63. package/dist/feedback/feedback-thread.js +31 -12
  64. package/dist/feedback/feedback-thread.js.map +1 -1
  65. package/dist/hooks/use-file-drop.d.ts +1 -1
  66. package/dist/i18n/defaults.d.ts +1 -1
  67. package/dist/i18n/kit-labels.d.ts +1 -1
  68. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  69. package/dist/i18n/locales/de-CH.d.ts +1 -1
  70. package/dist/i18n/locales/de-informal.d.ts +1 -1
  71. package/dist/i18n/locales/de.d.ts +1 -1
  72. package/dist/i18n/locales/es.d.ts +1 -1
  73. package/dist/i18n/locales/fr.d.ts +1 -1
  74. package/dist/i18n/locales/hu.d.ts +1 -1
  75. package/dist/i18n/locales/it.d.ts +1 -1
  76. package/dist/i18n/locales/zh.d.ts +1 -1
  77. package/dist/index.d.ts +5 -4
  78. package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
  79. package/dist/lib/strip-fade.d.ts +31 -0
  80. package/dist/lib/strip-fade.js +54 -0
  81. package/dist/lib/strip-fade.js.map +1 -0
  82. package/dist/rhf/fields.d.ts +1 -1
  83. package/dist/rhf/form.d.ts +1 -1
  84. package/dist/rhf/form.js +1 -1
  85. package/dist/rhf/form.js.map +1 -1
  86. package/dist/rhf.d.ts +1 -1
  87. package/dist/shell/app-shell.d.ts +11 -2
  88. package/dist/shell/app-shell.js +20 -4
  89. package/dist/shell/app-shell.js.map +1 -1
  90. package/dist/shell/top-bar-brand.d.ts +1 -1
  91. package/dist/shell.d.ts +1 -1
  92. package/dist/wizard/stepper-nav.d.ts +1 -1
  93. package/dist/wizard/stepper-nav.js +14 -2
  94. package/dist/wizard/stepper-nav.js.map +1 -1
  95. package/dist/wizard.d.ts +1 -1
  96. package/package.json +1 -1
  97. package/src/components/alert-banner.tsx +10 -2
  98. package/src/components/autocomplete.tsx +10 -1
  99. package/src/components/bulk-action-bar.tsx +12 -3
  100. package/src/components/calendar-heatmap.tsx +10 -5
  101. package/src/components/data-table-filter-popover.tsx +6 -3
  102. package/src/components/data-table.tsx +21 -7
  103. package/src/components/field.tsx +4 -1
  104. package/src/components/image-grid.tsx +30 -2
  105. package/src/components/measured-grid.tsx +3 -2
  106. package/src/components/multi-select.tsx +3 -0
  107. package/src/components/number-input.tsx +23 -4
  108. package/src/components/series-chart-budget.ts +121 -0
  109. package/src/components/series-chart.tsx +201 -15
  110. package/src/components/slider.tsx +6 -2
  111. package/src/components/stat-tile.tsx +8 -3
  112. package/src/components/treemap.tsx +30 -5
  113. package/src/components/ui.tsx +91 -11
  114. package/src/feedback/feedback-thread.tsx +36 -13
  115. package/src/lib/strip-fade.ts +82 -0
  116. package/src/rhf/form.tsx +3 -1
  117. package/src/shell/app-shell.tsx +34 -7
  118. package/src/wizard/stepper-nav.tsx +23 -2
@@ -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. */
@@ -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
  }
@@ -1,10 +1,11 @@
1
- import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
1
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { Link, NavLink, matchPath, useLocation } from "react-router";
5
5
  import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
6
6
  import type { LucideIcon } from "lucide-react";
7
7
  import { cn } from "../lib/cn";
8
+ import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
8
9
  import { dirOf, type Direction } from "../lib/direction";
9
10
  import { readStored, writeStored } from "../lib/safe-storage";
10
11
  import { Tooltip } from "../components/tooltip";
@@ -140,6 +141,15 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
140
141
  * bottom bar — the phone's counterpart of the sidebar's second level. Default true;
141
142
  * `false` keeps the single bar, where a group entry only links to its own `to`. */
142
143
  mobileSubNav?: boolean;
144
+ /**
145
+ * How the phone sub-nav lays out a group with more pages than fit on one row.
146
+ * `"wrap"` (default): every page in view, on as many rows as it takes — the bar
147
+ * grows. `"scroll"`: one row that scrolls sideways, faded at a cut edge, with the
148
+ * current page scrolled into view. A long group took four rows (~130px) of a 390px
149
+ * screen above the bottom bar (showcase audit); scrolling gives that back at the
150
+ * price of pages hidden past the edge, so it is the app's call, per shell.
151
+ */
152
+ mobileSubNavLayout?: "wrap" | "scroll";
143
153
  /**
144
154
  * Lay the shell out inside its PARENT'S box instead of the viewport: `h-full` rather
145
155
  * than `h-dvh`, its own scroll container at every width, a bottom bar pinned to the
@@ -255,6 +265,7 @@ export function AppShell({
255
265
  expandLabel,
256
266
  subNav = "flyout",
257
267
  mobileSubNav = true,
268
+ mobileSubNavLayout = "wrap",
258
269
  toggleGroupLabel,
259
270
  embedded: embeddedProp,
260
271
  className,
@@ -433,7 +444,7 @@ export function AppShell({
433
444
  ref={navRef}
434
445
  className="md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]"
435
446
  >
436
- {mobileSubNav && <MobileSubNav nav={nav} />}
447
+ {mobileSubNav && <MobileSubNav nav={nav} layout={mobileSubNavLayout} />}
437
448
  <nav
438
449
  data-tour="nav"
439
450
  className="grid"
@@ -586,8 +597,17 @@ function MobileNavItem({ item }: { item: AppShellNavItem }) {
586
597
  * It slides in over 200ms (fade only under reduced motion) and wraps onto more rows
587
598
  * rather than scrolling, so every page of the group is in view at once.
588
599
  */
589
- function MobileSubNav({ nav }: { nav: AppShellNavItem[] }) {
600
+ function MobileSubNav({ nav, layout }: { nav: AppShellNavItem[]; layout: "wrap" | "scroll" }) {
590
601
  const { pathname } = useLocation();
602
+ const scroll = layout === "scroll";
603
+ const listRef = useRef<HTMLUListElement>(null);
604
+ const fade = useStripFade(listRef);
605
+ // The current page into view — the row only, never the page.
606
+ useLayoutEffect(() => {
607
+ const list = listRef.current;
608
+ const current = list?.querySelector<HTMLElement>('[aria-current="page"]');
609
+ if (scroll && list && current) scrollIntoStrip(list, current);
610
+ }, [scroll, pathname]);
591
611
  const group = nav.find(
592
612
  (item) =>
593
613
  !!item.items?.length &&
@@ -604,10 +624,17 @@ function MobileSubNav({ nav }: { nav: AppShellNavItem[] }) {
604
624
  className="animate-subnav border-b border-[var(--border)]"
605
625
  >
606
626
  <ul
607
- // Wraps onto as many rows as the group needs rather than scrolling sideways: a
608
- // sideways row hides pages past the edge, and every page of the group in view
609
- // is the point of the row. The bar grows with it — `--app-nav-h` measures it.
610
- className="flex flex-wrap gap-1.5 px-3 py-2"
627
+ ref={listRef}
628
+ // `wrap` (default) takes as many rows as the group needs: a sideways row hides
629
+ // pages past the edge, and every page of the group in view is the point of the
630
+ // row. The bar grows with it — `--app-nav-h` measures it. `scroll` is the app's
631
+ // opt-in trade: one row, the cut edge faded (see mobileSubNavLayout).
632
+ className={cn(
633
+ "flex gap-1.5 px-3 py-2",
634
+ scroll ? "overflow-x-auto overscroll-x-contain [scrollbar-width:none]" : "flex-wrap",
635
+ )}
636
+ data-overflow={scroll ? fade.overflow : undefined}
637
+ style={scroll && fade.mask ? { maskImage: fade.mask, WebkitMaskImage: fade.mask } : undefined}
611
638
  >
612
639
  {group.items!.map((sub) => (
613
640
  <li key={sub.to} className="shrink-0">
@@ -1,4 +1,4 @@
1
- import { useId, useMemo } from "react";
1
+ import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import type { ReactElement, ReactNode } from "react";
3
3
  import { Check } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
@@ -88,6 +88,25 @@ export function StepperNav<TData extends Record<string, unknown>>({
88
88
  const l = useKitLabels("wizard", DEFAULT_WIZARD_LABELS, labels);
89
89
  const cancelTitleId = useId();
90
90
 
91
+ // The step area never shrinks below the tallest step shown so far, so the button
92
+ // bar does not jump up and down as Next and Back move between a long step and a
93
+ // short one. This replaces a fixed `min-h-[300px]` (carried over from Kastlan,
94
+ // whose steps are all long forms), which did the same for steps under 300px but
95
+ // left a short wizard — two fields a step — with a ~200px hole above its buttons.
96
+ // Measured rather than guessed: the floor is whatever the app's own steps need.
97
+ const contentRef = useRef<HTMLDivElement>(null);
98
+ const [contentFloor, setContentFloor] = useState(0);
99
+ useLayoutEffect(() => {
100
+ const el = contentRef.current;
101
+ if (!el || typeof ResizeObserver === "undefined") return;
102
+ const observer = new ResizeObserver(() => {
103
+ const h = el.offsetHeight;
104
+ setContentFloor((floor) => (h > floor ? h : floor));
105
+ });
106
+ observer.observe(el);
107
+ return () => observer.disconnect();
108
+ }, []);
109
+
91
110
  const contextValue = useMemo(
92
111
  () => ({
93
112
  registerStepValidate: wizard.registerStepValidate,
@@ -235,7 +254,9 @@ export function StepperNav<TData extends Record<string, unknown>>({
235
254
 
236
255
  {/* Step content */}
237
256
  <WizardContextProvider value={contextValue}>
238
- <div className="min-h-[300px]">{children}</div>
257
+ <div style={contentFloor > 0 ? { minHeight: contentFloor } : undefined}>
258
+ <div ref={contentRef}>{children}</div>
259
+ </div>
239
260
  </WizardContextProvider>
240
261
 
241
262
  {/* Submit error */}