@funstack/ui 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/accordion/accordion-context.d.mts +5 -0
- package/dist/accordion/accordion-context.d.mts.map +1 -0
- package/dist/accordion/accordion-context.mjs +9 -0
- package/dist/accordion/accordion-context.mjs.map +1 -0
- package/dist/accordion/accordion-item.d.mts +53 -0
- package/dist/accordion/accordion-item.d.mts.map +1 -0
- package/dist/accordion/accordion-item.mjs +85 -0
- package/dist/accordion/accordion-item.mjs.map +1 -0
- package/dist/accordion/accordion-panel.d.mts +40 -0
- package/dist/accordion/accordion-panel.d.mts.map +1 -0
- package/dist/accordion/accordion-panel.mjs +40 -0
- package/dist/accordion/accordion-panel.mjs.map +1 -0
- package/dist/accordion/accordion-root.d.mts +90 -0
- package/dist/accordion/accordion-root.d.mts.map +1 -0
- package/dist/accordion/accordion-root.mjs +229 -0
- package/dist/accordion/accordion-root.mjs.map +1 -0
- package/dist/accordion/accordion-trigger.d.mts +37 -0
- package/dist/accordion/accordion-trigger.d.mts.map +1 -0
- package/dist/accordion/accordion-trigger.mjs +81 -0
- package/dist/accordion/accordion-trigger.mjs.map +1 -0
- package/dist/accordion/index.d.mts +20 -0
- package/dist/accordion/index.d.mts.map +1 -0
- package/dist/accordion/index.mjs +20 -0
- package/dist/accordion/index.mjs.map +1 -0
- package/dist/checkbox/checkbox.d.mts +92 -0
- package/dist/checkbox/checkbox.d.mts.map +1 -0
- package/dist/checkbox/checkbox.mjs +82 -0
- package/dist/checkbox/checkbox.mjs.map +1 -0
- package/dist/checkbox/index.d.mts +3 -0
- package/dist/checkbox/index.mjs +1 -0
- package/dist/dialog/dialog-close.d.mts +44 -0
- package/dist/dialog/dialog-close.d.mts.map +1 -0
- package/dist/dialog/dialog-close.mjs +76 -0
- package/dist/dialog/dialog-close.mjs.map +1 -0
- package/dist/dialog/dialog-context.mjs +13 -0
- package/dist/dialog/dialog-context.mjs.map +1 -0
- package/dist/dialog/dialog-description.d.mts +22 -0
- package/dist/dialog/dialog-description.d.mts.map +1 -0
- package/dist/dialog/dialog-description.mjs +31 -0
- package/dist/dialog/dialog-description.mjs.map +1 -0
- package/dist/dialog/dialog-form.d.mts +58 -0
- package/dist/dialog/dialog-form.d.mts.map +1 -0
- package/dist/dialog/dialog-form.mjs +108 -0
- package/dist/dialog/dialog-form.mjs.map +1 -0
- package/dist/dialog/dialog-popup.d.mts +81 -0
- package/dist/dialog/dialog-popup.d.mts.map +1 -0
- package/dist/dialog/dialog-popup.mjs +366 -0
- package/dist/dialog/dialog-popup.mjs.map +1 -0
- package/dist/dialog/dialog-root.d.mts +70 -0
- package/dist/dialog/dialog-root.d.mts.map +1 -0
- package/dist/dialog/dialog-root.mjs +105 -0
- package/dist/dialog/dialog-root.mjs.map +1 -0
- package/dist/dialog/dialog-title.d.mts +23 -0
- package/dist/dialog/dialog-title.d.mts.map +1 -0
- package/dist/dialog/dialog-title.mjs +31 -0
- package/dist/dialog/dialog-title.mjs.map +1 -0
- package/dist/dialog/dialog-trigger.d.mts +39 -0
- package/dist/dialog/dialog-trigger.d.mts.map +1 -0
- package/dist/dialog/dialog-trigger.mjs +80 -0
- package/dist/dialog/dialog-trigger.mjs.map +1 -0
- package/dist/dialog/index.d.mts +28 -0
- package/dist/dialog/index.d.mts.map +1 -0
- package/dist/dialog/index.mjs +28 -0
- package/dist/dialog/index.mjs.map +1 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.mjs +9 -0
- package/dist/internal/action-details.mjs +47 -0
- package/dist/internal/action-details.mjs.map +1 -0
- package/dist/internal/data-attr.mjs +13 -0
- package/dist/internal/data-attr.mjs.map +1 -0
- package/dist/internal/merge-props.mjs +119 -0
- package/dist/internal/merge-props.mjs.map +1 -0
- package/dist/internal/platform-attributes.d.mts +49 -0
- package/dist/internal/platform-attributes.d.mts.map +1 -0
- package/dist/internal/transition-boundary.mjs +52 -0
- package/dist/internal/transition-boundary.mjs.map +1 -0
- package/dist/internal/transition-types.mjs +15 -0
- package/dist/internal/transition-types.mjs.map +1 -0
- package/dist/internal/types.d.mts +38 -0
- package/dist/internal/types.d.mts.map +1 -0
- package/dist/internal/use-controllable-action.mjs +115 -0
- package/dist/internal/use-controllable-action.mjs.map +1 -0
- package/dist/internal/use-dev-warning.mjs +21 -0
- package/dist/internal/use-dev-warning.mjs.map +1 -0
- package/dist/internal/use-form-reset.mjs +50 -0
- package/dist/internal/use-form-reset.mjs.map +1 -0
- package/dist/internal/use-native-checked.mjs +113 -0
- package/dist/internal/use-native-checked.mjs.map +1 -0
- package/dist/internal/use-native-group-reconciliation.mjs +64 -0
- package/dist/internal/use-native-group-reconciliation.mjs.map +1 -0
- package/dist/internal/use-render.mjs +51 -0
- package/dist/internal/use-render.mjs.map +1 -0
- package/dist/internal/use-until-found.mjs +40 -0
- package/dist/internal/use-until-found.mjs.map +1 -0
- package/dist/radio-group/index.d.mts +20 -0
- package/dist/radio-group/index.d.mts.map +1 -0
- package/dist/radio-group/index.mjs +20 -0
- package/dist/radio-group/index.mjs.map +1 -0
- package/dist/radio-group/radio-group-context.mjs +13 -0
- package/dist/radio-group/radio-group-context.mjs.map +1 -0
- package/dist/radio-group/radio-group-item.d.mts +54 -0
- package/dist/radio-group/radio-group-item.d.mts.map +1 -0
- package/dist/radio-group/radio-group-item.mjs +88 -0
- package/dist/radio-group/radio-group-item.mjs.map +1 -0
- package/dist/radio-group/radio-group-label.d.mts +21 -0
- package/dist/radio-group/radio-group-label.d.mts.map +1 -0
- package/dist/radio-group/radio-group-label.mjs +30 -0
- package/dist/radio-group/radio-group-label.mjs.map +1 -0
- package/dist/radio-group/radio-group-root.d.mts +92 -0
- package/dist/radio-group/radio-group-root.d.mts.map +1 -0
- package/dist/radio-group/radio-group-root.mjs +137 -0
- package/dist/radio-group/radio-group-root.mjs.map +1 -0
- package/dist/switch/index.d.mts +3 -0
- package/dist/switch/index.mjs +1 -0
- package/dist/switch/switch.d.mts +78 -0
- package/dist/switch/switch.d.mts.map +1 -0
- package/dist/switch/switch.mjs +77 -0
- package/dist/switch/switch.mjs.map +1 -0
- package/dist/tabs/index.d.mts +24 -0
- package/dist/tabs/index.d.mts.map +1 -0
- package/dist/tabs/index.mjs +23 -0
- package/dist/tabs/index.mjs.map +1 -0
- package/dist/tabs/tabs-context.d.mts +7 -0
- package/dist/tabs/tabs-context.d.mts.map +1 -0
- package/dist/tabs/tabs-context.mjs +13 -0
- package/dist/tabs/tabs-context.mjs.map +1 -0
- package/dist/tabs/tabs-ids.mjs +39 -0
- package/dist/tabs/tabs-ids.mjs.map +1 -0
- package/dist/tabs/tabs-indicator.d.mts +32 -0
- package/dist/tabs/tabs-indicator.d.mts.map +1 -0
- package/dist/tabs/tabs-indicator.mjs +49 -0
- package/dist/tabs/tabs-indicator.mjs.map +1 -0
- package/dist/tabs/tabs-list.d.mts +34 -0
- package/dist/tabs/tabs-list.d.mts.map +1 -0
- package/dist/tabs/tabs-list.mjs +122 -0
- package/dist/tabs/tabs-list.mjs.map +1 -0
- package/dist/tabs/tabs-panel.d.mts +50 -0
- package/dist/tabs/tabs-panel.d.mts.map +1 -0
- package/dist/tabs/tabs-panel.mjs +81 -0
- package/dist/tabs/tabs-panel.mjs.map +1 -0
- package/dist/tabs/tabs-root.d.mts +64 -0
- package/dist/tabs/tabs-root.d.mts.map +1 -0
- package/dist/tabs/tabs-root.mjs +90 -0
- package/dist/tabs/tabs-root.mjs.map +1 -0
- package/dist/tabs/tabs-tab.d.mts +49 -0
- package/dist/tabs/tabs-tab.d.mts.map +1 -0
- package/dist/tabs/tabs-tab.mjs +86 -0
- package/dist/tabs/tabs-tab.mjs.map +1 -0
- package/dist/toggle/index.d.mts +3 -0
- package/dist/toggle/index.mjs +1 -0
- package/dist/toggle/toggle.d.mts +57 -0
- package/dist/toggle/toggle.d.mts.map +1 -0
- package/dist/toggle/toggle.mjs +59 -0
- package/dist/toggle/toggle.mjs.map +1 -0
- package/dist/tooltip/index.d.mts +20 -0
- package/dist/tooltip/index.d.mts.map +1 -0
- package/dist/tooltip/index.mjs +19 -0
- package/dist/tooltip/index.mjs.map +1 -0
- package/dist/tooltip/tooltip-context.d.mts +5 -0
- package/dist/tooltip/tooltip-context.d.mts.map +1 -0
- package/dist/tooltip/tooltip-context.mjs +13 -0
- package/dist/tooltip/tooltip-context.mjs.map +1 -0
- package/dist/tooltip/tooltip-popup.d.mts +49 -0
- package/dist/tooltip/tooltip-popup.d.mts.map +1 -0
- package/dist/tooltip/tooltip-popup.mjs +57 -0
- package/dist/tooltip/tooltip-popup.mjs.map +1 -0
- package/dist/tooltip/tooltip-root.d.mts +41 -0
- package/dist/tooltip/tooltip-root.d.mts.map +1 -0
- package/dist/tooltip/tooltip-root.mjs +86 -0
- package/dist/tooltip/tooltip-root.mjs.map +1 -0
- package/dist/tooltip/tooltip-trigger.d.mts +38 -0
- package/dist/tooltip/tooltip-trigger.d.mts.map +1 -0
- package/dist/tooltip/tooltip-trigger.mjs +55 -0
- package/dist/tooltip/tooltip-trigger.mjs.map +1 -0
- package/dist/tooltip/use-tooltip-interaction.mjs +131 -0
- package/dist/tooltip/use-tooltip-interaction.mjs.map +1 -0
- package/package.json +70 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transition-boundary.mjs","names":[],"sources":["../../src/internal/transition-boundary.tsx"],"sourcesContent":["\"use client\";\nimport { useEffect, useRef, ViewTransition } from \"react\";\nimport type { ReactNode, ViewTransitionClass } from \"react\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\n/**\n * Wraps a part's output in `<ViewTransition default={transition}>` **only when the consumer\n * passed `transition`** (conventions 5.1). With `transition === undefined` the node is returned\n * unwrapped and the part renders no boundary at all.\n *\n * Reason (docs/research/view-transition-mount-cost.md, issue #35): react-dom 19.3 starts a\n * document view transition whenever a `<ViewTransition>` mounts or is revealed by `<Activity>` in\n * a transition-lane commit, whatever its class resolves to (`\"none\"` included). The browser's\n * 250 ms root cross-fade then runs and every later transition commit waits for it. Exits and\n * updates of a `\"none\"` boundary are free, so an explicit `transition=\"none\"` keeps the boundary\n * and lets a consumer switch between `\"none\"` and a class without remounting.\n *\n * The boundary stays the outermost node of the part's output, so a part that is the top of an\n * inserted subtree gets enter / exit. A plain function (not a component), so it adds no fiber\n * between the boundary and the element.\n */\nexport function transitionBoundary(\n transition: ViewTransitionClass | undefined,\n node: ReactNode,\n): ReactNode {\n if (transition === undefined) return node;\n return <ViewTransition default={transition}>{node}</ViewTransition>;\n}\n\n/**\n * {@link transitionBoundary} plus the development warning for a non-static `transition`\n * (conventions 5.1): changing it between `undefined` and a value changes the element type at the\n * part's root, so React remounts the part's subtree (DOM and state lost). Warns once per\n * instance, from an effect. `part` is the public name used in the message (`\"Tabs.Panel\"`).\n *\n * Pass the *effective* value: a part that ignores `transition` in some mode (Accordion, Dialog\n * JS-free mode) passes `undefined` there.\n */\nexport function useTransitionBoundary(\n part: string,\n transition: ViewTransitionClass | undefined,\n node: ReactNode,\n): ReactNode {\n const hasBoundary = transition !== undefined;\n const initial = useRef(hasBoundary);\n const warned = useRef(false);\n useEffect(() => {\n if (!isDev || warned.current || hasBoundary === initial.current) return;\n warned.current = true;\n console.error(\n `${part}: \\`transition\\` changed between undefined and a value after mount. The part ` +\n \"renders a <ViewTransition> only when `transition` is given, so this remounts its \" +\n 'subtree (DOM and state are lost). Keep `transition` static; use `transition=\"none\"` ' +\n \"to switch animation off without removing the boundary.\",\n );\n }, [hasBoundary, part]);\n return transitionBoundary(transition, node);\n}\n"],"mappings":";;;;AAIA,MAAM,QAAQ,QAAQ,IAAI,aAAa;;;;;;;;;;;;;;;;;AAkBvC,SAAgB,mBACd,YACA,MACW;CACX,IAAI,eAAe,KAAA,GAAW,OAAO;CACrC,OAAO,oBAAC,gBAAD;EAAgB,SAAS;EAAa,UAAA;CAAqB,CAAA;AACpE;;;;;;;;;;AAWA,SAAgB,sBACd,MACA,YACA,MACW;CACX,MAAM,cAAc,eAAe,KAAA;CACnC,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,SAAS,OAAO,KAAK;CAC3B,gBAAgB;EACd,IAAI,CAAC,SAAS,OAAO,WAAW,gBAAgB,QAAQ,SAAS;EACjE,OAAO,UAAU;EACjB,QAAQ,MACN,GAAG,KAAK,+SAIV;CACF,GAAG,CAAC,aAAa,IAAI,CAAC;CACtB,OAAO,mBAAmB,YAAY,IAAI;AAC5C"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/internal/transition-types.ts
|
|
2
|
+
/**
|
|
3
|
+
* Builds the library's View Transition type names for one dispatch (conventions 5.3):
|
|
4
|
+
* `fsui-<component>` plus, when the change has a kind, `fsui-<component>-<change>`.
|
|
5
|
+
*
|
|
6
|
+
* Hyphens only: the names are also matched by `:active-view-transition-type(...)` in CSS.
|
|
7
|
+
*/
|
|
8
|
+
function transitionTypes(component, change) {
|
|
9
|
+
const base = `fsui-${component}`;
|
|
10
|
+
return change ? [base, `${base}-${change}`] : [base];
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { transitionTypes };
|
|
14
|
+
|
|
15
|
+
//# sourceMappingURL=transition-types.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transition-types.mjs","names":[],"sources":["../../src/internal/transition-types.ts"],"sourcesContent":["/**\n * Builds the library's View Transition type names for one dispatch (conventions 5.3):\n * `fsui-<component>` plus, when the change has a kind, `fsui-<component>-<change>`.\n *\n * Hyphens only: the names are also matched by `:active-view-transition-type(...)` in CSS.\n */\nexport function transitionTypes(\n component: string,\n change?: string | null | undefined,\n): readonly string[] {\n const base = `fsui-${component}`;\n return change ? [base, `${base}-${change}`] : [base];\n}\n"],"mappings":";;;;;;;AAMA,SAAgB,gBACd,WACA,QACmB;CACnB,MAAM,OAAO,QAAQ;CACrB,OAAO,SAAS,CAAC,MAAM,GAAG,KAAK,GAAG,QAAQ,IAAI,CAAC,IAAI;AACrD"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ReactElement, ReactNode } from "react";
|
|
2
|
+
//#region src/internal/types.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A callback the library runs inside a Transition. May be sync or async: the library awaits
|
|
5
|
+
* whatever it returns (a thenable is awaited, anything else is ignored). The return type is
|
|
6
|
+
* `unknown` rather than `void | Promise<void>` so that `(next) => save(next)` compiles when `save`
|
|
7
|
+
* returns data, as Server Functions usually do (conventions 2.2).
|
|
8
|
+
*/
|
|
9
|
+
export type Action<Args extends readonly unknown[] = []> = (...args: Args) => unknown;
|
|
10
|
+
export interface ActionDetails {
|
|
11
|
+
/** The DOM event that caused the action, or `undefined` for programmatic changes. */
|
|
12
|
+
readonly event: Event | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** An action called with the next value of a controllable state. */
|
|
15
|
+
export type ChangeAction<T, Details extends ActionDetails = ActionDetails> = Action<[next: T, details: Details]>;
|
|
16
|
+
/**
|
|
17
|
+
* Why a change action was dispatched (conventions 2.2). One library-wide vocabulary: every
|
|
18
|
+
* `<Component><State>ChangeDetails` that exposes `reason` declares it with this type.
|
|
19
|
+
*
|
|
20
|
+
* - `"activate"`: the user acted on a trigger part (click, Enter/Space, label click, focus in an
|
|
21
|
+
* automatic-activation mode). `details.event` is the native event.
|
|
22
|
+
* - `"dismiss"`: the user closed the component without acting on one of its parts: Escape,
|
|
23
|
+
* Android back, an assistive-technology dismiss gesture, or a click outside (light dismiss).
|
|
24
|
+
* `details.event` is the platform's `close` event. Dialog is the first component to use it.
|
|
25
|
+
* - `"platform"`: the browser changed the state itself: find-in-page, a form reset (`event` is
|
|
26
|
+
* the `reset` event), or interaction before hydration found at mount (`event` is `undefined`).
|
|
27
|
+
*/
|
|
28
|
+
export type ChangeReason = "activate" | "dismiss" | "platform";
|
|
29
|
+
/** A prop value that is either a plain value or a function of the part's state. */
|
|
30
|
+
export type StateValue<T, State> = T | ((state: State) => T);
|
|
31
|
+
/**
|
|
32
|
+
* Element polymorphism. Element form replaces the default element and the library merges its
|
|
33
|
+
* props into it; function form receives the merged props and the part's state and renders
|
|
34
|
+
* whatever it likes (it must spread `props` onto a DOM element).
|
|
35
|
+
*/
|
|
36
|
+
export type RenderProp<Props, State> = ReactElement<Partial<Props>> | ((props: Props, state: State) => ReactNode);
|
|
37
|
+
//#endregion
|
|
38
|
+
//# sourceMappingURL=types.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.mts","names":[],"sources":["../../src/internal/types.ts"],"mappings":";;;;;;;;YAQY,OAAO,4CAA4C,MAAM;iBAEpD;;WAEN,OAAO;;;YAIN,aAAa,GAAG,gBAAgB,gBAAgB,iBAAiB,QAC1E,MAAM,GAAG,SAAS;;;;;;;;;;;;;YAeT;;YAGA,WAAW,GAAG,SAAS,MAAM,OAAO,UAAU;;;;;;YAO9C,WAAW,OAAO,SAC1B,aAAa,QAAQ,YACnB,OAAO,OAAO,OAAO,UAAU"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { addTransitionType, startTransition, useEffect, useOptimistic, useRef, useState, useTransition } from "react";
|
|
3
|
+
//#region src/internal/use-controllable-action.ts
|
|
4
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
5
|
+
function capitalize(s) {
|
|
6
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
7
|
+
}
|
|
8
|
+
/** Reports an error that cannot reach a boundary (conventions 2.4: never swallow). */
|
|
9
|
+
function report(error) {
|
|
10
|
+
if (typeof reportError === "function") reportError(error);
|
|
11
|
+
else console.error(error);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The one place the state model of conventions section 3 is implemented: committed state,
|
|
15
|
+
* optimistic state (`useOptimistic`, reducer form), the component's own transition, the
|
|
16
|
+
* post-`await` write of uncontrolled state, the latest-dispatch guard, and the surfacing of
|
|
17
|
+
* superseded rejections (conventions 2.4).
|
|
18
|
+
*/
|
|
19
|
+
function useControllableAction(options) {
|
|
20
|
+
const { controlled, defaultValue, initialValue, action, transitionTypes, reducer, warn } = options;
|
|
21
|
+
const [supersededError, setSupersededError] = useState(null);
|
|
22
|
+
const hasSupersededError = useRef(false);
|
|
23
|
+
if (supersededError !== null) throw supersededError.error;
|
|
24
|
+
const [internal, setInternal] = useState(defaultValue === void 0 ? initialValue : defaultValue);
|
|
25
|
+
const isControlled = controlled !== void 0;
|
|
26
|
+
const committed = isControlled ? controlled : internal;
|
|
27
|
+
const [optimistic, setOptimistic] = useOptimistic(committed, (prev, patch) => reducer ? reducer(prev, patch.next, patch.details) : patch.next);
|
|
28
|
+
const [pending, startOwnTransition] = useTransition();
|
|
29
|
+
const latest = useRef(0);
|
|
30
|
+
const inFlight = useRef({
|
|
31
|
+
count: 0,
|
|
32
|
+
next: void 0
|
|
33
|
+
});
|
|
34
|
+
const mounted = useRef(false);
|
|
35
|
+
useDevWarnings(isControlled, defaultValue !== void 0, warn);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
mounted.current = true;
|
|
38
|
+
return () => {
|
|
39
|
+
mounted.current = false;
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
function dispatch(next, details) {
|
|
43
|
+
const prev = committed;
|
|
44
|
+
const id = ++latest.current;
|
|
45
|
+
inFlight.current.count += 1;
|
|
46
|
+
inFlight.current.next = next;
|
|
47
|
+
startOwnTransition(async () => {
|
|
48
|
+
for (const type of transitionTypes?.(next, prev, details) ?? []) addTransitionType(type);
|
|
49
|
+
setOptimistic({
|
|
50
|
+
next,
|
|
51
|
+
details
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await action?.(next, details);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
settle();
|
|
57
|
+
if (id === latest.current) throw error;
|
|
58
|
+
if (!mounted.current) return;
|
|
59
|
+
if (hasSupersededError.current) {
|
|
60
|
+
report(error);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
hasSupersededError.current = true;
|
|
64
|
+
setSupersededError({ error });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
settle();
|
|
68
|
+
if (!isControlled && id === latest.current) startTransition(() => setInternal(next));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function settle() {
|
|
72
|
+
inFlight.current.count -= 1;
|
|
73
|
+
if (inFlight.current.count === 0) inFlight.current.next = void 0;
|
|
74
|
+
}
|
|
75
|
+
function latestInFlight() {
|
|
76
|
+
const { count, next } = inFlight.current;
|
|
77
|
+
return count > 0 ? { next } : null;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
committed,
|
|
81
|
+
optimistic,
|
|
82
|
+
pending,
|
|
83
|
+
dispatch,
|
|
84
|
+
latestInFlight
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function useDevWarnings(isControlled, hasDefault, warn) {
|
|
88
|
+
const initialMode = useRef(isControlled);
|
|
89
|
+
const warnedBoth = useRef(false);
|
|
90
|
+
const warnedSwitch = useRef(false);
|
|
91
|
+
const component = warn?.component ?? "A component";
|
|
92
|
+
const prop = warn?.prop ?? "value";
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!isDev) return;
|
|
95
|
+
const defaultProp = `default${capitalize(prop)}`;
|
|
96
|
+
if (isControlled && hasDefault && !warnedBoth.current) {
|
|
97
|
+
warnedBoth.current = true;
|
|
98
|
+
console.error(`${component}: both \`${prop}\` and \`${defaultProp}\` are set. Use \`${prop}\` for controlled mode or \`${defaultProp}\` for uncontrolled mode, not both.`);
|
|
99
|
+
}
|
|
100
|
+
if (initialMode.current !== isControlled && !warnedSwitch.current) {
|
|
101
|
+
warnedSwitch.current = true;
|
|
102
|
+
const from = initialMode.current ? "controlled" : "uncontrolled";
|
|
103
|
+
console.error(`${component}: \`${prop}\` changed from ${from} to ${isControlled ? "controlled" : "uncontrolled"}. Switching modes after mount is not supported; keep \`${prop}\` either always defined or always undefined.`);
|
|
104
|
+
}
|
|
105
|
+
}, [
|
|
106
|
+
component,
|
|
107
|
+
prop,
|
|
108
|
+
isControlled,
|
|
109
|
+
hasDefault
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { useControllableAction };
|
|
114
|
+
|
|
115
|
+
//# sourceMappingURL=use-controllable-action.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-controllable-action.mjs","names":[],"sources":["../../src/internal/use-controllable-action.ts"],"sourcesContent":["\"use client\";\nimport {\n addTransitionType,\n startTransition,\n useEffect,\n useOptimistic,\n useRef,\n useState,\n useTransition,\n} from \"react\";\nimport type { ActionDetails, ChangeAction } from \"./types.js\";\n\nexport interface UseControllableActionOptions<T, Details extends ActionDetails> {\n /** The controlled prop (`x`). `undefined` means uncontrolled. */\n controlled: T | undefined;\n /** The `defaultX` prop as the consumer passed it; `undefined` when omitted. */\n defaultValue: T | undefined;\n /** Initial uncontrolled value when neither `x` nor `defaultX` is given. */\n initialValue: T;\n /** The `xChangeAction` prop. Awaited inside this hook's transition. */\n action: ChangeAction<T, Details> | undefined;\n /**\n * View Transition types to attach to the commit that ends a dispatch. Receives the previous\n * committed value and the details object (Tabs derives next/prev direction from them).\n */\n transitionTypes?: (next: T, prev: T, details: Details) => readonly string[];\n /**\n * Optimistic reducer (conventions 3.4). Default: replace the value with `next`. Multi-item\n * states (Accordion's open set) pass a reducer keyed by item, reading the item from `details`,\n * so stacked optimistic patches re-base onto a changed committed value.\n */\n reducer?: (prev: T, next: T, details: Details) => T;\n /** Names used in development warnings. */\n warn?: { readonly component: string; readonly prop: string };\n}\n\nexport interface ControllableAction<T, Details extends ActionDetails> {\n /** Committed value (`x ?? internal`). Content regions render this. */\n readonly committed: T;\n /** Optimistic value; trigger parts render this. */\n readonly optimistic: T;\n /** An action dispatched through this hook is in flight. */\n readonly pending: boolean;\n /** Runs the action inside the hook's transition with optimistic feedback. */\n readonly dispatch: (next: T, details: Details) => void;\n /**\n * The `next` of the most recent dispatch while any dispatch's action is still unsettled, else\n * `null`. Lets a component with a browser-owned path (Accordion's find-in-page reveal) build on\n * what the user already intends instead of on the DOM. Read it from handlers and effects only.\n */\n readonly latestInFlight: () => { readonly next: T } | null;\n}\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1);\n}\n\n/** Reports an error that cannot reach a boundary (conventions 2.4: never swallow). */\nfunction report(error: unknown): void {\n if (typeof reportError === \"function\") reportError(error);\n else console.error(error);\n}\n\n/**\n * The one place the state model of conventions section 3 is implemented: committed state,\n * optimistic state (`useOptimistic`, reducer form), the component's own transition, the\n * post-`await` write of uncontrolled state, the latest-dispatch guard, and the surfacing of\n * superseded rejections (conventions 2.4).\n */\nexport function useControllableAction<T, Details extends ActionDetails = ActionDetails>(\n options: UseControllableActionOptions<T, Details>,\n): ControllableAction<T, Details> {\n const { controlled, defaultValue, initialValue, action, transitionTypes, reducer, warn } =\n options;\n\n // React keeps only the newest thenable per `useTransition`, so a rejection from a dispatch that\n // was superseded by a later one would be dropped entirely (no boundary, no `reportError`). The\n // hook catches those itself, keeps the first, and throws it from render so it reaches the same\n // nearest Error Boundary as the latest dispatch's rejection does through React.\n //\n // The throw sits before `useTransition` on purpose: React retries an errored render with every\n // pending lane, and in that retry `useTransition` unwraps the still-pending action thenable and\n // suspends, which would hold the boundary commit until the newer action settles.\n const [supersededError, setSupersededError] = useState<{ readonly error: unknown } | null>(null);\n const hasSupersededError = useRef(false);\n if (supersededError !== null) throw supersededError.error;\n\n const [internal, setInternal] = useState<T>(\n defaultValue === undefined ? initialValue : defaultValue,\n );\n const isControlled = controlled !== undefined;\n const committed = isControlled ? controlled : internal;\n const [optimistic, setOptimistic] = useOptimistic(\n committed,\n (prev: T, patch: { next: T; details: Details }) =>\n reducer ? reducer(prev, patch.next, patch.details) : patch.next,\n );\n const [pending, startOwnTransition] = useTransition();\n const latest = useRef(0);\n const inFlight = useRef<{ count: number; next: T | undefined }>({ count: 0, next: undefined });\n const mounted = useRef(false);\n\n useDevWarnings(isControlled, defaultValue !== undefined, warn);\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n function dispatch(next: T, details: Details): void {\n const prev = committed;\n const id = ++latest.current;\n inFlight.current.count += 1;\n inFlight.current.next = next;\n startOwnTransition(async () => {\n for (const type of transitionTypes?.(next, prev, details) ?? []) addTransitionType(type);\n setOptimistic({ next, details });\n try {\n await action?.(next, details);\n } catch (error) {\n settle();\n // The latest dispatch's rejection takes React's own route to the boundary.\n if (id === latest.current) throw error;\n // Nothing to throw to once unmounted (React drops its own thenables the same way).\n if (!mounted.current) return;\n if (hasSupersededError.current) {\n // One boundary reset per burst is enough; later errors are still reported.\n report(error);\n return;\n }\n hasSupersededError.current = true;\n // Outside the transition by now (after an await), so this is an urgent update: the owner\n // re-renders promptly and throws.\n setSupersededError({ error });\n return;\n }\n settle();\n if (!isControlled && id === latest.current) {\n startTransition(() => setInternal(next));\n }\n });\n }\n\n function settle(): void {\n inFlight.current.count -= 1;\n if (inFlight.current.count === 0) inFlight.current.next = undefined;\n }\n\n function latestInFlight(): { readonly next: T } | null {\n const { count, next } = inFlight.current;\n return count > 0 ? { next: next as T } : null;\n }\n\n return { committed, optimistic, pending, dispatch, latestInFlight };\n}\n\nfunction useDevWarnings(\n isControlled: boolean,\n hasDefault: boolean,\n warn: { readonly component: string; readonly prop: string } | undefined,\n): void {\n const initialMode = useRef(isControlled);\n const warnedBoth = useRef(false);\n const warnedSwitch = useRef(false);\n const component = warn?.component ?? \"A component\";\n const prop = warn?.prop ?? \"value\";\n\n useEffect(() => {\n if (!isDev) return;\n const defaultProp = `default${capitalize(prop)}`;\n\n if (isControlled && hasDefault && !warnedBoth.current) {\n warnedBoth.current = true;\n console.error(\n `${component}: both \\`${prop}\\` and \\`${defaultProp}\\` are set. ` +\n `Use \\`${prop}\\` for controlled mode or \\`${defaultProp}\\` for uncontrolled mode, not both.`,\n );\n }\n\n if (initialMode.current !== isControlled && !warnedSwitch.current) {\n warnedSwitch.current = true;\n const from = initialMode.current ? \"controlled\" : \"uncontrolled\";\n const to = isControlled ? \"controlled\" : \"uncontrolled\";\n console.error(\n `${component}: \\`${prop}\\` changed from ${from} to ${to}. Switching modes after mount ` +\n `is not supported; keep \\`${prop}\\` either always defined or always undefined.`,\n );\n }\n }, [component, prop, isControlled, hasDefault]);\n}\n"],"mappings":";;;AAqDA,MAAM,QAAQ,QAAQ,IAAI,aAAa;AAEvC,SAAS,WAAW,GAAmB;CACrC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,EAAE,MAAM,CAAC;AAC9C;;AAGA,SAAS,OAAO,OAAsB;CACpC,IAAI,OAAO,gBAAgB,YAAY,YAAY,KAAK;MACnD,QAAQ,MAAM,KAAK;AAC1B;;;;;;;AAQA,SAAgB,sBACd,SACgC;CAChC,MAAM,EAAE,YAAY,cAAc,cAAc,QAAQ,iBAAiB,SAAS,SAChF;CAUF,MAAM,CAAC,iBAAiB,sBAAsB,SAA6C,IAAI;CAC/F,MAAM,qBAAqB,OAAO,KAAK;CACvC,IAAI,oBAAoB,MAAM,MAAM,gBAAgB;CAEpD,MAAM,CAAC,UAAU,eAAe,SAC9B,iBAAiB,KAAA,IAAY,eAAe,YAC9C;CACA,MAAM,eAAe,eAAe,KAAA;CACpC,MAAM,YAAY,eAAe,aAAa;CAC9C,MAAM,CAAC,YAAY,iBAAiB,cAClC,YACC,MAAS,UACR,UAAU,QAAQ,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI,MAAM,IAC/D;CACA,MAAM,CAAC,SAAS,sBAAsB,cAAc;CACpD,MAAM,SAAS,OAAO,CAAC;CACvB,MAAM,WAAW,OAA+C;EAAE,OAAO;EAAG,MAAM,KAAA;CAAU,CAAC;CAC7F,MAAM,UAAU,OAAO,KAAK;CAE5B,eAAe,cAAc,iBAAiB,KAAA,GAAW,IAAI;CAE7D,gBAAgB;EACd,QAAQ,UAAU;EAClB,aAAa;GACX,QAAQ,UAAU;EACpB;CACF,GAAG,CAAC,CAAC;CAEL,SAAS,SAAS,MAAS,SAAwB;EACjD,MAAM,OAAO;EACb,MAAM,KAAK,EAAE,OAAO;EACpB,SAAS,QAAQ,SAAS;EAC1B,SAAS,QAAQ,OAAO;EACxB,mBAAmB,YAAY;GAC7B,KAAK,MAAM,QAAQ,kBAAkB,MAAM,MAAM,OAAO,KAAK,CAAC,GAAG,kBAAkB,IAAI;GACvF,cAAc;IAAE;IAAM;GAAQ,CAAC;GAC/B,IAAI;IACF,MAAM,SAAS,MAAM,OAAO;GAC9B,SAAS,OAAO;IACd,OAAO;IAEP,IAAI,OAAO,OAAO,SAAS,MAAM;IAEjC,IAAI,CAAC,QAAQ,SAAS;IACtB,IAAI,mBAAmB,SAAS;KAE9B,OAAO,KAAK;KACZ;IACF;IACA,mBAAmB,UAAU;IAG7B,mBAAmB,EAAE,MAAM,CAAC;IAC5B;GACF;GACA,OAAO;GACP,IAAI,CAAC,gBAAgB,OAAO,OAAO,SACjC,sBAAsB,YAAY,IAAI,CAAC;EAE3C,CAAC;CACH;CAEA,SAAS,SAAe;EACtB,SAAS,QAAQ,SAAS;EAC1B,IAAI,SAAS,QAAQ,UAAU,GAAG,SAAS,QAAQ,OAAO,KAAA;CAC5D;CAEA,SAAS,iBAA8C;EACrD,MAAM,EAAE,OAAO,SAAS,SAAS;EACjC,OAAO,QAAQ,IAAI,EAAQ,KAAU,IAAI;CAC3C;CAEA,OAAO;EAAE;EAAW;EAAY;EAAS;EAAU;CAAe;AACpE;AAEA,SAAS,eACP,cACA,YACA,MACM;CACN,MAAM,cAAc,OAAO,YAAY;CACvC,MAAM,aAAa,OAAO,KAAK;CAC/B,MAAM,eAAe,OAAO,KAAK;CACjC,MAAM,YAAY,MAAM,aAAa;CACrC,MAAM,OAAO,MAAM,QAAQ;CAE3B,gBAAgB;EACd,IAAI,CAAC,OAAO;EACZ,MAAM,cAAc,UAAU,WAAW,IAAI;EAE7C,IAAI,gBAAgB,cAAc,CAAC,WAAW,SAAS;GACrD,WAAW,UAAU;GACrB,QAAQ,MACN,GAAG,UAAU,WAAW,KAAK,WAAW,YAAY,oBACzC,KAAK,8BAA8B,YAAY,oCAC5D;EACF;EAEA,IAAI,YAAY,YAAY,gBAAgB,CAAC,aAAa,SAAS;GACjE,aAAa,UAAU;GACvB,MAAM,OAAO,YAAY,UAAU,eAAe;GAElD,QAAQ,MACN,GAAG,UAAU,MAAM,KAAK,kBAAkB,KAAK,MAFtC,eAAe,eAAe,eAEiB,yDAC1B,KAAK,8CACrC;EACF;CACF,GAAG;EAAC;EAAW;EAAM;EAAc;CAAU,CAAC;AAChD"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
//#region src/internal/use-dev-warning.ts
|
|
4
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
5
|
+
/**
|
|
6
|
+
* Logs `message` through `console.error` once per component instance, the first time
|
|
7
|
+
* `condition` is true. Runs in an effect (never during render) so the warning cannot fire for a
|
|
8
|
+
* render that is discarded.
|
|
9
|
+
*/
|
|
10
|
+
function useDevWarning(condition, message) {
|
|
11
|
+
const warned = useRef(false);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!isDev || !condition || warned.current) return;
|
|
14
|
+
warned.current = true;
|
|
15
|
+
console.error(message);
|
|
16
|
+
}, [condition, message]);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { useDevWarning };
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=use-dev-warning.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-dev-warning.mjs","names":[],"sources":["../../src/internal/use-dev-warning.ts"],"sourcesContent":["\"use client\";\nimport { useEffect, useRef } from \"react\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\n/**\n * Logs `message` through `console.error` once per component instance, the first time\n * `condition` is true. Runs in an effect (never during render) so the warning cannot fire for a\n * render that is discarded.\n */\nexport function useDevWarning(condition: boolean, message: string): void {\n const warned = useRef(false);\n useEffect(() => {\n if (!isDev || !condition || warned.current) return;\n warned.current = true;\n console.error(message);\n }, [condition, message]);\n}\n"],"mappings":";;;AAGA,MAAM,QAAQ,QAAQ,IAAI,aAAa;;;;;;AAOvC,SAAgB,cAAc,WAAoB,SAAuB;CACvE,MAAM,SAAS,OAAO,KAAK;CAC3B,gBAAgB;EACd,IAAI,CAAC,SAAS,CAAC,aAAa,OAAO,SAAS;EAC5C,OAAO,UAAU;EACjB,QAAQ,MAAM,OAAO;CACvB,GAAG,CAAC,WAAW,OAAO,CAAC;AACzB"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffectEvent, useLayoutEffect, useRef } from "react";
|
|
3
|
+
//#region src/internal/use-form-reset.ts
|
|
4
|
+
/**
|
|
5
|
+
* Stable `reset` subscription on the form owner of `ref.current` (conventions 3.8).
|
|
6
|
+
*
|
|
7
|
+
* - The owner (`element.form`, which honours the `form` attribute) is re-read after every commit,
|
|
8
|
+
* but the listener is moved only when the owner changed. It is never removed and re-added on an
|
|
9
|
+
* ordinary re-render: React's automatic reset after a successful `<form action>` runs in the
|
|
10
|
+
* commit's mutation phase, between an effect's cleanup and its next run, so a subscription that
|
|
11
|
+
* followed every commit would miss it (rule 4).
|
|
12
|
+
* - The `reset` event fires before the reset algorithm, so `onReset` is called in a microtask,
|
|
13
|
+
* when the inputs hold their reset values (rule 5), and only if no listener cancelled the reset
|
|
14
|
+
* (rule 7).
|
|
15
|
+
* - `onReset` always sees the latest committed render's values (`useEffectEvent`).
|
|
16
|
+
*/
|
|
17
|
+
function useFormReset(ref, onReset) {
|
|
18
|
+
const handleReset = useEffectEvent((event) => {
|
|
19
|
+
onReset(event);
|
|
20
|
+
});
|
|
21
|
+
const subscription = useRef(null);
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
const form = ref.current?.form ?? null;
|
|
24
|
+
const current = subscription.current;
|
|
25
|
+
if (current !== null && current.form === form) return;
|
|
26
|
+
if (current !== null) current.form.removeEventListener("reset", current.listener);
|
|
27
|
+
subscription.current = null;
|
|
28
|
+
if (form === null) return;
|
|
29
|
+
const listener = (event) => {
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (event.defaultPrevented) return;
|
|
32
|
+
handleReset(event);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
form.addEventListener("reset", listener);
|
|
36
|
+
subscription.current = {
|
|
37
|
+
form,
|
|
38
|
+
listener
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
useLayoutEffect(() => () => {
|
|
42
|
+
const current = subscription.current;
|
|
43
|
+
if (current !== null) current.form.removeEventListener("reset", current.listener);
|
|
44
|
+
subscription.current = null;
|
|
45
|
+
}, []);
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { useFormReset };
|
|
49
|
+
|
|
50
|
+
//# sourceMappingURL=use-form-reset.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-form-reset.mjs","names":[],"sources":["../../src/internal/use-form-reset.ts"],"sourcesContent":["\"use client\";\nimport { useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\n/** A form-associated element whose form owner the hook follows. */\nexport type FormAssociatedElement = HTMLInputElement | HTMLFieldSetElement;\n\ninterface Subscription {\n readonly form: HTMLFormElement;\n readonly listener: (event: Event) => void;\n}\n\n/**\n * Stable `reset` subscription on the form owner of `ref.current` (conventions 3.8).\n *\n * - The owner (`element.form`, which honours the `form` attribute) is re-read after every commit,\n * but the listener is moved only when the owner changed. It is never removed and re-added on an\n * ordinary re-render: React's automatic reset after a successful `<form action>` runs in the\n * commit's mutation phase, between an effect's cleanup and its next run, so a subscription that\n * followed every commit would miss it (rule 4).\n * - The `reset` event fires before the reset algorithm, so `onReset` is called in a microtask,\n * when the inputs hold their reset values (rule 5), and only if no listener cancelled the reset\n * (rule 7).\n * - `onReset` always sees the latest committed render's values (`useEffectEvent`).\n */\nexport function useFormReset(\n ref: RefObject<FormAssociatedElement | null>,\n onReset: (event: Event) => void,\n): void {\n const handleReset = useEffectEvent((event: Event) => {\n onReset(event);\n });\n const subscription = useRef<Subscription | null>(null);\n\n // No deps on purpose: follows the form owner after every commit (a changed `form` attribute, the\n // element moved to another form), but re-subscribes only when the owner actually changed.\n useLayoutEffect(() => {\n const form = ref.current?.form ?? null;\n const current = subscription.current;\n if (current !== null && current.form === form) return;\n if (current !== null) current.form.removeEventListener(\"reset\", current.listener);\n subscription.current = null;\n if (form === null) return;\n const listener = (event: Event) => {\n queueMicrotask(() => {\n if (event.defaultPrevented) return;\n handleReset(event);\n });\n };\n form.addEventListener(\"reset\", listener);\n subscription.current = { form, listener };\n });\n\n // The only place the subscription is torn down: unmount (and StrictMode's simulated unmount,\n // after which the effect above subscribes again).\n useLayoutEffect(\n () => () => {\n const current = subscription.current;\n if (current !== null) current.form.removeEventListener(\"reset\", current.listener);\n subscription.current = null;\n },\n [],\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,SAAgB,aACd,KACA,SACM;CACN,MAAM,cAAc,gBAAgB,UAAiB;EACnD,QAAQ,KAAK;CACf,CAAC;CACD,MAAM,eAAe,OAA4B,IAAI;CAIrD,sBAAsB;EACpB,MAAM,OAAO,IAAI,SAAS,QAAQ;EAClC,MAAM,UAAU,aAAa;EAC7B,IAAI,YAAY,QAAQ,QAAQ,SAAS,MAAM;EAC/C,IAAI,YAAY,MAAM,QAAQ,KAAK,oBAAoB,SAAS,QAAQ,QAAQ;EAChF,aAAa,UAAU;EACvB,IAAI,SAAS,MAAM;EACnB,MAAM,YAAY,UAAiB;GACjC,qBAAqB;IACnB,IAAI,MAAM,kBAAkB;IAC5B,YAAY,KAAK;GACnB,CAAC;EACH;EACA,KAAK,iBAAiB,SAAS,QAAQ;EACvC,aAAa,UAAU;GAAE;GAAM;EAAS;CAC1C,CAAC;CAID,4BACc;EACV,MAAM,UAAU,aAAa;EAC7B,IAAI,YAAY,MAAM,QAAQ,KAAK,oBAAoB,SAAS,QAAQ,QAAQ;EAChF,aAAa,UAAU;CACzB,GACA,CAAC,CACH;AACF"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useFormReset } from "./use-form-reset.mjs";
|
|
3
|
+
import { useCallback, useEffectEvent, useLayoutEffect, useRef } from "react";
|
|
4
|
+
//#region src/internal/use-native-checked.ts
|
|
5
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
6
|
+
/**
|
|
7
|
+
* Per-element machinery for a controlled native `<input type="checkbox" | "radio">`
|
|
8
|
+
* (conventions 3.8): returns the ref to attach to the input (merged by `useRender`).
|
|
9
|
+
*
|
|
10
|
+
* 1. Ref callback: stores the node; in development warns once when it is not an `<input>` of
|
|
11
|
+
* the expected `type`.
|
|
12
|
+
* 2. Mount reconciliation (single controls): a click before hydration or Firefox's form-state
|
|
13
|
+
* restoration left the DOM different from what the user intends; reported once with
|
|
14
|
+
* `event: undefined`.
|
|
15
|
+
* 3. Re-apply after every commit: `checked` (optimistic, or the latest in-flight value),
|
|
16
|
+
* `indeterminate` and `defaultChecked`, none of which React manages for a controlled input
|
|
17
|
+
* except `checked`.
|
|
18
|
+
* 4. Form reset (single controls): in a microtask after the reset, compares `el.defaultChecked`
|
|
19
|
+
* with what the user intends and reports a difference with the `reset` event.
|
|
20
|
+
*
|
|
21
|
+
* The hook knows nothing about transitions or `reason`; the part wires `onPlatformChange`.
|
|
22
|
+
*/
|
|
23
|
+
function useNativeChecked(options) {
|
|
24
|
+
const { type, component, checked, defaultChecked, indeterminate, group = false } = options;
|
|
25
|
+
const node = useRef(null);
|
|
26
|
+
const warnedElement = useRef(false);
|
|
27
|
+
const ref = useCallback((el) => {
|
|
28
|
+
node.current = el;
|
|
29
|
+
if (isDev && !warnedElement.current && !isInputOfType(el, type)) {
|
|
30
|
+
warnedElement.current = true;
|
|
31
|
+
console.error(`${component}: expected to render an \`<input type="${type}">\` but got ${describeElement(el)}; the platform semantics (form participation, labels, keyboard, reset) need a real input. A \`render\` element must keep the input.`);
|
|
32
|
+
}
|
|
33
|
+
return () => {
|
|
34
|
+
node.current = null;
|
|
35
|
+
};
|
|
36
|
+
}, [type, component]);
|
|
37
|
+
/** What the user intends: the latest in-flight dispatch, else the optimistic value (rule 1). */
|
|
38
|
+
const readBase = useEffectEvent(() => options.latestInFlight?.()?.next ?? checked);
|
|
39
|
+
const report = useEffectEvent((next, event) => {
|
|
40
|
+
options.onPlatformChange?.(next, event);
|
|
41
|
+
});
|
|
42
|
+
const reconcileMount = useEffectEvent(() => {
|
|
43
|
+
if (group) return;
|
|
44
|
+
const el = node.current;
|
|
45
|
+
if (el === null) return;
|
|
46
|
+
if (el.checked !== readBase()) report(el.checked, void 0);
|
|
47
|
+
});
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
reconcileMount();
|
|
50
|
+
}, []);
|
|
51
|
+
const notifyMounted = useEffectEvent(() => {
|
|
52
|
+
if (group) options.onItemMounted?.(node.current);
|
|
53
|
+
});
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
notifyMounted();
|
|
56
|
+
}, []);
|
|
57
|
+
const renderToken = {};
|
|
58
|
+
const mountToken = useRef(null);
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
if (mountToken.current === null) mountToken.current = renderToken;
|
|
61
|
+
const el = node.current;
|
|
62
|
+
if (el === null) return;
|
|
63
|
+
if (defaultChecked !== void 0 && el.defaultChecked !== defaultChecked) el.defaultChecked = defaultChecked;
|
|
64
|
+
if (mountToken.current !== renderToken) {
|
|
65
|
+
const want = readBase();
|
|
66
|
+
if (el.checked !== want) el.checked = want;
|
|
67
|
+
}
|
|
68
|
+
if (indeterminate !== void 0 && el.indeterminate !== indeterminate) el.indeterminate = indeterminate;
|
|
69
|
+
});
|
|
70
|
+
useFormReset(node, (event) => {
|
|
71
|
+
if (group) return;
|
|
72
|
+
const el = node.current;
|
|
73
|
+
if (el === null) return;
|
|
74
|
+
const next = el.defaultChecked;
|
|
75
|
+
const base = options.latestInFlight?.()?.next ?? checked;
|
|
76
|
+
if (next !== base) options.onPlatformChange?.(next, event);
|
|
77
|
+
else if (el.checked !== base) el.checked = base;
|
|
78
|
+
});
|
|
79
|
+
return ref;
|
|
80
|
+
}
|
|
81
|
+
function isInputOfType(el, type) {
|
|
82
|
+
return el.tagName === "INPUT" && el.type === type;
|
|
83
|
+
}
|
|
84
|
+
function describeElement(el) {
|
|
85
|
+
const tag = el.tagName.toLowerCase();
|
|
86
|
+
return tag === "input" ? `\`<input type="${el.type}">\`` : `\`<${tag}>\``;
|
|
87
|
+
}
|
|
88
|
+
const warnedPreventDefault = /* @__PURE__ */ new WeakSet();
|
|
89
|
+
/**
|
|
90
|
+
* Shared `onChange` guard for checkable inputs (conventions 3.8 rule 9, 2.3 item 3):
|
|
91
|
+
*
|
|
92
|
+
* - A click that is already cancelled (`preventDefault()` in a pass-through `onClick`, which runs
|
|
93
|
+
* before the library's `onChange`) is not a change: the browser reverts the DOM after dispatch
|
|
94
|
+
* and React restores unchanged props. Nothing is dispatched.
|
|
95
|
+
* - Otherwise `dispatch()` runs (the action's sync prefix included); if the event is cancelled
|
|
96
|
+
* now, the action's sync prefix called `preventDefault()`, which desyncs the input while the
|
|
97
|
+
* action is pending. Development warns once per input element.
|
|
98
|
+
*/
|
|
99
|
+
function guardCheckableChange(event, dispatch, component = "A checkable input") {
|
|
100
|
+
if (event.defaultPrevented) return;
|
|
101
|
+
dispatch();
|
|
102
|
+
if (!isDev || !event.defaultPrevented) return;
|
|
103
|
+
const target = event.target;
|
|
104
|
+
if (target !== null) {
|
|
105
|
+
if (warnedPreventDefault.has(target)) return;
|
|
106
|
+
warnedPreventDefault.add(target);
|
|
107
|
+
}
|
|
108
|
+
console.error(`${component}: \`preventDefault()\` on the change event is not supported; it desyncs the input while the action is pending. Use controlled mode to veto a change.`);
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
export { guardCheckableChange, useNativeChecked };
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=use-native-checked.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-native-checked.mjs","names":[],"sources":["../../src/internal/use-native-checked.ts"],"sourcesContent":["\"use client\";\nimport { useCallback, useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefCallback } from \"react\";\nimport { useFormReset } from \"./use-form-reset.js\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nexport interface UseNativeCheckedOptions {\n /** Dev-only: the expected `input.type`. */\n readonly type: \"checkbox\" | \"radio\";\n /** Dev-only: the part's name in warnings (`\"Switch\"`, `\"RadioGroup.Item\"`). */\n readonly component: string;\n /** What the DOM must show after every commit: the optimistic value. */\n readonly checked: boolean;\n /**\n * The form-reset target. Uncontrolled parts pass the latest `defaultChecked` prop (`?? false`),\n * mirrored into `el.defaultChecked` after every commit. Controlled parts pass `undefined`:\n * React's mount-time value stays (conventions 3.8 rule 6).\n */\n readonly defaultChecked: boolean | undefined;\n /** Checkbox only: `el.indeterminate` after every commit (the part passes `indeterminate && !pending`). */\n readonly indeterminate?: boolean | undefined;\n /**\n * `true` inside a group (`RadioGroup.Item`): the hook does the element check and the re-apply\n * only; mount reconciliation and form reset are reported once per group by the Root through\n * `useNativeGroupReconciliation`. Default `false`: the hook reports both paths itself.\n */\n readonly group?: boolean | undefined;\n /**\n * Group members only: the Root's `onItemMounted` from `useNativeGroupReconciliation`. Called\n * from this member's mount layout effect so that a member mounting after the Root (a Suspense\n * boundary that hydrates later) still gets the group reconciled. Receives the member's element\n * (`null` if the ref is not attached). Ignored without `group`.\n */\n readonly onItemMounted?: ((element: HTMLInputElement | null) => void) | undefined;\n /** From `useControllableAction`: the value the DOM is compared against on the residual paths. */\n readonly latestInFlight?: (() => { readonly next: boolean } | null) | undefined;\n /**\n * Single controls only. The browser changed the value on a residual path and it differs from\n * what the user intends. `event` is the `reset` event, or `undefined` for mount reconciliation.\n * The part dispatches with `reason: \"platform\"`.\n */\n readonly onPlatformChange?: ((next: boolean, event: Event | undefined) => void) | undefined;\n}\n\n/**\n * Per-element machinery for a controlled native `<input type=\"checkbox\" | \"radio\">`\n * (conventions 3.8): returns the ref to attach to the input (merged by `useRender`).\n *\n * 1. Ref callback: stores the node; in development warns once when it is not an `<input>` of\n * the expected `type`.\n * 2. Mount reconciliation (single controls): a click before hydration or Firefox's form-state\n * restoration left the DOM different from what the user intends; reported once with\n * `event: undefined`.\n * 3. Re-apply after every commit: `checked` (optimistic, or the latest in-flight value),\n * `indeterminate` and `defaultChecked`, none of which React manages for a controlled input\n * except `checked`.\n * 4. Form reset (single controls): in a microtask after the reset, compares `el.defaultChecked`\n * with what the user intends and reports a difference with the `reset` event.\n *\n * The hook knows nothing about transitions or `reason`; the part wires `onPlatformChange`.\n */\nexport function useNativeChecked(options: UseNativeCheckedOptions): RefCallback<HTMLInputElement> {\n const { type, component, checked, defaultChecked, indeterminate, group = false } = options;\n const node = useRef<HTMLInputElement | null>(null);\n const warnedElement = useRef(false);\n\n const ref = useCallback(\n (el: HTMLInputElement) => {\n node.current = el;\n if (isDev && !warnedElement.current && !isInputOfType(el, type)) {\n warnedElement.current = true;\n console.error(\n `${component}: expected to render an \\`<input type=\"${type}\">\\` but got ` +\n `${describeElement(el)}; the platform semantics (form participation, labels, ` +\n `keyboard, reset) need a real input. A \\`render\\` element must keep the input.`,\n );\n }\n return () => {\n node.current = null;\n };\n },\n [type, component],\n );\n\n /** What the user intends: the latest in-flight dispatch, else the optimistic value (rule 1). */\n const readBase = useEffectEvent(() => options.latestInFlight?.()?.next ?? checked);\n const report = useEffectEvent((next: boolean, event: Event | undefined) => {\n options.onPlatformChange?.(next, event);\n });\n\n // 2. Mount reconciliation. Declared before the re-apply effect so that the hydrated DOM value is\n // read before anything overwrites it (rule 3). StrictMode's second run finds the first run's\n // dispatch in flight and does nothing.\n const reconcileMount = useEffectEvent(() => {\n if (group) return;\n const el = node.current;\n if (el === null) return;\n if (el.checked !== readBase()) report(el.checked, undefined);\n });\n useLayoutEffect(() => {\n reconcileMount();\n }, []);\n\n // 2b. Group members tell the Root they mounted; the Root reconciles the group again if its own\n // mount reconciliation already ran (a member mounting later), which covers the `checked`\n // write this member skips in its mount commit (below).\n const notifyMounted = useEffectEvent(() => {\n if (group) options.onItemMounted?.(node.current);\n });\n useLayoutEffect(() => {\n notifyMounted();\n }, []);\n\n // 3. Re-apply after every commit (rule 8). `checked` is skipped in the mount commit (including\n // StrictMode's re-run of it, which closes over the same render): React has just written it\n // from the prop, or kept the hydrated DOM value for the residual path to read. That matters\n // for groups, whose Root reconciles in its own layout effect, after its Items' effects.\n const renderToken = {};\n const mountToken = useRef<object | null>(null);\n useLayoutEffect(() => {\n if (mountToken.current === null) mountToken.current = renderToken;\n const el = node.current;\n if (el === null) return;\n // The order defaultChecked -> checked -> indeterminate is load-bearing for the first two.\n // React's automatic reset after a <form action> runs in the mutation phase, after react-dom\n // wrote `checked` and before this effect, and clears the dirty-checkedness flag; writing\n // `defaultChecked` (a new default from the same transition) then moves `checked` too, and the\n // `checked` write that follows restores the intended value. The other way round, the `checked`\n // write is skipped (it still matches) and the DOM ends up on the new default.\n // `indeterminate` is independent of both writes and goes last. Tested in\n // use-native-checked.test.tsx (\"re-apply order\").\n if (defaultChecked !== undefined && el.defaultChecked !== defaultChecked) {\n el.defaultChecked = defaultChecked;\n }\n if (mountToken.current !== renderToken) {\n const want = readBase();\n if (el.checked !== want) el.checked = want;\n }\n if (indeterminate !== undefined && el.indeterminate !== indeterminate) {\n el.indeterminate = indeterminate;\n }\n });\n\n // 4. Form reset (rules 4 to 7). `el.defaultChecked` is the value the browser reset to; by the\n // time the microtask runs after React's own reset, the re-apply effect may already have put\n // the optimistic value back into `checked`. (`useFormReset` already calls this through an\n // Effect Event, so it reads the latest render's options directly.)\n useFormReset(node, (event) => {\n if (group) return;\n const el = node.current;\n if (el === null) return;\n const next = el.defaultChecked;\n const base = options.latestInFlight?.()?.next ?? checked;\n if (next !== base) options.onPlatformChange?.(next, event);\n else if (el.checked !== base) el.checked = base;\n });\n\n return ref;\n}\n\nfunction isInputOfType(el: Element, type: string): boolean {\n return el.tagName === \"INPUT\" && (el as HTMLInputElement).type === type;\n}\n\nfunction describeElement(el: Element): string {\n const tag = el.tagName.toLowerCase();\n return tag === \"input\" ? `\\`<input type=\"${(el as HTMLInputElement).type}\">\\`` : `\\`<${tag}>\\``;\n}\n\nconst warnedPreventDefault = new WeakSet<EventTarget>();\n\n/**\n * Shared `onChange` guard for checkable inputs (conventions 3.8 rule 9, 2.3 item 3):\n *\n * - A click that is already cancelled (`preventDefault()` in a pass-through `onClick`, which runs\n * before the library's `onChange`) is not a change: the browser reverts the DOM after dispatch\n * and React restores unchanged props. Nothing is dispatched.\n * - Otherwise `dispatch()` runs (the action's sync prefix included); if the event is cancelled\n * now, the action's sync prefix called `preventDefault()`, which desyncs the input while the\n * action is pending. Development warns once per input element.\n */\nexport function guardCheckableChange(\n event: Event,\n dispatch: () => void,\n component: string = \"A checkable input\",\n): void {\n if (event.defaultPrevented) return;\n dispatch();\n if (!isDev || !event.defaultPrevented) return;\n const target = event.target;\n if (target !== null) {\n if (warnedPreventDefault.has(target)) return;\n warnedPreventDefault.add(target);\n }\n console.error(\n `${component}: \\`preventDefault()\\` on the change event is not supported; it desyncs the ` +\n `input while the action is pending. Use controlled mode to veto a change.`,\n );\n}\n"],"mappings":";;;;AAKA,MAAM,QAAQ,QAAQ,IAAI,aAAa;;;;;;;;;;;;;;;;;;AAyDvC,SAAgB,iBAAiB,SAAiE;CAChG,MAAM,EAAE,MAAM,WAAW,SAAS,gBAAgB,eAAe,QAAQ,UAAU;CACnF,MAAM,OAAO,OAAgC,IAAI;CACjD,MAAM,gBAAgB,OAAO,KAAK;CAElC,MAAM,MAAM,aACT,OAAyB;EACxB,KAAK,UAAU;EACf,IAAI,SAAS,CAAC,cAAc,WAAW,CAAC,cAAc,IAAI,IAAI,GAAG;GAC/D,cAAc,UAAU;GACxB,QAAQ,MACN,GAAG,UAAU,yCAAyC,KAAK,eACtD,gBAAgB,EAAE,EAAE,oIAE3B;EACF;EACA,aAAa;GACX,KAAK,UAAU;EACjB;CACF,GACA,CAAC,MAAM,SAAS,CAClB;;CAGA,MAAM,WAAW,qBAAqB,QAAQ,iBAAiB,CAAC,EAAE,QAAQ,OAAO;CACjF,MAAM,SAAS,gBAAgB,MAAe,UAA6B;EACzE,QAAQ,mBAAmB,MAAM,KAAK;CACxC,CAAC;CAKD,MAAM,iBAAiB,qBAAqB;EAC1C,IAAI,OAAO;EACX,MAAM,KAAK,KAAK;EAChB,IAAI,OAAO,MAAM;EACjB,IAAI,GAAG,YAAY,SAAS,GAAG,OAAO,GAAG,SAAS,KAAA,CAAS;CAC7D,CAAC;CACD,sBAAsB;EACpB,eAAe;CACjB,GAAG,CAAC,CAAC;CAKL,MAAM,gBAAgB,qBAAqB;EACzC,IAAI,OAAO,QAAQ,gBAAgB,KAAK,OAAO;CACjD,CAAC;CACD,sBAAsB;EACpB,cAAc;CAChB,GAAG,CAAC,CAAC;CAML,MAAM,cAAc,CAAC;CACrB,MAAM,aAAa,OAAsB,IAAI;CAC7C,sBAAsB;EACpB,IAAI,WAAW,YAAY,MAAM,WAAW,UAAU;EACtD,MAAM,KAAK,KAAK;EAChB,IAAI,OAAO,MAAM;EASjB,IAAI,mBAAmB,KAAA,KAAa,GAAG,mBAAmB,gBACxD,GAAG,iBAAiB;EAEtB,IAAI,WAAW,YAAY,aAAa;GACtC,MAAM,OAAO,SAAS;GACtB,IAAI,GAAG,YAAY,MAAM,GAAG,UAAU;EACxC;EACA,IAAI,kBAAkB,KAAA,KAAa,GAAG,kBAAkB,eACtD,GAAG,gBAAgB;CAEvB,CAAC;CAMD,aAAa,OAAO,UAAU;EAC5B,IAAI,OAAO;EACX,MAAM,KAAK,KAAK;EAChB,IAAI,OAAO,MAAM;EACjB,MAAM,OAAO,GAAG;EAChB,MAAM,OAAO,QAAQ,iBAAiB,CAAC,EAAE,QAAQ;EACjD,IAAI,SAAS,MAAM,QAAQ,mBAAmB,MAAM,KAAK;OACpD,IAAI,GAAG,YAAY,MAAM,GAAG,UAAU;CAC7C,CAAC;CAED,OAAO;AACT;AAEA,SAAS,cAAc,IAAa,MAAuB;CACzD,OAAO,GAAG,YAAY,WAAY,GAAwB,SAAS;AACrE;AAEA,SAAS,gBAAgB,IAAqB;CAC5C,MAAM,MAAM,GAAG,QAAQ,YAAY;CACnC,OAAO,QAAQ,UAAU,kBAAmB,GAAwB,KAAK,QAAQ,MAAM,IAAI;AAC7F;AAEA,MAAM,uCAAuB,IAAI,QAAqB;;;;;;;;;;;AAYtD,SAAgB,qBACd,OACA,UACA,YAAoB,qBACd;CACN,IAAI,MAAM,kBAAkB;CAC5B,SAAS;CACT,IAAI,CAAC,SAAS,CAAC,MAAM,kBAAkB;CACvC,MAAM,SAAS,MAAM;CACrB,IAAI,WAAW,MAAM;EACnB,IAAI,qBAAqB,IAAI,MAAM,GAAG;EACtC,qBAAqB,IAAI,MAAM;CACjC;CACA,QAAQ,MACN,GAAG,UAAU,qJAEf;AACF"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useFormReset } from "./use-form-reset.mjs";
|
|
3
|
+
import { useCallback, useEffectEvent, useLayoutEffect, useRef } from "react";
|
|
4
|
+
//#region src/internal/use-native-group-reconciliation.ts
|
|
5
|
+
/**
|
|
6
|
+
* Group adapter of the native-input machinery (conventions 3.8): one dispatch per group for the
|
|
7
|
+
* residual browser paths, from the Root rather than once per member.
|
|
8
|
+
*
|
|
9
|
+
* - Mount reconciliation, once, in a layout effect: `readCurrent()` vs `base()`. The members'
|
|
10
|
+
* `useNativeChecked({ group: true })` leaves `checked` alone in their mount commit, so this
|
|
11
|
+
* effect, which runs after the members' (children first), still reads the hydrated DOM.
|
|
12
|
+
* - Late members: a member mounting in a later commit (a Suspense boundary that hydrates after the
|
|
13
|
+
* Root, where a click before hydration changed the DOM) reports through `onItemMounted`, and the
|
|
14
|
+
* group is reconciled again the same way (see `UseNativeGroupReconciliationResult`).
|
|
15
|
+
* - Form reset through `useFormReset` on the container: in a microtask after the reset,
|
|
16
|
+
* `readDefault()` vs `base()`.
|
|
17
|
+
*
|
|
18
|
+
* Values are compared with `Object.is`.
|
|
19
|
+
*/
|
|
20
|
+
function useNativeGroupReconciliation(options) {
|
|
21
|
+
const { element } = options;
|
|
22
|
+
const latest = useRef(options);
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
latest.current = options;
|
|
25
|
+
});
|
|
26
|
+
/** The Root's own mount reconciliation has run (reset by its cleanup, StrictMode included). */
|
|
27
|
+
const rootReconciled = useRef(false);
|
|
28
|
+
/** A late reconciliation is queued; members mounting in the same commit share it. */
|
|
29
|
+
const lateQueued = useRef(false);
|
|
30
|
+
const reconcileMount = useEffectEvent(() => {
|
|
31
|
+
if (element.current === null) return;
|
|
32
|
+
reconcile(options, options.readCurrent());
|
|
33
|
+
});
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
reconcileMount();
|
|
36
|
+
rootReconciled.current = true;
|
|
37
|
+
return () => {
|
|
38
|
+
rootReconciled.current = false;
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
const onItemMounted = useCallback(() => {
|
|
42
|
+
if (!rootReconciled.current || lateQueued.current) return;
|
|
43
|
+
lateQueued.current = true;
|
|
44
|
+
queueMicrotask(() => {
|
|
45
|
+
lateQueued.current = false;
|
|
46
|
+
if (!rootReconciled.current) return;
|
|
47
|
+
const current = latest.current;
|
|
48
|
+
if (current.element.current === null) return;
|
|
49
|
+
reconcile(current, current.readCurrent());
|
|
50
|
+
});
|
|
51
|
+
}, []);
|
|
52
|
+
useFormReset(element, (event) => {
|
|
53
|
+
if (element.current === null) return;
|
|
54
|
+
reconcile(options, options.readDefault(), event);
|
|
55
|
+
});
|
|
56
|
+
return { onItemMounted };
|
|
57
|
+
}
|
|
58
|
+
function reconcile(options, next, event) {
|
|
59
|
+
if (!Object.is(next, options.base())) options.onPlatformChange(next, event);
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { useNativeGroupReconciliation };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=use-native-group-reconciliation.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-native-group-reconciliation.mjs","names":[],"sources":["../../src/internal/use-native-group-reconciliation.ts"],"sourcesContent":["\"use client\";\nimport { useCallback, useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useFormReset } from \"./use-form-reset.js\";\n\nexport interface UseNativeGroupReconciliationOptions<T> {\n /** The group's form-associated container (`RadioGroup.Root`'s `<fieldset>`). */\n readonly element: RefObject<HTMLFieldSetElement | null>;\n /** The group's value as the DOM shows it now (`checked` of the members). */\n readonly readCurrent: () => T;\n /** The value the browser resets the group to (`defaultChecked` of the members, rule 5). */\n readonly readDefault: () => T;\n /**\n * What the user intends (rule 1): the latest in-flight dispatch's `next`, else the optimistic\n * value. Use an explicit null check, `const f = latestInFlight(); return f === null ?\n * optimistic : f.next;`, not `?.next ?? optimistic`, which drops an in-flight dispatch of a\n * nullish value (a pending reset to \"nothing checked\").\n */\n readonly base: () => T;\n /**\n * The browser changed the group's value on a residual path and it differs from `base()`.\n * `event` is the `reset` event, or `undefined` for mount reconciliation (the Root's mount, or a\n * member mounting later). The Root dispatches once for the whole group with\n * `reason: \"platform\"`.\n */\n readonly onPlatformChange: (next: T, event: Event | undefined) => void;\n}\n\nexport interface UseNativeGroupReconciliationResult {\n /**\n * Stable. Pass it to every member, which hands it to\n * `useNativeChecked({ group: true, onItemMounted })`; the hook calls it from the member's mount\n * layout effect. A member that mounts after the Root's own mount reconciliation (inside a\n * Suspense boundary that hydrates later, selective hydration, or a member added on the client)\n * makes the adapter reconcile the group again: `readCurrent()` vs `base()`, in a microtask, once\n * for all members mounted in the same commit. Members mounting in the Root's own commit are\n * covered by the Root's mount reconciliation (their effects run before the Root's) and are\n * ignored.\n *\n * RadioGroup wiring: `const { onItemMounted } = useNativeGroupReconciliation(...)` in the Root,\n * put `onItemMounted` in the group context, and in each Item\n * `useNativeChecked({ type: \"radio\", group: true, onItemMounted: ctx.onItemMounted, ... })`.\n * The member's element argument is not used here; the Root may use it (dev warnings).\n */\n readonly onItemMounted: (element?: Element | null) => void;\n}\n\n/**\n * Group adapter of the native-input machinery (conventions 3.8): one dispatch per group for the\n * residual browser paths, from the Root rather than once per member.\n *\n * - Mount reconciliation, once, in a layout effect: `readCurrent()` vs `base()`. The members'\n * `useNativeChecked({ group: true })` leaves `checked` alone in their mount commit, so this\n * effect, which runs after the members' (children first), still reads the hydrated DOM.\n * - Late members: a member mounting in a later commit (a Suspense boundary that hydrates after the\n * Root, where a click before hydration changed the DOM) reports through `onItemMounted`, and the\n * group is reconciled again the same way (see `UseNativeGroupReconciliationResult`).\n * - Form reset through `useFormReset` on the container: in a microtask after the reset,\n * `readDefault()` vs `base()`.\n *\n * Values are compared with `Object.is`.\n */\nexport function useNativeGroupReconciliation<T>(\n options: UseNativeGroupReconciliationOptions<T>,\n): UseNativeGroupReconciliationResult {\n const { element } = options;\n\n // The latest committed options, for the late reconciliation, which runs in a microtask outside\n // any effect (so it cannot go through an Effect Event).\n const latest = useRef(options);\n useLayoutEffect(() => {\n latest.current = options;\n });\n\n /** The Root's own mount reconciliation has run (reset by its cleanup, StrictMode included). */\n const rootReconciled = useRef(false);\n /** A late reconciliation is queued; members mounting in the same commit share it. */\n const lateQueued = useRef(false);\n\n const reconcileMount = useEffectEvent(() => {\n if (element.current === null) return;\n reconcile(options, options.readCurrent());\n });\n useLayoutEffect(() => {\n reconcileMount();\n rootReconciled.current = true;\n return () => {\n rootReconciled.current = false;\n };\n }, []);\n\n const onItemMounted = useCallback(() => {\n if (!rootReconciled.current || lateQueued.current) return;\n lateQueued.current = true;\n queueMicrotask(() => {\n lateQueued.current = false;\n if (!rootReconciled.current) return;\n const current = latest.current;\n if (current.element.current === null) return;\n reconcile(current, current.readCurrent());\n });\n }, []);\n\n useFormReset(element, (event) => {\n if (element.current === null) return;\n reconcile(options, options.readDefault(), event);\n });\n\n return { onItemMounted };\n}\n\nfunction reconcile<T>(options: UseNativeGroupReconciliationOptions<T>, next: T, event?: Event) {\n if (!Object.is(next, options.base())) options.onPlatformChange(next, event);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA8DA,SAAgB,6BACd,SACoC;CACpC,MAAM,EAAE,YAAY;CAIpB,MAAM,SAAS,OAAO,OAAO;CAC7B,sBAAsB;EACpB,OAAO,UAAU;CACnB,CAAC;;CAGD,MAAM,iBAAiB,OAAO,KAAK;;CAEnC,MAAM,aAAa,OAAO,KAAK;CAE/B,MAAM,iBAAiB,qBAAqB;EAC1C,IAAI,QAAQ,YAAY,MAAM;EAC9B,UAAU,SAAS,QAAQ,YAAY,CAAC;CAC1C,CAAC;CACD,sBAAsB;EACpB,eAAe;EACf,eAAe,UAAU;EACzB,aAAa;GACX,eAAe,UAAU;EAC3B;CACF,GAAG,CAAC,CAAC;CAEL,MAAM,gBAAgB,kBAAkB;EACtC,IAAI,CAAC,eAAe,WAAW,WAAW,SAAS;EACnD,WAAW,UAAU;EACrB,qBAAqB;GACnB,WAAW,UAAU;GACrB,IAAI,CAAC,eAAe,SAAS;GAC7B,MAAM,UAAU,OAAO;GACvB,IAAI,QAAQ,QAAQ,YAAY,MAAM;GACtC,UAAU,SAAS,QAAQ,YAAY,CAAC;EAC1C,CAAC;CACH,GAAG,CAAC,CAAC;CAEL,aAAa,UAAU,UAAU;EAC/B,IAAI,QAAQ,YAAY,MAAM;EAC9B,UAAU,SAAS,QAAQ,YAAY,GAAG,KAAK;CACjD,CAAC;CAED,OAAO,EAAE,cAAc;AACzB;AAEA,SAAS,UAAa,SAAiD,MAAS,OAAe;CAC7F,IAAI,CAAC,OAAO,GAAG,MAAM,QAAQ,KAAK,CAAC,GAAG,QAAQ,iBAAiB,MAAM,KAAK;AAC5E"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { mergeProps, mergeRefs } from "./merge-props.mjs";
|
|
3
|
+
import { cloneElement, createElement, isValidElement, useMemo } from "react";
|
|
4
|
+
//#region src/internal/use-render.tsx
|
|
5
|
+
function resolveStateValue(value, state) {
|
|
6
|
+
return typeof value === "function" ? value(state) : value;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Whether a part whose default element is a `<button>` should add `type="button"`: no `render`,
|
|
10
|
+
* a `render` element whose type is the intrinsic `"button"`, or the function form (it receives
|
|
11
|
+
* button props and decides itself what to spread). An element of any other type (an `<a>`, a
|
|
12
|
+
* router `Link`) does not get it: `type` means something else, or nothing, there.
|
|
13
|
+
*/
|
|
14
|
+
function rendersButton(render) {
|
|
15
|
+
if (render === void 0 || typeof render === "function") return true;
|
|
16
|
+
return isValidElement(render) && render.type === "button";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolves `render` and the function-form `className` / `style` / `children` of a part against
|
|
20
|
+
* its state and produces the element (conventions 1.2, 1.3, 1.4).
|
|
21
|
+
*/
|
|
22
|
+
function useRender(options) {
|
|
23
|
+
const { defaultElement, render, state, className, style, children, props, libraryProps, ref } = options;
|
|
24
|
+
const consumerRef = props.ref;
|
|
25
|
+
const renderElement = isValidElement(render) ? render : void 0;
|
|
26
|
+
const elementRef = renderElement?.props.ref;
|
|
27
|
+
const mergedRef = useMemo(() => mergeRefs(ref, consumerRef, elementRef), [
|
|
28
|
+
ref,
|
|
29
|
+
consumerRef,
|
|
30
|
+
elementRef
|
|
31
|
+
]);
|
|
32
|
+
const partProps = {
|
|
33
|
+
...props,
|
|
34
|
+
ref: mergedRef,
|
|
35
|
+
className: resolveStateValue(className, state),
|
|
36
|
+
style: resolveStateValue(style, state),
|
|
37
|
+
children: resolveStateValue(children, state)
|
|
38
|
+
};
|
|
39
|
+
const controlled = libraryProps ?? {};
|
|
40
|
+
if (renderElement !== void 0) {
|
|
41
|
+
const { ref: _ignored, ...elementProps } = renderElement.props;
|
|
42
|
+
return cloneElement(renderElement, mergeProps(partProps, elementProps, controlled));
|
|
43
|
+
}
|
|
44
|
+
const resolved = mergeProps(partProps, {}, controlled);
|
|
45
|
+
if (typeof render === "function") return render(resolved, state);
|
|
46
|
+
return createElement(defaultElement, resolved);
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
export { rendersButton, resolveStateValue, useRender };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=use-render.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-render.mjs","names":[],"sources":["../../src/internal/use-render.tsx"],"sourcesContent":["\"use client\";\nimport { cloneElement, createElement, isValidElement, useMemo } from \"react\";\nimport type { CSSProperties, ReactNode, Ref } from \"react\";\nimport { mergeProps, mergeRefs } from \"./merge-props.js\";\nimport type { RenderProp, StateValue } from \"./types.js\";\n\ninterface ElementLikeProps {\n className?: string | undefined;\n style?: CSSProperties | undefined;\n children?: ReactNode;\n ref?: Ref<unknown> | undefined;\n}\n\n/** `data-*` attributes are valid JSX but absent from the element prop types. */\nexport type DataAttributes = { [key: `data-${string}`]: string | undefined };\n\nexport interface UseRenderOptions<Props extends ElementLikeProps, State> {\n /** Intrinsic element rendered when `render` is not given. */\n defaultElement: string;\n render: RenderProp<Props, State> | undefined;\n state: State;\n className: StateValue<string | undefined, State> | undefined;\n style: StateValue<CSSProperties | undefined, State> | undefined;\n children: StateValue<ReactNode, State> | undefined;\n /** The part's pass-through props for the element. A `render` element's own props win over these. */\n props: Omit<Props, \"className\" | \"style\" | \"children\"> & DataAttributes;\n /**\n * Library-controlled attributes the part computes (`role`, `aria-*` state, id wiring, `data-*`\n * state, the library's own event handlers). These win over a `render` element's props\n * (conventions 1.2).\n */\n libraryProps?: Partial<Omit<Props, \"className\" | \"style\" | \"children\">> & DataAttributes;\n /** The library's own ref, if the part needs the DOM node. */\n ref?: Ref<unknown> | undefined;\n}\n\nexport function resolveStateValue<T, State>(value: StateValue<T, State>, state: State): T {\n return typeof value === \"function\" ? (value as (state: State) => T)(state) : value;\n}\n\n/**\n * Whether a part whose default element is a `<button>` should add `type=\"button\"`: no `render`,\n * a `render` element whose type is the intrinsic `\"button\"`, or the function form (it receives\n * button props and decides itself what to spread). An element of any other type (an `<a>`, a\n * router `Link`) does not get it: `type` means something else, or nothing, there.\n */\nexport function rendersButton<Props, State>(render: RenderProp<Props, State> | undefined): boolean {\n if (render === undefined || typeof render === \"function\") return true;\n return isValidElement(render) && render.type === \"button\";\n}\n\n/**\n * Resolves `render` and the function-form `className` / `style` / `children` of a part against\n * its state and produces the element (conventions 1.2, 1.3, 1.4).\n */\nexport function useRender<Props extends ElementLikeProps, State>(\n options: UseRenderOptions<Props, State>,\n): ReactNode {\n const { defaultElement, render, state, className, style, children, props, libraryProps, ref } =\n options;\n\n const consumerRef = (props as ElementLikeProps).ref;\n const renderElement = isValidElement<ElementLikeProps>(render) ? render : undefined;\n const elementRef = renderElement?.props.ref;\n // Memoised so that the callback ref keeps its identity across renders and React does not\n // detach and re-attach the node every time.\n const mergedRef = useMemo(\n () => mergeRefs(ref, consumerRef, elementRef),\n [ref, consumerRef, elementRef],\n );\n\n const partProps = {\n ...props,\n ref: mergedRef,\n className: resolveStateValue(className, state),\n style: resolveStateValue(style, state),\n children: resolveStateValue(children, state),\n } as Record<string, unknown>;\n const controlled = (libraryProps ?? {}) as Record<string, unknown>;\n\n if (renderElement !== undefined) {\n // The element's ref is already part of `mergedRef`.\n const { ref: _ignored, ...elementProps } = renderElement.props as Record<string, unknown>;\n return cloneElement(\n renderElement,\n mergeProps(partProps, elementProps, controlled) as Partial<Props>,\n );\n }\n const resolved = mergeProps(partProps, {}, controlled) as unknown as Props;\n if (typeof render === \"function\") {\n return render(resolved, state);\n }\n return createElement(defaultElement, resolved);\n}\n"],"mappings":";;;;AAoCA,SAAgB,kBAA4B,OAA6B,OAAiB;CACxF,OAAO,OAAO,UAAU,aAAc,MAA8B,KAAK,IAAI;AAC/E;;;;;;;AAQA,SAAgB,cAA4B,QAAuD;CACjG,IAAI,WAAW,KAAA,KAAa,OAAO,WAAW,YAAY,OAAO;CACjE,OAAO,eAAe,MAAM,KAAK,OAAO,SAAS;AACnD;;;;;AAMA,SAAgB,UACd,SACW;CACX,MAAM,EAAE,gBAAgB,QAAQ,OAAO,WAAW,OAAO,UAAU,OAAO,cAAc,QACtF;CAEF,MAAM,cAAe,MAA2B;CAChD,MAAM,gBAAgB,eAAiC,MAAM,IAAI,SAAS,KAAA;CAC1E,MAAM,aAAa,eAAe,MAAM;CAGxC,MAAM,YAAY,cACV,UAAU,KAAK,aAAa,UAAU,GAC5C;EAAC;EAAK;EAAa;CAAU,CAC/B;CAEA,MAAM,YAAY;EAChB,GAAG;EACH,KAAK;EACL,WAAW,kBAAkB,WAAW,KAAK;EAC7C,OAAO,kBAAkB,OAAO,KAAK;EACrC,UAAU,kBAAkB,UAAU,KAAK;CAC7C;CACA,MAAM,aAAc,gBAAgB,CAAC;CAErC,IAAI,kBAAkB,KAAA,GAAW;EAE/B,MAAM,EAAE,KAAK,UAAU,GAAG,iBAAiB,cAAc;EACzD,OAAO,aACL,eACA,WAAW,WAAW,cAAc,UAAU,CAChD;CACF;CACA,MAAM,WAAW,WAAW,WAAW,CAAC,GAAG,UAAU;CACrD,IAAI,OAAO,WAAW,YACpB,OAAO,OAAO,UAAU,KAAK;CAE/B,OAAO,cAAc,gBAAgB,QAAQ;AAC/C"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useEffectEvent, useLayoutEffect, useRef } from "react";
|
|
3
|
+
//#region src/internal/use-until-found.ts
|
|
4
|
+
/**
|
|
5
|
+
* The single home of the `hidden="until-found"` workaround (conventions 7.4).
|
|
6
|
+
*
|
|
7
|
+
* react-dom 19.3 serialises `hidden` as a boolean attribute (`hidden=""` on client and server),
|
|
8
|
+
* so the part renders a boolean `hidden` and this hook upgrades the attribute to `"until-found"`
|
|
9
|
+
* through a ref in a layout effect. The upgrade is re-asserted after **every** commit while
|
|
10
|
+
* `active`: when the browser reveals the element it removes the attribute itself, and if the
|
|
11
|
+
* resulting action is rejected React sees no prop change (the boolean `hidden` stayed `true`
|
|
12
|
+
* throughout) and would leave the element visible.
|
|
13
|
+
*
|
|
14
|
+
* `beforematch` has no React prop, so a native listener is attached in an effect through the
|
|
15
|
+
* returned ref (conventions 2.6, 7.4). Attach the ref to the element that renders the boolean
|
|
16
|
+
* `hidden`.
|
|
17
|
+
*/
|
|
18
|
+
function useUntilFound(options) {
|
|
19
|
+
const { active } = options;
|
|
20
|
+
const node = useRef(null);
|
|
21
|
+
const onMatch = useEffectEvent((event) => {
|
|
22
|
+
options.onMatch(event);
|
|
23
|
+
});
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
const element = node.current;
|
|
26
|
+
if (element !== null && active && element.getAttribute("hidden") !== "until-found") element.setAttribute("hidden", "until-found");
|
|
27
|
+
});
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const element = node.current;
|
|
30
|
+
if (element === null) return;
|
|
31
|
+
const listener = (event) => onMatch(event);
|
|
32
|
+
element.addEventListener("beforematch", listener);
|
|
33
|
+
return () => element.removeEventListener("beforematch", listener);
|
|
34
|
+
});
|
|
35
|
+
return node;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
export { useUntilFound };
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=use-until-found.mjs.map
|