akanjs 3.0.0-alpha.34 → 3.0.0-alpha.35

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.
Files changed (49) hide show
  1. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  2. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  3. package/package.json +1 -1
  4. package/store/agentic/StToolBuilder.ts +6 -0
  5. package/types/store/agentic/StToolBuilder.d.ts +5 -0
  6. package/types/ui/Dialog/LegacyModal.d.ts +13 -0
  7. package/types/ui/Dialog/context.d.ts +6 -0
  8. package/types/ui/Dialog/index.d.ts +1 -0
  9. package/types/ui/Dropdown.d.ts +3 -1
  10. package/types/ui/LegacyModal.d.ts +24 -0
  11. package/types/ui/Model/New.d.ts +3 -1
  12. package/types/ui/Model/NewWrapper.d.ts +2 -0
  13. package/types/ui/Model/NewWrapper_Client.d.ts +2 -1
  14. package/types/ui/index.d.ts +1 -0
  15. package/types/ui/overlayLayer.d.ts +13 -0
  16. package/types/ui/overlayPosition.d.ts +28 -0
  17. package/types/vendor/use-agentic/AgenticSurface.d.ts +6 -0
  18. package/types/vendor/use-agentic/types.d.ts +2 -0
  19. package/types/webkit/index.d.ts +1 -0
  20. package/types/webkit/useBodyScrollLock.d.ts +2 -0
  21. package/ui/Data/ListContainer.tsx +3 -19
  22. package/ui/Dialog/LegacyModal.tsx +241 -0
  23. package/ui/Dialog/Modal.tsx +60 -188
  24. package/ui/Dialog/Provider.tsx +19 -3
  25. package/ui/Dialog/context.ts +7 -0
  26. package/ui/Dialog/index.tsx +2 -0
  27. package/ui/Dropdown.tsx +55 -21
  28. package/ui/LegacyModal.tsx +53 -0
  29. package/ui/Model/EditModal.tsx +27 -3
  30. package/ui/Model/EditWrapper.tsx +19 -4
  31. package/ui/Model/New.tsx +4 -0
  32. package/ui/Model/NewWrapper.tsx +2 -0
  33. package/ui/Model/NewWrapper_Client.tsx +21 -6
  34. package/ui/Model/Remove.tsx +17 -6
  35. package/ui/Model/RemoveWrapper.tsx +11 -2
  36. package/ui/Model/SureToRemove.tsx +21 -6
  37. package/ui/Model/ViewEditModal.tsx +67 -15
  38. package/ui/Model/ViewModal.tsx +26 -11
  39. package/ui/Model/ViewWrapper.tsx +14 -4
  40. package/ui/Popconfirm.tsx +81 -68
  41. package/ui/Select.tsx +85 -54
  42. package/ui/Signal/Doc.tsx +2 -1
  43. package/ui/index.ts +1 -0
  44. package/ui/overlayLayer.ts +9 -0
  45. package/ui/overlayPosition.ts +104 -0
  46. package/vendor/use-agentic/AgenticSurface.ts +11 -3
  47. package/vendor/use-agentic/types.ts +2 -0
  48. package/webkit/index.ts +1 -0
  49. package/webkit/useBodyScrollLock.tsx +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -27,6 +27,11 @@ export interface StToolMeta {
27
27
  effect?: ToolEffect;
28
28
  confirm?: ToolConfirm;
29
29
  guard?: ToolGuard;
30
+ /**
31
+ * Set by a component that renders once per row. It is only true when the row's id rides in an argument rather
32
+ * than in the closure, which is what makes every row's registration interchangeable.
33
+ */
34
+ shared?: boolean;
30
35
  }
31
36
 
