@fungi.computer/caps 0.3.1 → 0.5.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.
@@ -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;
@@ -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
- const result = (await options.onSubmit(parsed.value, value)) ?? {
65
- kind: "saved",
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
- return (_jsxs(Form, { ...props, busy: submitting, noValidate: true, onSubmit: (event) => {
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
- }, children: [children ?? _jsx(FormFields, { model: model, disabled: disabled }), footer !== undefined ? (footer) : (_jsx(SaveBar, { model: model, label: submitLabel, ...(discardLabel === undefined ? {} : { discardLabel }), ...(allowClean === undefined ? {} : { allowClean }) }))] }));
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({ schema, defaultValues, onSubmit });
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, canSubmit } = useSchemaFormStatus(model);
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 || !canSubmit || (!dirty && !allowClean), children: label })] }));
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
  }