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.
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/store/agentic/StToolBuilder.ts +6 -0
- package/types/store/agentic/StToolBuilder.d.ts +5 -0
- package/types/ui/Dialog/LegacyModal.d.ts +13 -0
- package/types/ui/Dialog/context.d.ts +6 -0
- package/types/ui/Dialog/index.d.ts +1 -0
- package/types/ui/Dropdown.d.ts +3 -1
- package/types/ui/LegacyModal.d.ts +24 -0
- package/types/ui/Model/New.d.ts +3 -1
- package/types/ui/Model/NewWrapper.d.ts +2 -0
- package/types/ui/Model/NewWrapper_Client.d.ts +2 -1
- package/types/ui/index.d.ts +1 -0
- package/types/ui/overlayLayer.d.ts +13 -0
- package/types/ui/overlayPosition.d.ts +28 -0
- package/types/vendor/use-agentic/AgenticSurface.d.ts +6 -0
- package/types/vendor/use-agentic/types.d.ts +2 -0
- package/types/webkit/index.d.ts +1 -0
- package/types/webkit/useBodyScrollLock.d.ts +2 -0
- package/ui/Data/ListContainer.tsx +3 -19
- package/ui/Dialog/LegacyModal.tsx +241 -0
- package/ui/Dialog/Modal.tsx +60 -188
- package/ui/Dialog/Provider.tsx +19 -3
- package/ui/Dialog/context.ts +7 -0
- package/ui/Dialog/index.tsx +2 -0
- package/ui/Dropdown.tsx +55 -21
- package/ui/LegacyModal.tsx +53 -0
- package/ui/Model/EditModal.tsx +27 -3
- package/ui/Model/EditWrapper.tsx +19 -4
- package/ui/Model/New.tsx +4 -0
- package/ui/Model/NewWrapper.tsx +2 -0
- package/ui/Model/NewWrapper_Client.tsx +21 -6
- package/ui/Model/Remove.tsx +17 -6
- package/ui/Model/RemoveWrapper.tsx +11 -2
- package/ui/Model/SureToRemove.tsx +21 -6
- package/ui/Model/ViewEditModal.tsx +67 -15
- package/ui/Model/ViewModal.tsx +26 -11
- package/ui/Model/ViewWrapper.tsx +14 -4
- package/ui/Popconfirm.tsx +81 -68
- package/ui/Select.tsx +85 -54
- package/ui/Signal/Doc.tsx +2 -1
- package/ui/index.ts +1 -0
- package/ui/overlayLayer.ts +9 -0
- package/ui/overlayPosition.ts +104 -0
- package/vendor/use-agentic/AgenticSurface.ts +11 -3
- package/vendor/use-agentic/types.ts +2 -0
- package/webkit/index.ts +1 -0
- package/webkit/useBodyScrollLock.tsx +29 -0
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -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;
|
package/types/ui/Dropdown.d.ts
CHANGED
|
@@ -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;
|
package/types/ui/Model/New.d.ts
CHANGED
|
@@ -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 {};
|
package/types/ui/index.d.ts
CHANGED
|
@@ -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. */
|
package/types/webkit/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -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
|
+
};
|