@cosmicdrift/kumiko-renderer-web 0.334.0 → 0.336.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/dist/app/app-schema-boot.d.ts +2 -0
- package/dist/app/app-schema-boot.js +13 -4
- package/dist/app/create-app.js +28 -24
- package/dist/app/dashboard-body.js +51 -16
- package/dist/layout/nav-tree.js +1 -1
- package/dist/layout/shell-header.js +9 -3
- package/dist/layout/theme-label.d.ts +9 -0
- package/dist/layout/theme-label.js +11 -0
- package/dist/layout/theme-menu-item.js +2 -7
- package/dist/layout/theme-toggle.js +2 -8
- package/dist/primitives/combobox.js +1 -1
- package/dist/primitives/embedded-list-input.js +1 -1
- package/dist/primitives/index.js +104 -44
- package/dist/primitives/layout.d.ts +1 -0
- package/dist/primitives/layout.js +7 -0
- package/dist/primitives/metric.d.ts +1 -0
- package/dist/primitives/metric.js +6 -1
- package/dist/primitives/narrow-pinned-footer.d.ts +10 -0
- package/dist/primitives/narrow-pinned-footer.js +90 -0
- package/dist/primitives/number-input.d.ts +2 -1
- package/dist/primitives/number-input.js +2 -2
- package/dist/primitives/use-narrow-viewport.d.ts +1 -0
- package/dist/primitives/use-narrow-viewport.js +20 -8
- package/dist/widgets/charts.js +2 -1
- package/dist/widgets/dashboard-list.d.ts +3 -2
- package/dist/widgets/dashboard-list.js +9 -3
- package/dist/widgets/drawer.js +6 -19
- package/dist/widgets/step-bar.d.ts +10 -4
- package/dist/widgets/step-bar.js +40 -15
- package/package.json +7 -7
- package/src/changes.json +79 -0
package/dist/primitives/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { InsideDrawerProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalTranslation, useTranslation, WriteFailedError, } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalTranslation, useTranslation, WriteFailedError, } from "@cosmicdrift/kumiko-renderer";
|
|
4
4
|
import { cva } from "class-variance-authority";
|
|
5
5
|
import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
|
|
6
6
|
import QRCode from "qrcode";
|
|
@@ -27,12 +27,13 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
|
|
|
27
27
|
import { EmbeddedListInput } from "./embedded-list-input.js";
|
|
28
28
|
import { FileUploadInput } from "./file-upload.js";
|
|
29
29
|
import { DefaultJsonView } from "./json-view.js";
|
|
30
|
-
import { screenPaddingClassName, screenWidthClassName } from "./layout.js";
|
|
30
|
+
import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
|
|
31
31
|
import { DefaultLightbox } from "./lightbox.js";
|
|
32
32
|
import { LocatedTimestampInput } from "./located-timestamp-input.js";
|
|
33
|
-
import { DefaultMetric, DefaultMetricBand } from "./metric.js";
|
|
33
|
+
import { DefaultMetric, DefaultMetricBand, InsideScreenFormColumnContext } from "./metric.js";
|
|
34
34
|
import { DefaultModal } from "./modal.js";
|
|
35
35
|
import { currencyDecimals, formatMoney, MoneyInput } from "./money-input.js";
|
|
36
|
+
import { NarrowPinnedFooter } from "./narrow-pinned-footer.js";
|
|
36
37
|
import { NumberInput } from "./number-input.js";
|
|
37
38
|
import { groupOptions, hasDescriptionOrGroup } from "./option-groups.js";
|
|
38
39
|
import { DefaultPageHeader } from "./page-header.js";
|
|
@@ -44,7 +45,7 @@ import { DefaultTabs } from "./tabs.js";
|
|
|
44
45
|
import { TimestampInput } from "./timestamp-input.js";
|
|
45
46
|
import { useToast } from "./toast.js";
|
|
46
47
|
import { TzInput } from "./tz-input.js";
|
|
47
|
-
import { useIsNarrowViewport } from "./use-narrow-viewport.js";
|
|
48
|
+
import { useIsBelowSmViewport, useIsNarrowViewport } from "./use-narrow-viewport.js";
|
|
48
49
|
// ---- Card-Chrome (eine Definition für Form/Section/Card) ----
|
|
49
50
|
// Die eine Card-Surface. Maße (Padding/Radius/Shadow) kommen aus --card-*
|
|
50
51
|
// CSS-Tokens (Defaults in styles.css), damit eine App sie einmal zentral in
|
|
@@ -84,12 +85,15 @@ const BUTTON_SIZE = {
|
|
|
84
85
|
function actionIconFor(icon) {
|
|
85
86
|
return icon !== undefined && Object.hasOwn(NAV_ICONS, icon) ? icon : undefined;
|
|
86
87
|
}
|
|
88
|
+
// Shared by DefaultButton's `secondary` variant and triggers that need Radix `asChild`
|
|
89
|
+
// prop forwarding (which DefaultButton does not support), so both stay visually identical.
|
|
90
|
+
const SECONDARY_BUTTON_EXTRA_CLASSES = "border-input hover:bg-muted";
|
|
87
91
|
function DefaultButton({ type = "button", onClick, disabled, loading, variant = "primary", size = "md", ariaLabel, title, pressed, expanded, width = "auto", children, testId, className, ref, icon, iconEnd, dataAttributes, }) {
|
|
88
92
|
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
|
|
89
93
|
// gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
|
|
90
94
|
const resolvedClassName = cn(variant === "link" ? "h-auto px-0 py-0" : "", variant === "danger-ghost"
|
|
91
95
|
? "text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
92
|
-
: "", variant === "ghost" ? "text-primary hover:text-primary hover:bg-primary/10" : "", variant === "secondary" ?
|
|
96
|
+
: "", variant === "ghost" ? "text-primary hover:text-primary hover:bg-primary/10" : "", variant === "secondary" ? SECONDARY_BUTTON_EXTRA_CLASSES : "", pressed === true
|
|
93
97
|
? "border-primary/40 bg-accent text-accent-foreground ring-1 ring-primary/30"
|
|
94
98
|
: "", width === "full" ? "w-full" : "", className);
|
|
95
99
|
const IconStart = icon !== undefined ? NAV_ICONS[icon] : undefined;
|
|
@@ -196,19 +200,27 @@ function withFieldIcon(icon, input) {
|
|
|
196
200
|
// Mirrors withFieldIcon on the right side: a muted, non-interactive unit
|
|
197
201
|
// suffix rendered inside the input's visual box. Pure decoration — never
|
|
198
202
|
// focusable, never touches the input's value.
|
|
199
|
-
|
|
203
|
+
// The unit changes the meaning of the value ("58 mi" vs "58 km"), so it is
|
|
204
|
+
// exposed as the input's description rather than hidden from assistive tech.
|
|
205
|
+
function unitSuffixId(inputId) {
|
|
206
|
+
return `${inputId}-unit`;
|
|
207
|
+
}
|
|
208
|
+
function withUnitSuffix(inputId, unit, input) {
|
|
200
209
|
if (unit === undefined)
|
|
201
210
|
return input;
|
|
202
|
-
return (_jsxs("div", { className: "relative", children: [input, _jsx("span", {
|
|
211
|
+
return (_jsxs("div", { className: "relative", children: [input, _jsx("span", { id: unitSuffixId(inputId), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: unit })] }));
|
|
203
212
|
}
|
|
204
213
|
const TRAILING_ACTION_ICONS = { clear: X, undo: Undo2 };
|
|
205
214
|
// In-field icon button on the right (same look as the date input's calendar
|
|
206
215
|
// trigger). The wrapped input must reserve the room itself (`pr-9`).
|
|
207
|
-
|
|
208
|
-
|
|
216
|
+
// `keepWrapper` holds the tree shape when the action comes and goes — a
|
|
217
|
+
// write-only input loses its action on the first keystroke, and swapping the
|
|
218
|
+
// wrapper would remount the input and drop focus mid-typing.
|
|
219
|
+
function withTrailingAction(action, input, keepWrapper = false) {
|
|
220
|
+
if (action === undefined && !keepWrapper)
|
|
209
221
|
return input;
|
|
210
|
-
const ActionIcon = TRAILING_ACTION_ICONS[action.icon];
|
|
211
|
-
return (_jsxs("div", { className: "relative", children: [input, _jsx("button", { type: "button", "aria-label": action.label, title: action.label, "data-testid": action.testId, onClick: action.onPress, className: inFieldIconButtonClass, children: _jsx(ActionIcon, { className: "size-4", "aria-hidden": "true" }) })] }));
|
|
222
|
+
const ActionIcon = action !== undefined ? TRAILING_ACTION_ICONS[action.icon] : undefined;
|
|
223
|
+
return (_jsxs("div", { className: "relative", children: [input, action !== undefined && ActionIcon !== undefined && (_jsx("button", { type: "button", "aria-label": action.label, title: action.label, "data-testid": action.testId, onClick: action.onPress, className: inFieldIconButtonClass, children: _jsx(ActionIcon, { className: "size-4", "aria-hidden": "true" }) }))] }));
|
|
212
224
|
}
|
|
213
225
|
// Default presentation for `kind: "select"` without a `display` of its own
|
|
214
226
|
// (board rule): up to 3 short options read as a segmented control, up to 3
|
|
@@ -235,6 +247,9 @@ function withoutSegmentedSelectPlaceholder(options) {
|
|
|
235
247
|
// segment (or the first when none is checked) sits in the tab order.
|
|
236
248
|
function SegmentedSelect({ id, name, value, onChange, options, disabled, required, hasError, }) {
|
|
237
249
|
const buttonRefs = useRef([]);
|
|
250
|
+
// An unknown/legacy value checks no segment; the first one must still be tabbable.
|
|
251
|
+
const selectedIndex = options.findIndex((opt) => opt.value === value);
|
|
252
|
+
const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
|
|
238
253
|
const selectAt = (index) => {
|
|
239
254
|
const target = options[index];
|
|
240
255
|
if (target === undefined)
|
|
@@ -256,7 +271,7 @@ function SegmentedSelect({ id, name, value, onChange, options, disabled, require
|
|
|
256
271
|
const checked = opt.value === value;
|
|
257
272
|
return (_jsx("button", { ref: (node) => {
|
|
258
273
|
buttonRefs.current[index] = node;
|
|
259
|
-
}, type: "button", role: "radio", "aria-checked": checked, tabIndex:
|
|
274
|
+
}, type: "button", role: "radio", "aria-checked": checked, tabIndex: index === tabStopIndex ? 0 : -1, disabled: disabled, "data-testid": `segmented-${id}-${opt.value}`, onClick: () => onChange(opt.value), onKeyDown: (e) => handleKeyDown(e, index), className: cn(
|
|
260
275
|
// Per-segment top/left border instead of the container's
|
|
261
276
|
// `divide-x`: divide-x only draws verticals between siblings
|
|
262
277
|
// in source order, which misplaces borders the moment a
|
|
@@ -320,9 +335,9 @@ function DefaultInput(props) {
|
|
|
320
335
|
case "email":
|
|
321
336
|
return (_jsx(UiInput, { type: "email", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "email" }));
|
|
322
337
|
case "password":
|
|
323
|
-
return withTrailingAction(props.trailingAction, _jsx(UiInput, { type: "password", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "current-password", className: props.trailingAction !== undefined ? "pr-9" : undefined }));
|
|
338
|
+
return withTrailingAction(props.trailingAction, _jsx(UiInput, { type: "password", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "current-password", className: props.trailingAction !== undefined ? "pr-9" : undefined }), true);
|
|
324
339
|
case "number":
|
|
325
|
-
return withUnitSuffix(props.unit, withFieldIcon(props.icon, _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) })));
|
|
340
|
+
return withUnitSuffix(props.id, props.unit, withFieldIcon(props.icon, _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.unit !== undefined && { ariaDescribedBy: unitSuffixId(props.id) }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) })));
|
|
326
341
|
case "range":
|
|
327
342
|
return (_jsx("input", { type: "range", ...common, min: props.min, max: props.max, step: props.step ?? 1, value: props.value, onChange: (e) => props.onChange(Number(e.target.value)), className: "w-full accent-primary" }));
|
|
328
343
|
case "boolean":
|
|
@@ -416,6 +431,11 @@ function DefaultInput(props) {
|
|
|
416
431
|
}),
|
|
417
432
|
onKeyDown: (e) => {
|
|
418
433
|
onKeyDown?.(e);
|
|
434
|
+
// Lets a caller that already handles Ctrl/Cmd+Enter (e.g. an
|
|
435
|
+
// "Enter sends" handler that ignores only Shift) opt out of the
|
|
436
|
+
// shortcut instead of submitting twice.
|
|
437
|
+
if (e.defaultPrevented)
|
|
438
|
+
return;
|
|
419
439
|
if (onSubmitShortcut === undefined || e.key !== "Enter" || !(e.metaKey || e.ctrlKey))
|
|
420
440
|
return;
|
|
421
441
|
e.preventDefault();
|
|
@@ -771,6 +791,7 @@ function isUnlabeledFalse(type, value, renderer) {
|
|
|
771
791
|
return (typeof renderer === "object" &&
|
|
772
792
|
renderer !== null &&
|
|
773
793
|
"format" in renderer &&
|
|
794
|
+
renderer.format === "boolean" &&
|
|
774
795
|
!("falseLabel" in renderer));
|
|
775
796
|
}
|
|
776
797
|
// True when the cell would render the default "✓": no renderer, or a boolean
|
|
@@ -810,15 +831,6 @@ function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }) {
|
|
|
810
831
|
// (während async onTrigger läuft) + confirm-pending-Action. Beide Sub-
|
|
811
832
|
// Components hatten denselben State-Block dupliziert + parallel zur
|
|
812
833
|
// Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
|
|
813
|
-
//
|
|
814
|
-
// The rule: an explicit confirm OR style=danger opens the dialog,
|
|
815
|
-
// everything else fires straight through.
|
|
816
|
-
// `confirmRequired` overrides the danger-implies-confirm default (e.g.
|
|
817
|
-
// schema-driven navigate/drawer actions where the target form is itself
|
|
818
|
-
// the confirmation).
|
|
819
|
-
function needsConfirm(action) {
|
|
820
|
-
return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
821
|
-
}
|
|
822
834
|
function useRowActionTrigger(row) {
|
|
823
835
|
const [busy, setBusy] = useState(false);
|
|
824
836
|
const { toast } = useToast();
|
|
@@ -869,7 +881,7 @@ function RowActionButton({ row, action, iconOnly = false, asLink = false, }) {
|
|
|
869
881
|
: "text-foreground hover:bg-accent";
|
|
870
882
|
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-testid": `row-${row.id}-action-${action.id}`, disabled: busy, ...(showIconOnly && { "aria-label": action.label, title: action.label }), onClick: (e) => {
|
|
871
883
|
e.stopPropagation();
|
|
872
|
-
if (
|
|
884
|
+
if (needsActionConfirm(action)) {
|
|
873
885
|
setConfirmOpen(true);
|
|
874
886
|
}
|
|
875
887
|
else {
|
|
@@ -890,10 +902,15 @@ function RowActionsKebab({ row, actions, }) {
|
|
|
890
902
|
const t = useTranslation();
|
|
891
903
|
const [pendingConfirm, setPendingConfirm] = useState(null);
|
|
892
904
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
893
|
-
return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("kumiko.list.row-actions.more"), "data-testid": `row-${row.id}-actions-menu`, className: cn("inline-flex size-7 items-center justify-center rounded-md max-md:size-11", "hover:bg-accent text-muted-foreground hover:text-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"), children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end",
|
|
905
|
+
return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("kumiko.list.row-actions.more"), "data-testid": `row-${row.id}-actions-menu`, className: cn("inline-flex size-7 items-center justify-center rounded-md max-md:size-11", "hover:bg-accent text-muted-foreground hover:text-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"), children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end",
|
|
906
|
+
// Radix refocuses the trigger after the exit animation, which by then sits behind the confirm dialog's aria-hidden.
|
|
907
|
+
onCloseAutoFocus: (e) => {
|
|
908
|
+
if (pendingConfirm !== null)
|
|
909
|
+
e.preventDefault();
|
|
910
|
+
}, children: actions.map((action) => (_jsx(DropdownMenuItem, { "data-testid": `row-${row.id}-action-${action.id}`, onSelect: (e) => {
|
|
894
911
|
e.preventDefault();
|
|
895
912
|
setMenuOpen(false);
|
|
896
|
-
if (
|
|
913
|
+
if (needsActionConfirm(action)) {
|
|
897
914
|
setPendingConfirm(action);
|
|
898
915
|
}
|
|
899
916
|
else {
|
|
@@ -914,7 +931,7 @@ function RowActionsKebab({ row, actions, }) {
|
|
|
914
931
|
// the DataTableRowAction schema (callers own confirm/danger handling per item).
|
|
915
932
|
function ActionOverflowMenu({ items, label, testId }) {
|
|
916
933
|
const [open, setOpen] = useState(false);
|
|
917
|
-
return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(UiButton, { type: "button", variant: "outline", size: "icon", className:
|
|
934
|
+
return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: SECONDARY_BUTTON_EXTRA_CLASSES, "aria-label": label, "data-testid": testId ?? "action-overflow-menu-trigger", children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: items.map((item) => {
|
|
918
935
|
const resolvedIcon = actionIconFor(item.icon);
|
|
919
936
|
return (_jsxs(DropdownMenuItem, { "data-testid": item.testId ?? `${testId ?? "action-overflow-menu"}-item-${item.id}`, disabled: item.disabled === true, onSelect: (e) => {
|
|
920
937
|
e.preventDefault();
|
|
@@ -1073,6 +1090,8 @@ function isMoneyValue(value) {
|
|
|
1073
1090
|
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
1074
1091
|
// - text/else → toString
|
|
1075
1092
|
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
|
|
1093
|
+
// Deduplicated per column key only (not per screen): a once-per-field-name
|
|
1094
|
+
// hint, so a second screen reusing the same field name stays silent.
|
|
1076
1095
|
const warnedTimestampColumns = new Set();
|
|
1077
1096
|
// A raw ISO string in a "text" column means the author forgot `renderer:
|
|
1078
1097
|
// { format: "timestamp" }` — guessing a format here could be wrong, so we
|
|
@@ -1290,12 +1309,31 @@ export function ScreenWidthProvider({ width, children, }) {
|
|
|
1290
1309
|
// onSubmit instead of activating the real ancestor <form>.
|
|
1291
1310
|
function FormRoot({ onSubmit, testId, className, children, }) {
|
|
1292
1311
|
if (useContext(InsideFormContext)) {
|
|
1293
|
-
return (_jsx("div", {
|
|
1312
|
+
return (_jsx("div", { "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
|
|
1313
|
+
// Implicit submission: the section's inputs belong to the OUTER
|
|
1314
|
+
// <form> in the DOM, so the browser would otherwise route Enter to
|
|
1315
|
+
// that form's default button.
|
|
1316
|
+
if (e.key !== "Enter" || e.nativeEvent.isComposing)
|
|
1317
|
+
return;
|
|
1318
|
+
const field = e.target;
|
|
1319
|
+
if (!(field instanceof HTMLInputElement))
|
|
1320
|
+
return;
|
|
1321
|
+
if (field.closest("[data-kumiko-form-root]") !== e.currentTarget)
|
|
1322
|
+
return;
|
|
1323
|
+
if (["checkbox", "radio", "submit", "button"].includes(field.type))
|
|
1324
|
+
return;
|
|
1325
|
+
e.preventDefault();
|
|
1326
|
+
onSubmit();
|
|
1327
|
+
}, onClickCapture: (e) => {
|
|
1294
1328
|
// @cast-boundary dom-event-target: closest() needs an Element, and
|
|
1295
1329
|
// click targets are always one in the browser/happy-dom.
|
|
1296
1330
|
const submitButton = e.target.closest("button[type=submit], button:not([type])");
|
|
1297
1331
|
if (submitButton === null)
|
|
1298
1332
|
return;
|
|
1333
|
+
// React events bubble through portals and nested degraded roots; a
|
|
1334
|
+
// real <form> only reacts to buttons in its own DOM subtree.
|
|
1335
|
+
if (submitButton.closest("[data-kumiko-form-root]") !== e.currentTarget)
|
|
1336
|
+
return;
|
|
1299
1337
|
e.preventDefault();
|
|
1300
1338
|
onSubmit();
|
|
1301
1339
|
}, "data-testid": testId, className: className, children: children }));
|
|
@@ -1368,10 +1406,18 @@ function flattenActionNodes(node) {
|
|
|
1368
1406
|
}
|
|
1369
1407
|
return Children.toArray(node);
|
|
1370
1408
|
}
|
|
1409
|
+
function unsavedStatusText(t, unsavedCount) {
|
|
1410
|
+
if (unsavedCount === 0)
|
|
1411
|
+
return t("kumiko.form.noUnsavedChanges");
|
|
1412
|
+
if (unsavedCount === 1)
|
|
1413
|
+
return t("kumiko.form.unsaved.one");
|
|
1414
|
+
return t("kumiko.form.unsaved.other", { count: unsavedCount });
|
|
1415
|
+
}
|
|
1371
1416
|
// Footer wrapper for DefaultForm's card and chromeless layouts alike — only
|
|
1372
1417
|
// the card-derived horizontal padding/border differs between them.
|
|
1373
1418
|
function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActions, fillHeight, unsavedCount, railed = false, }) {
|
|
1374
1419
|
const t = useTranslation();
|
|
1420
|
+
const isBelowSm = useIsBelowSmViewport();
|
|
1375
1421
|
if (actions === undefined && secondaryActions === undefined)
|
|
1376
1422
|
return null;
|
|
1377
1423
|
// Only split when sticky: the non-sticky (regular, non-wizard) footer must
|
|
@@ -1392,16 +1438,20 @@ function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActio
|
|
|
1392
1438
|
const hasNonPrimaryOverflow = nonPrimaryActionNodes !== undefined && nonPrimaryActionNodes.length > 0;
|
|
1393
1439
|
const renderedSecondary = stickyActions === true && hasPrimaryAction ? (_jsxs(_Fragment, { children: [secondaryActions, nonPrimaryActionNodes] })) : (secondaryActions);
|
|
1394
1440
|
const pinned = isPinnedFooter(stickyActions, fillHeight);
|
|
1441
|
+
if (pinned && isBelowSm) {
|
|
1442
|
+
return (_jsx(NarrowPinnedFooter, { nodes: [...flattenActionNodes(secondaryActions), ...flattenActionNodes(actions)], overflowLabel: unsavedCount !== undefined && unsavedCount > 0
|
|
1443
|
+
? `${t("kumiko.list.row-actions.more")}, ${unsavedStatusText(t, unsavedCount)}`
|
|
1444
|
+
: t("kumiko.list.row-actions.more"), unsavedCount: unsavedCount, testId: testId }));
|
|
1445
|
+
}
|
|
1395
1446
|
return (_jsxs("div", { className: cn(pinned
|
|
1396
1447
|
? cn("flex h-14 shrink-0 items-center justify-end gap-2 border-t border-border bg-card px-6", !railed && "md:pl-10")
|
|
1397
1448
|
: "flex flex-col-reverse gap-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4", !pinned && !chromeless && "px-[var(--card-padding)]", !pinned && !chromeless && cardFooterBorder, !pinned && fillHeight === true && "shrink-0"), children: [unsavedCount !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-unsaved` : undefined, className: cn("mr-auto flex min-w-0 items-center gap-2 text-[13px]",
|
|
1398
1449
|
// Only the stacked (flex-col-reverse) footer moves the status above the
|
|
1399
1450
|
// actions; the pinned single-row footer keeps it on the left.
|
|
1400
|
-
!pinned && "max-sm:order-last", unsavedCount > 0 ? "text-foreground-secondary" : "text-muted-foreground"), children: [unsavedCount > 0 && (_jsx("span", { className: "size-1.5 shrink-0 rounded-full bg-status-active", "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: unsavedCount
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
: t("kumiko.form.unsaved.other", { count: unsavedCount }) })] })), (secondaryActions !== undefined || hasNonPrimaryOverflow) && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions-secondary` : undefined, className: cn("flex flex-wrap items-center gap-2 max-sm:[&_button]:text-xs", pinned && hasNonPrimaryOverflow && "mr-auto"), children: renderedSecondary })), renderedActions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: cn("flex items-center gap-2", pinned
|
|
1451
|
+
!pinned && "max-sm:order-last", unsavedCount > 0 ? "text-foreground-secondary" : "text-muted-foreground"), children: [unsavedCount > 0 && (_jsx("span", { className: "size-1.5 shrink-0 rounded-full bg-status-active", "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: unsavedStatusText(t, unsavedCount) })] })), (secondaryActions !== undefined || hasNonPrimaryOverflow) && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions-secondary` : undefined, className: cn(
|
|
1452
|
+
// min-h-11: link-variant buttons (Copy link) are h-auto and would fall
|
|
1453
|
+
// below the 24px touch-target minimum on narrow viewports.
|
|
1454
|
+
"flex flex-wrap items-center gap-2 max-sm:[&_button]:min-h-11 max-sm:[&_button]:px-2 max-sm:[&_button]:text-xs", pinned && hasNonPrimaryOverflow && "mr-auto"), children: renderedSecondary })), renderedActions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: cn("flex items-center gap-2", pinned
|
|
1405
1455
|
? "shrink-0 max-md:[&>button]:min-h-11"
|
|
1406
1456
|
: "flex-wrap max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto",
|
|
1407
1457
|
// Below sm (640px): pin only the primary action to the viewport
|
|
@@ -1485,7 +1535,7 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1485
1535
|
// from xl, the section navigation; the footer stays pinned below it.
|
|
1486
1536
|
if (isScreenForm) {
|
|
1487
1537
|
const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
|
|
1488
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", width !== undefined ?
|
|
1538
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", width !== undefined ? screenFormColumnWidthClassName[width] : "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
|
|
1489
1539
|
}
|
|
1490
1540
|
// Pinned footer (fillHeight + stickyActions): the footer is a full-bleed
|
|
1491
1541
|
// sibling BELOW the padded shell, so the shell's bottom inset does not float
|
|
@@ -1578,13 +1628,16 @@ const FIELD_CELL_WIDTH_CLASS = {
|
|
|
1578
1628
|
// switch (1.15rem) gets (h-9 input − switch) / 2 margin to sit on the input line.
|
|
1579
1629
|
toggle: "w-auto min-w-40 shrink-0 self-start [&_[data-slot=switch]]:my-[0.55rem]",
|
|
1580
1630
|
};
|
|
1581
|
-
function DefaultGrid({ columns, children, testId, maxRows, flow, list }) {
|
|
1631
|
+
function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabelledBy, }) {
|
|
1582
1632
|
const insideDrawerBody = useContext(DrawerBodyContext);
|
|
1633
|
+
const groupProps = ariaLabelledBy !== undefined
|
|
1634
|
+
? { role: "group", "aria-labelledby": ariaLabelledBy }
|
|
1635
|
+
: {};
|
|
1583
1636
|
if (list === true) {
|
|
1584
|
-
return (_jsx("div", { "data-testid": testId, className: "flex flex-col divide-y divide-border", children: children }));
|
|
1637
|
+
return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-col divide-y divide-border", children: children }));
|
|
1585
1638
|
}
|
|
1586
1639
|
if (flow === true) {
|
|
1587
|
-
return (_jsx("div", { "data-testid": testId, className: cn("flex gap-4", insideDrawerBody
|
|
1640
|
+
return (_jsx("div", { ...groupProps, "data-testid": testId, className: cn("flex gap-4", insideDrawerBody
|
|
1588
1641
|
? "flex-col items-start"
|
|
1589
1642
|
: // items-end aligns inputs when labels wrap to different heights; a
|
|
1590
1643
|
// below-control appendix grows only the one cell and would push its
|
|
@@ -1595,7 +1648,7 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list }) {
|
|
|
1595
1648
|
// self-sized tiles) instead of N equal-width, container-stretched tracks.
|
|
1596
1649
|
// maxRows/scrolling don't apply — the row just wraps.
|
|
1597
1650
|
if (columns === "auto") {
|
|
1598
|
-
return (_jsx("div", { "data-testid": testId, className: "flex flex-wrap items-center gap-4", children: children }));
|
|
1651
|
+
return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-wrap items-center gap-4", children: children }));
|
|
1599
1652
|
}
|
|
1600
1653
|
// Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
|
|
1601
1654
|
// die Author-deklarierte Spaltenzahl. Inline-style schreibt
|
|
@@ -1623,7 +1676,7 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list }) {
|
|
|
1623
1676
|
}),
|
|
1624
1677
|
// workaround: duplicate @types/react instances break direct CSSProperties cast
|
|
1625
1678
|
};
|
|
1626
|
-
return (_jsx("div", { "data-testid": testId, className: "grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
|
|
1679
|
+
return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
|
|
1627
1680
|
}
|
|
1628
1681
|
function DefaultGridCell({ span, width, children }) {
|
|
1629
1682
|
const insideDrawerBody = useContext(DrawerBodyContext);
|
|
@@ -1680,8 +1733,8 @@ function DefaultLink({ href, variant = "default", target, rel, onPress, classNam
|
|
|
1680
1733
|
function DefaultProgress({ value, tone, ariaLabel, testId }) {
|
|
1681
1734
|
return _jsx(ProgressBar, { value: value, tone: tone, ariaLabel: ariaLabel, testId: testId });
|
|
1682
1735
|
}
|
|
1683
|
-
function DefaultStepBar({ steps, currentIndex, compactLabel, onStepSelect, narrowLayout, orientation, heading, description, subtitles, upNext, testId, compactTestId, }) {
|
|
1684
|
-
return (_jsx(StepBar, { steps: steps, currentIndex: currentIndex, compactLabel: compactLabel, onStepSelect: onStepSelect, narrowLayout: narrowLayout, orientation: orientation, heading: heading, description: description, subtitles: subtitles, upNext: upNext, testId: testId, compactTestId: compactTestId }));
|
|
1736
|
+
function DefaultStepBar({ steps, currentIndex, compactLabel, onStepSelect, doneSteps, selectableSteps, narrowLayout, orientation, heading, description, subtitles, upNext, testId, compactTestId, }) {
|
|
1737
|
+
return (_jsx(StepBar, { steps: steps, currentIndex: currentIndex, compactLabel: compactLabel, onStepSelect: onStepSelect, doneSteps: doneSteps, selectableSteps: selectableSteps, narrowLayout: narrowLayout, orientation: orientation, heading: heading, description: description, subtitles: subtitles, upNext: upNext, testId: testId, compactTestId: compactTestId }));
|
|
1685
1738
|
}
|
|
1686
1739
|
function DefaultWizardStepGroup({ hidden, inset, children }) {
|
|
1687
1740
|
const visibleClass = inset === true ? "flex min-w-0 flex-col gap-4 p-6" : "contents";
|
|
@@ -1715,7 +1768,7 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1715
1768
|
s.headerActions !== undefined ? (_jsxs("div", { className: cn("flex flex-col gap-3 px-[var(--card-padding)] pt-6 pb-4", fillHeight && "shrink-0"), children: [(s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined) && (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [s.title !== undefined && (_jsx("h3", { className: "text-base font-semibold leading-none tracking-tight", children: s.title })), s.subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: s.subtitle }))] }), s.headerActions] })), s.headerContent] })) : null;
|
|
1716
1769
|
const header = s.header ?? defaultHeader;
|
|
1717
1770
|
const hasHeader = header !== null && header !== undefined;
|
|
1718
|
-
const card = (_jsxs("div", { "data-slot": "card",
|
|
1771
|
+
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col", "overflow-hidden",
|
|
1719
1772
|
// Same "no flex-1" reasoning as DefaultForm's own fillHeight card
|
|
1720
1773
|
// (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
|
|
1721
1774
|
// an ancestor is itself height-constrained.
|
|
@@ -1727,7 +1780,7 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1727
1780
|
// FormSections' fillHeight === true && "min-h-0" treatment of the
|
|
1728
1781
|
// section it wraps.
|
|
1729
1782
|
fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && (_jsx("div", { className: cn(cardFooter, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
|
|
1730
|
-
return screenBody ? _jsx(FormScreenShell, { children: card }) : card;
|
|
1783
|
+
return (_jsx(InsideScreenFormColumnContext.Provider, { value: false, children: screenBody ? _jsx(FormScreenShell, { children: card }) : card }));
|
|
1731
1784
|
}
|
|
1732
1785
|
// otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
|
|
1733
1786
|
// redundancy — more resilient to camera/lighting issues than the default, no
|
|
@@ -1736,20 +1789,27 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1736
1789
|
// violate the rules of hooks.
|
|
1737
1790
|
function QrSecretValue({ value }) {
|
|
1738
1791
|
const [svg, setSvg] = useState(null);
|
|
1792
|
+
const [failed, setFailed] = useState(false);
|
|
1739
1793
|
useEffect(() => {
|
|
1740
1794
|
let cancelled = false;
|
|
1795
|
+
setFailed(false);
|
|
1741
1796
|
QRCode.toString(value, { type: "svg", errorCorrectionLevel: "H" })
|
|
1742
1797
|
.then((result) => {
|
|
1743
1798
|
if (!cancelled)
|
|
1744
1799
|
setSvg(result);
|
|
1745
1800
|
})
|
|
1746
1801
|
.catch(() => {
|
|
1747
|
-
//
|
|
1802
|
+
// A generic `qr` field may have no separate text display, so fall back to the plain value.
|
|
1803
|
+
if (!cancelled)
|
|
1804
|
+
setFailed(true);
|
|
1748
1805
|
});
|
|
1749
1806
|
return () => {
|
|
1750
1807
|
cancelled = true;
|
|
1751
1808
|
};
|
|
1752
1809
|
}, [value]);
|
|
1810
|
+
if (failed) {
|
|
1811
|
+
return (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: value }));
|
|
1812
|
+
}
|
|
1753
1813
|
if (svg === null)
|
|
1754
1814
|
return null;
|
|
1755
1815
|
return (_jsx("div", { className: "h-40 w-40",
|
|
@@ -2,6 +2,7 @@ import type { FormWidth } from "@cosmicdrift/kumiko-renderer";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
export type ScreenWidth = FormWidth;
|
|
4
4
|
export declare const screenWidthClassName: Record<ScreenWidth, string>;
|
|
5
|
+
export declare const screenFormColumnWidthClassName: Record<ScreenWidth, string>;
|
|
5
6
|
export declare const screenPaddingClassName = "px-4 pb-6 pt-5 md:px-10 md:pb-10 md:pt-7";
|
|
6
7
|
declare const STACK_GAP: {
|
|
7
8
|
readonly sm: "gap-2";
|
|
@@ -8,6 +8,13 @@ export const screenWidthClassName = {
|
|
|
8
8
|
"4xl": "max-w-4xl mx-auto",
|
|
9
9
|
full: "max-w-full",
|
|
10
10
|
};
|
|
11
|
+
// Screen-form column variant: left-aligned (no mx-auto) so the column lines up
|
|
12
|
+
// with the screen's other left-aligned content, e.g. the header region.
|
|
13
|
+
export const screenFormColumnWidthClassName = {
|
|
14
|
+
"3xl": "max-w-3xl",
|
|
15
|
+
"4xl": "max-w-4xl",
|
|
16
|
+
full: "max-w-full",
|
|
17
|
+
};
|
|
11
18
|
// Shared screen padding for every screen container (PageSection,
|
|
12
19
|
// FormScreenShell, list screens and the pinned-footer screen form). The inset
|
|
13
20
|
// is the one the settings screens use, so a list, a dashboard and a form start
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MetricBandProps, MetricProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
+
export declare const InsideScreenFormColumnContext: import("react").Context<boolean>;
|
|
3
4
|
export declare function DefaultMetricBand({ lead, subtitle, children, testId, }: MetricBandProps): ReactNode;
|
|
4
5
|
export declare function DefaultMetric({ label, value, testId, onPress }: MetricProps): ReactNode;
|
|
@@ -2,9 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
const InsideMetricBandContext = createContext(false);
|
|
5
|
+
// The screen-form column already insets and spaces its children; a band in its
|
|
6
|
+
// header region must not add its own padding on top. DefaultCard resets this
|
|
7
|
+
// to false because a card body is a different inset surface.
|
|
8
|
+
export const InsideScreenFormColumnContext = createContext(false);
|
|
5
9
|
// Column counts follow the board: 2 below md, 3 below lg, 5 from lg.
|
|
6
10
|
export function DefaultMetricBand({ lead, subtitle, children, testId, }) {
|
|
7
|
-
|
|
11
|
+
const insideScreenFormColumn = useContext(InsideScreenFormColumnContext);
|
|
12
|
+
return (_jsxs("div", { className: cn("flex shrink-0 flex-col gap-3", !insideScreenFormColumn && "px-6 pb-4 pt-3.5"), "data-testid": testId, children: [lead !== undefined && _jsx("div", { "data-kumiko-layout": "metric-band-lead", children: lead }), subtitle !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-x-2 text-[13px] text-foreground-secondary [&_a]:text-primary", children: subtitle })), _jsx(InsideMetricBandContext.Provider, { value: true, children: _jsx("dl", { className: "m-0 grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-5", children: children }) })] }));
|
|
8
13
|
}
|
|
9
14
|
export function DefaultMetric({ label, value, testId, onPress }) {
|
|
10
15
|
const insideBand = useContext(InsideMetricBandContext);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** Pinned footer below `sm`: always one fixed-height row, Back as icon on the left,
|
|
3
|
+
* everything else in a "…" popover (hidden while none of it is enabled), the primary
|
|
4
|
+
* action filling the rest. */
|
|
5
|
+
export declare function NarrowPinnedFooter({ nodes, overflowLabel, unsavedCount, testId, }: {
|
|
6
|
+
readonly nodes: readonly ReactNode[];
|
|
7
|
+
readonly overflowLabel: string;
|
|
8
|
+
readonly unsavedCount: number | undefined;
|
|
9
|
+
readonly testId: string | undefined;
|
|
10
|
+
}): ReactNode;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
4
|
+
import { MoreHorizontal } from "lucide-react";
|
|
5
|
+
import { cloneElement, isValidElement, useLayoutEffect, useState, } from "react";
|
|
6
|
+
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { Button as UiButton } from "../ui/button.js";
|
|
8
|
+
function asFooterElement(node) {
|
|
9
|
+
return isValidElement(node) ? node : undefined;
|
|
10
|
+
}
|
|
11
|
+
function isBackNode(node) {
|
|
12
|
+
return asFooterElement(node)?.props[FOOTER_ACTION_ROLE_PROP] === "back";
|
|
13
|
+
}
|
|
14
|
+
function isPrimaryNode(node) {
|
|
15
|
+
const props = asFooterElement(node)?.props;
|
|
16
|
+
return props?.[FOOTER_ACTION_ROLE_PROP] === "primary" || props?.type === "submit";
|
|
17
|
+
}
|
|
18
|
+
function isStickyMarkedNode(node) {
|
|
19
|
+
return asFooterElement(node)?.props[STICKY_PRIMARY_ACTION_PROP] === true;
|
|
20
|
+
}
|
|
21
|
+
function lastOf(items) {
|
|
22
|
+
return items[items.length - 1];
|
|
23
|
+
}
|
|
24
|
+
function splitNarrowFooterNodes(nodes) {
|
|
25
|
+
const back = nodes.find(isBackNode);
|
|
26
|
+
const primary = lastOf(nodes.filter(isPrimaryNode)) ?? lastOf(nodes.filter(isStickyMarkedNode));
|
|
27
|
+
return {
|
|
28
|
+
back,
|
|
29
|
+
primary,
|
|
30
|
+
overflow: nodes.filter((node) => node !== back && node !== primary),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function BackIconButton({ node }) {
|
|
34
|
+
const { children, ariaLabel } = node.props;
|
|
35
|
+
return cloneElement(node, {
|
|
36
|
+
size: "icon",
|
|
37
|
+
ariaLabel: typeof children === "string" ? children : ariaLabel,
|
|
38
|
+
children: null,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function PrimaryButton({ node }) {
|
|
42
|
+
const element = asFooterElement(node);
|
|
43
|
+
if (element === undefined || !isPrimaryNode(node)) {
|
|
44
|
+
return _jsx("div", { className: "min-w-0 flex-1 [&>button]:w-full", children: node });
|
|
45
|
+
}
|
|
46
|
+
const narrowLabel = element.props[NARROW_LABEL_PROP];
|
|
47
|
+
return cloneElement(element, {
|
|
48
|
+
className: "min-w-0 flex-1",
|
|
49
|
+
children: _jsx("span", { className: "min-w-0 truncate", children: narrowLabel ?? element.props.children }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Overflow nodes are arbitrary (slot mounts may render nothing, buttons may be disabled),
|
|
53
|
+
// so "has something to offer" is read from the mounted DOM, not from the elements.
|
|
54
|
+
const ENABLED_ACTION_SELECTOR = "button:not(:disabled):not([aria-disabled='true']), a[href]:not([aria-disabled='true'])";
|
|
55
|
+
function useContainsEnabledAction(container) {
|
|
56
|
+
const [containsEnabledAction, setContainsEnabledAction] = useState(false);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (container === null)
|
|
59
|
+
return;
|
|
60
|
+
const update = () => setContainsEnabledAction(container.querySelector(ENABLED_ACTION_SELECTOR) !== null);
|
|
61
|
+
update();
|
|
62
|
+
const observer = new MutationObserver(update);
|
|
63
|
+
observer.observe(container, {
|
|
64
|
+
subtree: true,
|
|
65
|
+
childList: true,
|
|
66
|
+
attributes: true,
|
|
67
|
+
attributeFilter: ["disabled", "aria-disabled", "href"],
|
|
68
|
+
});
|
|
69
|
+
return () => observer.disconnect();
|
|
70
|
+
}, [container]);
|
|
71
|
+
return containsEnabledAction;
|
|
72
|
+
}
|
|
73
|
+
function OverflowPopover({ nodes, label, unsavedCount, testId, }) {
|
|
74
|
+
const [open, setOpen] = useState(false);
|
|
75
|
+
const [content, setContent] = useState(null);
|
|
76
|
+
const hasEnabledAction = useContainsEnabledAction(content);
|
|
77
|
+
const isDirty = unsavedCount !== undefined && unsavedCount > 0;
|
|
78
|
+
return (_jsxs(PopoverPrimitive.Root, { open: open && hasEnabledAction, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs(UiButton, { type: "button", variant: "outline", size: "icon", className: cn("relative shrink-0 border-input hover:bg-muted", !hasEnabledAction && "hidden"), "aria-label": label, "data-testid": testId !== undefined ? `${testId}-overflow` : undefined, children: [_jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }), isDirty && (_jsx("span", { "aria-hidden": "true", "data-testid": testId !== undefined ? `${testId}-overflow-badge` : undefined, className: "absolute top-1 right-1 size-2 rounded-full bg-status-active" }))] }) }), _jsx(PopoverPrimitive.Portal, { forceMount: true, children: _jsx(PopoverPrimitive.Content, { ref: setContent, forceMount: true, side: "top", align: "center", sideOffset: 8, onClick: (event) => {
|
|
79
|
+
if (event.target.closest("button") !== null)
|
|
80
|
+
setOpen(false);
|
|
81
|
+
}, className: cn("z-50 flex w-[calc(100vw-2rem)] flex-col gap-1 rounded-md border border-border bg-card p-1 shadow-md", "[&_button]:w-full [&_button]:justify-start [&_button]:text-left", "data-[state=closed]:hidden"), children: nodes }) })] }));
|
|
82
|
+
}
|
|
83
|
+
/** Pinned footer below `sm`: always one fixed-height row, Back as icon on the left,
|
|
84
|
+
* everything else in a "…" popover (hidden while none of it is enabled), the primary
|
|
85
|
+
* action filling the rest. */
|
|
86
|
+
export function NarrowPinnedFooter({ nodes, overflowLabel, unsavedCount, testId, }) {
|
|
87
|
+
const { back, primary, overflow } = splitNarrowFooterNodes(nodes);
|
|
88
|
+
const backElement = asFooterElement(back);
|
|
89
|
+
return (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "flex h-14 shrink-0 items-center gap-2 border-t border-border bg-card px-4", children: [backElement !== undefined && _jsx(BackIconButton, { node: backElement }), overflow.length > 0 && (_jsx(OverflowPopover, { nodes: overflow, label: overflowLabel, unsavedCount: unsavedCount, testId: testId })), primary !== undefined && _jsx(PrimaryButton, { node: primary })] }));
|
|
90
|
+
}
|
|
@@ -13,5 +13,6 @@ export type NumberInputProps = {
|
|
|
13
13
|
readonly placeholder?: string;
|
|
14
14
|
readonly testId?: string;
|
|
15
15
|
readonly className?: string;
|
|
16
|
+
readonly ariaDescribedBy?: string;
|
|
16
17
|
};
|
|
17
|
-
export declare function NumberInput({ id, name, value, onChange, locale, grouping, integer, disabled, required, hasError, placeholder, testId, className, }: NumberInputProps): ReactNode;
|
|
18
|
+
export declare function NumberInput({ id, name, value, onChange, locale, grouping, integer, disabled, required, hasError, placeholder, testId, className, ariaDescribedBy, }: NumberInputProps): ReactNode;
|
|
@@ -11,7 +11,7 @@ import { Input as UiInput } from "../ui/input.js";
|
|
|
11
11
|
import { parseLocaleNumber, resolveSafeLocale } from "./locale-number.js";
|
|
12
12
|
// 20 is the Intl maximum: the display must never round a stored value.
|
|
13
13
|
const MAX_FRACTION_DIGITS = 20;
|
|
14
|
-
export function NumberInput({ id, name, value, onChange, locale, grouping = true, integer = false, disabled, required, hasError, placeholder, testId, className, }) {
|
|
14
|
+
export function NumberInput({ id, name, value, onChange, locale, grouping = true, integer = false, disabled, required, hasError, placeholder, testId, className, ariaDescribedBy, }) {
|
|
15
15
|
const resolvedLocale = resolveSafeLocale(locale);
|
|
16
16
|
const [focused, setFocused] = useState(false);
|
|
17
17
|
const [draft, setDraft] = useState("");
|
|
@@ -41,7 +41,7 @@ export function NumberInput({ id, name, value, onChange, locale, grouping = true
|
|
|
41
41
|
return;
|
|
42
42
|
onChange(parsed);
|
|
43
43
|
};
|
|
44
|
-
return (_jsx(UiInput, { ref: inputRef, type: "text", inputMode: integer ? "numeric" : "decimal", autoComplete: "off", id: id, name: name, disabled: disabled, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": testId, value: focused ? draft : format(grouping), onFocus: () => {
|
|
44
|
+
return (_jsx(UiInput, { ref: inputRef, type: "text", inputMode: integer ? "numeric" : "decimal", autoComplete: "off", id: id, name: name, disabled: disabled, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "aria-describedby": ariaDescribedBy, "data-testid": testId, value: focused ? draft : format(grouping), onFocus: () => {
|
|
45
45
|
setDraft(format(false));
|
|
46
46
|
setFocused(true);
|
|
47
47
|
}, onBlur: () => setFocused(false), onChange: (e) => handleChange(e.target.value), ...(placeholder !== undefined && { placeholder }), className: cn("text-left tabular-nums", className) }));
|
|
@@ -3,14 +3,22 @@ import { useSyncExternalStore } from "react";
|
|
|
3
3
|
// because that file is vendored shadcn (regenerated via scripts/sync-shadcn.ts)
|
|
4
4
|
// and cannot be imported from without risking a future overwrite.
|
|
5
5
|
const MOBILE_BREAKPOINT = 768;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
const SM_BREAKPOINT = 640;
|
|
7
|
+
// Module-level stores keep subscribe stable, so useSyncExternalStore does not
|
|
8
|
+
// resubscribe on every render (FormFooter re-renders on each keystroke).
|
|
9
|
+
function createBelowBreakpointStore(breakpoint) {
|
|
10
|
+
const query = `(max-width: ${breakpoint - 1}px)`;
|
|
11
|
+
return {
|
|
12
|
+
subscribe: (callback) => {
|
|
13
|
+
const mql = window.matchMedia(query);
|
|
14
|
+
mql.addEventListener("change", callback);
|
|
15
|
+
return () => mql.removeEventListener("change", callback);
|
|
16
|
+
},
|
|
17
|
+
getSnapshot: () => window.matchMedia(query).matches,
|
|
18
|
+
};
|
|
13
19
|
}
|
|
20
|
+
const belowMobileStore = createBelowBreakpointStore(MOBILE_BREAKPOINT);
|
|
21
|
+
const belowSmStore = createBelowBreakpointStore(SM_BREAKPOINT);
|
|
14
22
|
function getServerSnapshot() {
|
|
15
23
|
return false;
|
|
16
24
|
}
|
|
@@ -19,5 +27,9 @@ function getServerSnapshot() {
|
|
|
19
27
|
// render regardless of actual viewport, this reads the real value up front
|
|
20
28
|
// via `useSyncExternalStore` — no wrong-then-corrected first render.
|
|
21
29
|
export function useIsNarrowViewport() {
|
|
22
|
-
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
30
|
+
return useSyncExternalStore(belowMobileStore.subscribe, belowMobileStore.getSnapshot, getServerSnapshot);
|
|
31
|
+
}
|
|
32
|
+
// Tailwind's `sm` boundary: true below 640px.
|
|
33
|
+
export function useIsBelowSmViewport() {
|
|
34
|
+
return useSyncExternalStore(belowSmStore.subscribe, belowSmStore.getSnapshot, getServerSnapshot);
|
|
23
35
|
}
|