@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.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.
Files changed (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. package/src/changes.json +6 -0
@@ -0,0 +1,181 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Building, CalendarDays, Globe, Hash, KeyRound, Link, Lock, Mail, MapPin, Phone, Search, Tag, Undo2, User, X, } from "lucide-react";
3
+ import { Fragment, useContext, } from "react";
4
+ import { fieldIssueText } from "../../field-issue-text.js";
5
+ import { useTranslation, } from "../../index.js";
6
+ import { cn } from "../lib/cn.js";
7
+ import { Label as UiLabel } from "../ui/label.js";
8
+ import { StatusBadge } from "../widgets/status-badge.js";
9
+ import { inFieldIconButtonClass } from "./calendar-popover.js";
10
+ import { groupOptions } from "./option-groups.js";
11
+ import { SegmentedControl } from "./segmented-select.js";
12
+ import "../react-css-custom-properties.js";
13
+ import { DefaultButton } from "./button.js";
14
+ import { FieldDescriptionContext, FieldLabelledContext, FieldLayoutContext } from "./contexts.js";
15
+ // ---- Field (Label + Error) ----
16
+ // Matches DefaultBanner's aria-labelledby target: a non-labelable control
17
+ // (e.g. Banner's <div>) can't take a real htmlFor, so Field always also
18
+ // gives its label a stable id that such controls can point to.
19
+ export function fieldLabelId(id) {
20
+ return `${id}-label`;
21
+ }
22
+ function useFieldLabelProps(id, name) {
23
+ const insideLabelledField = useContext(FieldLabelledContext);
24
+ return insideLabelledField ? { "aria-labelledby": fieldLabelId(id) } : { "aria-label": name };
25
+ }
26
+ function fieldDescriptionId(id) {
27
+ return `${id}-description`;
28
+ }
29
+ function fieldErrorId(id) {
30
+ return `${id}-error`;
31
+ }
32
+ export function DefaultField(props) {
33
+ const describedBy = [
34
+ props.description !== undefined ? fieldDescriptionId(props.id) : undefined,
35
+ props.issues !== undefined && props.issues.length > 0 ? fieldErrorId(props.id) : undefined,
36
+ ]
37
+ .filter((id) => id !== undefined)
38
+ .join(" ");
39
+ return (_jsx(FieldLabelledContext.Provider, { value: props.label !== "", children: _jsx(FieldDescriptionContext.Provider, { value: describedBy === "" ? undefined : describedBy, children: _jsx(DefaultFieldBody, { ...props }) }) }));
40
+ }
41
+ function DefaultFieldBody({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
42
+ const t = useTranslation();
43
+ const hasError = issues !== undefined && issues.length > 0;
44
+ const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: layout === "inline" ? undefined : "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
45
+ const errorsEl = hasError ? (_jsx("div", { id: fieldErrorId(id), role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: fieldIssueText(t, issue) }, `${issue.path}:${issue.code}`))) })) : null;
46
+ // Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
47
+ if (layout === "inline") {
48
+ return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FieldLayoutContext.Provider, { value: "inline", children: children }), labelEl, labelAppendix !== undefined && labelAppendix] }), errorsEl] }));
49
+ }
50
+ const changedMarker = changed === true ? (_jsxs("span", { "data-testid": testId !== undefined ? `${testId}-changed` : undefined, className: "inline-flex items-center gap-1 text-xs text-status-active", children: [_jsx("span", { className: "size-1.5 rounded-full bg-current", "aria-hidden": "true" }), t("kumiko.form.changed")] })) : null;
51
+ const statusEl = status !== undefined ? (_jsx(StatusBadge, { tone: status.tone, testId: status.testId ?? (testId !== undefined ? `${testId}-status` : undefined), children: status.label })) : null;
52
+ const belowControl = appendixPlacement === "below-control";
53
+ // Settings row: label/help/origin on the left, control on the right. The
54
+ // hairlines between rows come from the list Grid, not from the row itself.
55
+ if (layout === "row") {
56
+ return (_jsxs("div", { "data-testid": testId, className: cn("group/row relative grid gap-x-6 gap-y-2 py-4 @2xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] @2xl:grid-rows-[auto_auto_auto_1fr] @2xl:gap-y-0",
57
+ // Switch rows keep label left / switch right when stacked.
58
+ "@max-2xl:has-[[data-slot=switch]]:grid-cols-[minmax(0,1fr)_auto]", accent === true && "@max-2xl:pl-3"), children: [accent === true && (_jsx("span", { "aria-hidden": "true", "data-testid": testId !== undefined ? `${testId}-accent` : undefined, className: "absolute inset-y-3 w-0.5 bg-status-active @max-2xl:left-0 @2xl:-left-4" })), _jsxs("div", { className: "flex min-w-0 items-center gap-2 @2xl:col-start-1 @2xl:row-start-1", children: [labelEl, changedMarker, labelAppendix !== undefined && labelAppendix, statusEl] }), description !== undefined && (_jsx("div", { id: fieldDescriptionId(id), "data-testid": testId !== undefined ? `${testId}-description` : undefined, className: "min-w-0 text-sm text-muted-foreground @2xl:col-start-1 @2xl:row-start-2 @2xl:mt-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-span-2", children: description })), _jsxs("div", { className: "flex min-w-0 flex-col gap-1.5 has-[[data-slot=switch]]:items-end @2xl:col-start-2 @2xl:row-span-4 @2xl:row-start-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-start-2 @max-2xl:group-has-[[data-slot=switch]]/row:row-start-1", children: [_jsx(FieldLayoutContext.Provider, { value: "row", children: children }), errorsEl] }), fieldAppendix !== undefined && (_jsx("div", { className: "min-w-0 @2xl:col-start-1 @2xl:row-start-3 @2xl:mt-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-span-2", children: fieldAppendix }))] }));
59
+ }
60
+ return (_jsxs("div", { "data-testid": testId, ...(belowControl && fieldAppendix !== undefined && { "data-appendix-below": "" }), className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [changedMarker !== null ? (_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [labelEl, changedMarker] })) : (labelEl), labelAppendix !== undefined && labelAppendix, statusEl] }), description !== undefined && (_jsx("div", { id: fieldDescriptionId(id), "data-testid": testId !== undefined ? `${testId}-description` : undefined, className: "text-sm text-muted-foreground", children: description })), !belowControl && fieldAppendix !== undefined && fieldAppendix, children, errorsEl, belowControl && fieldAppendix !== undefined && fieldAppendix] }));
61
+ }
62
+ // ---- Input ----
63
+ // Field-icon registry: `EditFieldSpec.icon`/`InputProps.icon` sets a
64
+ // symbolic key, mapped here to a lucide component. Unknown keys → no
65
+ // icon (clean fallback, no boot-fail). Mirrors NAV_ICONS' pattern
66
+ // (nav-tree.tsx) — a separate, smaller registry instead of a shared
67
+ // import, because field icons cover a different use case (email, phone,
68
+ // location, …) than nav icons (dashboard, tables, …).
69
+ const FIELD_ICONS = {
70
+ mail: Mail,
71
+ lock: Lock,
72
+ hash: Hash,
73
+ search: Search,
74
+ user: User,
75
+ phone: Phone,
76
+ calendar: CalendarDays,
77
+ link: Link,
78
+ tag: Tag,
79
+ building: Building,
80
+ globe: Globe,
81
+ key: KeyRound,
82
+ "map-pin": MapPin,
83
+ };
84
+ export function fieldIconFor(icon) {
85
+ return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
86
+ ? FIELD_ICONS[icon]
87
+ : undefined;
88
+ }
89
+ // Wraps a text/number input with a left-positioned prefix icon when
90
+ // `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
91
+ // tailwind-merge) only the left padding of the vendored ui/input.tsx —
92
+ // right padding and other defaults stay untouched.
93
+ export const FIELD_ICON_CLASS = "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground";
94
+ export function withFieldIcon(icon, input) {
95
+ const Icon = fieldIconFor(icon);
96
+ if (Icon === undefined)
97
+ return input;
98
+ return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: FIELD_ICON_CLASS }), input] }));
99
+ }
100
+ const INPUT_AFFIX_CLASS = "inline-flex shrink-0 items-center text-sm text-muted-foreground max-md:min-h-11";
101
+ // Prefix/suffix text and the fill button sit beside the input, not inside it,
102
+ // so the input keeps its own padding contract (icon, unit). The wrapper is
103
+ // only added when an addon exists — a plain input keeps its tree shape.
104
+ export function withInputAddons(addons, input) {
105
+ const { name, prefix, suffix, fill } = addons;
106
+ if (prefix === undefined && suffix === undefined && fill === undefined)
107
+ return input;
108
+ return (_jsxs("div", { "data-slot": "input-addons", className: "flex items-center gap-2", children: [prefix !== undefined && (_jsx("span", { "data-testid": `input-prefix-${name}`, className: INPUT_AFFIX_CLASS, children: prefix })), _jsx("div", { className: "min-w-0 flex-1", children: input }), suffix !== undefined && (_jsx("span", { "data-testid": `input-suffix-${name}`, className: INPUT_AFFIX_CLASS, children: suffix })), fill !== undefined && (_jsx(DefaultButton, { variant: "secondary", size: "sm", onClick: fill.onPress, loading: fill.busy === true, disabled: fill.disabled === true, testId: fill.testId ?? `input-fill-${name}`, children: fill.label }))] }));
109
+ }
110
+ // The unit changes the meaning of the value ("58 mi" vs "58 km"), so it is
111
+ // exposed as the input's description rather than hidden from assistive tech.
112
+ export function unitSuffixId(inputId) {
113
+ return `${inputId}-unit`;
114
+ }
115
+ const TRAILING_ACTION_ICONS = { clear: X, undo: Undo2 };
116
+ // In-field icon button on the right (same look as the date input's calendar
117
+ // trigger). The wrapped input must reserve the room itself (`pr-9`).
118
+ // `keepWrapper` holds the tree shape when the action comes and goes — a
119
+ // write-only input loses its action on the first keystroke, and swapping the
120
+ // wrapper would remount the input and drop focus mid-typing.
121
+ export function withTrailingAction(action, input, keepWrapper = false) {
122
+ if (action === undefined && !keepWrapper)
123
+ return input;
124
+ const ActionIcon = action !== undefined ? TRAILING_ACTION_ICONS[action.icon] : undefined;
125
+ 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" }) }))] }));
126
+ }
127
+ // Default presentation for `kind: "select"` without a `display` of its own
128
+ // (board rule): up to 3 short options read as a segmented control, up to 3
129
+ // longer ones as a vertical radio list, everything else as a dropdown. Labels
130
+ // arrive translated, so the widget can differ between UI languages (#2606).
131
+ const SEGMENTED_SELECT_MAX_OPTIONS = 3;
132
+ const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 16;
133
+ export function defaultSelectPresentation(options) {
134
+ if (options.length === 0 || options.length > SEGMENTED_SELECT_MAX_OPTIONS)
135
+ return "dropdown";
136
+ return options.every((option) => option.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
137
+ ? "segmented"
138
+ : "radioList";
139
+ }
140
+ // A "" value is the unselected placeholder, not a real choice — a radio
141
+ // group can't render an unchecked-everything state as its own segment
142
+ // (#2606 follow-up). Filtered once so the eligibility count and the
143
+ // rendered segments never drift apart.
144
+ export function withoutSegmentedSelectPlaceholder(options) {
145
+ return options.filter((option) => option.value !== "");
146
+ }
147
+ export function SegmentedSelect(props) {
148
+ const labelProps = useFieldLabelProps(props.id, props.name);
149
+ return _jsx(SegmentedControl, { ...props, labelProps: labelProps });
150
+ }
151
+ const RADIO_CARD_CLASS = "flex items-start gap-3 rounded-lg border border-input bg-background px-[15px] py-[13px] text-sm font-normal text-foreground " +
152
+ "has-[:checked]:border-2 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:checked]:px-[14px] has-[:checked]:py-3 " +
153
+ "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:disabled]:opacity-50";
154
+ export function RadioListSelect({ id, name, value, onChange, options, variant = "list", disabled, required, hasError, }) {
155
+ const labelProps = useFieldLabelProps(id, name);
156
+ const card = variant === "card";
157
+ // One radiogroup over all sections keeps native arrow-key navigation across groups
158
+ // (radios share `name` in DOM order).
159
+ const descriptionIdOf = (opt) => `${id}-option-${options.findIndex((o) => o.value === opt.value)}-description`;
160
+ return (_jsx("div", { role: "radiogroup", ...labelProps, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `radio-list-${id}`, "data-radio-list": "", className: "flex flex-col gap-2", children: groupOptions(options).map((section) => (_jsxs(Fragment, { children: [section.heading !== undefined && (_jsx("div", { "data-testid": `radio-list-${id}-group-${section.heading}`, className: "mt-1 text-xs font-medium text-muted-foreground", children: section.heading })), section.options.map((opt) => (_jsxs("label", { className: card
161
+ ? RADIO_CARD_CLASS
162
+ : "flex items-start gap-2 text-sm font-normal text-foreground has-[:disabled]:opacity-50", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled === true || opt.disabled === true, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
163
+ }
164
+ // The vendored shadcn Textarea carries `field-sizing: content`, which derives
165
+ // the box height from the content and makes the `rows` attribute inert (#2677).
166
+ // A declared row count therefore only survives as a min-height floor: the field
167
+ // starts at `rows` lines tall and still grows with its content. The addend is
168
+ // the textarea frame — py-2 top+bottom plus the 1px borders.
169
+ export function textareaMinHeight(rows) {
170
+ return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
171
+ }
172
+ export function withInlineDisabledHint(option) {
173
+ if (option.disabled !== true || option.description === undefined)
174
+ return option;
175
+ return {
176
+ value: option.value,
177
+ label: `${option.label} (${option.description})`,
178
+ disabled: true,
179
+ ...(option.group !== undefined && { group: option.group }),
180
+ };
181
+ }
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from "react";
2
+ import { type FormProps, type FormSectionNavItem, type FormWidth } from "../../index.ts";
3
+ import "../react-css-custom-properties.ts";
4
+ export declare function FormRoot({ onSubmit, testId, className, children, focusFirstErrorKey, }: {
5
+ readonly onSubmit: FormProps["onSubmit"];
6
+ readonly testId?: string;
7
+ readonly className?: string;
8
+ readonly children: ReactNode;
9
+ readonly focusFirstErrorKey?: number | undefined;
10
+ }): ReactNode;
11
+ export declare function isPinnedFooter(stickyActions: boolean | undefined, fillHeight: boolean | undefined): boolean;
12
+ export declare function FormSections({ children, chromeless, stickyActions, fillHeight, screenForm, }: {
13
+ readonly children: ReactNode;
14
+ readonly chromeless: boolean;
15
+ readonly stickyActions: boolean | undefined;
16
+ readonly fillHeight: boolean | undefined;
17
+ readonly screenForm?: boolean;
18
+ }): ReactNode;
19
+ export declare function FormTitleBlock({ title, subtitle, titleAction, testId, bordered, fillHeight, }: {
20
+ readonly title: ReactNode;
21
+ readonly subtitle: ReactNode;
22
+ readonly titleAction: ReactNode;
23
+ readonly testId: string | undefined;
24
+ readonly bordered: boolean;
25
+ readonly fillHeight: boolean | undefined;
26
+ }): ReactNode;
27
+ export declare function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActions, fillHeight, unsavedCount, railed, }: {
28
+ readonly actions: ReactNode;
29
+ readonly secondaryActions: ReactNode;
30
+ readonly testId: string | undefined;
31
+ readonly chromeless: boolean;
32
+ readonly stickyActions: boolean | undefined;
33
+ readonly fillHeight: boolean | undefined;
34
+ readonly unsavedCount?: number;
35
+ /** Footer sits under a step rail: symmetric px-6 instead of the form column's left inset. */
36
+ readonly railed?: boolean;
37
+ }): ReactNode;
38
+ export declare const SECTION_NAV_MIN_ITEMS = 3;
39
+ export declare function FormSectionNav({ items, }: {
40
+ readonly items: readonly FormSectionNavItem[];
41
+ }): ReactNode;
42
+ export type FormScreenShellWidth = FormWidth;
43
+ export declare function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }: {
44
+ readonly children: ReactNode;
45
+ readonly className?: string;
46
+ readonly testId?: string;
47
+ readonly maxWidth?: FormScreenShellWidth;
48
+ /** Stacks children in a `h-full` flex column instead of normal document
49
+ * flow, so a `flex-1 min-h-0` child can claim the remaining height below
50
+ * the others (fw#2722 — DefaultForm's tabs+relatedList case). Default
51
+ * false: unchanged, content-sized height. */
52
+ readonly fillHeight?: boolean;
53
+ }): ReactNode;
@@ -0,0 +1,252 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, Fragment, isValidElement, useContext, useEffect, useRef, useState, } from "react";
3
+ import { STICKY_PRIMARY_ACTION_PROP, useTranslation, } from "../../index.js";
4
+ import { cn } from "../lib/cn.js";
5
+ import { screenPaddingClassName, screenWidthClassName } from "./layout.js";
6
+ import { NarrowPinnedFooter } from "./narrow-pinned-footer.js";
7
+ import { STICKY_FOOTER_SAFE_AREA_CLASS, STICKY_FOOTER_SPACER_CLASS } from "./sticky-action-bar.js";
8
+ import { useKeyboardInset } from "./use-keyboard-inset.js";
9
+ import { useIsBelowSmViewport } from "./use-narrow-viewport.js";
10
+ import "../react-css-custom-properties.js";
11
+ import { cardFooterBorder, cardHeaderBorder } from "./card-styles.js";
12
+ import { InsideFormContext, ScreenFormContext, ScreenWidthContext } from "./contexts.js";
13
+ // Extension sections that render their own <form> via BareFormProvider
14
+ // (legacy custom-form pattern, e.g. ChangeEmailSection before fw#2703) land
15
+ // inside RenderEdit's host <form> (ExtensionSectionMount, render-edit.tsx) —
16
+ // nested <form> elements are invalid DOM, and real browsers can silently
17
+ // fall back to a native GET submit of the OUTER form, leaking field values
18
+ // into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
19
+ // a form avoids the nesting; the captured click still routes to THIS form's
20
+ // onSubmit instead of activating the real ancestor <form>.
21
+ const SUBMIT_CONTROL_SELECTOR = [
22
+ "button[type=submit]:not(:disabled)",
23
+ "button:not([type]):not(:disabled)",
24
+ "input[type=submit]:not(:disabled)",
25
+ "input[type=image]:not(:disabled)",
26
+ ].join(", ");
27
+ export function FormRoot({ onSubmit, testId, className, children, focusFirstErrorKey, }) {
28
+ const rootRef = useRef(null);
29
+ useEffect(() => {
30
+ if (focusFirstErrorKey === undefined || focusFirstErrorKey === 0)
31
+ return;
32
+ const firstInvalid = rootRef.current?.querySelector('[aria-invalid="true"]');
33
+ if (firstInvalid === null || firstInvalid === undefined)
34
+ return;
35
+ firstInvalid.focus();
36
+ firstInvalid.scrollIntoView({ block: "center" });
37
+ }, [focusFirstErrorKey]);
38
+ if (useContext(InsideFormContext)) {
39
+ return (_jsx("div", { ref: (node) => {
40
+ rootRef.current = node;
41
+ }, "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
42
+ // Implicit submission: the section's inputs belong to the OUTER
43
+ // <form> in the DOM, so the browser would otherwise route Enter to
44
+ // that form's default button.
45
+ if (e.key !== "Enter" || e.nativeEvent.isComposing)
46
+ return;
47
+ const field = e.target;
48
+ if (!(field instanceof HTMLInputElement))
49
+ return;
50
+ if (field.closest("[data-kumiko-form-root]") !== e.currentTarget)
51
+ return;
52
+ if (["checkbox", "radio", "submit", "button"].includes(field.type))
53
+ return;
54
+ e.preventDefault();
55
+ onSubmit();
56
+ }, onClickCapture: (e) => {
57
+ // @cast-boundary dom-event-target: closest() needs an Element, and
58
+ // click targets are always one in the browser/happy-dom.
59
+ const submitButton = e.target.closest(SUBMIT_CONTROL_SELECTOR);
60
+ if (submitButton === null)
61
+ return;
62
+ // React events bubble through portals and nested degraded roots; a
63
+ // real <form> only reacts to buttons in its own DOM subtree.
64
+ if (submitButton.closest("[data-kumiko-form-root]") !== e.currentTarget)
65
+ return;
66
+ e.preventDefault();
67
+ onSubmit();
68
+ }, "data-testid": testId, className: className, children: children }));
69
+ }
70
+ return (_jsx("form", { ref: (node) => {
71
+ rootRef.current = node;
72
+ }, onSubmit: (e) => {
73
+ e.preventDefault();
74
+ onSubmit(e);
75
+ }, "data-testid": testId, className: className, children: children }));
76
+ }
77
+ // stickyActions alone pins the primary action with `position: fixed` on
78
+ // narrow viewports; together with fillHeight the flex chain pins the footer
79
+ // on every viewport instead.
80
+ export function isPinnedFooter(stickyActions, fillHeight) {
81
+ return stickyActions === true && fillHeight === true;
82
+ }
83
+ // Sections wrapper for DefaultForm's card and chromeless layouts alike —
84
+ // only the card-derived padding on non-section children differs between
85
+ // them (see `chromeless` doc on FormProps).
86
+ export function FormSections({ children, chromeless, stickyActions, fillHeight, screenForm = false, }) {
87
+ if (screenForm) {
88
+ return (_jsx("div", { className: "flex flex-col gap-9", children: _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: children }) }) }));
89
+ }
90
+ return (_jsx("div", { className: cn("flex flex-col",
91
+ // For section children (auto-UI edit) a line separates THEM — they pad
92
+ // themselves. Flat fields (custom screens) get padding + rhythm, no line
93
+ // between every field.
94
+ "[&>section:not(:first-child)]:border-t",
95
+ // Margins, not padding: padding would land inside a child that has its own border/height.
96
+ !chromeless && "[&>:not(section)]:mx-6 [&>:not(section)]:my-3", !chromeless && "[&>:not(section):first-child]:mt-6 [&>:not(section):last-child]:mb-6",
97
+ // ponytail: fixed footer now pins only the primary action (single
98
+ // button row + its own p-4, fw#2606) instead of the whole footer —
99
+ // shrunk from pb-32 accordingly. Widen again if a wizard's primary
100
+ // action ever wraps to two rows.
101
+ stickyActions === true && fillHeight !== true && STICKY_FOOTER_SPACER_CLASS,
102
+ // Same "no flex-1" reasoning as the card above: this is the
103
+ // one section allowed to shrink (min-h-0) inside the card, not
104
+ // one forced to grow past its content.
105
+ // Chromeless has no card above to hold a floor, so the sections do.
106
+ fillHeight === true && (chromeless ? "min-h-64" : "min-h-0"),
107
+ // The flex chain pins the footer, so the sections are the scroll surface.
108
+ isPinnedFooter(stickyActions, fillHeight) && "flex-1 overflow-y-auto"), children: _jsx(InsideFormContext.Provider, { value: true, children: children }) }));
109
+ }
110
+ // Title/subtitle block shared by DefaultForm's card and chromeless layouts —
111
+ // RenderEdit already withholds screen.description from `subtitle` in tabs
112
+ // mode (chromeless), so whatever arrives here still renders, just without card chrome.
113
+ export function FormTitleBlock({ title, subtitle, titleAction, testId, bordered, fillHeight, }) {
114
+ if (title === undefined && subtitle === undefined && titleAction === undefined)
115
+ return null;
116
+ return (_jsxs("div", { className: cn("flex items-start justify-between gap-4", bordered ? cn(cardHeaderBorder, "px-6 pb-4 pt-5") : "pb-4", fillHeight === true && "shrink-0"), children: [_jsxs("div", { className: "min-w-0", children: [title !== undefined && (_jsx("h2", { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "text-lg font-semibold tracking-tight", children: title })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "mt-1 text-sm text-muted-foreground", children: subtitle }))] }), titleAction !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-title-action` : undefined, className: "flex shrink-0 flex-wrap items-center justify-end gap-2", children: titleAction }))] }));
117
+ }
118
+ // `formActions` (render-edit.tsx) mixes a non-submit wizard Back button in
119
+ // with the submit-type Next/Finish button inside one Fragment — only the
120
+ // submit one is the "primary" action that fw#1918 needs pinned above a
121
+ // virtual keyboard. Back reads as `type="button"`, same as `secondaryActions`
122
+ // (Cancel/Delete/…), so it can safely join that group on mobile. A footer-slot
123
+ // mount can't expose `type="submit"` (its button is opaque app code), so it
124
+ // opts in via `STICKY_PRIMARY_ACTION_PROP` instead (`ScreenSlots.footerPrimary`).
125
+ function isPrimaryStickyAction(node) {
126
+ if (!isValidElement(node))
127
+ return false;
128
+ const props = node.props;
129
+ return props.type === "submit" || props[STICKY_PRIMARY_ACTION_PROP] === true;
130
+ }
131
+ // `actions` from render-edit.tsx arrives as a single `<>…</>` Fragment
132
+ // element (not spread children) — Children.toArray only flattens fragments
133
+ // nested inside an already-flattened children list, not a Fragment passed
134
+ // as the whole value, so the Fragment's own children are unwrapped first.
135
+ function flattenActionNodes(node) {
136
+ if (isValidElement(node) && node.type === Fragment) {
137
+ return Children.toArray(node.props.children);
138
+ }
139
+ return Children.toArray(node);
140
+ }
141
+ function unsavedStatusText(t, unsavedCount) {
142
+ if (unsavedCount === 0)
143
+ return t("kumiko.form.noUnsavedChanges");
144
+ if (unsavedCount === 1)
145
+ return t("kumiko.form.unsaved.one");
146
+ return t("kumiko.form.unsaved.other", { count: unsavedCount });
147
+ }
148
+ // Footer wrapper for DefaultForm's card and chromeless layouts alike — only
149
+ // the card-derived horizontal padding/border differs between them.
150
+ export function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActions, fillHeight, unsavedCount, railed = false, }) {
151
+ const t = useTranslation();
152
+ const isBelowSm = useIsBelowSmViewport();
153
+ const keyboardInset = useKeyboardInset();
154
+ if (actions === undefined && secondaryActions === undefined)
155
+ return null;
156
+ // Only split when sticky: the non-sticky (regular, non-wizard) footer must
157
+ // reproduce the previous DOM exactly (form-action-bar.test.tsx pins
158
+ // `-actions`/`-actions-secondary` content 1:1 to the `actions`/
159
+ // `secondaryActions` props).
160
+ const actionNodes = stickyActions === true ? flattenActionNodes(actions) : undefined;
161
+ const primaryActionNodes = actionNodes?.filter(isPrimaryStickyAction);
162
+ const hasPrimaryAction = primaryActionNodes !== undefined && primaryActionNodes.length > 0;
163
+ // No primary node found among `actions` (e.g. a wizard step with only
164
+ // secondary buttons): fall back to pinning the whole, unpartitioned
165
+ // `actions` node instead of splitting it — matches the pre-split fw#1918
166
+ // behaviour where sticky footers always kept `actions` fixed.
167
+ const nonPrimaryActionNodes = hasPrimaryAction
168
+ ? actionNodes?.filter((node) => !isPrimaryStickyAction(node))
169
+ : undefined;
170
+ const renderedActions = stickyActions === true ? (hasPrimaryAction ? primaryActionNodes : actions) : actions;
171
+ const hasNonPrimaryOverflow = nonPrimaryActionNodes !== undefined && nonPrimaryActionNodes.length > 0;
172
+ const renderedSecondary = stickyActions === true && hasPrimaryAction ? (_jsxs(_Fragment, { children: [secondaryActions, nonPrimaryActionNodes] })) : (secondaryActions);
173
+ const pinned = isPinnedFooter(stickyActions, fillHeight);
174
+ const fixedFooterStyle = stickyActions === true && !pinned && isBelowSm && keyboardInset > 0
175
+ ? { bottom: keyboardInset }
176
+ : undefined;
177
+ // Below sm the pinned footer swaps to NarrowPinnedFooter; the subtree remounts, so a
178
+ // confirm dialog owned by an action button closes on a breakpoint change (rotate, resize).
179
+ if (pinned && isBelowSm) {
180
+ return (_jsx(NarrowPinnedFooter, { nodes: [...flattenActionNodes(secondaryActions), ...flattenActionNodes(actions)], overflowLabel: unsavedCount !== undefined && unsavedCount > 0
181
+ ? `${t("kumiko.list.row-actions.more")}, ${unsavedStatusText(t, unsavedCount)}`
182
+ : t("kumiko.list.row-actions.more"), unsavedCount: unsavedCount, unsavedStatus: unsavedCount !== undefined && unsavedCount > 0
183
+ ? unsavedStatusText(t, unsavedCount)
184
+ : undefined, testId: testId, keyboardInset: keyboardInset }));
185
+ }
186
+ return (_jsxs("div", { className: cn(pinned
187
+ ? cn("flex h-14 shrink-0 items-center justify-end gap-2 border-t border-border bg-card px-6", !railed && "md:pl-10")
188
+ : "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]",
189
+ // Only the stacked (flex-col-reverse) footer moves the status above the
190
+ // actions; the pinned single-row footer keeps it on the left.
191
+ !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(
192
+ // min-h-11: link-variant buttons (Copy link) are h-auto and would fall
193
+ // below the 24px touch-target minimum on narrow viewports.
194
+ "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, style: fixedFooterStyle, className: cn("flex items-center gap-2", pinned
195
+ ? "shrink-0 max-md:[&>button]:min-h-11"
196
+ : "flex-wrap max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto",
197
+ // Below sm (640px): pin only the primary action to the viewport
198
+ // bottom instead of normal flow, so a virtual keyboard shrinking
199
+ // the viewport can't push it out of reach (fw#1918). `fixed`
200
+ // escapes the card's `overflow-hidden` (only transform/filter/
201
+ // contain ancestors trap it, confirmed against
202
+ // AppLayout/SidebarInset — neither sets those).
203
+ stickyActions === true &&
204
+ !pinned &&
205
+ cn("max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:p-4 max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)]", STICKY_FOOTER_SAFE_AREA_CLASS)), children: renderedActions }))] }));
206
+ }
207
+ export const SECTION_NAV_MIN_ITEMS = 3;
208
+ // "On this page" navigation next to a screen form. The active entry follows
209
+ // the section nearest the top of the scroll surface; without IntersectionObserver
210
+ // (happy-dom, old engines) the first entry stays active until one is clicked.
211
+ export function FormSectionNav({ items, }) {
212
+ const t = useTranslation();
213
+ const [activeId, setActiveId] = useState(items[0]?.id);
214
+ useEffect(() => {
215
+ if (typeof IntersectionObserver === "undefined")
216
+ return;
217
+ const visibleIds = new Set();
218
+ const observer = new IntersectionObserver((entries) => {
219
+ for (const entry of entries) {
220
+ if (entry.isIntersecting)
221
+ visibleIds.add(entry.target.id);
222
+ else
223
+ visibleIds.delete(entry.target.id);
224
+ }
225
+ const firstVisible = items.find((item) => visibleIds.has(item.id));
226
+ if (firstVisible !== undefined)
227
+ setActiveId(firstVisible.id);
228
+ }, { rootMargin: "0px 0px -60% 0px" });
229
+ for (const item of items) {
230
+ const target = document.getElementById(item.id);
231
+ if (target !== null)
232
+ observer.observe(target);
233
+ }
234
+ return () => observer.disconnect();
235
+ }, [items]);
236
+ return (_jsxs("nav", { "aria-label": t("kumiko.form.on-this-page"), "data-testid": "form-section-nav", className: "sticky top-0 hidden w-[200px] shrink-0 flex-col gap-0.5 self-start xl:flex", children: [_jsx("span", { className: "pb-1.5 pl-3 text-xs text-muted-foreground", children: t("kumiko.form.on-this-page") }), items.map((item) => {
237
+ const active = item.id === activeId;
238
+ return (_jsx("button", { type: "button", "data-testid": `form-section-nav-${item.id}`, ...(active && { "aria-current": "true" }), onClick: () => {
239
+ setActiveId(item.id);
240
+ document
241
+ .getElementById(item.id)
242
+ ?.scrollIntoView?.({ behavior: "smooth", block: "start" });
243
+ }, className: cn("border-l-2 px-3 py-[5px] text-left text-sm", active
244
+ ? "border-primary font-semibold text-primary"
245
+ : "border-border text-foreground-secondary hover:text-foreground"), children: item.title }, item.id));
246
+ })] }));
247
+ }
248
+ export function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }) {
249
+ const contextWidth = useContext(ScreenWidthContext);
250
+ const width = maxWidth ?? contextWidth ?? "4xl";
251
+ return (_jsx("div", { "data-testid": testId, className: cn(screenPaddingClassName, "w-full", screenWidthClassName[width], fillHeight === true && "h-full flex flex-col min-h-0", className), children: children }));
252
+ }
@@ -1,34 +1,11 @@
1
- import { type ReactNode } from "react";
2
1
  import { applyFormatSpec } from "../../headless/index.ts";
3
- import { type CardProps, type CorePrimitives, type FormWidth } from "../../index.ts";
2
+ import type { CorePrimitives } from "../../index.ts";
4
3
  import "../react-css-custom-properties.ts";
5
- export declare const END_LABEL_MIN_ROWS = 50;
6
- export declare function isComponentRendererRef(renderer: unknown): {
7
- readonly name: string;
8
- } | undefined;
4
+ export { DefaultCard } from "./card.tsx";
5
+ export { BareFormProvider, EmbeddedFormProvider, ScreenWidthProvider } from "./contexts.tsx";
6
+ export { defaultCellRender, isComponentRendererRef } from "./data-table-cell.tsx";
7
+ export type { FormScreenShellWidth } from "./form.tsx";
8
+ export { FormScreenShell } from "./form.tsx";
9
+ export { END_LABEL_MIN_ROWS } from "./pager.tsx";
9
10
  export { applyFormatSpec };
10
- export declare function defaultCellRender(value: unknown, type: string, optionLabels?: Readonly<Record<string, string>>, locale?: string, columnKey?: string, grouping?: boolean): string;
11
- export declare function EmbeddedFormProvider({ children }: {
12
- children: ReactNode;
13
- }): ReactNode;
14
- export declare function BareFormProvider({ children }: {
15
- children: ReactNode;
16
- }): ReactNode;
17
- export declare function ScreenWidthProvider({ width, children, }: {
18
- readonly width: FormWidth | undefined;
19
- readonly children: ReactNode;
20
- }): ReactNode;
21
- export type FormScreenShellWidth = FormWidth;
22
- export declare function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }: {
23
- readonly children: ReactNode;
24
- readonly className?: string;
25
- readonly testId?: string;
26
- readonly maxWidth?: FormScreenShellWidth;
27
- /** Stacks children in a `h-full` flex column instead of normal document
28
- * flow, so a `flex-1 min-h-0` child can claim the remaining height below
29
- * the others (fw#2722 — DefaultForm's tabs+relatedList case). Default
30
- * false: unchanged, content-sized height. */
31
- readonly fillHeight?: boolean;
32
- }): ReactNode;
33
- export declare function DefaultCard({ slots, options, className, testId, dataAttributes, children, }: CardProps): ReactNode;
34
11
  export declare const defaultPrimitives: CorePrimitives;