@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.
- package/dist/chart.d.ts +2 -1
- package/dist/components/alert-banner.js +5 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +1 -1
- package/dist/components/autocomplete.js +9 -2
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +6 -3
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/calendar-heatmap.js +5 -5
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +3 -3
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +12 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -0
- package/dist/components/field.js +4 -1
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/image-grid.d.ts +8 -1
- package/dist/components/image-grid.js +16 -3
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/measured-grid.js +2 -2
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +5 -2
- package/dist/components/number-input.js +11 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/series-chart-budget.d.ts +60 -0
- package/dist/components/series-chart-budget.js +56 -0
- package/dist/components/series-chart-budget.js.map +1 -0
- package/dist/components/series-chart.d.ts +54 -2
- package/dist/components/series-chart.js +104 -20
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/slider.js +6 -2
- package/dist/components/slider.js.map +1 -1
- package/dist/components/stat-tile.js +3 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/treemap.d.ts +3 -2
- package/dist/components/treemap.js +14 -3
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +44 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-thread.js +31 -12
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
- package/dist/lib/strip-fade.d.ts +31 -0
- package/dist/lib/strip-fade.js +54 -0
- package/dist/lib/strip-fade.js.map +1 -0
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf/form.js +1 -1
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +11 -2
- package/dist/shell/app-shell.js +20 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +14 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +10 -2
- package/src/components/autocomplete.tsx +10 -1
- package/src/components/bulk-action-bar.tsx +12 -3
- package/src/components/calendar-heatmap.tsx +10 -5
- package/src/components/data-table-filter-popover.tsx +6 -3
- package/src/components/data-table.tsx +21 -7
- package/src/components/field.tsx +4 -1
- package/src/components/image-grid.tsx +30 -2
- package/src/components/measured-grid.tsx +3 -2
- package/src/components/multi-select.tsx +3 -0
- package/src/components/number-input.tsx +23 -4
- package/src/components/series-chart-budget.ts +121 -0
- package/src/components/series-chart.tsx +201 -15
- package/src/components/slider.tsx +6 -2
- package/src/components/stat-tile.tsx +8 -3
- package/src/components/treemap.tsx +30 -5
- package/src/components/ui.tsx +91 -11
- package/src/feedback/feedback-thread.tsx +36 -13
- package/src/lib/strip-fade.ts +82 -0
- package/src/rhf/form.tsx +3 -1
- package/src/shell/app-shell.tsx +34 -7
- 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
|
-
|
|
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
|
}
|
package/src/shell/app-shell.tsx
CHANGED
|
@@ -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
|
-
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
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
|
|
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 */}
|