32
37
  interface StToolArg {
@@ -115,6 +120,7 @@ export class StToolBuilder<Args extends unknown[] = []> {
115
120
  name,
116
121
  description: spec.meta.desc,
117
122
  effect: spec.meta.effect,
123
+ ...(spec.meta.shared ? { shared: true } : {}),
118
124
  parameters: StToolBuilder.parametersOf(spec.args),
119
125
 
120
126
  ...(spec.meta.confirm === undefined && !name.startsWith("remove")
@@ -6,6 +6,11 @@ export interface StToolMeta {
6
6
  effect?: ToolEffect;
7
7
  confirm?: ToolConfirm;
8
8
  guard?: ToolGuard;
9
+ /**
10
+ * Set by a component that renders once per row. It is only true when the row's id rides in an argument rather
11
+ * than in the closure, which is what makes every row's registration interchangeable.
12
+ */
13
+ shared?: boolean;
9
14
  }
10
15
  interface StToolArg {
11
16
  name: string;
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from "react";
2
+ export interface LegacyModalProps {
3
+ className?: string;
4
+ bodyClassName?: string;
5
+ confirmClose?: boolean;
6
+ children?: ReactNode;
7
+ onCancel?: () => void;
8
+ }
9
+ /**
10
+ * Previous modal skin, kept for screens built around its motion: spring open/close and a drag-to-dismiss
11
+ * sheet on touch. New work composes {@link Modal}, which draws the same slots with no animation.
12
+ */
13
+ export declare const LegacyModal: ({ className, bodyClassName, confirmClose, children, onCancel }: LegacyModalProps) => import("react").ReactPortal | null;
@@ -4,6 +4,12 @@ export interface DialogContextType {
4
4
  setOpen: (open: boolean) => void;
5
5
  openDialog: () => void;
6
6
  closeDialog: () => void;
7
+ /**
8
+ * How this dialog actually dismisses, handed up by whichever surface is drawing it. `confirmClose` and
9
+ * `onCancel` hang off that path, so a close that only flipped `open` would skip both — which is what made an
10
+ * agent's close, and `Dialog.Close`, quietly different from clicking the X.
11
+ */
12
+ registerDismiss: (dismiss: (() => void) | null) => void;
7
13
  title: ReactNode;
8
14
  setTitle: (title: ReactNode) => void;
9
15
  action: ReactNode;
@@ -2,6 +2,7 @@ import { type ProviderProps } from "./Provider.d.ts";
2
2
  export declare const Dialog: {
3
3
  ({ children, ...props }: ProviderProps): import("react/jsx-runtime").JSX.Element;
4
4
  Modal: ({ className, bodyClassName, confirmClose, children, onCancel }: import("./Modal.d.ts").ModalProps) => import("react").ReactPortal | null;
5
+ LegacyModal: ({ className, bodyClassName, confirmClose, children, onCancel }: import("./LegacyModal.d.ts").LegacyModalProps) => import("react").ReactPortal | null;
5
6
  Title: ({ children }: import("./Title.d.ts").TitleProps) => null;
6
7
  Action: ({ children }: import("./Action.d.ts").ActionProps) => null;
7
8
  Trigger: ({ className, children }: import("./Trigger.d.ts").TriggerProps) => import("react/jsx-runtime").JSX.Element;
@@ -12,8 +12,10 @@ export interface DropdownProps {
12
12
  buttonClassName?: string;
13
13
  /** Additional classes for the dropdown content panel. */
14
14
  dropdownClassName?: string;
15
+ /** Trigger edge the menu lines up with. Position is computed, so a `left-0` class cannot do this. */
16
+ align?: "start" | "end";
15
17
  }
16
- export declare const DefaultDropdown: ({ value, content, className, buttonClassName, dropdownClassName }: DropdownProps) => import("react/jsx-runtime").JSX.Element;
18
+ export declare const DefaultDropdown: ({ value, content, className, buttonClassName, dropdownClassName, align, }: DropdownProps) => import("react/jsx-runtime").JSX.Element;
17
19
  /**
18
20
  * Dropdown. Resolves to a route-scoped override when a `page/**\/_overrides.tsx`
19
21
  * in the route's ancestry declares one, otherwise renders {@link DefaultDropdown}.
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+ export interface LegacyModalProps {
3
+ /** Additional classes for the modal surface. */
4
+ className?: string;
5
+ /** Optional modal title. */
6
+ title?: string | ReactNode;
7
+ /** Optional action area, usually footer buttons. */
8
+ action?: ReactNode;
9
+ /** Controlled open state. */
10
+ open: boolean;
11
+ /** Called when the modal requests closing. */
12
+ onCancel: () => void;
13
+ /** Additional classes for the content body. */
14
+ bodyClassName?: string;
15
+ children?: ReactNode;
16
+ /** Ask for close confirmation before dismissing. */
17
+ confirmClose?: boolean;
18
+ }
19
+ /**
20
+ * Previous akanjs modal skin: spring open/close and a drag-to-dismiss sheet on touch. Kept for screens
21
+ * built around that motion. New work uses {@link Modal}, which takes the same props with no animation,
22
+ * and unlike this one resolves through the `Modal` override slot.
23
+ */
24
+ export declare const LegacyModal: ({ className, title, action, open, onCancel, bodyClassName, children, confirmClose, }: LegacyModalProps) => import("react/jsx-runtime").JSX.Element;
@@ -11,6 +11,8 @@ interface NewProps<Full = any> {
11
11
  renderTitle?: ((model: {
12
12
  id: string;
13
13
  }) => string | ReactNode) | string;
14
+ /** Suffixes the tool this button publishes. Only a second create button for the same slice needs one. */
15
+ namespace?: string;
14
16
  }
15
- export default function New({ className, wrapperClassName, type, children, slice, modal, partial, renderTitle, }: NewProps): import("react/jsx-runtime").JSX.Element;
17
+ export default function New({ className, wrapperClassName, type, children, slice, modal, partial, renderTitle, namespace, }: NewProps): import("react/jsx-runtime").JSX.Element;
16
18
  export {};
@@ -8,6 +8,8 @@ interface NewWrapperProps<Full = any> {
8
8
  setDefault?: boolean;
9
9
  modal?: string | null;
10
10
  resets?: string[] | null;
11
+ /** Suffixes the tool this trigger publishes. Only a second create trigger for the same slice needs one. */
12
+ namespace?: string;
11
13
  }
12
14
  export default function NewWrapper<Full>({ partial, ...props }: NewWrapperProps<Full>): import("react/jsx-runtime").JSX.Element;
13
15
  export {};
@@ -8,6 +8,7 @@ interface NewWrapperProps<Full = any> {
8
8
  setDefault?: boolean;
9
9
  modal?: string | null;
10
10
  resets?: string[] | null;
11
+ namespace?: string;
11
12
  }
12
- export declare const NewWrapper_Client: <Full>({ children, slice, partial, setDefault, className, modal, resets, }: NewWrapperProps<Full>) => import("react/jsx-runtime").JSX.Element;
13
+ export declare const NewWrapper_Client: <Full>({ children, slice, partial, setDefault, className, modal, resets, namespace, }: NewWrapperProps<Full>) => import("react/jsx-runtime").JSX.Element;
13
14
  export {};
@@ -23,6 +23,7 @@ export { InfiniteScroll } from "./InfiniteScroll.d.ts";
23
23
  export { Input } from "./Input.d.ts";
24
24
  export { KeyboardAvoiding } from "./KeyboardAvoiding.d.ts";
25
25
  export { Layout } from "./Layout.d.ts";
26
+ export { LegacyModal } from "./LegacyModal.d.ts";
26
27
  export { Link } from "./Link.d.ts";
27
28
  export { Load } from "./Load.d.ts";
28
29
  export { Loading } from "./Loading.d.ts";
@@ -8,6 +8,19 @@
8
8
  * recognises its own overlays exactly, instead of guessing from how close a dialog happens to be.
9
9
  */
10
10
  export declare const OVERLAY_LAYER_ATTR = "data-akan-overlay";
11
+ /**
12
+ * Stacking order for overlays that portal to `document.body`, where nesting no longer decides who is on
13
+ * top. A Popconfirm outranks a dropdown menu because `Model.Remove` draws one from inside a menu item,
14
+ * and its scrim outranks the menu so the click that answers the confirm is not also a menu selection.
15
+ * A Select's options stay under both: it is a field, and whatever opened over it took the click that
16
+ * dismisses it.
17
+ */
18
+ export declare const overlayZ: {
19
+ readonly select: 90;
20
+ readonly dropdown: 100;
21
+ readonly popconfirmScrim: 105;
22
+ readonly popconfirm: 110;
23
+ };
11
24
  /** Wrap the content a dismissable container owns, with the scope from {@link useOverlayScope}. */
12
25
  export declare const OverlayOwnerProvider: import("react").Provider<string>;
13
26
  /** Scope a dismissable container hands to its content. Nests as a `parent/child` path. */
@@ -0,0 +1,28 @@
1
+ import { type RefObject } from "react";
2
+ export interface OverlayPosition {
3
+ top: number;
4
+ left: number;
5
+ /** Placed above the trigger, because there was no room below. */
6
+ above: boolean;
7
+ /** The trigger's centre relative to the panel's left edge, held clear of the panel's rounded corners. */
8
+ anchorOffset: number;
9
+ /** The trigger's own width, for a panel that lines up with the control it drops out of. */
10
+ anchorWidth: number;
11
+ }
12
+ /**
13
+ * Places a portalled panel against its trigger in viewport coordinates.
14
+ *
15
+ * A panel positioned inside its own tree is clipped by every `overflow` ancestor it has — the modal
16
+ * surface, the modal's scrolling body, a table's scroll container, the dropdown menu a row verb sits in —
17
+ * so overlay panels portal out to `document.body` and are placed here instead of by CSS.
18
+ * `position: fixed` in place would not do it either: a fixed element inside a transformed ancestor is laid
19
+ * out and clipped by that ancestor, and the modal surface animates a transform.
20
+ */
21
+ export declare const useOverlayPosition: ({ opened, triggerRef, panelRef, align, gap, }: {
22
+ opened: boolean;
23
+ triggerRef: RefObject<HTMLElement | null>;
24
+ panelRef: RefObject<HTMLElement | null>;
25
+ align: "start" | "end";
26
+ /** Distance from the trigger. Raise it for a panel whose pointer sticks out past its own edge. */
27
+ gap?: number;
28
+ }) => OverlayPosition | null;
@@ -15,6 +15,12 @@ export declare class AgenticSurface {
15
15
  static childPath(parent: string[], id: string): string[];
16
16
  static fullName(scope: string[], name: string): string;
17
17
  subscribe(listener: () => void): () => void;
18
+ /**
19
+ * A row component registers the same name once per row. That is legal when the tool takes the row's id as an
20
+ * argument rather than closing over it, because every registration is then interchangeable and last-wins picks
21
+ * an equivalent one — `shared` is the registrant saying so, and it turns fifty rows from fifty collisions into
22
+ * one declaration. A shared name landing on top of a name nobody shared is still a real clash, and still warns.
23
+ */
18
24
  registerTool(scope: string[], entry: ToolEntry): () => void;
19
25
  registerResource(scope: string[], entry: ResourceEntry): () => void;
20
26
  openScope(parent: string[], scope: ScopeEntry): () => void;
@@ -11,6 +11,8 @@ export interface ToolEntry {
11
11
  effect?: ToolEffect;
12
12
  confirm?: ToolConfirm;
13
13
  guard?: ToolGuard;
14
+ /** Declares that repeat registrations of this name are interchangeable — see `AgenticSurface.registerTool`. */
15
+ shared?: boolean;
14
16
  run: (args: Record<string, unknown>) => unknown;
15
17
  }
16
18
  /** One call an agent made through the surface, in the order it made them. */
@@ -3,6 +3,7 @@ export { createRobotPage } from "./createRobotPage.d.ts";
3
3
  export { createSitemapPage } from "./createSitemapPage.d.ts";
4
4
  export { lazy } from "./lazy.d.ts";
5
5
  export type * from "./types.d.ts";
6
+ export { useBodyScrollLock } from "./useBodyScrollLock.d.ts";
6
7
  export { useCamera } from "./useCamera.d.ts";
7
8
  export { useCodepush } from "./useCodepush.d.ts";
8
9
  export { useContact } from "./useContact.d.ts";
@@ -0,0 +1,2 @@
1
+ /** Hold `document.body` unscrollable while `active`. */
2
+ export declare const useBodyScrollLock: (active: boolean) => void;
@@ -143,11 +143,6 @@ export default function ListContainer<
143
143
  editModel: `edit${modelClassName}`,
144
144
  viewModel: `view${modelClassName}`,
145
145
  removeModel: `remove${modelClassName}`,
146
- submitModel: `submit${modelClassName}`,
147
- setModelModal: `set${modelClassName}Modal`,
148
- resetModel: `reset${modelClassName}`,
149
- cancelEditOfModel: `cancelEditOf${modelClassName}`,
150
- closeViewOfModel: `closeViewOf${modelClassName}`,
151
146
  };
152
147
  const namesOfSlice = {
153
148
  modelList: sliceName.replace(names.model, names.modelList),
@@ -166,8 +161,6 @@ export default function ListContainer<
166
161
  editModel: sliceName.replace(names.model, names.editModel),
167
162
  viewModel: sliceName.replace(names.model, names.viewModel),
168
163
  removeModel: sliceName.replace(names.model, names.removeModel),
169
- cancelEditOfModel: sliceName.replace(names.model, names.cancelEditOfModel),
170
- closeViewOfModel: sliceName.replace(names.model, names.closeViewOfModel),
171
164
  };
172
165
  const [view, setView] = useState(type);
173
166
  const limitOfModel = storeUse[namesOfSlice.limitOfModel]() as number;
@@ -243,33 +236,24 @@ export default function ListContainer<
243
236
  st.tool(rowActions.includes("edit") && renderTemplate ? namesOfSlice.editModel : null, {
244
237
  desc: `Open one ${modelName} in the edit form.`,
245
238
  effect: "state",
239
+ shared: true,
246
240
  })
247
241
  .arg("modelId", ID)
248
242
  .exec((modelId) => storeDo[namesOfSlice.editModel](modelId));
249
243
  st.tool(rowActions.includes("view") && renderView ? namesOfSlice.viewModel : null, {
250
244
  desc: `Open one ${modelName} in the detail view.`,
251
245
  effect: "state",
246
+ shared: true,
252
247
  })
253
248
  .arg("modelId", ID)
254
249
  .exec((modelId) => storeDo[namesOfSlice.viewModel](modelId));
255
250
  st.tool(rowActions.includes("remove") ? namesOfSlice.removeModel : null, {
256
251
  desc: `Remove one ${modelName}.`,
257
252
  effect: "mutation",
253
+ shared: true,
258
254
  })
259
255
  .arg("modelId", ID)
260
256
  .exec((modelId) => storeDo[namesOfSlice.removeModel](modelId));
261
- st.tool(renderTemplate ? names.submitModel : null, {
262
- desc: `Save the ${modelName} the edit form holds.`,
263
- effect: "mutation",
264
- }).exec(() => storeDo[names.submitModel]({ sliceName }));
265
- st.tool(renderTemplate ? namesOfSlice.cancelEditOfModel : null, {
266
- desc: `Close the ${modelName} edit form without saving.`,
267
- effect: "state",
268
- }).exec(() => storeDo[names.setModelModal](null));
269
- st.tool(renderView ? namesOfSlice.closeViewOfModel : null, {
270
- desc: `Close the ${modelName} detail view.`,
271
- effect: "state",
272
- }).exec(() => storeDo[names.resetModel]());
273
257
  const scopePath = useScreenScope({
274
258
  id: sliceName,
275
259
  kind: refName,
@@ -0,0 +1,241 @@
1
+ "use client";
2
+ import { useDrag } from "@use-gesture/react";
3
+ import { cn, usePage } from "akanjs/client";
4
+ import { animated } from "akanjs/ui";
5
+ import { useBodyScrollLock, useEscapeKey } from "akanjs/webkit";
6
+ import { type ReactNode, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
7
+ import { createPortal } from "react-dom";
8
+ import { BiX } from "react-icons/bi";
9
+ import { config, useSpring } from "react-spring";
10
+ import { buttonRecipe } from "../Button";
11
+ import { useOverlayLayerProps } from "../overlayLayer";
12
+
13
+ import { DialogContext } from "./context";
14
+
15
+ const MODAL_MARGIN = 0;
16
+ const OPACITY = { START: 0, END: 1 };
17
+
18
+ const interpolate = (o: number, i: number, t: number) => {
19
+ return o + (i - o) * t;
20
+ };
21
+
22
+ export interface LegacyModalProps {
23
+ className?: string;
24
+ bodyClassName?: string;
25
+ confirmClose?: boolean;
26
+ children?: ReactNode;
27
+ onCancel?: () => void;
28
+ }
29
+ /**
30
+ * Previous modal skin, kept for screens built around its motion: spring open/close and a drag-to-dismiss
31
+ * sheet on touch. New work composes {@link Modal}, which draws the same slots with no animation.
32
+ */
33
+ export const LegacyModal = ({ className, bodyClassName, confirmClose, children, onCancel }: LegacyModalProps) => {
34
+ const { open, setOpen, registerDismiss, title, action } = useContext(DialogContext);
35
+ const { l } = usePage();
36
+ const ref = useRef<HTMLDivElement>(null);
37
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38
+ const closingRef = useRef(false);
39
+ const focusedElementRef = useRef<HTMLElement | null>(null);
40
+ const titleId = useId();
41
+ const contentId = useId();
42
+
43
+ const overlayLayerProps = useOverlayLayerProps();
44
+ const [{ translate }, api] = useSpring(() => ({ translate: 1 }));
45
+ const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
46
+ const [isMounted, setIsMounted] = useState(open);
47
+ const [showBackground, setShowBackground] = useState(false);
48
+
49
+ const openModal = useCallback(
50
+ async ({ canceled }: { canceled?: boolean } = {}) => {
51
+ closingRef.current = false;
52
+ setIsMounted(true);
53
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
54
+ closeTimerRef.current = setTimeout(() => {
55
+ setShowBackground(true);
56
+ }, 100);
57
+ await Promise.all(api.start({ translate: 0, immediate: false, config: canceled ? config.wobbly : config.stiff }));
58
+ },
59
+ [api],
60
+ );
61
+
62
+ const closeModal = useCallback(
63
+ async ({
64
+ velocity = 0,
65
+ confirmClose,
66
+ notifyCancel = true,
67
+ }: {
68
+ velocity?: number;
69
+ confirmClose?: boolean;
70
+ notifyCancel?: boolean;
71
+ }) => {
72
+ if (closingRef.current) return;
73
+ if (confirmClose && !window.confirm(l("base.confirmClose"))) {
74
+ return;
75
+ }
76
+
77
+ closingRef.current = true;
78
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
79
+ closeTimerRef.current = setTimeout(() => {
80
+ setShowBackground(false);
81
+ }, 100);
82
+ await Promise.all(api.start({ translate: 1, immediate: false, config: { ...config.stiff, velocity } }));
83
+ setIsMounted(false);
84
+ setOpen(false);
85
+ if (notifyCancel) onCancel?.();
86
+ closingRef.current = false;
87
+ },
88
+ [api, l, onCancel, setOpen],
89
+ );
90
+
91
+ const requestClose = useCallback(
92
+ (options?: { velocity?: number }) => {
93
+ void closeModal({ velocity: options?.velocity, confirmClose });
94
+ },
95
+ [closeModal, confirmClose],
96
+ );
97
+
98
+ const bind = useDrag(
99
+ ({ last, velocity: [, vy], direction: [, dy], offset: [, oy], movement: [, my], cancel, canceled }) => {
100
+ if (!ref.current) return;
101
+ const height = Math.max((ref.current.clientHeight || MODAL_MARGIN) - MODAL_MARGIN, 1);
102
+ if (my > 70) cancel();
103
+ if (last) {
104
+ if (my > height * 0.5 || (vy > 0.5 && dy > 0)) requestClose({ velocity: vy / height });
105
+ else void openModal({ canceled });
106
+ } else void api.start({ translate: oy / height, immediate: true });
107
+ },
108
+ { from: () => [0, translate.get()], filterTaps: true, bounds: { top: 0 }, rubberband: true },
109
+ );
110
+
111
+ const opacity = translate.to((t) => {
112
+ return interpolate(OPACITY.END, OPACITY.START, t);
113
+ });
114
+ const translateY = translate.to((t) => {
115
+ return `${t * 100}%`;
116
+ });
117
+
118
+ useEffect(() => {
119
+ if (typeof document === "undefined") return;
120
+ setPortalElement(document.body);
121
+ }, []);
122
+
123
+ useEffect(() => {
124
+ if (open) {
125
+ void openModal();
126
+ }
127
+ }, [open, openModal]);
128
+
129
+ useEffect(() => {
130
+ if (!open && isMounted) {
131
+ void closeModal({ notifyCancel: false });
132
+ }
133
+ }, [closeModal, isMounted, open]);
134
+
135
+ useBodyScrollLock(isMounted);
136
+
137
+ useEffect(() => {
138
+ if (!isMounted || !portalElement || typeof document === "undefined") return;
139
+
140
+ focusedElementRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
141
+ queueMicrotask(() => {
142
+ ref.current?.focus();
143
+ });
144
+
145
+ return () => {
146
+ if (focusedElementRef.current && document.contains(focusedElementRef.current)) {
147
+ focusedElementRef.current.focus();
148
+ }
149
+ focusedElementRef.current = null;
150
+ };
151
+ }, [isMounted, portalElement]);
152
+
153
+ const latestClose = useRef(requestClose);
154
+ latestClose.current = requestClose;
155
+ useEffect(() => {
156
+ registerDismiss(() => {
157
+ latestClose.current();
158
+ });
159
+ return () => {
160
+ registerDismiss(null);
161
+ };
162
+ }, [registerDismiss]);
163
+
164
+ useEscapeKey(isMounted, requestClose);
165
+
166
+ useEffect(() => {
167
+ return () => {
168
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
169
+ };
170
+ }, []);
171
+
172
+ if (!isMounted || !portalElement) return null;
173
+
174
+ return createPortal(
175
+ <>
176
+ <div
177
+ {...overlayLayerProps}
178
+ className={cn("fixed inset-0 z-10", showBackground && "animate-fadeIn bg-black/50 backdrop-blur-sm")}
179
+ />
180
+ <div
181
+ {...overlayLayerProps}
182
+ className="fixed inset-0 z-10 flex items-center justify-center p-4"
183
+ onClick={(event) => {
184
+ if (event.target !== event.currentTarget) return;
185
+ requestClose();
186
+ }}
187
+ >
188
+ <animated.div
189
+ ref={ref}
190
+ style={{ translateY, opacity }}
191
+
192
+ className={cn(
193
+ "relative flex max-h-full w-full max-w-2xl flex-col overflow-hidden rounded-box border border-border bg-card text-card-foreground shadow-2xl shadow-black/25 outline-none",
194
+ className,
195
+ )}
196
+ role="dialog"
197
+ aria-modal="true"
198
+ aria-labelledby={title ? titleId : undefined}
199
+ aria-describedby={contentId}
200
+ tabIndex={-1}
201
+ >
202
+ <animated.div
203
+ {...bind()}
204
+ className={cn(
205
+ "flex shrink-0 touch-pan-y flex-col",
206
+ title && "border-border/70 border-b bg-card/80 backdrop-blur-sm",
207
+ )}
208
+ >
209
+ <div className="flex justify-center pt-2 md:hidden">
210
+ <div className="h-1 w-10 rounded-full bg-foreground/15" />
211
+ </div>
212
+ <div className="flex items-start gap-3 px-5 pt-4 pb-3">
213
+ <div className="min-w-0 flex-1 font-semibold text-lg leading-snug" id={titleId}>
214
+ {title}
215
+ </div>
216
+ <button
217
+ aria-label="Close"
218
+ className={buttonRecipe(
219
+ { variant: "ghost", size: "icon" },
220
+ "-mt-1 -mr-2 size-8 shrink-0 rounded-full text-foreground/45 hover:text-foreground",
221
+ )}
222
+ onClick={() => requestClose()}
223
+ type="button"
224
+ >
225
+ <BiX className="text-2xl" />
226
+ </button>
227
+ </div>
228
+ </animated.div>
229
+ <div
230
+ className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-5 pt-4 pb-5", bodyClassName)}
231
+ id={contentId}
232
+ >
233
+ {children}
234
+ </div>
235
+ {action ? <div className="shrink-0 border-border/70 border-t bg-muted/30 px-5 py-4">{action}</div> : null}
236
+ </animated.div>
237
+ </div>
238
+ </>,
239
+ portalElement,
240
+ );
241
+ };