@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.
- package/README.md +4 -3
- package/dist/components/alert-banner.js +5 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +9 -1
- package/dist/components/amount-input.js +10 -4
- package/dist/components/amount-input.js.map +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/error-boundary.js +2 -1
- package/dist/components/error-boundary.js.map +1 -1
- 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 +92 -0
- package/dist/components/money-field.js +40 -0
- package/dist/components/money-field.js.map +1 -0
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +13 -2
- package/dist/components/number-field.js +4 -0
- package/dist/components/number-field.js.map +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/page-header.js +29 -3
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/series-chart.js +37 -8
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +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/text-link.js +2 -1
- package/dist/components/text-link.js.map +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-dialog.d.ts +10 -1
- package/dist/feedback/feedback-dialog.js +13 -3
- package/dist/feedback/feedback-dialog.js.map +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/de.js +1 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +1 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +1 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +1 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +1 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +1 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
- package/dist/lib/document-navigation.d.ts +15 -0
- package/dist/lib/document-navigation.js +13 -0
- package/dist/lib/document-navigation.js.map +1 -0
- 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 +7 -4
- package/src/components/alert-banner.tsx +10 -2
- package/src/components/amount-input.tsx +18 -4
- 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/error-boundary.tsx +2 -1
- 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/money-field.tsx +75 -0
- package/src/components/multi-select.tsx +3 -0
- package/src/components/number-field.tsx +14 -0
- package/src/components/number-input.tsx +23 -4
- package/src/components/page-header.tsx +38 -3
- package/src/components/series-chart.tsx +56 -8
- package/src/components/stat-tile.tsx +8 -3
- package/src/components/text-link.tsx +2 -1
- package/src/components/treemap.tsx +30 -5
- package/src/components/ui.tsx +91 -11
- package/src/feedback/feedback-dialog.tsx +20 -2
- package/src/feedback/feedback-thread.tsx +36 -13
- package/src/i18n/locales/de.ts +1 -0
- package/src/i18n/locales/es.ts +1 -0
- package/src/i18n/locales/fr.ts +1 -0
- package/src/i18n/locales/hu.ts +1 -0
- package/src/i18n/locales/it.ts +1 -0
- package/src/i18n/locales/zh.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/document-navigation.ts +17 -0
- 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
|
@@ -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
|
-
|
|
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;
|
|
40
|
-
*
|
|
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
|
|
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={
|
|
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={
|
|
330
|
+
stroke={halo}
|
|
306
331
|
strokeWidth={2.5}
|
|
307
332
|
strokeLinejoin="round"
|
|
308
333
|
paintOrder="stroke"
|
package/src/components/ui.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
|
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
|
|
3092
|
+
className={cn(TAB_ADD_CLASSES, wrap ? TAB_ADD_WRAP_CLASSES : TAB_ADD_PHONE_CLASSES)}
|
|
3017
3093
|
>
|
|
3018
3094
|
<Plus aria-hidden />
|
|
3019
|
-
{
|
|
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
|
-
|
|
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
|
|
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. */
|
package/src/i18n/locales/de.ts
CHANGED
|
@@ -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",
|
package/src/i18n/locales/es.ts
CHANGED
|
@@ -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",
|
package/src/i18n/locales/fr.ts
CHANGED
|
@@ -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",
|
package/src/i18n/locales/hu.ts
CHANGED
|
@@ -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",
|
package/src/i18n/locales/it.ts
CHANGED
|
@@ -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",
|
package/src/i18n/locales/zh.ts
CHANGED
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
|
-
|
|
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
|
}
|