@fungi.computer/caps 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/blocks/status-tray.d.ts +8 -2
- package/dist/blocks/status-tray.js +26 -10
- package/dist/components/dialog.d.ts +7 -2
- package/dist/components/dialog.js +7 -4
- package/dist/components/dropdown-menu.js +4 -4
- package/dist/components/field.d.ts +20 -0
- package/dist/components/field.js +8 -8
- package/dist/form-fields.d.ts +3 -1
- package/dist/form-fields.js +25 -18
- package/dist/lib/format-money.d.ts +8 -2
- package/dist/lib/format-money.js +10 -7
- package/dist/schema-form.d.ts +13 -1
- package/dist/schema-form.js +46 -13
- package/dist/styles.css +176 -26
- package/docs/api/blocks/release-tag.md +7 -7
- package/docs/api/blocks/status-tray.md +11 -10
- package/docs/api/components/dialog.md +17 -1
- package/docs/api/components/field.md +14 -9
- package/docs/api/form.md +48 -8
- package/docs/api/lib/format-money.md +8 -6
- package/package.json +4 -4
- package/src/styles.css +266 -50
package/README.md
CHANGED
|
@@ -127,6 +127,13 @@ Caps owns interaction and chooses the semantic destination. A null destination
|
|
|
127
127
|
waits for the shell root to mount, including during server rendering. Base UI
|
|
128
128
|
menu, Combobox and dialog portals still accept explicit container overrides.
|
|
129
129
|
|
|
130
|
+
`DialogContent` and the composed `DialogPopup` accept `size="fill"` for a
|
|
131
|
+
full-window frame. It fills the portal's fixed-position containing block (the
|
|
132
|
+
viewport by default), without centring or a maximum width, and retains Caps
|
|
133
|
+
padding, radius and material. Omit `size` or use `size="default"` for the
|
|
134
|
+
existing width-limited frame; `DialogContent` centres it. Focus targets and
|
|
135
|
+
Escape ownership are unchanged.
|
|
136
|
+
|
|
130
137
|
In body mode, isolate the App root so page controls cannot paint over portaled
|
|
131
138
|
dialogs. Agar's `mountApp` does this. Dialogs and menus rank by portal order;
|
|
132
139
|
tooltips and toasts use their Stipe layers.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type HTMLAttributes, type ReactNode } from "react";
|
|
1
|
+
import { type ComponentPropsWithRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
2
|
import { type StatusGlyph, type StatusMotion, type StatusTone } from "../components/status.js";
|
|
3
3
|
/**
|
|
4
4
|
* One footer readout and the small tray it opens. Semantic data only: the
|
|
@@ -43,6 +43,12 @@ export type StatusTrayProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
|
|
|
43
43
|
leading?: ReactNode;
|
|
44
44
|
/** Trailing controls after the chips (Send, Expand, …). */
|
|
45
45
|
trailing?: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Compose a native button trigger, forwarding these attributes, children
|
|
48
|
+
* and ref. StatusTray still owns disclosure, dismissal and return focus.
|
|
49
|
+
* Omit to retain the default chip and its theme-owned finish.
|
|
50
|
+
*/
|
|
51
|
+
renderTrigger?: ((props: ComponentPropsWithRef<"button">) => ReactNode) | undefined;
|
|
46
52
|
/**
|
|
47
53
|
* Structural seat. `housing` sits on the plastic below a screen; `screen`
|
|
48
54
|
* sits inside a Display as flat, screen-native text (the wiki's screen
|
|
@@ -59,7 +65,7 @@ export type StatusTrayProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
|
|
|
59
65
|
hostInset?: number;
|
|
60
66
|
};
|
|
61
67
|
/** A footer row of readout chips; one opens its tray above the row at a time. */
|
|
62
|
-
export declare function StatusTray({ items, openId, onOpenChange, leading, trailing, placement, align, hostInset, className, ...props }: StatusTrayProps): import("react/jsx-runtime").JSX.Element;
|
|
68
|
+
export declare function StatusTray({ items, openId, onOpenChange, leading, trailing, renderTrigger, placement, align, hostInset, className, ...props }: StatusTrayProps): import("react/jsx-runtime").JSX.Element;
|
|
63
69
|
/** A group of tray rows. Precede it with a DataListHeading to label it. */
|
|
64
70
|
export declare function StatusTrayList({ children, className, ...props }: HTMLAttributes<HTMLDivElement>): import("react/jsx-runtime").JSX.Element;
|
|
65
71
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import { Fragment, useEffect, useId, useRef, } from "react";
|
|
4
4
|
import { IconButton } from "../components/icon-button.js";
|
|
@@ -6,7 +6,7 @@ import { Popover, PopoverAnchor } from "../components/popover.js";
|
|
|
6
6
|
import { Status, } from "../components/status.js";
|
|
7
7
|
import { cn } from "../lib/cn.js";
|
|
8
8
|
/** A footer row of readout chips; one opens its tray above the row at a time. */
|
|
9
|
-
export function StatusTray({ items, openId, onOpenChange, leading, trailing, placement = "housing", align = "end", hostInset, className, ...props }) {
|
|
9
|
+
export function StatusTray({ items, openId, onOpenChange, leading, trailing, renderTrigger, placement = "housing", align = "end", hostInset, className, ...props }) {
|
|
10
10
|
const scope = useId();
|
|
11
11
|
const root = useRef(null);
|
|
12
12
|
const panel = useRef(null);
|
|
@@ -50,14 +50,30 @@ export function StatusTray({ items, openId, onOpenChange, leading, trailing, pla
|
|
|
50
50
|
document.removeEventListener("pointerdown", onPointer);
|
|
51
51
|
};
|
|
52
52
|
}, [activeId, onOpenChange]);
|
|
53
|
-
return (_jsxs(PopoverAnchor, { ...props, ref: root, className: cn("caps-status-tray", className), "data-placement": placement, "data-align": align, children: [open ? (_jsxs(Popover, { open: true, ref: panel, tabIndex: -1, side: "top", id: `${scope}-${open.id}`, role: "dialog", "aria-labelledby": `${scope}-title`, className: "caps-status-tray-panel", "data-status-tray-panel": open.id, children: [_jsxs("div", { className: "caps-status-tray-head", children: [open.tone ? (_jsx(Status, { size: "compact", tone: open.tone, glyph: open.glyph ?? "dot" })) : null, _jsx("span", { id: `${scope}-title`, className: "caps-status-tray-title", children: open.title ?? open.label }), open.actions, _jsx(IconButton, { "aria-label": "Close tray", icon: _jsx(X, { "aria-hidden": "true" }), onClick: close })] }), _jsx("div", { className: "caps-status-tray-body", children: open.content })] })) : null, placement === "host" ? null : (_jsxs("div", { className: "caps-status-tray-row", children: [leading, _jsx("div", { className: "caps-status-tray-chips", children: items.map((item) =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
return (_jsxs(PopoverAnchor, { ...props, ref: root, className: cn("caps-status-tray", className), "data-placement": placement, "data-align": align, children: [open ? (_jsxs(Popover, { open: true, ref: panel, tabIndex: -1, side: "top", id: `${scope}-${open.id}`, role: "dialog", "aria-labelledby": `${scope}-title`, className: "caps-status-tray-panel", "data-status-tray-panel": open.id, children: [_jsxs("div", { className: "caps-status-tray-head", children: [open.tone ? (_jsx(Status, { size: "compact", tone: open.tone, glyph: open.glyph ?? "dot" })) : null, _jsx("span", { id: `${scope}-title`, className: "caps-status-tray-title", children: open.title ?? open.label }), open.actions, _jsx(IconButton, { "aria-label": "Close tray", icon: _jsx(X, { "aria-hidden": "true" }), onClick: close })] }), _jsx("div", { className: "caps-status-tray-body", children: open.content })] })) : null, placement === "host" ? null : (_jsxs("div", { className: "caps-status-tray-row", children: [leading, _jsx("div", { className: "caps-status-tray-chips", children: items.map((item) => {
|
|
54
|
+
const trigger = {
|
|
55
|
+
ref: (node) => {
|
|
56
|
+
if (node)
|
|
57
|
+
triggers.current.set(item.id, node);
|
|
58
|
+
else
|
|
59
|
+
triggers.current.delete(item.id);
|
|
60
|
+
},
|
|
61
|
+
type: "button",
|
|
62
|
+
className: renderTrigger ? undefined : "caps-status-tray-chip",
|
|
63
|
+
"aria-expanded": activeId === item.id,
|
|
64
|
+
"aria-controls": activeId === item.id ? `${scope}-${item.id}` : undefined,
|
|
65
|
+
"aria-label": item.accessibleLabel,
|
|
66
|
+
disabled: item.disabled,
|
|
67
|
+
title: item.disabled ? item.accessibleLabel : undefined,
|
|
68
|
+
"aria-haspopup": "dialog",
|
|
69
|
+
"data-status-tray-chip": item.id,
|
|
70
|
+
onClick: () => {
|
|
71
|
+
onOpenChange(openId === item.id ? null : item.id);
|
|
72
|
+
},
|
|
73
|
+
children: (_jsxs(_Fragment, { children: [item.tone ? (_jsx(Status, { size: "compact", glyph: item.glyph ?? "dot", motion: item.motion ?? "none", tone: item.tone })) : null, _jsx("span", { className: "caps-status-tray-label", children: item.label }), item.count !== undefined && item.count > 0 ? (_jsx("span", { className: "caps-status-tray-count", children: item.count })) : null] })),
|
|
74
|
+
};
|
|
75
|
+
return (_jsxs(Fragment, { children: [renderTrigger ? (renderTrigger(trigger)) : (_jsx("button", { ...trigger })), item.after] }, item.id));
|
|
76
|
+
}) }), trailing] }))] }));
|
|
61
77
|
}
|
|
62
78
|
/** A group of tray rows. Precede it with a DataListHeading to label it. */
|
|
63
79
|
export function StatusTrayList({ children, className, ...props }) {
|
|
@@ -18,10 +18,15 @@ export declare const DialogTrigger: import("react").ForwardRefExoticComponent<Om
|
|
|
18
18
|
export declare const DialogPortal: import("react").ForwardRefExoticComponent<Omit<import("@base-ui/react/dialog").DialogPortalProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
|
|
19
19
|
/** The styled backdrop for a composed Dialog. */
|
|
20
20
|
export declare const DialogBackdrop: import("react").ForwardRefExoticComponent<Omit<import("@base-ui/react/dialog").DialogBackdropProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
|
|
21
|
+
/** Props accepted by DialogPopup. */
|
|
22
|
+
export type DialogPopupProps = BaseDialog.Popup.Props & {
|
|
23
|
+
/** Fill the portal's positioning context instead of using the default width. */
|
|
24
|
+
size?: "default" | "fill";
|
|
25
|
+
};
|
|
21
26
|
/** The styled popup with Base UI composition and caller-controlled focus targets. */
|
|
22
|
-
export declare const DialogPopup: import("react").ForwardRefExoticComponent<Omit<
|
|
27
|
+
export declare const DialogPopup: import("react").ForwardRefExoticComponent<Omit<DialogPopupProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
|
|
23
28
|
/** Props accepted by DialogContent. */
|
|
24
|
-
export type DialogContentProps =
|
|
29
|
+
export type DialogContentProps = DialogPopupProps & Pick<BaseDialog.Portal.Props, "container" | "keepMounted">;
|
|
25
30
|
/**
|
|
26
31
|
* The default portal, backdrop and popup composition for a Dialog.
|
|
27
32
|
* Keeps child state across closes. Like Base UI, only full modal mode has a backdrop.
|
|
@@ -39,17 +39,20 @@ export const DialogBackdrop = forwardRef(function DialogBackdrop({ className, ..
|
|
|
39
39
|
return (_jsx(BaseDialog.Backdrop, { ...props, ref: ref, className: (state) => cn("caps-dialog-backdrop fixed inset-0 bg-black/40", resolveClassName(className, state)) }));
|
|
40
40
|
});
|
|
41
41
|
/** The styled popup with Base UI composition and caller-controlled focus targets. */
|
|
42
|
-
export const DialogPopup = forwardRef(function DialogPopup({ className, finalFocus, ...props }, ref) {
|
|
42
|
+
export const DialogPopup = forwardRef(function DialogPopup({ className, finalFocus, size = "default", ...props }, ref) {
|
|
43
43
|
const dialog = useDialogContext();
|
|
44
|
-
return (_jsx(BaseDialog.Popup, { ...props, className: (state) => cn("caps-dialog-popup
|
|
44
|
+
return (_jsx(BaseDialog.Popup, { ...props, "data-size": size, className: (state) => cn("caps-dialog-popup overflow-y-auto overscroll-contain rounded-box bg-base-100 p-lg text-base-content shadow-xl", size === "fill"
|
|
45
|
+
? "fixed inset-0 h-full w-full max-w-none"
|
|
46
|
+
: "max-h-screen w-11/12 max-w-(--container-lg)", resolveClassName(className, state)), finalFocus: dialog.interactionOwner === "host" ? false : (finalFocus ?? true), ref: ref }));
|
|
45
47
|
});
|
|
46
48
|
/**
|
|
47
49
|
* The default portal, backdrop and popup composition for a Dialog.
|
|
48
50
|
* Keeps child state across closes. Like Base UI, only full modal mode has a backdrop.
|
|
49
51
|
*/
|
|
50
|
-
export const DialogContent = forwardRef(function DialogContent({ container, keepMounted = true, className, ...props }, ref) {
|
|
52
|
+
export const DialogContent = forwardRef(function DialogContent({ container, keepMounted = true, className, size = "default", ...props }, ref) {
|
|
51
53
|
const { modal } = useDialogContext();
|
|
52
|
-
return (_jsxs(DialogPortal, { container: container, keepMounted: keepMounted, children: [modal === true ? _jsx(DialogBackdrop, {}) : null, _jsx(DialogPopup, { ...props, className: (state) => cn(
|
|
54
|
+
return (_jsxs(DialogPortal, { container: container, keepMounted: keepMounted, children: [modal === true ? _jsx(DialogBackdrop, {}) : null, _jsx(DialogPopup, { ...props, size: size, className: (state) => cn(size === "default" &&
|
|
55
|
+
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2", resolveClassName(className, state)), ref: ref })] }));
|
|
53
56
|
});
|
|
54
57
|
/** A footer layout for caller-provided Dialog actions. */
|
|
55
58
|
export function DialogFooter({ className, ...props }) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { createContext, forwardRef, useContext, useCallback, useImperativeHandle, useRef, } from "react";
|
|
@@ -79,12 +79,12 @@ export const DropdownMenuItem = forwardRef(function DropdownMenuItem({ className
|
|
|
79
79
|
}, ref: ref }));
|
|
80
80
|
});
|
|
81
81
|
/** A checkable item in a DropdownMenu. */
|
|
82
|
-
export const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem({ className, onClick, onSelect, ...props }, ref) {
|
|
83
|
-
return (
|
|
82
|
+
export const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem({ children, className, onClick, onSelect, ...props }, ref) {
|
|
83
|
+
return (_jsxs(BaseMenu.CheckboxItem, { ...props, className: (state) => cn(itemClasses, resolveClassName(className, state)), onClick: (event) => {
|
|
84
84
|
onClick?.(event);
|
|
85
85
|
if (!event.defaultPrevented)
|
|
86
86
|
onSelect?.(event);
|
|
87
|
-
}, ref: ref }));
|
|
87
|
+
}, ref: ref, children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(BaseMenu.CheckboxItemIndicator, { "aria-hidden": "true", className: "shrink-0", children: "\u2713" })] }));
|
|
88
88
|
});
|
|
89
89
|
/** A visual separator in a DropdownMenu. */
|
|
90
90
|
export const DropdownMenuSeparator = forwardRef(function DropdownMenuSeparator({ className, ...props }, ref) {
|
|
@@ -21,6 +21,8 @@ export declare function FormFooter({ children, divider, error, layout, status, .
|
|
|
21
21
|
export type FormProps = {
|
|
22
22
|
/** Disables every control while a submission is in flight. */
|
|
23
23
|
busy?: boolean;
|
|
24
|
+
/** Opt-in label/control grid; the default field rhythm is unchanged. */
|
|
25
|
+
layout?: "columns" | undefined;
|
|
24
26
|
} & FormHTMLAttributes<HTMLFormElement>;
|
|
25
27
|
/**
|
|
26
28
|
* A form with Caps' field rhythm. While `busy`, its fieldset disables every
|
|
@@ -29,11 +31,15 @@ export type FormProps = {
|
|
|
29
31
|
export declare const Form: import("react").ForwardRefExoticComponent<{
|
|
30
32
|
/** Disables every control while a submission is in flight. */
|
|
31
33
|
busy?: boolean;
|
|
34
|
+
/** Opt-in label/control grid; the default field rhythm is unchanged. */
|
|
35
|
+
layout?: "columns" | undefined;
|
|
32
36
|
} & FormHTMLAttributes<HTMLFormElement> & import("react").RefAttributes<HTMLFormElement>>;
|
|
33
37
|
/** Props accepted by FormSection. */
|
|
34
38
|
export type FormSectionProps = {
|
|
35
39
|
title: ReactNode;
|
|
36
40
|
description?: ReactNode;
|
|
41
|
+
/** Opt-in label/control grid when used without a Form parent. */
|
|
42
|
+
layout?: "columns" | undefined;
|
|
37
43
|
/** Heading level inside the page outline. */
|
|
38
44
|
level?: 2 | 3 | 4;
|
|
39
45
|
children: ReactNode;
|
|
@@ -42,6 +48,8 @@ export type FormSectionProps = {
|
|
|
42
48
|
export declare const FormSection: import("react").ForwardRefExoticComponent<{
|
|
43
49
|
title: ReactNode;
|
|
44
50
|
description?: ReactNode;
|
|
51
|
+
/** Opt-in label/control grid when used without a Form parent. */
|
|
52
|
+
layout?: "columns" | undefined;
|
|
45
53
|
/** Heading level inside the page outline. */
|
|
46
54
|
level?: 2 | 3 | 4;
|
|
47
55
|
children: ReactNode;
|
|
@@ -49,7 +57,11 @@ export declare const FormSection: import("react").ForwardRefExoticComponent<{
|
|
|
49
57
|
/** Props accepted by FieldSet. */
|
|
50
58
|
export type FieldSetProps = {
|
|
51
59
|
legend: ReactNode;
|
|
60
|
+
/** Full legend on hover when a columns form clips it to one line. */
|
|
61
|
+
legendTitle?: string;
|
|
52
62
|
description?: ReactNode;
|
|
63
|
+
/** Opt-in label/control grid when used without a Form parent. */
|
|
64
|
+
layout?: "columns" | undefined;
|
|
53
65
|
/** The group's announced error, such as "Choose at least one right". */
|
|
54
66
|
error?: ReactNode;
|
|
55
67
|
children: ReactNode;
|
|
@@ -60,7 +72,11 @@ export type FieldSetProps = {
|
|
|
60
72
|
*/
|
|
61
73
|
export declare const FieldSet: import("react").ForwardRefExoticComponent<{
|
|
62
74
|
legend: ReactNode;
|
|
75
|
+
/** Full legend on hover when a columns form clips it to one line. */
|
|
76
|
+
legendTitle?: string;
|
|
63
77
|
description?: ReactNode;
|
|
78
|
+
/** Opt-in label/control grid when used without a Form parent. */
|
|
79
|
+
layout?: "columns" | undefined;
|
|
64
80
|
/** The group's announced error, such as "Choose at least one right". */
|
|
65
81
|
error?: ReactNode;
|
|
66
82
|
children: ReactNode;
|
|
@@ -76,6 +92,8 @@ export type FieldProps = {
|
|
|
76
92
|
*/
|
|
77
93
|
icon?: ReactNode;
|
|
78
94
|
label?: ReactNode;
|
|
95
|
+
/** Full label on hover when a columns form clips it to one line. */
|
|
96
|
+
labelTitle?: string;
|
|
79
97
|
/**
|
|
80
98
|
* `vertical` stacks label, control and error; `horizontal` puts the control
|
|
81
99
|
* opposite its copy (settings rows); `inline` puts a checkbox or switch
|
|
@@ -102,6 +120,8 @@ export declare const Field: import("react").ForwardRefExoticComponent<{
|
|
|
102
120
|
*/
|
|
103
121
|
icon?: ReactNode;
|
|
104
122
|
label?: ReactNode;
|
|
123
|
+
/** Full label on hover when a columns form clips it to one line. */
|
|
124
|
+
labelTitle?: string;
|
|
105
125
|
/**
|
|
106
126
|
* `vertical` stacks label, control and error; `horizontal` puts the control
|
|
107
127
|
* opposite its copy (settings rows); `inline` puts a checkbox or switch
|
package/dist/components/field.js
CHANGED
|
@@ -25,21 +25,21 @@ export function FormFooter({ children, divider = true, error, layout = "end", st
|
|
|
25
25
|
* A form with Caps' field rhythm. While `busy`, its fieldset disables every
|
|
26
26
|
* control and the form reports `aria-busy`; validation stays with the caller.
|
|
27
27
|
*/
|
|
28
|
-
export const Form = forwardRef(function Form({ busy = false, children, className, ...props }, ref) {
|
|
29
|
-
return (_jsx("form", { ...props, "aria-busy": busy || undefined, className: cn("caps-form", className), ref: ref, children: _jsx("fieldset", { className: "caps-form-body", disabled: busy, children: children }) }));
|
|
28
|
+
export const Form = forwardRef(function Form({ busy = false, children, className, layout, ...props }, ref) {
|
|
29
|
+
return (_jsx("form", { ...props, "aria-busy": busy || undefined, className: cn("caps-form", className), "data-layout": layout, ref: ref, children: _jsx("fieldset", { className: "caps-form-body", disabled: busy, children: children }) }));
|
|
30
30
|
});
|
|
31
31
|
const sectionHeadings = { 2: "h2", 3: "h3", 4: "h4" };
|
|
32
32
|
/** A titled group of fields; consecutive sections share one quiet rule. */
|
|
33
|
-
export const FormSection = forwardRef(function FormSection({ children, className, description, level = 3, title, ...props }, ref) {
|
|
33
|
+
export const FormSection = forwardRef(function FormSection({ children, className, description, layout, level = 3, title, ...props }, ref) {
|
|
34
34
|
const id = useId();
|
|
35
35
|
const Heading = sectionHeadings[level];
|
|
36
|
-
return (_jsxs("section", { ...props, "aria-labelledby": `${id}-title`, className: cn("caps-form-section", className), ref: ref, children: [_jsxs("header", { className: "caps-form-section-copy", children: [_jsx(Heading, { className: "caps-form-section-title", id: `${id}-title`, children: title }), description ? (_jsx("p", { className: "caps-form-section-description", children: description })) : null] }), _jsx("div", { className: "caps-form-section-fields", children: children })] }));
|
|
36
|
+
return (_jsxs("section", { ...props, "aria-labelledby": `${id}-title`, className: cn("caps-form-section", className), "data-layout": layout, ref: ref, children: [_jsxs("header", { className: "caps-form-section-copy", children: [_jsx(Heading, { className: "caps-form-section-title", id: `${id}-title`, children: title }), description ? (_jsx("p", { className: "caps-form-section-description", children: description })) : null] }), _jsx("div", { className: "caps-form-section-fields", children: children })] }));
|
|
37
37
|
});
|
|
38
38
|
/**
|
|
39
39
|
* A legend-named group of related choices, such as a list of inline
|
|
40
40
|
* checkbox Fields. `disabled` disables every control inside.
|
|
41
41
|
*/
|
|
42
|
-
export const FieldSet = forwardRef(function FieldSet({ children, className, description, error, legend, ...props }, ref) {
|
|
42
|
+
export const FieldSet = forwardRef(function FieldSet({ children, className, description, error, layout, legend, legendTitle, ...props }, ref) {
|
|
43
43
|
const id = useId();
|
|
44
44
|
const describedBy = [
|
|
45
45
|
description ? `${id}-description` : props["aria-describedby"],
|
|
@@ -47,7 +47,7 @@ export const FieldSet = forwardRef(function FieldSet({ children, className, desc
|
|
|
47
47
|
]
|
|
48
48
|
.filter(Boolean)
|
|
49
49
|
.join(" ") || undefined;
|
|
50
|
-
return (_jsxs("fieldset", { ...props, "aria-describedby": describedBy, "aria-invalid": error ? true : props["aria-invalid"], className: cn("caps-fieldset", className), ref: ref, children: [_jsx("legend", { className: "caps-fieldset-legend", children: legend }), description ? (_jsx("p", { className: "caps-fieldset-description", id: `${id}-description`, children: description })) : null, _jsx("div", { className: "caps-fieldset-fields", children: children }), error === undefined ? null : (_jsx(FieldError, { id: `${id}-error`, children: error }))] }));
|
|
50
|
+
return (_jsxs("fieldset", { ...props, "aria-describedby": describedBy, "aria-invalid": error ? true : props["aria-invalid"], className: cn("caps-fieldset", className), "data-layout": layout, ref: ref, children: [_jsx("legend", { className: "caps-fieldset-legend", title: legendTitle, children: legend }), description ? (_jsx("p", { className: "caps-fieldset-description", id: `${id}-description`, children: description })) : null, _jsx("div", { className: "caps-fieldset-fields", children: children }), error === undefined ? null : (_jsx(FieldError, { id: `${id}-error`, children: error }))] }));
|
|
51
51
|
});
|
|
52
52
|
/** The labelled control inside an InputGroup: its first non-addon element. */
|
|
53
53
|
function groupControl(group) {
|
|
@@ -60,7 +60,7 @@ function groupControl(group) {
|
|
|
60
60
|
return controls[0] ?? null;
|
|
61
61
|
}
|
|
62
62
|
/** A labelled form-field layout that renders caller-provided control and supporting content. */
|
|
63
|
-
export const Field = forwardRef(function Field({ children, className, description, error, icon, label, optional, orientation = "vertical", readout = false, required, ...props }, ref) {
|
|
63
|
+
export const Field = forwardRef(function Field({ children, className, description, error, icon, label, labelTitle, optional, orientation = "vertical", readout = false, required, ...props }, ref) {
|
|
64
64
|
const generatedId = useId();
|
|
65
65
|
const child = isValidElement(children) ? children : null;
|
|
66
66
|
// An InputGroup is layout: the labelled control is the input inside it.
|
|
@@ -97,7 +97,7 @@ export const Field = forwardRef(function Field({ children, className, descriptio
|
|
|
97
97
|
: part))
|
|
98
98
|
: cloneElement(child, clonedProps)
|
|
99
99
|
: children;
|
|
100
|
-
const labelContent = label ? (_jsx(Label, { htmlFor: readout ? undefined : controlId,
|
|
100
|
+
const labelContent = label ? (_jsx(Label, { htmlFor: readout ? undefined : controlId, title: labelTitle,
|
|
101
101
|
// A choice's own label reads as body text; the group's legend carries weight.
|
|
102
102
|
className: orientation === "inline" ? "font-normal" : undefined, ...(required === undefined ? {} : { required }), ...(optional === undefined ? {} : { optional }), children: label })) : null;
|
|
103
103
|
const copy = label || description ? (_jsxs("div", { className: "caps-field-copy", children: [labelContent, description ? (_jsx("p", { className: "caps-field-description", id: descriptionId, children: description })) : null] })) : null;
|
package/dist/form-fields.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { SchemaFormModel } from "./schema-form.js";
|
|
|
3
3
|
/** Props accepted by FormFields. */
|
|
4
4
|
export type FormFieldsProps<V extends FormValues> = {
|
|
5
5
|
model: SchemaFormModel<V>;
|
|
6
|
+
/** Opt-in label/control grid for generated sections and groups. */
|
|
7
|
+
layout?: "columns" | undefined;
|
|
6
8
|
/** Render only these fields, in this order, without section headings. */
|
|
7
9
|
names?: readonly (keyof V & string)[];
|
|
8
10
|
/** Disable every field, such as while an uncertain request is retried. */
|
|
@@ -12,4 +14,4 @@ export type FormFieldsProps<V extends FormValues> = {
|
|
|
12
14
|
* Render a schema form's fields from its schema: every section in order, or
|
|
13
15
|
* only `names` (for placing fields in Screen cells or custom layouts).
|
|
14
16
|
*/
|
|
15
|
-
export declare function FormFields<V extends FormValues>({ model, names, disabled, }: FormFieldsProps<V>): (import("react/jsx-runtime").JSX.Element | null)[];
|
|
17
|
+
export declare function FormFields<V extends FormValues>({ model, names, disabled, layout, }: FormFieldsProps<V>): (import("react/jsx-runtime").JSX.Element | null)[];
|
package/dist/form-fields.js
CHANGED
|
@@ -159,7 +159,7 @@ const groupKinds = new Set([
|
|
|
159
159
|
"icon-checkboxes",
|
|
160
160
|
]);
|
|
161
161
|
/** Choice groups: a legend names the group; each option names its input. */
|
|
162
|
-
function SpecGroup({ spec, field, locked, error, }) {
|
|
162
|
+
function SpecGroup({ spec, field, locked, error, layout, }) {
|
|
163
163
|
const disabled = locked || spec.readOnly === true;
|
|
164
164
|
const legend = spec.label;
|
|
165
165
|
if (spec.kind === "checkboxes" || spec.kind === "icon-checkboxes") {
|
|
@@ -169,7 +169,7 @@ function SpecGroup({ spec, field, locked, error, }) {
|
|
|
169
169
|
? [...selected, value]
|
|
170
170
|
: selected.filter((current) => current !== value));
|
|
171
171
|
};
|
|
172
|
-
return (_jsx(FieldSet, { legend: legend, description: spec.help, disabled: disabled, error: error ?? null, children: spec.kind === "icon-checkboxes" ? (_jsx("div", { className: "caps-field-icon-choices", children: spec.options.map((option) => (_jsx(IconCheckbox, { name: spec.name, value: option.value, label: option.label, ...(option.caption === undefined
|
|
172
|
+
return (_jsx(FieldSet, { legend: legend, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: spec.kind === "icon-checkboxes" ? (_jsx("div", { className: "caps-field-icon-choices", children: spec.options.map((option) => (_jsx(IconCheckbox, { name: spec.name, value: option.value, label: option.label, ...(option.caption === undefined
|
|
173
173
|
? {}
|
|
174
174
|
: { caption: option.caption }), icon: option.icon, disabled: option.disabled, checked: selected.includes(option.value), onBlur: field.handleBlur, onChange: (event) => {
|
|
175
175
|
toggle(option.value, event.currentTarget.checked);
|
|
@@ -178,7 +178,7 @@ function SpecGroup({ spec, field, locked, error, }) {
|
|
|
178
178
|
} }) }, option.value)))) }));
|
|
179
179
|
}
|
|
180
180
|
const cards = spec.options.some((option) => option.description !== undefined || option.meta !== undefined);
|
|
181
|
-
return (_jsx(FieldSet, { legend: legend, description: spec.help, disabled: disabled, error: error ?? null, children: _jsx(RadioGroup, { name: spec.name, "aria-label": legend, value: text(field), onValueChange: (next) => {
|
|
181
|
+
return (_jsx(FieldSet, { legend: legend, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: _jsx(RadioGroup, { name: spec.name, "aria-label": legend, value: text(field), onValueChange: (next) => {
|
|
182
182
|
field.handleChange(next);
|
|
183
183
|
field.handleBlur();
|
|
184
184
|
},
|
|
@@ -206,45 +206,52 @@ function RenderedControl({ spec, field, locked, error, render, ...control }) {
|
|
|
206
206
|
"aria-invalid": error !== undefined,
|
|
207
207
|
});
|
|
208
208
|
}
|
|
209
|
-
function SchemaField({ form, spec, locked, }) {
|
|
209
|
+
function SchemaField({ form, spec, locked, layout, }) {
|
|
210
210
|
const field = useField({ form, name: spec.name });
|
|
211
211
|
const submitted = useSelector(form.store, (state) => state.submissionAttempts > 0);
|
|
212
212
|
const error = field.state.meta.isBlurred || submitted
|
|
213
213
|
? fieldError(field.state.meta)
|
|
214
214
|
: undefined;
|
|
215
215
|
if (spec.render === undefined && groupKinds.has(spec.kind))
|
|
216
|
-
return (_jsx(SpecGroup, { spec: spec, field: field, locked: locked, error: error }));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
216
|
+
return (_jsx(SpecGroup, { spec: spec, field: field, locked: locked, error: error, layout: layout }));
|
|
217
|
+
// A columns form chooses one rhythm; per-field layout hints only govern
|
|
218
|
+
// the unchanged default presentation.
|
|
219
|
+
const orientation = layout === "columns"
|
|
220
|
+
? spec.kind === "switch" || spec.kind === "checkbox"
|
|
220
221
|
? "horizontal"
|
|
221
|
-
: "vertical"
|
|
222
|
-
|
|
222
|
+
: "vertical"
|
|
223
|
+
: spec.kind === "checkbox"
|
|
224
|
+
? "inline"
|
|
225
|
+
: (spec.layout ?? (spec.kind === "switch" ? "row" : "stacked")) ===
|
|
226
|
+
"row"
|
|
227
|
+
? "horizontal"
|
|
228
|
+
: "vertical";
|
|
229
|
+
return (_jsx(Field, { label: spec.label, ...(layout === "columns" ? { labelTitle: spec.label } : {}), description: spec.help, icon: spec.icon, orientation: orientation, error: error, ...(spec.required === undefined ? {} : { required: spec.required }), ...(spec.optional === undefined ? {} : { optional: spec.optional }), children: spec.render === undefined ? (_jsx(SpecControl, { spec: spec, field: field, locked: locked })) : (_jsx(RenderedControl, { spec: spec, field: field, locked: locked, error: error, render: spec.render })) }));
|
|
223
230
|
}
|
|
224
231
|
const readValues = Schema.decodeUnknownOption(Schema.Record(Schema.String, inputSchema));
|
|
225
|
-
function Visible({ form, spec, locked, }) {
|
|
232
|
+
function Visible({ form, spec, locked, layout, }) {
|
|
226
233
|
const when = spec.when;
|
|
227
234
|
const shown = useSelector(form.store, (state) => when === undefined
|
|
228
235
|
? true
|
|
229
236
|
: when(Option.getOrElse(readValues(state.values), () => ({}))));
|
|
230
237
|
if (!shown)
|
|
231
238
|
return null;
|
|
232
|
-
return _jsx(SchemaField, { form: form, spec: spec, locked: locked });
|
|
239
|
+
return (_jsx(SchemaField, { form: form, spec: spec, locked: locked, layout: layout }));
|
|
233
240
|
}
|
|
234
|
-
function SectionFields({ form, section, locked, }) {
|
|
235
|
-
const fields = section.fields.map((spec) => (_jsx(Visible, { form: form, spec: spec, locked: locked }, spec.name)));
|
|
236
|
-
return section.title === undefined ? (fields) : (_jsx(FormSection, { title: section.title, description: section.description, children: fields }));
|
|
241
|
+
function SectionFields({ form, section, locked, layout, }) {
|
|
242
|
+
const fields = section.fields.map((spec) => (_jsx(Visible, { form: form, spec: spec, locked: locked, layout: layout }, spec.name)));
|
|
243
|
+
return section.title === undefined ? (fields) : (_jsx(FormSection, { title: section.title, description: section.description, layout: layout, children: fields }));
|
|
237
244
|
}
|
|
238
245
|
/**
|
|
239
246
|
* Render a schema form's fields from its schema: every section in order, or
|
|
240
247
|
* only `names` (for placing fields in Screen cells or custom layouts).
|
|
241
248
|
*/
|
|
242
|
-
export function FormFields({ model, names, disabled = false, }) {
|
|
249
|
+
export function FormFields({ model, names, disabled = false, layout, }) {
|
|
243
250
|
const form = model.form;
|
|
244
251
|
if (names !== undefined)
|
|
245
252
|
return names.map((name) => {
|
|
246
253
|
const spec = model.description.fields.find((field) => field.name === name);
|
|
247
|
-
return spec === undefined ? null : (_jsx(Visible, { form: form, spec: spec, locked: disabled }, name));
|
|
254
|
+
return spec === undefined ? null : (_jsx(Visible, { form: form, spec: spec, locked: disabled, layout: layout }, name));
|
|
248
255
|
});
|
|
249
|
-
return model.description.sections.map((section) => (_jsx(SectionFields, { form: form, section: section, locked: disabled }, section.id ?? "")));
|
|
256
|
+
return model.description.sections.map((section) => (_jsx(SectionFields, { form: form, section: section, locked: disabled, layout: layout }, section.id ?? "")));
|
|
250
257
|
}
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Format USD to cents, marking non-zero sub-cent amounts rather than rounding
|
|
3
|
+
* them to zero. Callers needing the supported five-decimal precision opt in
|
|
4
|
+
* with `precision: "exact"`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatMoney(amount: number, options?: Readonly<{
|
|
7
|
+
precision: "exact";
|
|
8
|
+
}>): string;
|
package/dist/lib/format-money.js
CHANGED
|
@@ -4,19 +4,22 @@ const regularUsd = new Intl.NumberFormat("en-US", {
|
|
|
4
4
|
minimumFractionDigits: 2,
|
|
5
5
|
maximumFractionDigits: 2,
|
|
6
6
|
});
|
|
7
|
-
const
|
|
7
|
+
const exactUsd = new Intl.NumberFormat("en-US", {
|
|
8
8
|
style: "currency",
|
|
9
9
|
currency: "USD",
|
|
10
10
|
minimumFractionDigits: 2,
|
|
11
11
|
maximumFractionDigits: 5,
|
|
12
12
|
});
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Format USD to cents, marking non-zero sub-cent amounts rather than rounding
|
|
15
|
+
* them to zero. Callers needing the supported five-decimal precision opt in
|
|
16
|
+
* with `precision: "exact"`.
|
|
17
|
+
*/
|
|
18
|
+
export function formatMoney(amount, options) {
|
|
15
19
|
if (!Number.isFinite(amount))
|
|
16
20
|
throw new RangeError("Money amount must be finite");
|
|
17
|
-
|
|
21
|
+
const subcent = amount !== 0 && Math.abs(amount) < 0.01;
|
|
22
|
+
if (subcent && (options?.precision !== "exact" || Math.abs(amount) < 0.00001))
|
|
18
23
|
return amount < 0 ? ">-$0.01" : "<$0.01";
|
|
19
|
-
return
|
|
20
|
-
? subcentUsd.format(amount)
|
|
21
|
-
: regularUsd.format(amount);
|
|
24
|
+
return subcent ? exactUsd.format(amount) : regularUsd.format(amount);
|
|
22
25
|
}
|
package/dist/schema-form.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export type SchemaFormOptions<V extends FormValues, Out> = {
|
|
|
33
33
|
defaultValues: V;
|
|
34
34
|
/** Receives the parsed value, never the raw input. */
|
|
35
35
|
onSubmit: (value: Out, raw: V) => Promise<SubmitResult<V> | undefined> | Promise<void> | SubmitResult<V> | undefined;
|
|
36
|
+
/** Called only after a saved result, with the new raw-value baseline. */
|
|
37
|
+
onSaved?: ((values: V) => void) | undefined;
|
|
38
|
+
/** Safe message for a thrown submit; the cause is logged, never shown. */
|
|
39
|
+
failure?: string | undefined;
|
|
36
40
|
};
|
|
37
41
|
/** The schema as TanStack validates it: raw values in, any output. */
|
|
38
42
|
export type Validator<V> = StandardSchemaV1<V, unknown>;
|
|
@@ -75,6 +79,9 @@ export type SchemaFormPresentation = {
|
|
|
75
79
|
submitLabel?: ReactNode;
|
|
76
80
|
/** Show a Discard action while there are unsaved changes. */
|
|
77
81
|
discardLabel?: ReactNode;
|
|
82
|
+
/** A secondary exit action, shown even when clean; never submits or discards. */
|
|
83
|
+
onCancel?: () => void;
|
|
84
|
+
cancelLabel?: ReactNode;
|
|
78
85
|
/** Keep the primary action available on a clean form (create forms). */
|
|
79
86
|
allowClean?: boolean;
|
|
80
87
|
/** Replaces the SaveBar, such as a footer with a retry key; `null` drops it. */
|
|
@@ -90,6 +97,8 @@ export type SchemaFormPresentation = {
|
|
|
90
97
|
/** Props accepted by SchemaForm: a model from useSchemaForm, or its options. */
|
|
91
98
|
export type SchemaFormProps<V extends FormValues, Out> = SchemaFormPresentation & ({
|
|
92
99
|
model: SchemaFormModel<V>;
|
|
100
|
+
onSaved?: never;
|
|
101
|
+
failure?: never;
|
|
93
102
|
} | SchemaFormOptions<V, Out>);
|
|
94
103
|
/**
|
|
95
104
|
* A whole form from its schema: fields, sections, validation, busy state and
|
|
@@ -104,6 +113,9 @@ export type SaveBarProps<V extends FormValues> = {
|
|
|
104
113
|
label: ReactNode;
|
|
105
114
|
/** Show a Discard action while there are unsaved changes. */
|
|
106
115
|
discardLabel?: ReactNode;
|
|
116
|
+
/** A secondary exit action that never submits or resets the form. */
|
|
117
|
+
onCancel?: (() => void) | undefined;
|
|
118
|
+
cancelLabel?: ReactNode;
|
|
107
119
|
/**
|
|
108
120
|
* Keep the primary action available on a clean form (create forms that may
|
|
109
121
|
* submit their defaults). By default a clean form has nothing to save.
|
|
@@ -117,4 +129,4 @@ export type SaveBarProps<V extends FormValues> = {
|
|
|
117
129
|
* The form's action row: the refused-submit error, a quiet "Unsaved changes"
|
|
118
130
|
* or "Saved" readout, an optional Discard, and the one primary submit.
|
|
119
131
|
*/
|
|
120
|
-
export declare function SaveBar<V extends FormValues>({ model, label, discardLabel, allowClean, dirtyLabel, buttonProps, ...props }: SaveBarProps<V>): import("react/jsx-runtime").JSX.Element;
|
|
132
|
+
export declare function SaveBar<V extends FormValues>({ model, label, discardLabel, onCancel, cancelLabel, allowClean, dirtyLabel, buttonProps, ...props }: SaveBarProps<V>): import("react/jsx-runtime").JSX.Element;
|
package/dist/schema-form.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { evaluate, useForm, useSelector, } from "@tanstack/react-form";
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import { useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Button } from "./components/button.js";
|
|
5
5
|
import { Form, FormFooter, } from "./components/field.js";
|
|
6
6
|
import { Status } from "./components/status.js";
|
|
7
7
|
import { FormFields } from "./form-fields.js";
|
|
8
8
|
import { describeSchema, standardSchema, } from "./form-schema.js";
|
|
9
9
|
const serverKey = "onServer";
|
|
10
|
+
/** Keep the user's place when a submitted form reveals validation errors. */
|
|
11
|
+
function focusFirstInvalid(form) {
|
|
12
|
+
const invalid = form?.querySelector('[aria-invalid="true"]');
|
|
13
|
+
if (invalid?.matches("input, select, textarea, button, [tabindex]")) {
|
|
14
|
+
invalid.focus();
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
invalid
|
|
18
|
+
?.querySelector("input, select, textarea, button")
|
|
19
|
+
?.focus();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
10
22
|
function hasServerError(field) {
|
|
11
23
|
return field.state.meta.errorMap.onServer !== undefined;
|
|
12
24
|
}
|
|
@@ -61,9 +73,17 @@ export function useSchemaForm(options) {
|
|
|
61
73
|
setError(parsed.issues[0]?.message ?? "Check the form and try again.");
|
|
62
74
|
return;
|
|
63
75
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = (await options.onSubmit(parsed.value, value)) ?? {
|
|
79
|
+
kind: "saved",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
console.error("SchemaForm submit failed", cause);
|
|
84
|
+
setError(options.failure ?? "Couldn't save changes. Try again.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
67
87
|
if (result.kind === "refused") {
|
|
68
88
|
setError(result.error ?? null);
|
|
69
89
|
const wide = formApi;
|
|
@@ -76,6 +96,7 @@ export function useSchemaForm(options) {
|
|
|
76
96
|
setSavedBaseline(baseline);
|
|
77
97
|
formApi.reset(baseline);
|
|
78
98
|
setSaved(result.message ?? "Saved");
|
|
99
|
+
options.onSaved?.(baseline);
|
|
79
100
|
},
|
|
80
101
|
});
|
|
81
102
|
return {
|
|
@@ -98,16 +119,28 @@ export function useSchemaFormStatus(model) {
|
|
|
98
119
|
const canSubmit = useSelector(model.form.store, (state) => state.canSubmit);
|
|
99
120
|
return { dirty, submitting, canSubmit };
|
|
100
121
|
}
|
|
101
|
-
function BoundSchemaForm({ model, submitLabel = "Save changes", discardLabel, allowClean, footer, children, disabled = false, ...props }) {
|
|
122
|
+
function BoundSchemaForm({ model, submitLabel = "Save changes", discardLabel, onCancel, cancelLabel, allowClean, layout, footer, children, disabled = false, ...props }) {
|
|
102
123
|
const { submitting } = useSchemaFormStatus(model);
|
|
103
|
-
|
|
124
|
+
const formRef = useRef(null);
|
|
125
|
+
return (_jsxs(Form, { ...props, ref: formRef, layout: layout, busy: submitting, noValidate: true, onSubmit: (event) => {
|
|
104
126
|
event.preventDefault();
|
|
105
127
|
if (!model.form.state.isSubmitting)
|
|
106
|
-
void model.submit()
|
|
107
|
-
|
|
128
|
+
void model.submit().then(() => {
|
|
129
|
+
// React commits field errors after TanStack completes validation.
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
focusFirstInvalid(formRef.current);
|
|
132
|
+
}, 0);
|
|
133
|
+
});
|
|
134
|
+
}, children: [children ?? (_jsx(FormFields, { model: model, disabled: disabled, layout: layout })), footer !== undefined ? (footer) : (_jsx(SaveBar, { model: model, label: submitLabel, discardLabel: onCancel === undefined ? discardLabel : undefined, onCancel: onCancel, cancelLabel: cancelLabel, allowClean: allowClean ?? onCancel !== undefined, divider: onCancel === undefined }))] }));
|
|
108
135
|
}
|
|
109
|
-
function OwnedSchemaForm({ schema, defaultValues, onSubmit, ...props }) {
|
|
110
|
-
const model = useSchemaForm({
|
|
136
|
+
function OwnedSchemaForm({ schema, defaultValues, onSubmit, onSaved, failure, ...props }) {
|
|
137
|
+
const model = useSchemaForm({
|
|
138
|
+
schema,
|
|
139
|
+
defaultValues,
|
|
140
|
+
onSubmit,
|
|
141
|
+
onSaved,
|
|
142
|
+
failure,
|
|
143
|
+
});
|
|
111
144
|
return _jsx(BoundSchemaForm, { ...props, model: model });
|
|
112
145
|
}
|
|
113
146
|
/**
|
|
@@ -122,8 +155,8 @@ export function SchemaForm(props) {
|
|
|
122
155
|
* The form's action row: the refused-submit error, a quiet "Unsaved changes"
|
|
123
156
|
* or "Saved" readout, an optional Discard, and the one primary submit.
|
|
124
157
|
*/
|
|
125
|
-
export function SaveBar({ model, label, discardLabel, allowClean = false, dirtyLabel = "Unsaved changes", buttonProps, ...props }) {
|
|
126
|
-
const { dirty, submitting
|
|
158
|
+
export function SaveBar({ model, label, discardLabel, onCancel, cancelLabel = "Cancel", allowClean = false, dirtyLabel = "Unsaved changes", buttonProps, ...props }) {
|
|
159
|
+
const { dirty, submitting } = useSchemaFormStatus(model);
|
|
127
160
|
const status = submitting ? null : model.saved && !dirty ? (_jsx(Status, { tone: "success", size: "compact", label: model.saved })) : dirty ? (_jsx(Status, { tone: "neutral", size: "compact", label: dirtyLabel })) : null;
|
|
128
|
-
return (_jsxs(FormFooter, { ...props, error: model.error, status: status, children: [discardLabel && dirty ? (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: model.discard, children: discardLabel })) : null, _jsx(Button, { ...buttonProps, type: "submit", loading: submitting, disabled: submitting || !
|
|
161
|
+
return (_jsxs(FormFooter, { ...props, error: model.error, status: status, children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: onCancel, children: cancelLabel })) : discardLabel && dirty ? (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: model.discard, children: discardLabel })) : null, _jsx(Button, { ...buttonProps, type: "submit", loading: submitting, disabled: submitting || (!dirty && !allowClean), title: !dirty && !allowClean ? "No changes to save" : undefined, children: label })] }));
|
|
129
162
|
}
|