@slithy/modal-kit 0.1.2

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/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # @slithy/modal-kit
2
+
3
+ The headless React layer for the `@slithy` modal system. Provides primitives, behavior hooks, and a renderer — everything needed to build a fully functional modal UI with no animation dependency.
4
+
5
+ `modal-kit` + `modal-core` are sufficient for a production modal system. Add `@slithy/modal-spring` (or a custom adapter) only if you need animated transitions.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pnpm add @slithy/modal-core @slithy/modal-kit
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Minimal Setup (no animation)
18
+
19
+ ```tsx
20
+ import { useModalStore } from '@slithy/modal-core'
21
+ import { Modal, ModalRenderer } from '@slithy/modal-kit'
22
+
23
+ // Render once at your app root
24
+ export function App() {
25
+ const { backdropId, modals } = useModalStore(({ backdropId, modals }) => ({
26
+ backdropId,
27
+ modals,
28
+ }))
29
+ const showBackdrop = !!backdropId && modals.length > 0
30
+
31
+ return (
32
+ <>
33
+ <main>{/* your app */}</main>
34
+ <ModalRenderer
35
+ renderBackdrop={() => showBackdrop ? <ModalBackdrop /> : null}
36
+ />
37
+ </>
38
+ )
39
+ }
40
+
41
+ // Open a modal from anywhere
42
+ useModalStore.getState().openModal(
43
+ <Modal aria-label="My Modal">
44
+ <p>Content</p>
45
+ </Modal>,
46
+ { triggerEvent: event }
47
+ )
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Primitives
53
+
54
+ These are the building blocks. Each owns its CSS via `styleDedup` and can be composed directly or wrapped by animation adapters.
55
+
56
+ ### `ModalContainer`
57
+
58
+ Fixed-position layout shell. Handles horizontal alignment and scroll behavior.
59
+
60
+ ```tsx
61
+ <ModalContainer alignX="center" scrollable modalId={id}>
62
+ {/* ModalContent, etc. */}
63
+ </ModalContainer>
64
+ ```
65
+
66
+ | Prop | Type | Default |
67
+ |---|---|---|
68
+ | `alignX` | `'center' \| 'left' \| 'right'` | `'center'` |
69
+ | `scrollable` | `boolean` | — |
70
+ | `modalId` | `string` | — |
71
+ | `onBackdropClick` | `() => void` | — |
72
+ | `className` | `string` | — |
73
+
74
+ ### `ModalContent`
75
+
76
+ Content wrapper. Controls vertical alignment, pointer events during close, and the `data-disable-opacity` toggle.
77
+
78
+ ```tsx
79
+ <ModalContent alignY="middle" modalState={modalState} disableOpacityTransition>
80
+ {/* ModalDialog, etc. */}
81
+ </ModalContent>
82
+ ```
83
+
84
+ | Prop | Type | Default |
85
+ |---|---|---|
86
+ | `alignY` | `'middle' \| 'top' \| 'bottom'` | — |
87
+ | `modalState` | `ModalState` | — |
88
+ | `disableOpacityTransition` | `boolean` | — |
89
+ | `style` | `CSSProperties` | — |
90
+
91
+ ### `ModalDialog`
92
+
93
+ The `<dialog open>` element with correct ARIA attributes, focus target, and `modal-dialog` styles.
94
+
95
+ ```tsx
96
+ <ModalDialog
97
+ ref={contentRef}
98
+ aria-label="My Modal"
99
+ onKeyDown={onKeyDown}
100
+ >
101
+ {children}
102
+ </ModalDialog>
103
+ ```
104
+
105
+ | Prop | Type |
106
+ |---|---|
107
+ | `aria-label` | `string` |
108
+ | `className` | `string` |
109
+ | `onKeyDown` | `(e: KeyboardEvent<HTMLDialogElement>) => void` |
110
+ | `style` | `CSSProperties` |
111
+
112
+ ### `ModalBackdrop`
113
+
114
+ Fixed-position backdrop with click handling via `usePointerClick`.
115
+
116
+ ```tsx
117
+ <ModalBackdrop onClick={handleClose} />
118
+ ```
119
+
120
+ | Prop | Type |
121
+ |---|---|
122
+ | `onClick` | `() => void` |
123
+ | `className` | `string` |
124
+ | `style` | `CSSProperties` |
125
+
126
+ ---
127
+
128
+ ## Components
129
+
130
+ ### `Modal`
131
+
132
+ Reference implementation. Composes all four primitives into a functional non-animated modal. Use it directly for a no-animation setup, or as a reference when building a custom adapter.
133
+
134
+ ```tsx
135
+ <Modal
136
+ aria-label="Settings"
137
+ alignX="center"
138
+ alignY="middle"
139
+ contentClassName="my-card"
140
+ dismissible
141
+ >
142
+ {children}
143
+ </Modal>
144
+ ```
145
+
146
+ ### `ModalRenderer`
147
+
148
+ Renders all open modals from the store. Place once at the app root.
149
+
150
+ ```tsx
151
+ <ModalRenderer
152
+ renderBackdrop={() => showBackdrop ? <ModalBackdrop /> : null}
153
+ renderLayer={(children) => <LayerProvider>{children}</LayerProvider>}
154
+ renderPortal={(children) => <Portal>{children}</Portal>}
155
+ />
156
+ ```
157
+
158
+ | Prop | Type | Description |
159
+ |---|---|---|
160
+ | `renderBackdrop` | `() => ReactNode` | Backdrop; caller controls visibility and animation |
161
+ | `renderLayer` | `(children) => ReactNode` | Wrap all modals in a layer context |
162
+ | `renderPortal` | `(children) => ReactNode` | Wrap each modal in a portal |
163
+
164
+ ---
165
+
166
+ ## Hooks
167
+
168
+ ### `useModalLogic`
169
+
170
+ The core modal hook. Manages registration, lifecycle, focus, and Escape key. Called by `Modal` and by animation adapters.
171
+
172
+ ```ts
173
+ const {
174
+ contentRef, // ref to attach to <dialog>
175
+ handleCloseModal, // call to begin the close sequence
176
+ isTopModal, // whether this is the frontmost modal
177
+ layerIsActive, // whether this modal's layer is active
178
+ markAtRest, // call after enter animation completes (when delayAtRest: true)
179
+ modalId, // this modal's store ID
180
+ modalState, // 'opening' | 'open' | 'closing' | 'closed'
181
+ skipAnimation, // whether to skip animation for this instance
182
+ } = useModalLogic({
183
+ afterClose,
184
+ afterOpen,
185
+ delayAtRest, // when true, caller must call markAtRest() to unblock closing
186
+ layerIsActive, // pass from useLayerState for full layer coordination
187
+ onLeave, // (done) => void — call done() after leave animation
188
+ })
189
+ ```
190
+
191
+ ### `useDialogKeyDown`
192
+
193
+ Composes the Escape-key close handler and `useTrapFocus` into a single `onKeyDown` for a `<dialog>`.
194
+
195
+ ```ts
196
+ const onKeyDown = useDialogKeyDown({
197
+ dismissible,
198
+ handleCloseModal,
199
+ isTopModal,
200
+ layerIsActive,
201
+ onKeyDown: trapFocus.onKeyDown,
202
+ })
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Building an Adapter
208
+
209
+ An adapter:
210
+
211
+ 1. Calls `useModalLogic` (with `delayAtRest: true` if it animates)
212
+ 2. Wraps `ModalContent`, `ModalDialog`, etc. with its animation library (e.g. `animated(ModalDialog)`)
213
+ 3. Calls `markAtRest()` when the enter animation completes
214
+ 4. Passes `onLeave` to play the leave animation before calling `done()`
215
+ 5. Wraps `ModalRenderer` with `renderBackdrop` to inject an animated backdrop
216
+
217
+ See `@slithy/modal-spring` for a complete example.
218
+
219
+ ---
220
+
221
+ ## Exports
222
+
223
+ | Export | Description |
224
+ |---|---|
225
+ | `Modal` | Reference non-animated modal component |
226
+ | `ModalBackdrop` | Fixed-position backdrop primitive |
227
+ | `ModalContainer` | Layout shell primitive |
228
+ | `ModalContent` | Content wrapper primitive |
229
+ | `ModalDialog` | `<dialog>` element primitive |
230
+ | `ModalRenderer` | Renders all open modals from the store |
231
+ | `useDialogKeyDown` | Escape + trapFocus key handler hook |
232
+ | `useModalLogic` | Core lifecycle and behavior hook |
233
+ | `ModalBackdropProps` | — |
234
+ | `ModalContentProps` | — |
235
+ | `ModalDialogProps` | — |
236
+ | `ModalProps` | — |
237
+ | `ModalRendererProps` | — |
238
+ | `UseModalLogicOptions` | — |
239
+ | `UseModalLogicResult` | — |
@@ -0,0 +1,126 @@
1
+ import * as react from 'react';
2
+ import { CSSProperties, KeyboardEvent } from 'react';
3
+ import * as react_jsx_runtime from 'react/jsx-runtime';
4
+ import { ModalElement, ModalState } from '@slithy/modal-core';
5
+
6
+ type ModalBackdropProps = {
7
+ className?: string;
8
+ onClick?: () => void;
9
+ style?: CSSProperties;
10
+ };
11
+ declare const ModalBackdrop: react.ForwardRefExoticComponent<ModalBackdropProps & react.RefAttributes<HTMLDivElement>>;
12
+
13
+ type ContainerProps = {
14
+ alignX?: "center" | "left" | "right";
15
+ children: React.ReactNode;
16
+ className?: string;
17
+ modalId?: ModalElement["id"];
18
+ onBackdropClick?: () => void;
19
+ scrollable?: boolean;
20
+ };
21
+ declare const ModalContainer: ({ alignX, children, className, modalId, onBackdropClick, scrollable, }: ContainerProps) => react_jsx_runtime.JSX.Element;
22
+
23
+ type ModalProps = {
24
+ "aria-label"?: string;
25
+ afterClose?: () => void;
26
+ afterOpen?: () => void;
27
+ alignX?: "center" | "left" | "right";
28
+ alignY?: "middle" | "top" | "bottom";
29
+ children?: React.ReactNode;
30
+ containerScrolling?: boolean;
31
+ contentClassName?: string;
32
+ disableOpacityTransition?: boolean;
33
+ dismissible?: boolean;
34
+ /**
35
+ * Called when the modal begins closing, with a `done` callback to invoke
36
+ * once any leave animation has finished. If omitted, the modal is removed
37
+ * from the store synchronously.
38
+ *
39
+ * Animation adapters provide this to delay removal until their animation
40
+ * completes.
41
+ */
42
+ onLeave?: (done: () => void) => void;
43
+ };
44
+ declare const Modal: ({ "aria-label": ariaLabel, afterClose, afterOpen, alignX, alignY, children, containerScrolling, contentClassName, disableOpacityTransition, dismissible, onLeave, }: ModalProps) => react_jsx_runtime.JSX.Element;
45
+
46
+ type ModalContentProps = {
47
+ alignY?: "middle" | "top" | "bottom";
48
+ children?: React.ReactNode;
49
+ disableOpacityTransition?: boolean;
50
+ modalState?: ModalState;
51
+ style?: CSSProperties;
52
+ };
53
+ declare const ModalContent: react.ForwardRefExoticComponent<ModalContentProps & react.RefAttributes<HTMLDivElement>>;
54
+
55
+ type ModalDialogProps = {
56
+ 'aria-label'?: string;
57
+ children?: React.ReactNode;
58
+ className?: string;
59
+ onKeyDown?: (e: KeyboardEvent<HTMLDialogElement>) => void;
60
+ style?: CSSProperties;
61
+ };
62
+ declare const ModalDialog: react.ForwardRefExoticComponent<ModalDialogProps & react.RefAttributes<HTMLDialogElement>>;
63
+
64
+ type UseDialogKeyDownOptions = {
65
+ dismissible: boolean;
66
+ handleCloseModal: () => void;
67
+ isTopModal: boolean;
68
+ layerIsActive: boolean;
69
+ onKeyDown?: (e: KeyboardEvent<HTMLDialogElement>) => void;
70
+ };
71
+ declare function useDialogKeyDown({ dismissible, handleCloseModal, isTopModal, layerIsActive, onKeyDown, }: UseDialogKeyDownOptions): (e: KeyboardEvent<HTMLDialogElement>) => void;
72
+
73
+ type ModalRendererProps = {
74
+ /** Render an animated backdrop. Provided by animation adapters. */
75
+ renderBackdrop?: () => React.ReactNode;
76
+ /**
77
+ * Wrap all modal content in a layer context. Pass a `<LayerProvider>` from
78
+ * `@slithy/layers` for full layer-stack coordination. Defaults to a passthrough.
79
+ */
80
+ renderLayer?: (children: React.ReactNode) => React.ReactNode;
81
+ /**
82
+ * Wrap each modal in a portal. Pass a `<Portal>` from `@slithy/portal` (or
83
+ * any portal implementation) to render modals outside the current DOM tree.
84
+ * Defaults to a passthrough — no portaling.
85
+ */
86
+ renderPortal?: (children: React.ReactNode) => React.ReactNode;
87
+ };
88
+ declare const ModalRenderer: ({ renderBackdrop, renderLayer, renderPortal, }?: ModalRendererProps) => react.ReactNode;
89
+
90
+ type UseModalLogicOptions = {
91
+ afterClose?: () => void;
92
+ afterOpen?: () => void;
93
+ /**
94
+ * When true, `atRest` (which unblocks close) is NOT set by the internal RAF.
95
+ * Instead, the caller must invoke the returned `markAtRest` function when the
96
+ * modal is ready to be closed — e.g. after an enter animation completes.
97
+ */
98
+ delayAtRest?: boolean;
99
+ /**
100
+ * Whether the modal's layer is currently the active (topmost) layer in the
101
+ * app. Used to gate Escape key handling when higher layers (dropdowns,
102
+ * tooltips, etc.) are open on top of the modal.
103
+ *
104
+ * Defaults to `true`. Pass a value from `useLayerState` when using
105
+ * `@slithy/layers` for full layer-stack coordination.
106
+ */
107
+ layerIsActive?: boolean;
108
+ onLeave?: (done: () => void) => void;
109
+ };
110
+ type UseModalLogicResult = {
111
+ contentRef: React.RefObject<HTMLDialogElement | null>;
112
+ handleCloseModal: () => void;
113
+ skipAnimation: boolean | undefined;
114
+ isTopModal: boolean;
115
+ layerIsActive: boolean;
116
+ /**
117
+ * Only meaningful when `delayAtRest: true` was passed. Call this once the
118
+ * modal is fully open (e.g. after an enter animation) to allow closing.
119
+ */
120
+ markAtRest: () => void;
121
+ modalId: string;
122
+ modalState: ModalState;
123
+ };
124
+ declare function useModalLogic({ afterClose, afterOpen, delayAtRest, layerIsActive, onLeave, }: UseModalLogicOptions): UseModalLogicResult;
125
+
126
+ export { Modal, ModalBackdrop, type ModalBackdropProps, ModalContainer, ModalContent, type ModalContentProps, ModalDialog, type ModalDialogProps, type ModalProps, ModalRenderer, type ModalRendererProps, type UseModalLogicOptions, type UseModalLogicResult, useDialogKeyDown, useModalLogic };
package/dist/index.js ADDED
@@ -0,0 +1,414 @@
1
+ // src/ModalBackdrop.tsx
2
+ import { usePointerClick } from "@slithy/utils";
3
+ import { forwardRef } from "react";
4
+
5
+ // #style-inject:#style-inject
6
+ function styleInject(css, { insertAt } = {}) {
7
+ if (!css || typeof document === "undefined") return;
8
+ const head = document.head || document.getElementsByTagName("head")[0];
9
+ const style = document.createElement("style");
10
+ style.type = "text/css";
11
+ if (insertAt === "top") {
12
+ if (head.firstChild) {
13
+ head.insertBefore(style, head.firstChild);
14
+ } else {
15
+ head.appendChild(style);
16
+ }
17
+ } else {
18
+ head.appendChild(style);
19
+ }
20
+ if (style.styleSheet) {
21
+ style.styleSheet.cssText = css;
22
+ } else {
23
+ style.appendChild(document.createTextNode(css));
24
+ }
25
+ }
26
+
27
+ // src/ModalBackdrop.css
28
+ styleInject("[data-slithy=modal-backdrop] {\n background: rgba(0, 0, 0, 0.25);\n inset: 0;\n overflow: hidden;\n position: fixed;\n user-select: none;\n -webkit-user-select: none;\n}\n");
29
+
30
+ // src/ModalBackdrop.tsx
31
+ import { jsx } from "react/jsx-runtime";
32
+ var ModalBackdrop = forwardRef(
33
+ ({ className, onClick, style }, ref) => {
34
+ const pointerClick = usePointerClick(onClick);
35
+ return /* @__PURE__ */ jsx(
36
+ "div",
37
+ {
38
+ ref,
39
+ ...pointerClick,
40
+ "aria-hidden": "true",
41
+ className,
42
+ "data-slithy": "modal-backdrop",
43
+ "data-testid": "modal-backdrop",
44
+ style
45
+ }
46
+ );
47
+ }
48
+ );
49
+ ModalBackdrop.displayName = "ModalBackdrop";
50
+
51
+ // src/ModalContainer.css
52
+ styleInject('[data-slithy=modal-container] {\n backface-visibility: hidden;\n font-size: 0px;\n inset: 0;\n line-height: 1em;\n outline: none;\n overflow-x: hidden;\n position: fixed;\n touch-action: manipulation;\n -webkit-tap-highlight-color: transparent;\n}\n[data-slithy=modal-container]::before {\n content: "";\n display: inline-block;\n height: 100%;\n vertical-align: middle;\n width: 0;\n}\n[data-slithy=modal-container-click-target] {\n inset: 0;\n position: fixed;\n}\n');
53
+
54
+ // src/ModalContainer.tsx
55
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
56
+ var ModalContainer = ({
57
+ alignX = "center",
58
+ children,
59
+ className,
60
+ modalId,
61
+ onBackdropClick,
62
+ scrollable
63
+ }) => /* @__PURE__ */ jsxs(
64
+ "div",
65
+ {
66
+ className,
67
+ "data-modalid": modalId,
68
+ "data-slithy": "modal-container",
69
+ "data-testid": "modal-container",
70
+ style: { overflowY: scrollable ? "auto" : "hidden", textAlign: alignX },
71
+ children: [
72
+ /* @__PURE__ */ jsx2(
73
+ "div",
74
+ {
75
+ "aria-hidden": "true",
76
+ "data-slithy": "modal-container-click-target",
77
+ onClick: onBackdropClick
78
+ }
79
+ ),
80
+ children
81
+ ]
82
+ }
83
+ );
84
+
85
+ // src/Modal.tsx
86
+ import { useTrapFocus } from "@slithy/utils";
87
+
88
+ // src/ModalContent.tsx
89
+ import { forwardRef as forwardRef2 } from "react";
90
+
91
+ // src/ModalContent.css
92
+ styleInject("[data-slithy=modal-content] {\n display: inline-block;\n width: 100%;\n}\n[data-slithy=modal-content][data-disable-opacity] {\n opacity: 1 !important;\n}\n");
93
+
94
+ // src/ModalContent.tsx
95
+ import { jsx as jsx3 } from "react/jsx-runtime";
96
+ var ModalContent = forwardRef2(
97
+ ({ alignY, children, disableOpacityTransition, modalState, style }, ref) => /* @__PURE__ */ jsx3(
98
+ "div",
99
+ {
100
+ ref,
101
+ "data-disable-opacity": disableOpacityTransition || void 0,
102
+ "data-slithy": "modal-content",
103
+ style: {
104
+ pointerEvents: modalState === "closing" ? "none" : void 0,
105
+ verticalAlign: alignY,
106
+ ...style
107
+ },
108
+ children
109
+ }
110
+ )
111
+ );
112
+ ModalContent.displayName = "ModalContent";
113
+
114
+ // src/ModalDialog.tsx
115
+ import { forwardRef as forwardRef3 } from "react";
116
+
117
+ // src/ModalDialog.css
118
+ styleInject("[data-slithy=modal-dialog] {\n background-color: Canvas;\n border: none;\n display: inline-block;\n font-size: 1rem;\n line-height: normal;\n padding: 0;\n position: relative;\n text-align: left;\n touch-action: none;\n}\n[data-slithy=modal-dialog]:focus-visible {\n outline: none;\n}\n");
119
+
120
+ // src/ModalDialog.tsx
121
+ import { jsx as jsx4 } from "react/jsx-runtime";
122
+ var ModalDialog = forwardRef3(
123
+ ({ "aria-label": ariaLabel, children, className, onKeyDown, style }, ref) => /* @__PURE__ */ jsx4(
124
+ "dialog",
125
+ {
126
+ ref,
127
+ open: true,
128
+ "aria-label": ariaLabel,
129
+ "aria-modal": "true",
130
+ className,
131
+ "data-slithy": "modal-dialog",
132
+ onKeyDown,
133
+ tabIndex: -1,
134
+ style,
135
+ children
136
+ }
137
+ )
138
+ );
139
+ ModalDialog.displayName = "ModalDialog";
140
+
141
+ // src/useDialogKeyDown.ts
142
+ import { useCallback } from "react";
143
+ function useDialogKeyDown({
144
+ dismissible,
145
+ handleCloseModal,
146
+ isTopModal,
147
+ layerIsActive,
148
+ onKeyDown
149
+ }) {
150
+ return useCallback(
151
+ (e) => {
152
+ if (e.key === "Escape" && dismissible && isTopModal && layerIsActive) {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ handleCloseModal();
156
+ return;
157
+ }
158
+ onKeyDown?.(e);
159
+ },
160
+ [dismissible, handleCloseModal, isTopModal, layerIsActive, onKeyDown]
161
+ );
162
+ }
163
+
164
+ // src/useModalLogic.ts
165
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef, useState } from "react";
166
+ import { useModalStore } from "@slithy/modal-core";
167
+ import { getFocusableElements, useComponentMounted, usePrevious } from "@slithy/utils";
168
+
169
+ // src/ModalProvider.tsx
170
+ import { useEffect } from "react";
171
+ import { createStoreContext } from "@slithy/utils";
172
+ import { jsx as jsx5 } from "react/jsx-runtime";
173
+ var {
174
+ storeContext: ModalStoreContext,
175
+ useState: useModalState,
176
+ useStore: useModalContextStore
177
+ } = createStoreContext({
178
+ defaultState: () => ({
179
+ enqueuedToClose: false,
180
+ modalId: "",
181
+ state: "closed",
182
+ triggerElement: void 0
183
+ }),
184
+ name: "ModalProvider"
185
+ });
186
+ var ModalProvider = ({
187
+ children,
188
+ enqueuedToClose = false,
189
+ modalId,
190
+ skipAnimation,
191
+ state = "closed",
192
+ triggerElement
193
+ }) => {
194
+ const currentStore = useModalContextStore(void 0, {
195
+ enqueuedToClose,
196
+ modalId,
197
+ skipAnimation,
198
+ state,
199
+ triggerElement
200
+ });
201
+ useEffect(() => {
202
+ currentStore.setState({ enqueuedToClose, modalId, skipAnimation, state, triggerElement });
203
+ }, [currentStore, enqueuedToClose, modalId, skipAnimation, state, triggerElement]);
204
+ return /* @__PURE__ */ jsx5(ModalStoreContext.Provider, { value: currentStore, children });
205
+ };
206
+
207
+ // src/useModalLogic.ts
208
+ function useModalLogic({
209
+ afterClose,
210
+ afterOpen,
211
+ delayAtRest = false,
212
+ layerIsActive = true,
213
+ onLeave
214
+ }) {
215
+ const { enqueuedToClose, modalId, state: modalState, triggerElement } = useModalState(
216
+ ({ enqueuedToClose: enqueuedToClose2, modalId: modalId2, state, triggerElement: triggerElement2 }) => ({
217
+ enqueuedToClose: enqueuedToClose2,
218
+ modalId: modalId2,
219
+ state,
220
+ triggerElement: triggerElement2
221
+ })
222
+ );
223
+ const { removeModal, setBackdropId, skipAnimation, topModalId } = useModalStore(
224
+ ({ modals, removeModal: removeModal2, setBackdropId: setBackdropId2, topModalId: topModalId2 }) => ({
225
+ removeModal: removeModal2,
226
+ setBackdropId: setBackdropId2,
227
+ skipAnimation: modals.find((m) => m.id === modalId)?.skipAnimation,
228
+ topModalId: topModalId2
229
+ })
230
+ );
231
+ const isMounted = useComponentMounted();
232
+ const [atRest, setAtRest] = useState(false);
233
+ const isClosingRef = useRef(false);
234
+ const contentRef = useRef(null);
235
+ const isTopModal = modalId === topModalId;
236
+ const previousIsTopModal = usePrevious(isTopModal);
237
+ const completeClose = useCallback2(() => {
238
+ removeModal(modalId);
239
+ afterClose?.();
240
+ }, [removeModal, modalId, afterClose]);
241
+ const handleCloseModal = useCallback2(() => {
242
+ if (!atRest || isClosingRef.current) return;
243
+ isClosingRef.current = true;
244
+ useModalStore.getState().updateModalState({ id: modalId, nextState: "closing" });
245
+ setBackdropId(modalId);
246
+ setTimeout(() => {
247
+ if (!triggerElement || !document.contains(triggerElement)) return;
248
+ triggerElement.focus();
249
+ if (triggerElement.contains(document.activeElement)) return;
250
+ const { firstElement } = getFocusableElements(triggerElement);
251
+ firstElement?.focus();
252
+ }, 0);
253
+ if (onLeave) {
254
+ onLeave(completeClose);
255
+ } else {
256
+ completeClose();
257
+ }
258
+ }, [atRest, modalId, setBackdropId, triggerElement, onLeave, completeClose]);
259
+ const handleCloseModalRef = useRef(handleCloseModal);
260
+ handleCloseModalRef.current = handleCloseModal;
261
+ useEffect2(() => {
262
+ if (enqueuedToClose && atRest) {
263
+ handleCloseModalRef.current();
264
+ }
265
+ }, [enqueuedToClose, atRest]);
266
+ const markAtRest = useCallback2(() => {
267
+ setAtRest(true);
268
+ }, []);
269
+ useEffect2(() => {
270
+ const open = () => {
271
+ if (!isMounted.current) return;
272
+ if (!delayAtRest) setAtRest(true);
273
+ useModalStore.getState().updateModalState({ id: modalId, nextState: "open" });
274
+ afterOpen?.();
275
+ if (contentRef.current && !contentRef.current.contains(document.activeElement)) {
276
+ contentRef.current.focus();
277
+ }
278
+ };
279
+ const skip = useModalStore.getState().modals.find((m) => m.id === modalId)?.skipAnimation;
280
+ if (skip) {
281
+ open();
282
+ return;
283
+ }
284
+ const raf = requestAnimationFrame(open);
285
+ return () => cancelAnimationFrame(raf);
286
+ }, []);
287
+ useEffect2(() => {
288
+ if (!contentRef.current || !isTopModal) return;
289
+ if (contentRef.current.contains(document.activeElement)) return;
290
+ if (!previousIsTopModal) {
291
+ contentRef.current.focus();
292
+ }
293
+ }, [isTopModal, previousIsTopModal]);
294
+ return {
295
+ contentRef,
296
+ handleCloseModal,
297
+ skipAnimation,
298
+ isTopModal,
299
+ layerIsActive,
300
+ markAtRest,
301
+ modalId,
302
+ modalState
303
+ };
304
+ }
305
+
306
+ // src/Modal.tsx
307
+ import { jsx as jsx6 } from "react/jsx-runtime";
308
+ var Modal = ({
309
+ "aria-label": ariaLabel,
310
+ afterClose,
311
+ afterOpen,
312
+ alignX = "center",
313
+ alignY = "middle",
314
+ children,
315
+ containerScrolling = true,
316
+ contentClassName,
317
+ disableOpacityTransition,
318
+ dismissible = true,
319
+ onLeave
320
+ }) => {
321
+ const trapFocus = useTrapFocus();
322
+ const {
323
+ contentRef,
324
+ handleCloseModal,
325
+ isTopModal,
326
+ layerIsActive,
327
+ modalId,
328
+ modalState
329
+ } = useModalLogic({ afterClose, afterOpen, onLeave });
330
+ const onKeyDown = useDialogKeyDown({
331
+ dismissible,
332
+ handleCloseModal,
333
+ isTopModal,
334
+ layerIsActive,
335
+ onKeyDown: trapFocus.onKeyDown
336
+ });
337
+ return /* @__PURE__ */ jsx6(
338
+ ModalContainer,
339
+ {
340
+ alignX,
341
+ modalId,
342
+ onBackdropClick: dismissible ? handleCloseModal : void 0,
343
+ scrollable: containerScrolling,
344
+ children: /* @__PURE__ */ jsx6(
345
+ ModalContent,
346
+ {
347
+ alignY,
348
+ disableOpacityTransition,
349
+ modalState,
350
+ children: /* @__PURE__ */ jsx6(
351
+ ModalDialog,
352
+ {
353
+ ref: contentRef,
354
+ "aria-label": ariaLabel,
355
+ className: contentClassName,
356
+ onKeyDown,
357
+ children
358
+ }
359
+ )
360
+ }
361
+ )
362
+ }
363
+ );
364
+ };
365
+
366
+ // src/ModalRenderer.tsx
367
+ import { Fragment, useLayoutEffect } from "react";
368
+ import { useModalStore as useModalStore2 } from "@slithy/modal-core";
369
+ import { bodyOverflowEffect } from "@slithy/utils";
370
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs2 } from "react/jsx-runtime";
371
+ var ModalRenderer = ({
372
+ renderBackdrop,
373
+ renderLayer,
374
+ renderPortal
375
+ } = {}) => {
376
+ const { enqueuedToClose, modals } = useModalStore2(
377
+ ({ enqueuedToClose: enqueuedToClose2, modals: modals2 }) => ({ enqueuedToClose: enqueuedToClose2, modals: modals2 })
378
+ );
379
+ const modalsExist = modals.length > 0;
380
+ useLayoutEffect(() => bodyOverflowEffect(modalsExist), [modalsExist]);
381
+ const wrapLayer = renderLayer ?? ((c) => c);
382
+ const wrapPortal = renderPortal ?? ((c) => c);
383
+ return wrapLayer(
384
+ /* @__PURE__ */ jsxs2(Fragment2, { children: [
385
+ renderBackdrop?.(),
386
+ modals.map(({ id, modal, skipAnimation, state, triggerElement }) => {
387
+ const isEnqueuedToClose = enqueuedToClose.includes(id);
388
+ return /* @__PURE__ */ jsx7(Fragment, { children: wrapPortal(
389
+ /* @__PURE__ */ jsx7(
390
+ ModalProvider,
391
+ {
392
+ enqueuedToClose: isEnqueuedToClose,
393
+ modalId: id,
394
+ skipAnimation,
395
+ state,
396
+ triggerElement,
397
+ children: modal
398
+ }
399
+ )
400
+ ) }, id);
401
+ })
402
+ ] })
403
+ );
404
+ };
405
+ export {
406
+ Modal,
407
+ ModalBackdrop,
408
+ ModalContainer,
409
+ ModalContent,
410
+ ModalDialog,
411
+ ModalRenderer,
412
+ useDialogKeyDown,
413
+ useModalLogic
414
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@slithy/modal-kit",
3
+ "version": "0.1.2",
4
+ "description": "Headless modal React components for @slithy/modal-core.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "types": "./dist/index.d.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "sideEffects": [
16
+ "*.css"
17
+ ],
18
+ "dependencies": {
19
+ "@slithy/modal-core": "0.1.2",
20
+ "@slithy/utils": "0.3.0"
21
+ },
22
+ "peerDependencies": {
23
+ "react": "^17 || ^18 || ^19"
24
+ },
25
+ "devDependencies": {
26
+ "@testing-library/jest-dom": "^6",
27
+ "@testing-library/react": "^16",
28
+ "@types/react": "^19",
29
+ "@vitejs/plugin-react": "^6",
30
+ "@vitest/coverage-v8": "^4.1.2",
31
+ "jsdom": "^29.0.1",
32
+ "react": "^19",
33
+ "react-dom": "^19",
34
+ "tsup": "^8",
35
+ "typescript": "^5",
36
+ "vitest": "^4.1.2",
37
+ "@slithy/eslint-config": "0.0.0",
38
+ "@slithy/tsconfig": "0.0.0"
39
+ },
40
+ "author": "mjcampagna",
41
+ "license": "ISC",
42
+ "scripts": {
43
+ "clean": "rm -rf dist",
44
+ "build": "rm -rf dist && tsup",
45
+ "dev": "tsup --watch",
46
+ "typecheck": "tsc --noEmit",
47
+ "lint": "eslint .",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest"
50
+ }
51
+ }