@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.
@@ -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" ? "border-input hover:bg-muted" : "", pressed === true
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
- function withUnitSuffix(unit, input) {
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", { "aria-hidden": "true", className: "pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground", children: unit })] }));
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
- function withTrailingAction(action, input) {
208
- if (action === undefined)
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: checked || (value === "" && index === 0) ? 0 : -1, disabled: disabled, "data-testid": `segmented-${id}-${opt.value}`, onClick: () => onChange(opt.value), onKeyDown: (e) => handleKeyDown(e, index), className: cn(
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 (needsConfirm(action)) {
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", children: actions.map((action) => (_jsx(DropdownMenuItem, { "data-testid": `row-${row.id}-action-${action.id}`, onSelect: (e) => {
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 (needsConfirm(action)) {
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: "border-input hover:bg-muted", "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) => {
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", { onClickCapture: (e) => {
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 === 0
1401
- ? t("kumiko.form.noUnsavedChanges")
1402
- : unsavedCount === 1
1403
- ? t("kumiko.form.unsaved.one")
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 ? screenWidthClassName[width] : "max-w-[640px]"), children: [headerRegion, _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
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", ...dataAttributes, "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col", "overflow-hidden",
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
- // a reveal value always has the plaintext/manual-entry display alongside — QR is just convenience
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
- return (_jsxs("div", { className: "flex shrink-0 flex-col gap-3 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 }) })] }));
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) }));
@@ -1 +1,2 @@
1
1
  export declare function useIsNarrowViewport(): boolean;
2
+ export declare function useIsBelowSmViewport(): boolean;
@@ -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
- function subscribe(callback) {
7
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
8
- mql.addEventListener("change", callback);
9
- return () => mql.removeEventListener("change", callback);
10
- }
11
- function getSnapshot() {
12
- return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
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
  }