@timeax/form-palette 0.0.1
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/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/core/core-root.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { useCore } from "@/core/hooks/use-core";
|
|
6
|
+
import { ErrorStrip } from "@/core/errors/error-strip";
|
|
7
|
+
import type { CoreContext, Dict } from "@/schema/core";
|
|
8
|
+
|
|
9
|
+
export interface CoreRootProps
|
|
10
|
+
extends React.FormHTMLAttributes<HTMLFormElement> {
|
|
11
|
+
/**
|
|
12
|
+
* If true, the global ErrorStrip will not be rendered automatically.
|
|
13
|
+
*/
|
|
14
|
+
noErrorStrip?: boolean;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optional hook invoked after CoreRoot orchestrates the submit.
|
|
18
|
+
*
|
|
19
|
+
* - The native event is already `preventDefault()`-ed.
|
|
20
|
+
* - The adapter flow is triggered via `form.go(...)`.
|
|
21
|
+
* - Use this to tap into submit without breaking the core.
|
|
22
|
+
*/
|
|
23
|
+
onSubmitForm?(
|
|
24
|
+
event: React.FormEvent<HTMLFormElement>,
|
|
25
|
+
form: CoreContext<Dict>
|
|
26
|
+
): void | Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CoreRoot: actual <form> element wired to the core runtime.
|
|
31
|
+
*
|
|
32
|
+
* Responsibilities:
|
|
33
|
+
* - Own the native submit event and prevent full-page navigation.
|
|
34
|
+
* - Delegate submit orchestration to form.go().
|
|
35
|
+
* - Optionally render the global ErrorStrip at the top.
|
|
36
|
+
*/
|
|
37
|
+
export function CoreRoot(props: CoreRootProps) {
|
|
38
|
+
const { noErrorStrip, onSubmitForm, children, ...rest } = props;
|
|
39
|
+
|
|
40
|
+
const form = useCore<Dict>();
|
|
41
|
+
|
|
42
|
+
const handleSubmit = React.useCallback(
|
|
43
|
+
(event: React.FormEvent<HTMLFormElement>) => {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
if (form.props.activateButtonOnChange && !form.isDirty()) return;
|
|
46
|
+
// Core submit orchestration (adapter-specific behaviour lives inside).
|
|
47
|
+
form.go();
|
|
48
|
+
|
|
49
|
+
// Optional host-level hook.
|
|
50
|
+
if (onSubmitForm) {
|
|
51
|
+
void onSubmitForm(event, form);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If the host provided a native onSubmit prop, call it too.
|
|
55
|
+
if (typeof rest.onSubmit === "function") {
|
|
56
|
+
rest.onSubmit(event);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
[form, onSubmitForm, rest.onSubmit]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// We intentionally override onSubmit so the core owns submit routing.
|
|
64
|
+
const { onSubmit: _ignored, ...passThrough } = rest;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<form onSubmit={handleSubmit} {...passThrough}>
|
|
68
|
+
{!noErrorStrip && <ErrorStrip form={form} />}
|
|
69
|
+
{children}
|
|
70
|
+
</form>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/core/core-shell.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { CoreProvider } from "@/core/core-provider";
|
|
6
|
+
import { CoreRoot, type CoreRootProps } from "@/core/core-root";
|
|
7
|
+
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
import type { AdapterKey } from "@/schema/adapter";
|
|
10
|
+
import type { CoreProps, Dict } from "@/schema/core";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Combined provider + form-root wrapper.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* <CoreShell adapter="local" schema={schema} formProps={{ className: "space-y-4" }}>
|
|
17
|
+
* {...fields + buttons...}
|
|
18
|
+
* </CoreShell>
|
|
19
|
+
*/
|
|
20
|
+
export interface CoreShellProps<
|
|
21
|
+
V extends Dict = Dict,
|
|
22
|
+
S extends z.ZodType | undefined = z.ZodType | undefined,
|
|
23
|
+
K extends AdapterKey = "local",
|
|
24
|
+
> extends CoreProps<V, S, K> {
|
|
25
|
+
/**
|
|
26
|
+
* Props passed directly to the underlying <form> element via CoreRoot.
|
|
27
|
+
*/
|
|
28
|
+
formProps?: CoreRootProps;
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CoreShell<
|
|
33
|
+
V extends Dict,
|
|
34
|
+
S extends z.ZodType | undefined,
|
|
35
|
+
K extends AdapterKey = "local",
|
|
36
|
+
>(props: CoreShellProps<V, S, K>) {
|
|
37
|
+
const { formProps, children, ...coreProps } = props;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<CoreProvider<V, S, K> {...(coreProps as CoreProps<V, S, K>)}>
|
|
41
|
+
<CoreRoot {...(formProps ?? {})}>{children}</CoreRoot>
|
|
42
|
+
</CoreProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/core/errors/error-strip.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { useCore } from "@/core/hooks/use-core";
|
|
6
|
+
import type { CoreContext, Dict } from "@/schema/core";
|
|
7
|
+
|
|
8
|
+
export interface ErrorStripProps extends React.HTMLAttributes<HTMLElement> {
|
|
9
|
+
/**
|
|
10
|
+
* Optional explicit form context. If omitted, the strip will use
|
|
11
|
+
* the nearest CoreProvider via useCore().
|
|
12
|
+
*/
|
|
13
|
+
form?: CoreContext<Dict>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optional explicit messages. If provided, these are used instead of
|
|
17
|
+
* form.getUncaught().
|
|
18
|
+
*/
|
|
19
|
+
messages?: readonly string[];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Custom renderer for each message.
|
|
23
|
+
*/
|
|
24
|
+
renderMessage?: (message: string, index: number) => React.ReactNode;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wrapper element type. Defaults to "div".
|
|
28
|
+
*/
|
|
29
|
+
as?: React.ElementType;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props forwarded to the inner <ul> element.
|
|
33
|
+
*/
|
|
34
|
+
listProps?: React.HTMLAttributes<HTMLUListElement>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Simple global/uncaught error renderer.
|
|
39
|
+
*
|
|
40
|
+
* Reads messages from `form.getUncaught()` (unless `messages` is provided)
|
|
41
|
+
* and renders them as a list.
|
|
42
|
+
*/
|
|
43
|
+
export function ErrorStrip(props: ErrorStripProps) {
|
|
44
|
+
const {
|
|
45
|
+
form: formProp,
|
|
46
|
+
messages: messagesProp,
|
|
47
|
+
renderMessage,
|
|
48
|
+
as: As = "div",
|
|
49
|
+
listProps,
|
|
50
|
+
...wrapperProps
|
|
51
|
+
} = props;
|
|
52
|
+
|
|
53
|
+
const ctxFromHook = useCore<Dict>();
|
|
54
|
+
const form = formProp ?? ctxFromHook;
|
|
55
|
+
|
|
56
|
+
const messages = messagesProp ?? form?.getUncaught?.() ?? [];
|
|
57
|
+
|
|
58
|
+
if (!messages.length) return null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<As {...wrapperProps}>
|
|
62
|
+
<ul {...listProps}>
|
|
63
|
+
{messages.map((msg, index) => (
|
|
64
|
+
<li key={index}>
|
|
65
|
+
{renderMessage ? renderMessage(msg, index) : msg}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
</As>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/core/errors/map-error-bag.ts
|
|
2
|
+
|
|
3
|
+
export type ErrorBag = Record<string, string | string[] | undefined | null>;
|
|
4
|
+
|
|
5
|
+
export type ErrorBagMapResult = {
|
|
6
|
+
/** Field-specific errors keyed by field name. */
|
|
7
|
+
fieldErrors: Record<string, string>;
|
|
8
|
+
/** Errors that could not be mapped to a specific field. */
|
|
9
|
+
uncaught: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Map a generic "error bag" object into field errors + uncaught messages.
|
|
14
|
+
*
|
|
15
|
+
* Typical input:
|
|
16
|
+
* {
|
|
17
|
+
* name: "Name is required",
|
|
18
|
+
* email: ["Email is invalid"],
|
|
19
|
+
* message: "Something went wrong" // global
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Heuristics:
|
|
23
|
+
* - Keys like "message", "error", "_", "global" → treated as global/uncaught.
|
|
24
|
+
* - Everything else → treated as a field error.
|
|
25
|
+
* - Array values are joined with "\n".
|
|
26
|
+
*/
|
|
27
|
+
export function mapErrorBag(bag: ErrorBag): ErrorBagMapResult {
|
|
28
|
+
const fieldErrors: Record<string, string> = {};
|
|
29
|
+
const uncaught: string[] = [];
|
|
30
|
+
|
|
31
|
+
const GLOBAL_KEYS = new Set(["message", "error", "errors", "_", "global"]);
|
|
32
|
+
|
|
33
|
+
for (const [key, raw] of Object.entries(bag)) {
|
|
34
|
+
if (raw == null) continue;
|
|
35
|
+
|
|
36
|
+
const value = Array.isArray(raw)
|
|
37
|
+
? raw.filter(Boolean).join("\n")
|
|
38
|
+
: String(raw);
|
|
39
|
+
|
|
40
|
+
if (!value) continue;
|
|
41
|
+
|
|
42
|
+
if (GLOBAL_KEYS.has(key)) {
|
|
43
|
+
uncaught.push(value);
|
|
44
|
+
} else {
|
|
45
|
+
const existing = fieldErrors[key];
|
|
46
|
+
fieldErrors[key] = existing ? `${existing}\n${value}` : value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { fieldErrors, uncaught };
|
|
51
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/core/errors/map-zod.ts
|
|
2
|
+
import type { $ZodIssue, $ZodError } from "zod/v4/core";
|
|
3
|
+
|
|
4
|
+
export type ZodErrorMapResult = {
|
|
5
|
+
/** Field-specific errors keyed by field name. */
|
|
6
|
+
fieldErrors: Record<string, string>;
|
|
7
|
+
/** Errors that could not be mapped to a specific field. */
|
|
8
|
+
uncaught: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map a ZodError into field-specific errors + uncaught messages.
|
|
13
|
+
*
|
|
14
|
+
* Heuristics:
|
|
15
|
+
* - If issue.path[0] is a string → treated as a field name.
|
|
16
|
+
* - Otherwise → message is pushed into `uncaught`.
|
|
17
|
+
*
|
|
18
|
+
* If a field has multiple issues, messages are joined with `\n`.
|
|
19
|
+
*/
|
|
20
|
+
export function mapZodError(error: $ZodError): ZodErrorMapResult {
|
|
21
|
+
const fieldErrors: Record<string, string> = {};
|
|
22
|
+
const uncaught: string[] = [];
|
|
23
|
+
|
|
24
|
+
for (const issue of error.issues as $ZodIssue[]) {
|
|
25
|
+
const path = issue.path;
|
|
26
|
+
const message = issue.message || "Validation error";
|
|
27
|
+
|
|
28
|
+
const first = path[0];
|
|
29
|
+
|
|
30
|
+
if (typeof first === "string" && first.length > 0) {
|
|
31
|
+
const existing = fieldErrors[first];
|
|
32
|
+
fieldErrors[first] = existing ? `${existing}\n${message}` : message;
|
|
33
|
+
} else {
|
|
34
|
+
uncaught.push(message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { fieldErrors, uncaught };
|
|
39
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// src/core/hooks/use-button.ts
|
|
2
|
+
// noinspection JSUnusedGlobalSymbols
|
|
3
|
+
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { useCoreContext } from "@/core/hooks/use-core-context";
|
|
7
|
+
import type { CoreContext, Dict } from "@/schema/core";
|
|
8
|
+
import type { ButtonRef } from "@/schema/field";
|
|
9
|
+
|
|
10
|
+
export interface UseButtonOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Logical name of the button.
|
|
13
|
+
*
|
|
14
|
+
* Used by the core to:
|
|
15
|
+
* - mark this as the "active" button before submit
|
|
16
|
+
* - toggle loading/disabled specifically for this button
|
|
17
|
+
*/
|
|
18
|
+
name: string;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* If true, clicking this button should trigger a submit:
|
|
22
|
+
*
|
|
23
|
+
* - form.setActiveButton(name)
|
|
24
|
+
* - form.go()
|
|
25
|
+
*/
|
|
26
|
+
submit?: boolean;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initial disabled state.
|
|
30
|
+
*/
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optional click handler.
|
|
35
|
+
*
|
|
36
|
+
* This runs *in addition to* the submit behavior (if `submit` is true).
|
|
37
|
+
* You can call `event.preventDefault()` to prevent the auto-submit.
|
|
38
|
+
*/
|
|
39
|
+
onClick?(
|
|
40
|
+
event: React.MouseEvent<HTMLButtonElement>,
|
|
41
|
+
form: CoreContext<Dict>
|
|
42
|
+
): void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UseButtonReturn {
|
|
46
|
+
/**
|
|
47
|
+
* Current loading state, controlled by the core (via adapters) and
|
|
48
|
+
* optionally by the host via setLoading.
|
|
49
|
+
*/
|
|
50
|
+
loading: boolean;
|
|
51
|
+
setLoading(loading: boolean): void;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Current disabled state.
|
|
55
|
+
*/
|
|
56
|
+
disabled: boolean;
|
|
57
|
+
setDisabled(disabled: boolean): void;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ref for the underlying <button>.
|
|
61
|
+
*/
|
|
62
|
+
ref: React.RefObject<HTMLButtonElement>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Click handler wired to the core.
|
|
66
|
+
*/
|
|
67
|
+
onClick(event: React.MouseEvent<HTMLButtonElement>): void;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convenience bundle for spreading onto a <button>.
|
|
71
|
+
*
|
|
72
|
+
* Example:
|
|
73
|
+
* const btn = useButton({ name: "save", submit: true });
|
|
74
|
+
* return <button {...btn.buttonProps}>Save</button>;
|
|
75
|
+
*/
|
|
76
|
+
buttonProps: {
|
|
77
|
+
ref: React.RefObject<HTMLButtonElement>;
|
|
78
|
+
disabled: boolean;
|
|
79
|
+
"data-loading"?: "true" | "false";
|
|
80
|
+
onClick(event: React.MouseEvent<HTMLButtonElement>): void;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* useButton
|
|
86
|
+
*
|
|
87
|
+
* - Registers a ButtonRef with the core.
|
|
88
|
+
* - Cooperates with setActiveButton + adapter-based submit.
|
|
89
|
+
* - Handles loading/disabled toggling via the core's callbacks.
|
|
90
|
+
*/
|
|
91
|
+
export function useButton(options: UseButtonOptions): UseButtonReturn {
|
|
92
|
+
const form = useCoreContext<Dict>();
|
|
93
|
+
|
|
94
|
+
const { name, submit, disabled: disabledProp = false, onClick } = options;
|
|
95
|
+
|
|
96
|
+
const [loading, setLoadingState] = React.useState<boolean>(false);
|
|
97
|
+
const [disabled, setDisabledState] = React.useState<boolean>(
|
|
98
|
+
Boolean(disabledProp)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const ref = React.useRef<HTMLButtonElement>(null);
|
|
102
|
+
|
|
103
|
+
// Keep latest options for callbacks
|
|
104
|
+
const optsRef = React.useRef<UseButtonOptions>(options);
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
optsRef.current = options;
|
|
107
|
+
}, [options]);
|
|
108
|
+
|
|
109
|
+
// Build the ButtonRef once
|
|
110
|
+
const buttonRef = React.useRef<ButtonRef | (ButtonRef & any) | null>(null);
|
|
111
|
+
|
|
112
|
+
if (!buttonRef.current) {
|
|
113
|
+
// @ts-ignore
|
|
114
|
+
const btn: ButtonRef & {
|
|
115
|
+
loading: boolean;
|
|
116
|
+
disabled: boolean;
|
|
117
|
+
setLoading?(v: boolean): void;
|
|
118
|
+
setDisabled?(v: boolean): void;
|
|
119
|
+
ref?: React.RefObject<HTMLButtonElement>;
|
|
120
|
+
} = {
|
|
121
|
+
name,
|
|
122
|
+
// Accessor for "loading" as required by ButtonRef
|
|
123
|
+
set loading(v: boolean) {
|
|
124
|
+
setLoadingState(v);
|
|
125
|
+
},
|
|
126
|
+
// Accessor for "disable" (note: interface uses `disable`, not `disabled`)
|
|
127
|
+
//@ts-ignore
|
|
128
|
+
set disable(v: boolean) {
|
|
129
|
+
setDisabledState(v);
|
|
130
|
+
},
|
|
131
|
+
// Extra properties used by CoreProvider via any-casts
|
|
132
|
+
get loading() {
|
|
133
|
+
return loading;
|
|
134
|
+
},
|
|
135
|
+
setDisabled(v: boolean) {
|
|
136
|
+
setDisabledState(v);
|
|
137
|
+
},
|
|
138
|
+
get disabled() {
|
|
139
|
+
return disabled;
|
|
140
|
+
},
|
|
141
|
+
ref,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Also expose setLoading for CoreProvider's convenience
|
|
145
|
+
(btn as any).setLoading = (v: boolean) => {
|
|
146
|
+
setLoadingState(v);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
buttonRef.current = btn;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Keep mutable button properties in sync when name changes
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (!buttonRef.current) return;
|
|
155
|
+
buttonRef.current.name = name;
|
|
156
|
+
}, [name]);
|
|
157
|
+
|
|
158
|
+
// Register this button with the core
|
|
159
|
+
React.useEffect(() => {
|
|
160
|
+
if (!buttonRef.current) return;
|
|
161
|
+
|
|
162
|
+
// Expose to the core runtime so submitWithAdapter can toggle loading.
|
|
163
|
+
(form as any).button = buttonRef.current;
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
// On unmount, if the core still points to this button,
|
|
167
|
+
// we simply clear it.
|
|
168
|
+
const anyForm = form as any;
|
|
169
|
+
if (anyForm.button === buttonRef.current) {
|
|
170
|
+
anyForm.button = null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, [form]);
|
|
175
|
+
|
|
176
|
+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
177
|
+
const currentOpts = optsRef.current;
|
|
178
|
+
const shouldSubmit = !!currentOpts.submit;
|
|
179
|
+
|
|
180
|
+
// Host-level handler first
|
|
181
|
+
if (currentOpts.onClick) {
|
|
182
|
+
currentOpts.onClick(event, form);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (event.defaultPrevented) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (shouldSubmit) {
|
|
190
|
+
// Mark this as the active button for the submit cycle.
|
|
191
|
+
form.setActiveButton(currentOpts.name);
|
|
192
|
+
|
|
193
|
+
// Kick off the standard submit pipeline.
|
|
194
|
+
form.go();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const setLoading = (v: boolean) => {
|
|
199
|
+
setLoadingState(v);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const setDisabled = (v: boolean) => {
|
|
203
|
+
setDisabledState(v);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
loading,
|
|
208
|
+
setLoading,
|
|
209
|
+
disabled,
|
|
210
|
+
setDisabled,
|
|
211
|
+
ref,
|
|
212
|
+
onClick: handleClick,
|
|
213
|
+
buttonProps: {
|
|
214
|
+
ref,
|
|
215
|
+
disabled: disabled || loading,
|
|
216
|
+
"data-loading": loading ? "true" : "false",
|
|
217
|
+
onClick: handleClick,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/core/hooks/use-core-context.ts
|
|
2
|
+
import { useContext } from "react";
|
|
3
|
+
import { CoreContextReact } from "@/core/context";
|
|
4
|
+
import type { CoreContext, Dict } from "@/schema/core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Typed hook to access the current core/form context.
|
|
8
|
+
*
|
|
9
|
+
* Must be used inside a <CoreProvider>. If no provider is found,
|
|
10
|
+
* this will throw to make misuse obvious.
|
|
11
|
+
*/
|
|
12
|
+
export function useCoreContext<V extends Dict = Dict>(): CoreContext<V> {
|
|
13
|
+
const ctx = useContext(CoreContextReact);
|
|
14
|
+
|
|
15
|
+
if (!ctx) {
|
|
16
|
+
throw new Error("useCoreContext must be used within a <CoreProvider>.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return ctx as CoreContext<V>;
|
|
20
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/core/hooks/use-core.ts
|
|
2
|
+
import type { CoreContext, Dict } from "@/schema/core";
|
|
3
|
+
import { useCoreContext } from "./use-core-context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convenience alias for useCoreContext.
|
|
7
|
+
*
|
|
8
|
+
* This mirrors the legacy useForm hook: you get the full CoreContext,
|
|
9
|
+
* and can call core.values(), core.submit(), core.go(), etc.
|
|
10
|
+
*/
|
|
11
|
+
export function useCore<V extends Dict = Dict>(): CoreContext<V> {
|
|
12
|
+
return useCoreContext<V>();
|
|
13
|
+
}
|