@xsolla/xui-modal 0.148.0 → 0.148.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 +599 -0
- package/package.json +5 -5
package/README.md
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
# Modal
|
|
2
|
+
|
|
3
|
+
A cross-platform React modal dialog system for displaying focused overlay content. Built on a provider/context architecture with a hook-based API, the Modal package supports three presentation types (`popup`, `bottom-sheet`, `full-screen`), structured headers with back/close controls, content alignment, custom header/footer slots, focus trapping, and programmatic open/close — on both React (web) and React Native.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @xsolla/xui-modal @xsolla/xui-core
|
|
9
|
+
# or
|
|
10
|
+
yarn add @xsolla/xui-modal @xsolla/xui-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Peer dependencies:** `react >= 16.8.0`, `react-dom >= 16`, `styled-components >= 4`
|
|
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`
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
65
|
+
import { Button } from '@xsolla/xui-button';
|
|
66
|
+
|
|
67
|
+
function OpenModalButton() {
|
|
68
|
+
const [openModal, closeModal] = useModal(() => (
|
|
69
|
+
<Modal onClose={closeModal} title="Confirm Action">
|
|
70
|
+
<p>Are you sure you want to proceed?</p>
|
|
71
|
+
<Button onPress={closeModal}>Close</Button>
|
|
72
|
+
</Modal>
|
|
73
|
+
));
|
|
74
|
+
|
|
75
|
+
return <Button onPress={openModal}>Open Modal</Button>;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Demo
|
|
80
|
+
|
|
81
|
+
### Basic Modal with Hook
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
import { Modal, ModalProvider, useModal } from '@xsolla/xui-modal';
|
|
85
|
+
import { Button } from '@xsolla/xui-button';
|
|
86
|
+
|
|
87
|
+
function ModalContent({ onClose }: { onClose: () => void }) {
|
|
88
|
+
return (
|
|
89
|
+
<Modal onClose={onClose} title="Withdrawal dialog">
|
|
90
|
+
<h2>Modal Title</h2>
|
|
91
|
+
<p>This is the modal content. You can put any content here.</p>
|
|
92
|
+
<Button onPress={onClose}>Close</Button>
|
|
93
|
+
</Modal>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function App() {
|
|
98
|
+
const [openModal, closeModal] = useModal(() => (
|
|
99
|
+
<ModalContent onClose={closeModal} />
|
|
100
|
+
));
|
|
101
|
+
|
|
102
|
+
return <Button onPress={openModal}>Open Modal</Button>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default function Example() {
|
|
106
|
+
return (
|
|
107
|
+
<ModalProvider>
|
|
108
|
+
<App />
|
|
109
|
+
</ModalProvider>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Modal Types
|
|
115
|
+
|
|
116
|
+
Each type controls how the dialog is positioned and styled:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
120
|
+
import { Button } from '@xsolla/xui-button';
|
|
121
|
+
|
|
122
|
+
function ModalTypesDemo() {
|
|
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
|
+
));
|
|
128
|
+
|
|
129
|
+
const [openSheet, closeSheet] = useModal(() => (
|
|
130
|
+
<Modal type="bottom-sheet" onClose={closeSheet} title="Bottom Sheet">
|
|
131
|
+
<p>Anchored to the bottom. Full width with rounded top corners only.</p>
|
|
132
|
+
</Modal>
|
|
133
|
+
));
|
|
134
|
+
|
|
135
|
+
const [openFull, closeFull] = useModal(() => (
|
|
136
|
+
<Modal type="full-screen" onClose={closeFull} title="Full Screen">
|
|
137
|
+
<p>Fills the entire viewport. No border-radius, no shadow.</p>
|
|
138
|
+
</Modal>
|
|
139
|
+
));
|
|
140
|
+
|
|
141
|
+
return (
|
|
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
|
+
```
|
|
150
|
+
|
|
151
|
+
### Back and Close Buttons
|
|
152
|
+
|
|
153
|
+
The header renders automatically when `onBack` or `onClose` are provided. `onBack` places a ChevronLeft button on the left; `onClose` places an X button on the right:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
157
|
+
import { Button } from '@xsolla/xui-button';
|
|
158
|
+
|
|
159
|
+
function BackAndCloseDemo() {
|
|
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
|
+
));
|
|
169
|
+
|
|
170
|
+
return <Button onPress={open}>Open</Button>;
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Content Alignment
|
|
175
|
+
|
|
176
|
+
Control whether content is left-aligned (default) or centered:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
<Modal align="center" onClose={close} title="Centered content">
|
|
180
|
+
<h2>Welcome</h2>
|
|
181
|
+
<p>This text is centered within the modal.</p>
|
|
182
|
+
</Modal>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Custom Header
|
|
186
|
+
|
|
187
|
+
Pass a `header` prop to replace the default back/close button row entirely:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
import { FlexButton } from '@xsolla/xui-button';
|
|
191
|
+
|
|
192
|
+
<Modal
|
|
193
|
+
header={
|
|
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>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Footer
|
|
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';
|
|
211
|
+
|
|
212
|
+
<Modal onClose={close} onBack={close} footer={
|
|
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
|
+
```
|
|
221
|
+
|
|
222
|
+
### Open Content (Edge-to-Edge)
|
|
223
|
+
|
|
224
|
+
Set `openContent` to remove inner padding so children fill the modal edge-to-edge. Useful for images, maps, or custom backgrounds:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
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
|
+
```
|
|
235
|
+
|
|
236
|
+
### Custom Width
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
<Modal onClose={close} maxWidth={900} title="Wide modal">
|
|
240
|
+
<p>This modal has a custom max width of 900px.</p>
|
|
241
|
+
</Modal>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Prevent Close on Outside Click
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
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
|
+
```
|
|
252
|
+
|
|
253
|
+
### Confirmation Dialog
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
257
|
+
import { Button, ButtonGroup } from '@xsolla/xui-button';
|
|
258
|
+
|
|
259
|
+
function DeleteConfirmation() {
|
|
260
|
+
const [open, close] = useModal(() => (
|
|
261
|
+
<Modal
|
|
262
|
+
onClose={close}
|
|
263
|
+
closeOutside={false}
|
|
264
|
+
maxWidth={400}
|
|
265
|
+
title="Delete Item?"
|
|
266
|
+
footer={
|
|
267
|
+
<ButtonGroup orientation="horizontal" size="xl">
|
|
268
|
+
<Button variant="secondary" tone="mono" onPress={close}>Cancel</Button>
|
|
269
|
+
<Button tone="alert" onPress={() => { deleteItem(); close(); }}>Delete</Button>
|
|
270
|
+
</ButtonGroup>
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
<p>This action cannot be undone. Are you sure you want to delete this item?</p>
|
|
274
|
+
</Modal>
|
|
275
|
+
));
|
|
276
|
+
|
|
277
|
+
return <Button tone="alert" onPress={open}>Delete Item</Button>;
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Form Modal
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
285
|
+
import { Button, ButtonGroup } from '@xsolla/xui-button';
|
|
286
|
+
import { Input } from '@xsolla/xui-input';
|
|
287
|
+
|
|
288
|
+
function CreateUserModal() {
|
|
289
|
+
const [open, close] = useModal(() => (
|
|
290
|
+
<Modal
|
|
291
|
+
onClose={close}
|
|
292
|
+
closeOutside={false}
|
|
293
|
+
maxWidth={500}
|
|
294
|
+
title="Create User"
|
|
295
|
+
footer={
|
|
296
|
+
<ButtonGroup orientation="horizontal" size="xl">
|
|
297
|
+
<Button variant="secondary" onPress={close}>Cancel</Button>
|
|
298
|
+
<Button variant="primary" tone="brand" onPress={handleSubmit}>Create</Button>
|
|
299
|
+
</ButtonGroup>
|
|
300
|
+
}
|
|
301
|
+
>
|
|
302
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
303
|
+
<Input label="Name" placeholder="Enter name" />
|
|
304
|
+
<Input label="Email" placeholder="Enter email" />
|
|
305
|
+
</div>
|
|
306
|
+
</Modal>
|
|
307
|
+
));
|
|
308
|
+
|
|
309
|
+
return <Button onPress={open}>Add User</Button>;
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Multi-Step Modal
|
|
314
|
+
|
|
315
|
+
Use `onBack` to navigate between steps within a single modal:
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
import { Modal, useModal } from '@xsolla/xui-modal';
|
|
319
|
+
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
|
+
|
|
325
|
+
return (
|
|
326
|
+
<Modal
|
|
327
|
+
onClose={onClose}
|
|
328
|
+
onBack={step > 1 ? () => setStep((s) => s - 1) : undefined}
|
|
329
|
+
title={`Step ${step} of 3`}
|
|
330
|
+
>
|
|
331
|
+
<p>Content for step {step}</p>
|
|
332
|
+
{step < 3 ? (
|
|
333
|
+
<Button onPress={() => setStep((s) => s + 1)}>Next</Button>
|
|
334
|
+
) : (
|
|
335
|
+
<Button onPress={onClose}>Finish</Button>
|
|
336
|
+
)}
|
|
337
|
+
</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
|
+
));
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div style={{ display: 'flex', gap: 16 }}>
|
|
362
|
+
<Button onPress={openFirst}>Open Modal 1</Button>
|
|
363
|
+
<Button onPress={openSecond}>Open Modal 2</Button>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## API Reference
|
|
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:
|
|
451
|
+
|
|
452
|
+
```tsx
|
|
453
|
+
import { useContext } from 'react';
|
|
454
|
+
import { ModalContext } from '@xsolla/xui-modal';
|
|
455
|
+
|
|
456
|
+
function CustomModalTrigger() {
|
|
457
|
+
const { onOpenModal, onCloseModal } = useContext(ModalContext);
|
|
458
|
+
const key = 'my-custom-modal';
|
|
459
|
+
|
|
460
|
+
const open = () => onOpenModal(key, () => (
|
|
461
|
+
<Modal onClose={() => onCloseModal(key)}>
|
|
462
|
+
<p>Custom modal</p>
|
|
463
|
+
</Modal>
|
|
464
|
+
));
|
|
465
|
+
|
|
466
|
+
return <button onClick={open}>Open</button>;
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Exported Types
|
|
471
|
+
|
|
472
|
+
| Type | Description |
|
|
473
|
+
| :--- | :---------- |
|
|
474
|
+
| `ModalProps` | Props for the `Modal` component. |
|
|
475
|
+
| `ModalSize` | `"sm" \| "md" \| "lg"` |
|
|
476
|
+
| `ModalVariant` | `"popup" \| "bottom-sheet" \| "full-screen"` |
|
|
477
|
+
| `ModalType` | `(props: any) => ReactNode` — the render function signature. |
|
|
478
|
+
| `ModalContextType` | `{ onOpenModal, onCloseModal }` context shape. |
|
|
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
|
|
493
|
+
```
|
|
494
|
+
|
|
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
|
+
## Platform Support
|
|
515
|
+
|
|
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
|
+
| Feature | Web | React Native |
|
|
536
|
+
| :------ | :-- | :----------- |
|
|
537
|
+
| Provider setup | `ModalProvider` | `ModalProvider` (root renders nothing) |
|
|
538
|
+
| Hook API | `useModal()` → `[open, close]` | Same signature (but root is no-op) |
|
|
539
|
+
| Portal | `ReactDOM.createPortal` into `document.body` | No portal (stub) |
|
|
540
|
+
| Overlay | Fixed-positioned dark backdrop | N/A |
|
|
541
|
+
| Focus trapping | Keyboard Tab trapping | N/A |
|
|
542
|
+
| Escape key | Closes modal | N/A |
|
|
543
|
+
| Click outside | Dismisses when `closeOutside={true}` | N/A |
|
|
544
|
+
|
|
545
|
+
## Keyboard Interaction
|
|
546
|
+
|
|
547
|
+
| Key | Action |
|
|
548
|
+
| :-- | :----- |
|
|
549
|
+
| `Escape` | Closes the modal (when `onClose` is provided) |
|
|
550
|
+
| `Tab` | Moves focus to next focusable element within the modal (trapped) |
|
|
551
|
+
| `Shift + Tab` | Moves focus to previous focusable element (trapped) |
|
|
552
|
+
| `Enter` | Activates the focused button |
|
|
553
|
+
|
|
554
|
+
## Accessibility
|
|
555
|
+
|
|
556
|
+
- Modal renders with `role="dialog"` and `aria-modal="true"`
|
|
557
|
+
- When `title` is provided, a visually hidden element is created with `aria-labelledby`
|
|
558
|
+
- When `title` is not provided, `aria-label` is used as the accessible name
|
|
559
|
+
- Focus is trapped within the modal — Tab and Shift+Tab cycle through focusable elements
|
|
560
|
+
- On open, focus moves to `initialFocusRef`, the close button, or the first focusable element
|
|
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
|
|
565
|
+
|
|
566
|
+
## Troubleshooting
|
|
567
|
+
|
|
568
|
+
### "useModal must be used within a ModalProvider"
|
|
569
|
+
|
|
570
|
+
The `useModal` hook (or direct `ModalContext` access) was called outside a `ModalProvider`. Ensure your component tree has a `ModalProvider` ancestor:
|
|
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
|
+
```
|
|
584
|
+
|
|
585
|
+
### Modal appears behind other content
|
|
586
|
+
|
|
587
|
+
`ModalRoot` uses `z-index: 1000` via portal into `document.body`. If your content uses a higher z-index, the modal may be hidden. Ensure `ModalProvider` is placed high in the component tree. Toast notifications use `z-index: 9999` and will appear above modals by default.
|
|
588
|
+
|
|
589
|
+
### Click-outside not working for portaled dropdowns
|
|
590
|
+
|
|
591
|
+
If your modal contains components that render into portals (like `Select` or `Dropdown`), those portaled elements are outside the modal DOM tree. The modal uses `data-modal-id` to detect these cases — ensure portaled content inside the modal uses `useModalId` from `@xsolla/xui-core` and sets `data-modal-id` on its root element.
|
|
592
|
+
|
|
593
|
+
### Bottom-sheet or full-screen modal not filling width
|
|
594
|
+
|
|
595
|
+
`maxWidth` is forced to `100%` for `bottom-sheet` and `full-screen` types. If you're seeing constrained width, ensure no parent container is restricting width.
|
|
596
|
+
|
|
597
|
+
### Focus not returning after close
|
|
598
|
+
|
|
599
|
+
The modal saves `document.activeElement` on mount and restores focus on unmount. If the trigger element is removed from the DOM between open and close, focus cannot be restored. Ensure the trigger element persists in the DOM while the modal is open.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xsolla/xui-modal",
|
|
3
|
-
"version": "0.148.
|
|
3
|
+
"version": "0.148.2",
|
|
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.148.
|
|
17
|
-
"@xsolla/xui-core": "0.148.
|
|
18
|
-
"@xsolla/xui-icons-base": "0.148.
|
|
19
|
-
"@xsolla/xui-primitives-core": "0.148.
|
|
16
|
+
"@xsolla/xui-button": "0.148.2",
|
|
17
|
+
"@xsolla/xui-core": "0.148.2",
|
|
18
|
+
"@xsolla/xui-icons-base": "0.148.2",
|
|
19
|
+
"@xsolla/xui-primitives-core": "0.148.2"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"react": ">=16.8.0",
|