@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.
- package/dist/app/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2006
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/package.json +5 -5
- 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
|
|
2
|
+
import type { CorePrimitives } from "../../index.ts";
|
|
4
3
|
import "../react-css-custom-properties.ts";
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
}
|
|
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;
|