@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.
Files changed (2) hide show
  1. package/README.md +599 -0
  2. 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.0",
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.0",
17
- "@xsolla/xui-core": "0.148.0",
18
- "@xsolla/xui-icons-base": "0.148.0",
19
- "@xsolla/xui-primitives-core": "0.148.0"
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",