@xsolla/xui-modal 0.150.0 → 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.
Files changed (2) hide show
  1. package/README.md +201 -458
  2. 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 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.
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 @xsolla/xui-core
9
- # or
10
- yarn add @xsolla/xui-modal @xsolla/xui-core
8
+ npm install @xsolla/xui-modal
11
9
  ```
12
10
 
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`
11
+ ## Imports
62
12
 
63
13
  ```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
- }
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
- ## Demo
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
- ### Basic Modal with Hook
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 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>
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 Example() {
48
+ export default function QuickStart() {
106
49
  return (
107
50
  <ModalProvider>
108
- <App />
51
+ <Trigger />
109
52
  </ModalProvider>
110
53
  );
111
54
  }
112
55
  ```
113
56
 
114
- ### Modal Types
115
-
116
- Each type controls how the dialog is positioned and styled:
57
+ ## API Reference
117
58
 
118
- ```tsx
119
- import { Modal, useModal } from '@xsolla/xui-modal';
120
- import { Button } from '@xsolla/xui-button';
59
+ ### `<Modal>`
121
60
 
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
- ));
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
- 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
- ));
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
- 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
- ```
82
+ Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).
150
83
 
151
- ### Back and Close Buttons
84
+ ### Modal types
152
85
 
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:
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
- ```tsx
156
- import { Modal, useModal } from '@xsolla/xui-modal';
157
- import { Button } from '@xsolla/xui-button';
92
+ ### `<ModalProvider>`
158
93
 
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
- ));
94
+ Wraps your app, manages the open-modal stack, and renders `ModalRoot`.
169
95
 
170
- return <Button onPress={open}>Open</Button>;
171
- }
172
- ```
96
+ | Prop | Type | Description |
97
+ | --- | --- | --- |
98
+ | `children` | `ReactNode` | **Required.** Application content. |
173
99
 
174
- ### Content Alignment
100
+ ### `<WorkArea>`
175
101
 
176
- Control whether content is left-aligned (default) or centered:
102
+ Internal chrome used by `Modal`. Can be rendered standalone for custom layouts.
177
103
 
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
- ```
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
- ### Custom Header
114
+ Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).
186
115
 
187
- Pass a `header` prop to replace the default back/close button row entirely:
116
+ ### `useModal(renderFn)`
188
117
 
189
- ```tsx
190
- import { FlexButton } from '@xsolla/xui-button';
118
+ Returns `[open, close]` for a single modal. Must be called inside a `ModalProvider`.
191
119
 
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>
120
+ ```typescript
121
+ function useModal(modal: (props: any) => ReactNode): [() => void, () => void];
203
122
  ```
204
123
 
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';
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
- <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
- ```
126
+ ### `useModalId()`
221
127
 
222
- ### Open Content (Edge-to-Edge)
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
- Set `openContent` to remove inner padding so children fill the modal edge-to-edge. Useful for images, maps, or custom backgrounds:
130
+ ### `ModalContext`
225
131
 
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
- ```
132
+ The raw React context. Most apps consume `useModal`, but advanced cases can call `onOpenModal(key, renderFn)` and `onCloseModal(key)` directly.
235
133
 
236
- ### Custom Width
134
+ ### Exported types
237
135
 
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
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
- ```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
- ```
148
+ ## Examples
252
149
 
253
- ### Confirmation Dialog
150
+ ### Modal types
254
151
 
255
152
  ```tsx
256
- import { Modal, useModal } from '@xsolla/xui-modal';
257
- import { Button, ButtonGroup } from '@xsolla/xui-button';
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 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>
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
- return <Button tone="alert" onPress={open}>Delete Item</Button>;
182
+ export default function Example() {
183
+ return (
184
+ <ModalProvider>
185
+ <Demo />
186
+ </ModalProvider>
187
+ );
278
188
  }
279
189
  ```
280
190
 
281
- ### Form Modal
191
+ ### Confirmation dialog
282
192
 
283
193
  ```tsx
284
- import { Modal, useModal } from '@xsolla/xui-modal';
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 CreateUserModal() {
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={500}
294
- title="Create User"
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 variant="primary" tone="brand" onPress={handleSubmit}>Create</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
- <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
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 <Button onPress={open}>Add User</Button>;
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
- ### Multi-Step Modal
225
+ export default function ConfirmDialog() {
226
+ return (
227
+ <ModalProvider>
228
+ <ConfirmTrigger />
229
+ </ModalProvider>
230
+ );
231
+ }
232
+ ```
314
233
 
315
- Use `onBack` to navigate between steps within a single modal:
234
+ ### Multi-step modal
316
235
 
317
236
  ```tsx
318
- import { Modal, useModal } from '@xsolla/xui-modal';
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
- return (
241
+ function MultiStep() {
242
+ const [step, setStep] = React.useState(1);
243
+ const [open, close] = useModal(() => (
326
244
  <Modal
327
- onClose={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>Content for step {step}</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={onClose}>Finish</Button>
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
- <div style={{ display: 'flex', gap: 16 }}>
362
- <Button onPress={openFirst}>Open Modal 1</Button>
363
- <Button onPress={openSecond}>Open Modal 2</Button>
364
- </div>
262
+ <ModalProvider>
263
+ <MultiStep />
264
+ </ModalProvider>
365
265
  );
366
266
  }
367
267
  ```
368
268
 
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:
269
+ ### Edge-to-edge content
451
270
 
452
271
  ```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';
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
- const open = () => onOpenModal(key, () => (
461
- <Modal onClose={() => onCloseModal(key)}>
462
- <p>Custom modal</p>
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
- | 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
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 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 |
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 within the modal (trapped) |
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
- - 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
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
- 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
- ```
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`. 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.
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
- 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.
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 `bottom-sheet` and `full-screen` types. If you're seeing constrained width, ensure no parent container is restricting width.
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 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.
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.150.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.150.0",
17
- "@xsolla/xui-core": "0.150.0",
18
- "@xsolla/xui-icons-base": "0.150.0",
19
- "@xsolla/xui-primitives-core": "0.150.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",