@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 +239 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +414 -0
- package/package.json +51 -0
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` | — |
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|