@xsolla/xui-modal 0.149.1 → 0.151.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -458
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,599 +1,342 @@
|
|
|
1
1
|
# Modal
|
|
2
2
|
|
|
3
|
-
A cross-platform React modal dialog system
|
|
3
|
+
A cross-platform React modal dialog system. A provider/context/hook trio drives a stack of dialogs supporting three presentation types (`popup`, `bottom-sheet`, `full-screen`), structured headers, focus trapping, and programmatic open/close on both web and React Native.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @xsolla/xui-modal
|
|
9
|
-
# or
|
|
10
|
-
yarn add @xsolla/xui-modal @xsolla/xui-core
|
|
8
|
+
npm install @xsolla/xui-modal
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
## Architecture
|
|
16
|
-
|
|
17
|
-
The modal system uses a **Provider → Context → Hook** pattern:
|
|
18
|
-
|
|
19
|
-
```
|
|
20
|
-
┌──────────────────────────────────────────────────────┐
|
|
21
|
-
│ ModalProvider │
|
|
22
|
-
│ ┌──────────────┐ ┌────────────────────────────┐ │
|
|
23
|
-
│ │ ModalContext │ │ ModalRoot (portal / stub) │ │
|
|
24
|
-
│ │ - openModal │ │ ┌───────────────────────┐ │ │
|
|
25
|
-
│ │ - closeModal│──▶│ │ Overlay + Modal │ │ │
|
|
26
|
-
│ └──────────────┘ │ └───────────────────────┘ │ │
|
|
27
|
-
│ ▲ └────────────────────────────┘ │
|
|
28
|
-
│ │ useModal(renderFn) │
|
|
29
|
-
│ ┌─────┴──────┐ │
|
|
30
|
-
│ │ Your App │ │
|
|
31
|
-
│ └────────────┘ │
|
|
32
|
-
└──────────────────────────────────────────────────────┘
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
1. **`ModalProvider`** — Wraps your application. Manages a record of open modals (keyed by random ID), and renders `ModalRoot` as a sibling inside the provider.
|
|
36
|
-
2. **`ModalContext`** — React context that exposes `onOpenModal(key, renderFn)` and `onCloseModal(key)` to descendants.
|
|
37
|
-
3. **`useModal(renderFn)`** — Consumer hook that returns `[open, close]` functions for programmatic control of a single modal.
|
|
38
|
-
4. **`ModalRoot`** — Renders the top-most modal from the stack. On web, uses `ReactDOM.createPortal` into `document.body` with a fixed overlay. On React Native, this is a no-op stub (native modal rendering is not yet supported via the provider flow).
|
|
39
|
-
5. **`Modal`** — The dialog component with built-in header (back/close buttons), scrollable body, and optional footer. Supports three presentation types.
|
|
40
|
-
6. **`WorkArea`** — Internal chrome component that handles border-radius, box-shadow, background color, and alignment based on modal type.
|
|
41
|
-
|
|
42
|
-
## Quick Start
|
|
43
|
-
|
|
44
|
-
### 1. Wrap your app with `ModalProvider`
|
|
45
|
-
|
|
46
|
-
```tsx
|
|
47
|
-
import { XUIProvider } from '@xsolla/xui-core';
|
|
48
|
-
import { ModalProvider } from '@xsolla/xui-modal';
|
|
49
|
-
|
|
50
|
-
export default function App() {
|
|
51
|
-
return (
|
|
52
|
-
<XUIProvider initialMode="dark">
|
|
53
|
-
<ModalProvider>
|
|
54
|
-
<YourApp />
|
|
55
|
-
</ModalProvider>
|
|
56
|
-
</XUIProvider>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### 2. Open modals with `useModal`
|
|
11
|
+
## Imports
|
|
62
12
|
|
|
63
13
|
```tsx
|
|
64
|
-
import {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
14
|
+
import {
|
|
15
|
+
Modal,
|
|
16
|
+
ModalProvider,
|
|
17
|
+
ModalContext,
|
|
18
|
+
WorkArea,
|
|
19
|
+
useModal,
|
|
20
|
+
useModalId,
|
|
21
|
+
type ModalProps,
|
|
22
|
+
type ModalSize,
|
|
23
|
+
type ModalVariant,
|
|
24
|
+
type WorkAreaProps,
|
|
25
|
+
type WorkAreaSize,
|
|
26
|
+
} from '@xsolla/xui-modal';
|
|
77
27
|
```
|
|
78
28
|
|
|
79
|
-
|
|
29
|
+
`useModalId` is re-exported from `@xsolla/xui-core` and is consumed by portaled descendants (e.g. `Select`, `Dropdown`) so click-outside detection ignores content that logically belongs to the modal.
|
|
80
30
|
|
|
81
|
-
|
|
31
|
+
## Quick start
|
|
82
32
|
|
|
83
33
|
```tsx
|
|
34
|
+
import * as React from 'react';
|
|
84
35
|
import { Modal, ModalProvider, useModal } from '@xsolla/xui-modal';
|
|
85
36
|
import { Button } from '@xsolla/xui-button';
|
|
86
37
|
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
<Modal onClose={
|
|
90
|
-
<
|
|
91
|
-
<
|
|
92
|
-
<Button onPress={onClose}>Close</Button>
|
|
38
|
+
function Trigger() {
|
|
39
|
+
const [open, close] = useModal(() => (
|
|
40
|
+
<Modal onClose={close} title="Confirm action">
|
|
41
|
+
<p>Are you sure you want to proceed?</p>
|
|
42
|
+
<Button onPress={close}>Close</Button>
|
|
93
43
|
</Modal>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function App() {
|
|
98
|
-
const [openModal, closeModal] = useModal(() => (
|
|
99
|
-
<ModalContent onClose={closeModal} />
|
|
100
44
|
));
|
|
101
|
-
|
|
102
|
-
return <Button onPress={openModal}>Open Modal</Button>;
|
|
45
|
+
return <Button onPress={open}>Open modal</Button>;
|
|
103
46
|
}
|
|
104
47
|
|
|
105
|
-
export default function
|
|
48
|
+
export default function QuickStart() {
|
|
106
49
|
return (
|
|
107
50
|
<ModalProvider>
|
|
108
|
-
<
|
|
51
|
+
<Trigger />
|
|
109
52
|
</ModalProvider>
|
|
110
53
|
);
|
|
111
54
|
}
|
|
112
55
|
```
|
|
113
56
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
Each type controls how the dialog is positioned and styled:
|
|
57
|
+
## API Reference
|
|
117
58
|
|
|
118
|
-
|
|
119
|
-
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
120
|
-
import { Button } from '@xsolla/xui-button';
|
|
59
|
+
### `<Modal>`
|
|
121
60
|
|
|
122
|
-
|
|
123
|
-
const [openPopup, closePopup] = useModal(() => (
|
|
124
|
-
<Modal type="popup" onClose={closePopup} title="Popup">
|
|
125
|
-
<p>Centered with border-radius and shadow. Max-width constrained (680px default).</p>
|
|
126
|
-
</Modal>
|
|
127
|
-
));
|
|
61
|
+
The dialog component with header, body, and footer zones. Accepts a `ref` forwarded to the inner `WorkArea`. Unknown props are spread onto the root `Box`. `size` is a typed `ModalProps` field but is never consumed by `Modal`'s internal layout; it passes through via `...rest` to the root `Box` with no built-in styling effect.
|
|
128
62
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
63
|
+
| Prop | Type | Default | Description |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| `children` | `ReactNode` | — | **Required.** Modal body content. |
|
|
66
|
+
| `type` | `"popup" \| "bottom-sheet" \| "full-screen"` | `"popup"` | Presentation type. Drives positioning, border-radius, and shadow. |
|
|
67
|
+
| `align` | `"left" \| "center"` | `"left"` | Content alignment within the body. |
|
|
68
|
+
| `onClose` | `() => void` | — | Close callback. When provided, renders a close (X) button and enables Escape and click-outside dismissal. |
|
|
69
|
+
| `onBack` | `() => void` | — | Back callback. Renders a chevron-left button on the left of the header. |
|
|
70
|
+
| `header` | `ReactNode` | — | Custom header — replaces the default back/close row. |
|
|
71
|
+
| `footer` | `ReactNode` | — | Footer content rendered below children. |
|
|
72
|
+
| `openContent` | `boolean` | `false` | Removes inner padding so children fill edge-to-edge. |
|
|
73
|
+
| `closeOutside` | `boolean` | `true` | Whether clicking outside closes the modal (requires `onClose`). |
|
|
74
|
+
| `maxWidth` | `string \| number` | `680` | Forced to `100%` for `bottom-sheet` and `full-screen`. |
|
|
75
|
+
| `minHeight` | `string \| number` | — | Minimum height of the modal. |
|
|
76
|
+
| `styled` | `CSSProperties` | — | Inline overrides on the modal container. |
|
|
77
|
+
| `title` | `string` | — | Visually hidden accessible title — drives `aria-labelledby`. For a visible heading, render it inside `children` or pass a custom `header`. |
|
|
78
|
+
| `aria-label` | `string` | — | Alternative accessible name when `title` is not used. |
|
|
79
|
+
| `aria-describedby` | `string` | — | ID of an element describing modal content. |
|
|
80
|
+
| `initialFocusRef` | `RefObject<HTMLElement>` | — | Element to focus on open. Falls back to the close button, then the first focusable child. |
|
|
140
81
|
|
|
141
|
-
|
|
142
|
-
<div style={{ display: 'flex', gap: 12 }}>
|
|
143
|
-
<Button onPress={openPopup}>Popup</Button>
|
|
144
|
-
<Button onPress={openSheet}>Bottom Sheet</Button>
|
|
145
|
-
<Button onPress={openFull}>Full Screen</Button>
|
|
146
|
-
</div>
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
```
|
|
82
|
+
Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).
|
|
150
83
|
|
|
151
|
-
###
|
|
84
|
+
### Modal types
|
|
152
85
|
|
|
153
|
-
|
|
86
|
+
| Type | Positioning | Border radius | Shadow | Max width |
|
|
87
|
+
| --- | --- | --- | --- | --- |
|
|
88
|
+
| `popup` (default) | Centred in the overlay | All corners | Yes | `680px` (or custom) |
|
|
89
|
+
| `bottom-sheet` | Anchored to the bottom | Top corners only | Yes | `100%` (forced) |
|
|
90
|
+
| `full-screen` | Fills the viewport | `0` | None | `100%` (forced) |
|
|
154
91
|
|
|
155
|
-
|
|
156
|
-
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
157
|
-
import { Button } from '@xsolla/xui-button';
|
|
92
|
+
### `<ModalProvider>`
|
|
158
93
|
|
|
159
|
-
|
|
160
|
-
const [open, close] = useModal(() => (
|
|
161
|
-
<Modal
|
|
162
|
-
onBack={() => console.log('Back pressed')}
|
|
163
|
-
onClose={close}
|
|
164
|
-
title="Dialog with navigation"
|
|
165
|
-
>
|
|
166
|
-
<p>This modal has both back and close buttons.</p>
|
|
167
|
-
</Modal>
|
|
168
|
-
));
|
|
94
|
+
Wraps your app, manages the open-modal stack, and renders `ModalRoot`.
|
|
169
95
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
96
|
+
| Prop | Type | Description |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `children` | `ReactNode` | **Required.** Application content. |
|
|
173
99
|
|
|
174
|
-
###
|
|
100
|
+
### `<WorkArea>`
|
|
175
101
|
|
|
176
|
-
|
|
102
|
+
Internal chrome used by `Modal`. Can be rendered standalone for custom layouts.
|
|
177
103
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
104
|
+
| Prop | Type | Default | Description |
|
|
105
|
+
| --- | --- | --- | --- |
|
|
106
|
+
| `children` | `ReactNode` | — | **Required.** Content. |
|
|
107
|
+
| `type` | `"popup" \| "bottom-sheet" \| "full-screen"` | `"popup"` | Affects border-radius and shadow. |
|
|
108
|
+
| `align` | `"left" \| "center"` | — | Content alignment. |
|
|
109
|
+
| `indent` | `"sm" \| "md" \| "lg"` | — | Padding hint passed alongside the work-area sizing. |
|
|
110
|
+
| `stretched` | `boolean` | `false` | Stretch to full height. |
|
|
111
|
+
| `openContent` | `boolean` | `false` | Edge-to-edge mode. |
|
|
112
|
+
| `fetching` | `boolean` | `false` | Renders a loading placeholder when `true`. |
|
|
184
113
|
|
|
185
|
-
|
|
114
|
+
Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).
|
|
186
115
|
|
|
187
|
-
|
|
116
|
+
### `useModal(renderFn)`
|
|
188
117
|
|
|
189
|
-
|
|
190
|
-
import { FlexButton } from '@xsolla/xui-button';
|
|
118
|
+
Returns `[open, close]` for a single modal. Must be called inside a `ModalProvider`.
|
|
191
119
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
|
195
|
-
<span>Custom Header</span>
|
|
196
|
-
<FlexButton variant="brand" size="xs">Done</FlexButton>
|
|
197
|
-
</div>
|
|
198
|
-
}
|
|
199
|
-
title="Custom header modal"
|
|
200
|
-
>
|
|
201
|
-
<p>Content below the custom header.</p>
|
|
202
|
-
</Modal>
|
|
120
|
+
```typescript
|
|
121
|
+
function useModal(modal: (props: any) => ReactNode): [() => void, () => void];
|
|
203
122
|
```
|
|
204
123
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
Pass a `footer` prop to render action buttons or other content below the body:
|
|
208
|
-
|
|
209
|
-
```tsx
|
|
210
|
-
import { Button, ButtonGroup } from '@xsolla/xui-button';
|
|
124
|
+
The hook generates a stable random key per component instance and keeps a ref to the latest render function — your closure values stay current without a dependency array.
|
|
211
125
|
|
|
212
|
-
|
|
213
|
-
<ButtonGroup orientation="horizontal" size="xl">
|
|
214
|
-
<Button variant="secondary" tone="mono" onPress={close}>Cancel</Button>
|
|
215
|
-
<Button variant="primary" tone="brand" onPress={handleConfirm}>Confirm</Button>
|
|
216
|
-
</ButtonGroup>
|
|
217
|
-
} title="Confirmation">
|
|
218
|
-
<p>Do you want to save your changes?</p>
|
|
219
|
-
</Modal>
|
|
220
|
-
```
|
|
126
|
+
### `useModalId()`
|
|
221
127
|
|
|
222
|
-
|
|
128
|
+
Returns the current modal's `data-modal-id`. Portaled descendants should set this attribute on their root so the modal recognises them as in-tree for click-outside checks.
|
|
223
129
|
|
|
224
|
-
|
|
130
|
+
### `ModalContext`
|
|
225
131
|
|
|
226
|
-
|
|
227
|
-
<Modal openContent onClose={close} title="Gallery">
|
|
228
|
-
<img
|
|
229
|
-
src="/hero-image.jpg"
|
|
230
|
-
alt="Hero"
|
|
231
|
-
style={{ width: '100%', display: 'block' }}
|
|
232
|
-
/>
|
|
233
|
-
</Modal>
|
|
234
|
-
```
|
|
132
|
+
The raw React context. Most apps consume `useModal`, but advanced cases can call `onOpenModal(key, renderFn)` and `onCloseModal(key)` directly.
|
|
235
133
|
|
|
236
|
-
###
|
|
134
|
+
### Exported types
|
|
237
135
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
136
|
+
| Type | Members |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `ModalProps` | Props for `<Modal>`. |
|
|
139
|
+
| `ModalSize` | `"sm" \| "md" \| "lg"` |
|
|
140
|
+
| `ModalVariant` | `"popup" \| "bottom-sheet" \| "full-screen"` |
|
|
141
|
+
| `ModalType` | `(props: any) => ReactNode` — the render function signature. |
|
|
142
|
+
| `ModalContextType` | `{ onOpenModal, onCloseModal }` |
|
|
143
|
+
| `ModalProviderProps` | Props for `<ModalProvider>`. |
|
|
144
|
+
| `ModalRootProps` | Props for the internal root. |
|
|
145
|
+
| `WorkAreaProps` | Props for `<WorkArea>`. |
|
|
146
|
+
| `WorkAreaSize` | `"sm" \| "md" \| "lg"` |
|
|
245
147
|
|
|
246
|
-
|
|
247
|
-
<Modal onClose={close} closeOutside={false} title="Required action">
|
|
248
|
-
<p>You must click a button to close this modal. Clicking outside will not dismiss it.</p>
|
|
249
|
-
<Button onPress={close}>Confirm and Close</Button>
|
|
250
|
-
</Modal>
|
|
251
|
-
```
|
|
148
|
+
## Examples
|
|
252
149
|
|
|
253
|
-
###
|
|
150
|
+
### Modal types
|
|
254
151
|
|
|
255
152
|
```tsx
|
|
256
|
-
import
|
|
257
|
-
import {
|
|
153
|
+
import * as React from 'react';
|
|
154
|
+
import { Modal, ModalProvider, useModal } from '@xsolla/xui-modal';
|
|
155
|
+
import { Button } from '@xsolla/xui-button';
|
|
258
156
|
|
|
259
|
-
function
|
|
260
|
-
const [
|
|
261
|
-
<Modal
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
<p>This action cannot be undone. Are you sure you want to delete this item?</p>
|
|
157
|
+
function Demo() {
|
|
158
|
+
const [openPopup, closePopup] = useModal(() => (
|
|
159
|
+
<Modal type="popup" onClose={closePopup} title="Popup">
|
|
160
|
+
<p>Centred with border-radius and shadow.</p>
|
|
161
|
+
</Modal>
|
|
162
|
+
));
|
|
163
|
+
const [openSheet, closeSheet] = useModal(() => (
|
|
164
|
+
<Modal type="bottom-sheet" onClose={closeSheet} title="Bottom sheet">
|
|
165
|
+
<p>Anchored to the bottom.</p>
|
|
166
|
+
</Modal>
|
|
167
|
+
));
|
|
168
|
+
const [openFull, closeFull] = useModal(() => (
|
|
169
|
+
<Modal type="full-screen" onClose={closeFull} title="Full screen">
|
|
170
|
+
<p>Fills the viewport.</p>
|
|
274
171
|
</Modal>
|
|
275
172
|
));
|
|
173
|
+
return (
|
|
174
|
+
<div style={{ display: 'flex', gap: 12 }}>
|
|
175
|
+
<Button onPress={openPopup}>Popup</Button>
|
|
176
|
+
<Button onPress={openSheet}>Bottom sheet</Button>
|
|
177
|
+
<Button onPress={openFull}>Full screen</Button>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
276
181
|
|
|
277
|
-
|
|
182
|
+
export default function Example() {
|
|
183
|
+
return (
|
|
184
|
+
<ModalProvider>
|
|
185
|
+
<Demo />
|
|
186
|
+
</ModalProvider>
|
|
187
|
+
);
|
|
278
188
|
}
|
|
279
189
|
```
|
|
280
190
|
|
|
281
|
-
###
|
|
191
|
+
### Confirmation dialog
|
|
282
192
|
|
|
283
193
|
```tsx
|
|
284
|
-
import
|
|
194
|
+
import * as React from 'react';
|
|
195
|
+
import { Modal, ModalProvider, useModal } from '@xsolla/xui-modal';
|
|
285
196
|
import { Button, ButtonGroup } from '@xsolla/xui-button';
|
|
286
|
-
import { Input } from '@xsolla/xui-input';
|
|
287
197
|
|
|
288
|
-
function
|
|
198
|
+
function ConfirmTrigger() {
|
|
199
|
+
const [confirmed, setConfirmed] = React.useState(false);
|
|
289
200
|
const [open, close] = useModal(() => (
|
|
290
201
|
<Modal
|
|
291
202
|
onClose={close}
|
|
292
203
|
closeOutside={false}
|
|
293
|
-
maxWidth={
|
|
294
|
-
title="
|
|
204
|
+
maxWidth={400}
|
|
205
|
+
title="Delete item?"
|
|
295
206
|
footer={
|
|
296
207
|
<ButtonGroup orientation="horizontal" size="xl">
|
|
297
|
-
<Button variant="secondary" onPress={close}>Cancel</Button>
|
|
298
|
-
<Button
|
|
208
|
+
<Button variant="secondary" tone="mono" onPress={close}>Cancel</Button>
|
|
209
|
+
<Button tone="alert" onPress={() => { setConfirmed(true); close(); }}>Delete</Button>
|
|
299
210
|
</ButtonGroup>
|
|
300
211
|
}
|
|
301
212
|
>
|
|
302
|
-
<
|
|
303
|
-
<Input label="Name" placeholder="Enter name" />
|
|
304
|
-
<Input label="Email" placeholder="Enter email" />
|
|
305
|
-
</div>
|
|
213
|
+
<p>This action cannot be undone.</p>
|
|
306
214
|
</Modal>
|
|
307
215
|
));
|
|
308
216
|
|
|
309
|
-
return
|
|
217
|
+
return (
|
|
218
|
+
<div>
|
|
219
|
+
<Button tone="alert" onPress={open}>Delete</Button>
|
|
220
|
+
{confirmed && <p>Deleted.</p>}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
310
223
|
}
|
|
311
|
-
```
|
|
312
224
|
|
|
313
|
-
|
|
225
|
+
export default function ConfirmDialog() {
|
|
226
|
+
return (
|
|
227
|
+
<ModalProvider>
|
|
228
|
+
<ConfirmTrigger />
|
|
229
|
+
</ModalProvider>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
314
233
|
|
|
315
|
-
|
|
234
|
+
### Multi-step modal
|
|
316
235
|
|
|
317
236
|
```tsx
|
|
318
|
-
import
|
|
237
|
+
import * as React from 'react';
|
|
238
|
+
import { Modal, ModalProvider, useModal } from '@xsolla/xui-modal';
|
|
319
239
|
import { Button } from '@xsolla/xui-button';
|
|
320
|
-
import { useState } from 'react';
|
|
321
|
-
|
|
322
|
-
function MultiStepContent({ onClose }: { onClose: () => void }) {
|
|
323
|
-
const [step, setStep] = useState(1);
|
|
324
240
|
|
|
325
|
-
|
|
241
|
+
function MultiStep() {
|
|
242
|
+
const [step, setStep] = React.useState(1);
|
|
243
|
+
const [open, close] = useModal(() => (
|
|
326
244
|
<Modal
|
|
327
|
-
onClose={
|
|
245
|
+
onClose={() => { setStep(1); close(); }}
|
|
328
246
|
onBack={step > 1 ? () => setStep((s) => s - 1) : undefined}
|
|
329
247
|
title={`Step ${step} of 3`}
|
|
330
248
|
>
|
|
331
|
-
<p>
|
|
249
|
+
<p>Step {step} content.</p>
|
|
332
250
|
{step < 3 ? (
|
|
333
251
|
<Button onPress={() => setStep((s) => s + 1)}>Next</Button>
|
|
334
252
|
) : (
|
|
335
|
-
<Button onPress={
|
|
253
|
+
<Button onPress={() => { setStep(1); close(); }}>Finish</Button>
|
|
336
254
|
)}
|
|
337
255
|
</Modal>
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
### Multiple Modals
|
|
343
|
-
|
|
344
|
-
Each `useModal` call manages an independent modal. Only the most recently opened modal is visible (stack behavior):
|
|
345
|
-
|
|
346
|
-
```tsx
|
|
347
|
-
function App() {
|
|
348
|
-
const [openFirst, closeFirst] = useModal(() => (
|
|
349
|
-
<Modal onClose={closeFirst} maxWidth={400} title="First Modal">
|
|
350
|
-
<p>This is the first modal.</p>
|
|
351
|
-
</Modal>
|
|
352
|
-
));
|
|
353
|
-
|
|
354
|
-
const [openSecond, closeSecond] = useModal(() => (
|
|
355
|
-
<Modal onClose={closeSecond} maxWidth={400} title="Second Modal">
|
|
356
|
-
<p>This is the second modal.</p>
|
|
357
|
-
</Modal>
|
|
358
256
|
));
|
|
257
|
+
return <Button onPress={open}>Open multi-step</Button>;
|
|
258
|
+
}
|
|
359
259
|
|
|
260
|
+
export default function MultiStepExample() {
|
|
360
261
|
return (
|
|
361
|
-
<
|
|
362
|
-
<
|
|
363
|
-
|
|
364
|
-
</div>
|
|
262
|
+
<ModalProvider>
|
|
263
|
+
<MultiStep />
|
|
264
|
+
</ModalProvider>
|
|
365
265
|
);
|
|
366
266
|
}
|
|
367
267
|
```
|
|
368
268
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
### ModalProvider
|
|
372
|
-
|
|
373
|
-
The root provider component that manages modal state and renders `ModalRoot`.
|
|
374
|
-
|
|
375
|
-
| Prop | Type | Default | Description |
|
|
376
|
-
| :--- | :--- | :------ | :---------- |
|
|
377
|
-
| `children` | `ReactNode` | — | Application content (required). |
|
|
378
|
-
|
|
379
|
-
### useModal
|
|
380
|
-
|
|
381
|
-
A hook that returns `[open, close]` functions for a single modal. Must be called within a `ModalProvider`.
|
|
382
|
-
|
|
383
|
-
**Signature:**
|
|
384
|
-
|
|
385
|
-
```typescript
|
|
386
|
-
function useModal(
|
|
387
|
-
modal: (props: any) => ReactNode
|
|
388
|
-
): [() => void, () => void]
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
| Parameter | Type | Description |
|
|
392
|
-
| :-------- | :--- | :---------- |
|
|
393
|
-
| `modal` | `(props: any) => ReactNode` | Render function that returns the modal content (typically a `<Modal>` component). |
|
|
394
|
-
|
|
395
|
-
**Returns:**
|
|
396
|
-
|
|
397
|
-
| Index | Type | Description |
|
|
398
|
-
| :---- | :--- | :---------- |
|
|
399
|
-
| `[0]` | `() => void` | `open` — Opens the modal. |
|
|
400
|
-
| `[1]` | `() => void` | `close` — Closes the modal. |
|
|
401
|
-
|
|
402
|
-
The hook generates a stable random key per component instance and keeps a ref to the latest render function, so the render function always uses current closure values without needing a dependency array.
|
|
403
|
-
|
|
404
|
-
### Modal
|
|
405
|
-
|
|
406
|
-
The dialog component with built-in header, body, and footer zones. Accepts a `ref` that is forwarded to the inner `WorkArea`.
|
|
407
|
-
|
|
408
|
-
| Prop | Type | Default | Description |
|
|
409
|
-
| :--- | :--- | :------ | :---------- |
|
|
410
|
-
| `children` | `ReactNode` | — | Modal body content (required). |
|
|
411
|
-
| `type` | `"popup" \| "bottom-sheet" \| "full-screen"` | `"popup"` | Modal presentation type. Controls positioning, border-radius, and shadow. |
|
|
412
|
-
| `align` | `"left" \| "center"` | `"left"` | Content alignment within the modal body. |
|
|
413
|
-
| `onClose` | `() => void` | — | Close callback. When provided, renders a close (X) `FlexButton` in the header and enables Escape key and click-outside dismissal. |
|
|
414
|
-
| `onBack` | `() => void` | — | Back callback. When provided, renders a back (ChevronLeft) `FlexButton` in the header. |
|
|
415
|
-
| `header` | `ReactNode` | — | Custom header content that replaces the default back/close button row entirely. |
|
|
416
|
-
| `footer` | `ReactNode` | — | Footer content rendered below children (e.g. `ButtonGroup`). |
|
|
417
|
-
| `openContent` | `boolean` | `false` | Removes inner padding so children fill the modal edge-to-edge. |
|
|
418
|
-
| `closeOutside` | `boolean` | `true` | Whether clicking outside the modal closes it (requires `onClose`). |
|
|
419
|
-
| `maxWidth` | `string \| number` | `680` | Maximum width. Ignored for `bottom-sheet` and `full-screen` types (forced to `100%`). |
|
|
420
|
-
| `minHeight` | `string \| number` | — | Minimum height of the modal. |
|
|
421
|
-
| `styled` | `CSSProperties` | — | Additional CSS styles applied to the modal container. |
|
|
422
|
-
| `title` | `string` | — | Accessible title — creates a visually hidden element with `aria-labelledby`. |
|
|
423
|
-
| `aria-label` | `string` | — | Alternative accessible name when `title` is not used. |
|
|
424
|
-
| `aria-describedby` | `string` | — | ID of element describing the modal content. |
|
|
425
|
-
| `initialFocusRef` | `RefObject<HTMLElement>` | — | Ref to the element that should receive focus when the modal opens. Defaults to the close button, then the first focusable element. |
|
|
426
|
-
| `size` | `"sm" \| "md" \| "lg"` | — | Size hint (passed through to the root `Box` via rest props). |
|
|
427
|
-
|
|
428
|
-
### Modal Types
|
|
429
|
-
|
|
430
|
-
| Type | Positioning | Border Radius | Shadow | Max Width |
|
|
431
|
-
| :--- | :---------- | :------------ | :----- | :-------- |
|
|
432
|
-
| `popup` (default) | Centered in viewport overlay | `12px` all corners | Dual-layer box shadow | `680px` (or custom) |
|
|
433
|
-
| `bottom-sheet` | Fixed to bottom of viewport | `12px` top corners only | Dual-layer box shadow | `100%` (forced) |
|
|
434
|
-
| `full-screen` | Fixed, fills entire viewport | `0` | None | `100%` (forced) |
|
|
435
|
-
|
|
436
|
-
### WorkArea
|
|
437
|
-
|
|
438
|
-
Internal chrome component used by `Modal`. Can be used standalone for custom modal layouts.
|
|
439
|
-
|
|
440
|
-
| Prop | Type | Default | Description |
|
|
441
|
-
| :--- | :--- | :------ | :---------- |
|
|
442
|
-
| `children` | `ReactNode` | — | Content to display (required). |
|
|
443
|
-
| `type` | `"popup" \| "bottom-sheet" \| "full-screen"` | `"popup"` | Affects border-radius and shadow. |
|
|
444
|
-
| `align` | `"left" \| "center"` | — | Content alignment. |
|
|
445
|
-
| `stretched` | `boolean` | `false` | Whether to stretch to full height. |
|
|
446
|
-
| `fetching` | `boolean` | `false` | Shows a loading indicator when `true`. |
|
|
447
|
-
|
|
448
|
-
### ModalContext
|
|
449
|
-
|
|
450
|
-
The React context object. Typically consumed via `useModal`, but available for advanced use cases:
|
|
269
|
+
### Edge-to-edge content
|
|
451
270
|
|
|
452
271
|
```tsx
|
|
453
|
-
import
|
|
454
|
-
import {
|
|
455
|
-
|
|
456
|
-
function CustomModalTrigger() {
|
|
457
|
-
const { onOpenModal, onCloseModal } = useContext(ModalContext);
|
|
458
|
-
const key = 'my-custom-modal';
|
|
272
|
+
import * as React from 'react';
|
|
273
|
+
import { Modal, ModalProvider, useModal } from '@xsolla/xui-modal';
|
|
274
|
+
import { Button } from '@xsolla/xui-button';
|
|
459
275
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
276
|
+
function HeroTrigger() {
|
|
277
|
+
const [open, close] = useModal(() => (
|
|
278
|
+
<Modal openContent onClose={close} title="Gallery">
|
|
279
|
+
<img src="/hero-image.jpg" alt="Hero" style={{ width: '100%', display: 'block' }} />
|
|
463
280
|
</Modal>
|
|
464
281
|
));
|
|
465
|
-
|
|
466
|
-
return <button onClick={open}>Open</button>;
|
|
282
|
+
return <Button onPress={open}>Open hero</Button>;
|
|
467
283
|
}
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
### Exported Types
|
|
471
284
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
| `ModalProviderProps` | Props for `ModalProvider`. |
|
|
480
|
-
| `ModalRootProps` | Props for `ModalRoot` (internal). |
|
|
481
|
-
| `WorkAreaProps` | Props for `WorkArea`. |
|
|
482
|
-
| `WorkAreaSize` | `"sm" \| "md" \| "lg"` |
|
|
483
|
-
|
|
484
|
-
## Theming
|
|
485
|
-
|
|
486
|
-
Modal uses the design system theme for all visual properties.
|
|
487
|
-
|
|
488
|
-
### Colors
|
|
489
|
-
|
|
490
|
-
```typescript
|
|
491
|
-
theme.colors.background.primary // Modal background
|
|
492
|
-
theme.colors.content.primary // Modal text and icon color
|
|
285
|
+
export default function EdgeToEdge() {
|
|
286
|
+
return (
|
|
287
|
+
<ModalProvider>
|
|
288
|
+
<HeroTrigger />
|
|
289
|
+
</ModalProvider>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
493
292
|
```
|
|
494
293
|
|
|
495
|
-
The overlay backdrop uses `rgba(0, 0, 0, 0.5)`.
|
|
496
|
-
|
|
497
|
-
### Sizing
|
|
498
|
-
|
|
499
|
-
Modal dimensions come from `theme.sizing.modal()`:
|
|
500
|
-
|
|
501
|
-
| Token | Value | Description |
|
|
502
|
-
| :---- | :---- | :---------- |
|
|
503
|
-
| `borderRadius` | `12` | Corner radius (0 for full-screen, top-only for bottom-sheet) |
|
|
504
|
-
| `headerPadding` | `8` | Padding around the header row |
|
|
505
|
-
| `contentPadding` | `40` | Padding around the body and footer (0 when `openContent`) |
|
|
506
|
-
| `headerButtonSize` | `"xl"` | Size passed to back/close `FlexButton` components |
|
|
507
|
-
| `headerGap` | `8` | Gap between header elements |
|
|
508
|
-
| `shadow` | `"0px 8px 12px 6px rgba(7,7,8,0.1), 0px 4px 4px rgba(7,7,8,0.2)"` | Box shadow (none for full-screen) |
|
|
509
|
-
| `maxWidth` | `680` | Default maximum width for popup type |
|
|
510
|
-
| `overlayColor` | `"rgba(0, 0, 0, 0.5)"` | Backdrop overlay color |
|
|
511
|
-
| `portalPadding` | `20` | Padding between the portal container and viewport edges |
|
|
512
|
-
| `zIndex` | `1000` | z-index of the portal overlay |
|
|
513
|
-
|
|
514
294
|
## Platform Support
|
|
515
295
|
|
|
516
|
-
This package supports both React (web) and React Native with an identical component API.
|
|
517
|
-
|
|
518
|
-
### React (Web)
|
|
519
|
-
|
|
520
|
-
- `ModalRoot` uses `ReactDOM.createPortal` to render into `document.body`
|
|
521
|
-
- The overlay is fixed-positioned with `z-index: 1000`, covering the entire viewport
|
|
522
|
-
- Escape key closes the modal, click-outside dismissal is supported
|
|
523
|
-
- Focus is trapped within the modal dialog
|
|
524
|
-
- Works with any React web framework (Next.js, Vite, Create React App, Remix, etc.)
|
|
525
|
-
|
|
526
|
-
### React Native
|
|
527
|
-
|
|
528
|
-
- `ModalRoot` is a no-op stub — the provider-based modal stack does not render on native
|
|
529
|
-
- The `Modal` component itself can be rendered directly (without the provider flow) for static display
|
|
530
|
-
- Uses `@xsolla/xui-primitives-native` primitives resolved at build time via `tsup`
|
|
531
|
-
- Full interactive modal support on native requires wrapping with a native modal solution
|
|
532
|
-
|
|
533
|
-
### Cross-Platform Comparison
|
|
534
|
-
|
|
535
296
|
| Feature | Web | React Native |
|
|
536
|
-
|
|
|
537
|
-
| Provider
|
|
538
|
-
| Hook
|
|
539
|
-
|
|
|
540
|
-
|
|
|
541
|
-
| Focus trapping | Keyboard Tab trapping | N/A |
|
|
297
|
+
| --- | --- | --- |
|
|
298
|
+
| Provider | `ModalProvider` (with `ModalRoot` portal) | `ModalProvider` (root is a no-op stub) |
|
|
299
|
+
| Hook | `useModal()` → `[open, close]` | Same signature; provider stack does not render |
|
|
300
|
+
| Overlay | Fixed-positioned backdrop | N/A |
|
|
301
|
+
| Focus trap | Keyboard Tab trapping | N/A |
|
|
542
302
|
| Escape key | Closes modal | N/A |
|
|
543
303
|
| Click outside | Dismisses when `closeOutside={true}` | N/A |
|
|
544
304
|
|
|
545
305
|
## Keyboard Interaction
|
|
546
306
|
|
|
547
307
|
| Key | Action |
|
|
548
|
-
|
|
|
549
|
-
| `Escape` | Closes the modal (when `onClose` is provided) |
|
|
550
|
-
| `Tab` | Moves focus to next focusable element
|
|
551
|
-
| `Shift + Tab` | Moves focus to previous focusable element (trapped) |
|
|
552
|
-
| `Enter` | Activates the focused button |
|
|
308
|
+
| --- | --- |
|
|
309
|
+
| `Escape` | Closes the modal (when `onClose` is provided). |
|
|
310
|
+
| `Tab` | Moves focus to the next focusable element (trapped). |
|
|
311
|
+
| `Shift + Tab` | Moves focus to the previous focusable element (trapped). |
|
|
312
|
+
| `Enter` | Activates the focused button. |
|
|
553
313
|
|
|
554
314
|
## Accessibility
|
|
555
315
|
|
|
556
|
-
-
|
|
557
|
-
-
|
|
558
|
-
-
|
|
559
|
-
-
|
|
560
|
-
-
|
|
561
|
-
- On close, focus returns to the element that was focused before the modal opened
|
|
562
|
-
- The close button has `aria-label="Close modal"` and the back button has `aria-label="Go back"`
|
|
563
|
-
- The overlay backdrop has `aria-hidden="true"`
|
|
564
|
-
- `data-modal-id` attribute enables click-outside detection to ignore portaled content belonging to the modal
|
|
316
|
+
- Renders with `role="dialog"` and `aria-modal="true"`.
|
|
317
|
+
- `title` creates a visually hidden labelled element via `aria-labelledby`; otherwise `aria-label` is used.
|
|
318
|
+
- Focus is trapped within the modal; on open it moves to `initialFocusRef`, the close button, or the first focusable element. On close, focus returns to the previously active element.
|
|
319
|
+
- The default close button has `aria-label="Close modal"`; the back button has `aria-label="Go back"`.
|
|
320
|
+
- `data-modal-id` lets click-outside detection ignore portaled content (e.g. `Select`, `Dropdown`) belonging to the modal.
|
|
565
321
|
|
|
566
322
|
## Troubleshooting
|
|
567
323
|
|
|
568
324
|
### "useModal must be used within a ModalProvider"
|
|
569
325
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
```tsx
|
|
573
|
-
// Correct — hook is inside the provider tree
|
|
574
|
-
<ModalProvider>
|
|
575
|
-
<ComponentThatCallsUseModal />
|
|
576
|
-
</ModalProvider>
|
|
577
|
-
|
|
578
|
-
// Wrong — hook is outside the provider
|
|
579
|
-
<ComponentThatCallsUseModal />
|
|
580
|
-
<ModalProvider>
|
|
581
|
-
<OtherContent />
|
|
582
|
-
</ModalProvider>
|
|
583
|
-
```
|
|
326
|
+
`useModal` (or direct `ModalContext` access) ran outside a `ModalProvider`. Ensure your component tree has a `ModalProvider` ancestor.
|
|
584
327
|
|
|
585
328
|
### Modal appears behind other content
|
|
586
329
|
|
|
587
|
-
`ModalRoot` uses `z-index: 1000` via portal into `document.body`.
|
|
330
|
+
`ModalRoot` uses `z-index: 1000` via portal into `document.body`. Toast notifications use `z-index: 9999` and will appear above modals by default.
|
|
588
331
|
|
|
589
332
|
### Click-outside not working for portaled dropdowns
|
|
590
333
|
|
|
591
|
-
|
|
334
|
+
Portaled descendants must set `data-modal-id` (read via `useModalId`) on their root so the modal recognises them as in-tree.
|
|
592
335
|
|
|
593
336
|
### Bottom-sheet or full-screen modal not filling width
|
|
594
337
|
|
|
595
|
-
`maxWidth` is forced to `100%` for
|
|
338
|
+
`maxWidth` is forced to `100%` for these types — check that no parent container is restricting width.
|
|
596
339
|
|
|
597
340
|
### Focus not returning after close
|
|
598
341
|
|
|
599
|
-
The modal saves `document.activeElement` on mount
|
|
342
|
+
The modal saves `document.activeElement` on mount. If the trigger element is removed from the DOM while the modal is open, focus cannot be restored.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xsolla/xui-modal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.151.0",
|
|
4
4
|
"main": "./web/index.js",
|
|
5
5
|
"module": "./web/index.mjs",
|
|
6
6
|
"types": "./web/index.d.ts",
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
"test:coverage": "vitest run --coverage"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@xsolla/xui-button": "0.
|
|
17
|
-
"@xsolla/xui-core": "0.
|
|
18
|
-
"@xsolla/xui-icons-base": "0.
|
|
19
|
-
"@xsolla/xui-primitives-core": "0.
|
|
16
|
+
"@xsolla/xui-button": "0.151.0",
|
|
17
|
+
"@xsolla/xui-core": "0.151.0",
|
|
18
|
+
"@xsolla/xui-icons-base": "0.151.0",
|
|
19
|
+
"@xsolla/xui-primitives-core": "0.151.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"react": ">=16.8.0",
|