@prenta/admin 0.97.4 → 0.97.5
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/CHANGELOG.md +6 -0
- package/dist/__tests__/components/modal.render.test.js +62 -2
- package/dist/__tests__/components/modal.render.test.js.map +1 -1
- package/dist/components/ui/Modal.d.ts +8 -0
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +22 -1
- package/dist/components/ui/Modal.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/components/modal.render.test.tsx +109 -1
- package/src/components/ui/Modal.tsx +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @actuate-media/cms-admin
|
|
2
2
|
|
|
3
|
+
## 0.97.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c3ae495: Fix Post Details (and other `Modal` + `onDismiss` surfaces) discarding the form when clicking a nested dialog such as the media picker — outside interactions now ignore events that belong to another `[role="dialog"]`.
|
|
8
|
+
|
|
3
9
|
## 0.97.4
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// @vitest-environment happy-dom
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog';
|
|
4
|
+
import { useState } from 'react';
|
|
3
5
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
6
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
5
7
|
/**
|
|
@@ -9,7 +11,14 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
|
|
9
11
|
* Escape / close both fire `onClose`. Hand-rolled `<div>` overlays gave us none
|
|
10
12
|
* of this.
|
|
11
13
|
*/
|
|
12
|
-
const { Modal } = await import('../../components/ui/Modal.js');
|
|
14
|
+
const { Modal, isNestedDialogEvent } = await import('../../components/ui/Modal.js');
|
|
15
|
+
function NestedDialog({ onOpenChange }) {
|
|
16
|
+
const [open, setOpen] = useState(true);
|
|
17
|
+
return (_jsx(Dialog.Root, { open: open, onOpenChange: (next) => {
|
|
18
|
+
setOpen(next);
|
|
19
|
+
onOpenChange?.(next);
|
|
20
|
+
}, children: _jsx(Dialog.Portal, { children: _jsxs(Dialog.Content, { "aria-describedby": undefined, children: [_jsx(Dialog.Title, { children: "Media picker" }), _jsx("button", { type: "button", children: "Choose image" })] }) }) }));
|
|
21
|
+
}
|
|
13
22
|
describe('Modal accessibility', () => {
|
|
14
23
|
it('does not render content while closed', () => {
|
|
15
24
|
render(_jsx(Modal, { open: false, onClose: () => { }, title: "Edit page", children: _jsx("p", { children: "Body" }) }));
|
|
@@ -33,5 +42,56 @@ describe('Modal accessibility', () => {
|
|
|
33
42
|
fireEvent.keyDown(document.body, { key: 'Escape' });
|
|
34
43
|
expect(onClose).toHaveBeenCalled();
|
|
35
44
|
});
|
|
45
|
+
it('routes Escape through onDismiss when provided', () => {
|
|
46
|
+
const onClose = vi.fn();
|
|
47
|
+
const onDismiss = vi.fn();
|
|
48
|
+
render(_jsx(Modal, { open: true, onClose: onClose, onDismiss: onDismiss, title: "Post details", children: _jsx("p", { children: "Body" }) }));
|
|
49
|
+
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
|
|
50
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
51
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it('keeps the parent open when interacting with a portaled nested dialog', async () => {
|
|
54
|
+
const onDismiss = vi.fn();
|
|
55
|
+
render(_jsx(Modal, { open: true, onClose: () => { }, onDismiss: onDismiss, title: "Post details", description: "Edit post details", children: _jsx("p", { children: "Body" }) }));
|
|
56
|
+
render(_jsx(NestedDialog, {}));
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
58
|
+
onDismiss.mockClear();
|
|
59
|
+
const chooseImage = screen.getByRole('button', { name: 'Choose image' });
|
|
60
|
+
fireEvent.pointerDown(chooseImage);
|
|
61
|
+
fireEvent.focusIn(chooseImage);
|
|
62
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
it('closes only the portaled nested dialog on Escape', async () => {
|
|
65
|
+
const onDismiss = vi.fn();
|
|
66
|
+
const onNestedOpenChange = vi.fn();
|
|
67
|
+
render(_jsx(Modal, { open: true, onClose: () => { }, onDismiss: onDismiss, title: "Post details", description: "Edit post details", children: _jsx("p", { children: "Body" }) }));
|
|
68
|
+
render(_jsx(NestedDialog, { onOpenChange: onNestedOpenChange }));
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
70
|
+
onDismiss.mockClear();
|
|
71
|
+
onNestedOpenChange.mockClear();
|
|
72
|
+
fireEvent.keyDown(screen.getByRole('dialog', { name: 'Media picker' }), { key: 'Escape' });
|
|
73
|
+
expect(onNestedOpenChange).toHaveBeenCalledWith(false);
|
|
74
|
+
expect(screen.queryByRole('dialog', { name: 'Media picker' })).toBeNull();
|
|
75
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('isNestedDialogEvent (UpChat #27)', () => {
|
|
79
|
+
it('detects events that belong to a different dialog than the Modal content', () => {
|
|
80
|
+
const parent = document.createElement('div');
|
|
81
|
+
parent.setAttribute('role', 'dialog');
|
|
82
|
+
const nested = document.createElement('div');
|
|
83
|
+
nested.setAttribute('role', 'dialog');
|
|
84
|
+
const button = document.createElement('button');
|
|
85
|
+
nested.appendChild(button);
|
|
86
|
+
document.body.append(parent, nested);
|
|
87
|
+
// MediaPicker / ConfirmDialog portals sit outside this Modal's content —
|
|
88
|
+
// their clicks must not count as "interact outside" for dismiss.
|
|
89
|
+
expect(isNestedDialogEvent(button, parent)).toBe(true);
|
|
90
|
+
expect(isNestedDialogEvent(button, nested)).toBe(false);
|
|
91
|
+
expect(isNestedDialogEvent(parent, parent)).toBe(false);
|
|
92
|
+
expect(isNestedDialogEvent(null, parent)).toBe(false);
|
|
93
|
+
parent.remove();
|
|
94
|
+
nested.remove();
|
|
95
|
+
});
|
|
36
96
|
});
|
|
37
97
|
//# sourceMappingURL=modal.render.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"modal.render.test.js","sourceRoot":"","sources":["../../../src/__tests__/components/modal.render.test.tsx"],"names":[],"mappings":";AAAA,gCAAgC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAA;AAElE;;;;;;GAMG;AAEH,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,8BAA8B,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"modal.render.test.js","sourceRoot":"","sources":["../../../src/__tests__/components/modal.render.test.tsx"],"names":[],"mappings":";AAAA,gCAAgC;AAChC,OAAO,KAAK,MAAM,MAAM,wBAAwB,CAAA;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAA;AAElE;;;;;;GAMG;AAEH,MAAM,EAAE,KAAK,EAAE,mBAAmB,EAAE,GAAG,MAAM,MAAM,CAAC,8BAA8B,CAAC,CAAA;AAEnF,SAAS,YAAY,CAAC,EAAE,YAAY,EAA8C;IAChF,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAA;IAEtC,OAAO,CACL,KAAC,MAAM,CAAC,IAAI,IACV,IAAI,EAAE,IAAI,EACV,YAAY,EAAE,CAAC,IAAI,EAAE,EAAE;YACrB,OAAO,CAAC,IAAI,CAAC,CAAA;YACb,YAAY,EAAE,CAAC,IAAI,CAAC,CAAA;QACtB,CAAC,YAED,KAAC,MAAM,CAAC,MAAM,cACZ,MAAC,MAAM,CAAC,OAAO,wBAAmB,SAAS,aACzC,KAAC,MAAM,CAAC,KAAK,+BAA4B,EACzC,iBAAQ,IAAI,EAAC,QAAQ,6BAAsB,IAC5B,GACH,GACJ,CACf,CAAA;AACH,CAAC;AAED,QAAQ,CAAC,qBAAqB,EAAE,GAAG,EAAE;IACnC,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,CACJ,KAAC,KAAK,IAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,KAAK,EAAC,WAAW,YACtD,+BAAW,GACL,CACT,CAAA;QACD,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IACjD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,CACJ,KAAC,KAAK,IAAC,IAAI,QAAC,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,KAAK,EAAC,WAAW,YAC9C,+BAAW,GACL,CACT,CAAA;QACD,sEAAsE;QACtE,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,UAAU,EAAE,CAAA;QACtE,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,UAAU,EAAE,CAAA;IAC3E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,OAAO,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QACvB,MAAM,CACJ,KAAC,KAAK,IAAC,IAAI,QAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAC,WAAW,YAC7C,+BAAW,GACL,CACT,CAAA;QACD,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC,CAAA;QACrE,MAAM,CAAC,OAAO,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAA;IAC1C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,OAAO,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QACvB,MAAM,CACJ,KAAC,KAAK,IAAC,IAAI,QAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAC,WAAW,YAC7C,+BAAW,GACL,CACT,CAAA;QACD,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAA;QACnD,MAAM,CAAC,OAAO,CAAC,CAAC,gBAAgB,EAAE,CAAA;IACpC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,MAAM,OAAO,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QACvB,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QACzB,MAAM,CACJ,KAAC,KAAK,IAAC,IAAI,QAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAC,cAAc,YACtE,+BAAW,GACL,CACT,CAAA;QACD,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAA;QAChE,MAAM,CAAC,SAAS,CAAC,CAAC,gBAAgB,EAAE,CAAA;QACpC,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAA;IACxC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACpF,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QACzB,MAAM,CACJ,KAAC,KAAK,IACJ,IAAI,QACJ,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,EACjB,SAAS,EAAE,SAAS,EACpB,KAAK,EAAC,cAAc,EACpB,WAAW,EAAC,mBAAmB,YAE/B,+BAAW,GACL,CACT,CAAA;QACD,MAAM,CAAC,KAAC,YAAY,KAAG,CAAC,CAAA;QACxB,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAA;QACtD,SAAS,CAAC,SAAS,EAAE,CAAA;QAErB,MAAM,WAAW,GAAG,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAA;QACxE,SAAS,CAAC,WAAW,CAAC,WAAW,CAAC,CAAA;QAClC,SAAS,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QAE9B,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAA;IAC1C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,KAAK,IAAI,EAAE;QAChE,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QACzB,MAAM,kBAAkB,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;QAClC,MAAM,CACJ,KAAC,KAAK,IACJ,IAAI,QACJ,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,EACjB,SAAS,EAAE,SAAS,EACpB,KAAK,EAAC,cAAc,EACpB,WAAW,EAAC,mBAAmB,YAE/B,+BAAW,GACL,CACT,CAAA;QACD,MAAM,CAAC,KAAC,YAAY,IAAC,YAAY,EAAE,kBAAkB,GAAI,CAAC,CAAA;QAC1D,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAA;QACtD,SAAS,CAAC,SAAS,EAAE,CAAA;QACrB,kBAAkB,CAAC,SAAS,EAAE,CAAA;QAE9B,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAA;QAE1F,MAAM,CAAC,kBAAkB,CAAC,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAA;QACtD,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QACzE,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAA;IAC1C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,kCAAkC,EAAE,GAAG,EAAE;IAChD,EAAE,CAAC,yEAAyE,EAAE,GAAG,EAAE;QACjF,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QAC5C,MAAM,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;QACrC,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QAC5C,MAAM,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;QACrC,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;QAC/C,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAA;QAC1B,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAEpC,yEAAyE;QACzE,iEAAiE;QACjE,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACtD,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACvD,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACvD,MAAM,CAAC,mBAAmB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAErD,MAAM,CAAC,MAAM,EAAE,CAAA;QACf,MAAM,CAAC,MAAM,EAAE,CAAA;IACjB,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
|
@@ -32,6 +32,14 @@ export interface ModalProps {
|
|
|
32
32
|
*/
|
|
33
33
|
onDismiss?: () => void;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* True when the event originated inside a *different* `[role="dialog"]` than
|
|
37
|
+
* `content` (e.g. MediaPickerModal / ConfirmDialog portaled above this Modal).
|
|
38
|
+
* Nested dialogs must own their own dismiss; treating their clicks as "outside"
|
|
39
|
+
* would discard the parent form before the nested selection can apply
|
|
40
|
+
* (UpChat #27 / featured-image picker).
|
|
41
|
+
*/
|
|
42
|
+
export declare function isNestedDialogEvent(target: EventTarget | null, content: EventTarget | null): boolean;
|
|
35
43
|
/**
|
|
36
44
|
* The canonical admin modal. Built on Radix `Dialog`, which provides the
|
|
37
45
|
* accessibility contract for free: focus is trapped while open and restored to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Modal.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Modal.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAU,MAAM,OAAO,CAAA;AAG9D,QAAA,MAAM,gBAAgB;;;IAGpB,mFAAmF;;CAE3E,CAAA;AAEV,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,gBAAgB,CAAA;AAErD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,SAAS,CAAA;IACnB,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,qDAAqD;IACrD,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IAC/C;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,GAAG,IAAI,EAC1B,OAAO,EAAE,WAAW,GAAG,IAAI,GAC1B,OAAO,CAIT;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,KAAK,CAAC,EACpB,IAAI,EACJ,OAAO,EACP,KAAK,EACL,QAAQ,EACR,OAAO,EACP,IAAW,EACX,WAAW,EACX,eAAe,EACf,SAAS,GACV,EAAE,UAAU,2CA2EZ"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as Dialog from '@radix-ui/react-dialog';
|
|
4
4
|
import { X } from 'lucide-react';
|
|
5
|
+
import { useRef } from 'react';
|
|
5
6
|
import { adminPortalContainer } from '../../lib/portal-container.js';
|
|
6
7
|
const MODAL_SIZE_CLASS = {
|
|
7
8
|
md: 'max-w-lg',
|
|
@@ -9,6 +10,19 @@ const MODAL_SIZE_CLASS = {
|
|
|
9
10
|
/** Wide form dialogs (post/page field editors) — body scrolls inside the shell. */
|
|
10
11
|
xl: 'flex max-h-[min(92vh,880px)] max-w-5xl flex-col',
|
|
11
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* True when the event originated inside a *different* `[role="dialog"]` than
|
|
15
|
+
* `content` (e.g. MediaPickerModal / ConfirmDialog portaled above this Modal).
|
|
16
|
+
* Nested dialogs must own their own dismiss; treating their clicks as "outside"
|
|
17
|
+
* would discard the parent form before the nested selection can apply
|
|
18
|
+
* (UpChat #27 / featured-image picker).
|
|
19
|
+
*/
|
|
20
|
+
export function isNestedDialogEvent(target, content) {
|
|
21
|
+
if (!(target instanceof Element) || !(content instanceof Element))
|
|
22
|
+
return false;
|
|
23
|
+
const owningDialog = target.closest('[role="dialog"]');
|
|
24
|
+
return Boolean(owningDialog && owningDialog !== content);
|
|
25
|
+
}
|
|
12
26
|
/**
|
|
13
27
|
* The canonical admin modal. Built on Radix `Dialog`, which provides the
|
|
14
28
|
* accessibility contract for free: focus is trapped while open and restored to
|
|
@@ -22,6 +36,7 @@ const MODAL_SIZE_CLASS = {
|
|
|
22
36
|
*/
|
|
23
37
|
export function Modal({ open, onClose, title, children, actions, size = 'md', description, initialFocusRef, onDismiss, }) {
|
|
24
38
|
const scrollBody = size === 'xl';
|
|
39
|
+
const contentRef = useRef(null);
|
|
25
40
|
function handleDismissAttempt() {
|
|
26
41
|
if (onDismiss)
|
|
27
42
|
onDismiss();
|
|
@@ -31,7 +46,13 @@ export function Modal({ open, onClose, title, children, actions, size = 'md', de
|
|
|
31
46
|
return (_jsx(Dialog.Root, { open: open, onOpenChange: (next) => {
|
|
32
47
|
if (!next)
|
|
33
48
|
handleDismissAttempt();
|
|
34
|
-
}, children: _jsxs(Dialog.Portal, { container: adminPortalContainer(), children: [_jsx(Dialog.Overlay, { className: "fixed inset-0 z-60 bg-black/50" }), _jsxs(Dialog.Content, { className: `bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`, onInteractOutside: (event) => {
|
|
49
|
+
}, children: _jsxs(Dialog.Portal, { container: adminPortalContainer(), children: [_jsx(Dialog.Overlay, { className: "fixed inset-0 z-60 bg-black/50" }), _jsxs(Dialog.Content, { ref: contentRef, className: `bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`, onInteractOutside: (event) => {
|
|
50
|
+
// Radix dispatches this custom event on the outside target, so
|
|
51
|
+
// `event.currentTarget` is not the parent Dialog.Content.
|
|
52
|
+
if (isNestedDialogEvent(event.target, contentRef.current)) {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
35
56
|
if (!onDismiss)
|
|
36
57
|
return;
|
|
37
58
|
event.preventDefault();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../../src/components/ui/Modal.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,KAAK,MAAM,MAAM,wBAAwB,CAAA;AAChD,OAAO,EAAE,CAAC,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../../src/components/ui/Modal.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prenta/admin",
|
|
3
|
-
"version": "0.97.
|
|
3
|
+
"version": "0.97.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -98,15 +98,15 @@
|
|
|
98
98
|
"tailwindcss": "^4.0.0",
|
|
99
99
|
"typescript": "^5.7.0",
|
|
100
100
|
"vitest": "^4.1.0",
|
|
101
|
-
"@prenta/core": "0.123.0",
|
|
102
|
-
"@prenta/plugin-ai": "0.6.0",
|
|
103
|
-
"@prenta/plugin-commerce": "0.2.0",
|
|
104
101
|
"@prenta/component-blocks": "0.3.0",
|
|
102
|
+
"@prenta/plugin-ai": "0.6.0",
|
|
105
103
|
"@prenta/plugin-blocks": "0.2.0",
|
|
106
|
-
"@prenta/plugin-
|
|
104
|
+
"@prenta/plugin-commerce": "0.2.0",
|
|
105
|
+
"@prenta/core": "0.123.0",
|
|
107
106
|
"@prenta/plugin-forms": "0.1.0",
|
|
108
|
-
"@prenta/plugin-
|
|
107
|
+
"@prenta/plugin-media": "0.2.0",
|
|
109
108
|
"@prenta/plugin-navigation": "0.3.0",
|
|
109
|
+
"@prenta/plugin-seo": "0.1.0",
|
|
110
110
|
"@prenta/sections-react": "0.17.2"
|
|
111
111
|
},
|
|
112
112
|
"scripts": {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
3
|
+
import { useState } from 'react'
|
|
2
4
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
5
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
6
|
|
|
@@ -10,7 +12,28 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|
|
10
12
|
* of this.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
|
-
const { Modal } = await import('../../components/ui/Modal.js')
|
|
15
|
+
const { Modal, isNestedDialogEvent } = await import('../../components/ui/Modal.js')
|
|
16
|
+
|
|
17
|
+
function NestedDialog({ onOpenChange }: { onOpenChange?: (open: boolean) => void }) {
|
|
18
|
+
const [open, setOpen] = useState(true)
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Dialog.Root
|
|
22
|
+
open={open}
|
|
23
|
+
onOpenChange={(next) => {
|
|
24
|
+
setOpen(next)
|
|
25
|
+
onOpenChange?.(next)
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<Dialog.Portal>
|
|
29
|
+
<Dialog.Content aria-describedby={undefined}>
|
|
30
|
+
<Dialog.Title>Media picker</Dialog.Title>
|
|
31
|
+
<button type="button">Choose image</button>
|
|
32
|
+
</Dialog.Content>
|
|
33
|
+
</Dialog.Portal>
|
|
34
|
+
</Dialog.Root>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
14
37
|
|
|
15
38
|
describe('Modal accessibility', () => {
|
|
16
39
|
it('does not render content while closed', () => {
|
|
@@ -54,4 +77,89 @@ describe('Modal accessibility', () => {
|
|
|
54
77
|
fireEvent.keyDown(document.body, { key: 'Escape' })
|
|
55
78
|
expect(onClose).toHaveBeenCalled()
|
|
56
79
|
})
|
|
80
|
+
|
|
81
|
+
it('routes Escape through onDismiss when provided', () => {
|
|
82
|
+
const onClose = vi.fn()
|
|
83
|
+
const onDismiss = vi.fn()
|
|
84
|
+
render(
|
|
85
|
+
<Modal open onClose={onClose} onDismiss={onDismiss} title="Post details">
|
|
86
|
+
<p>Body</p>
|
|
87
|
+
</Modal>,
|
|
88
|
+
)
|
|
89
|
+
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
|
90
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
91
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('keeps the parent open when interacting with a portaled nested dialog', async () => {
|
|
95
|
+
const onDismiss = vi.fn()
|
|
96
|
+
render(
|
|
97
|
+
<Modal
|
|
98
|
+
open
|
|
99
|
+
onClose={() => {}}
|
|
100
|
+
onDismiss={onDismiss}
|
|
101
|
+
title="Post details"
|
|
102
|
+
description="Edit post details"
|
|
103
|
+
>
|
|
104
|
+
<p>Body</p>
|
|
105
|
+
</Modal>,
|
|
106
|
+
)
|
|
107
|
+
render(<NestedDialog />)
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
109
|
+
onDismiss.mockClear()
|
|
110
|
+
|
|
111
|
+
const chooseImage = screen.getByRole('button', { name: 'Choose image' })
|
|
112
|
+
fireEvent.pointerDown(chooseImage)
|
|
113
|
+
fireEvent.focusIn(chooseImage)
|
|
114
|
+
|
|
115
|
+
expect(onDismiss).not.toHaveBeenCalled()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('closes only the portaled nested dialog on Escape', async () => {
|
|
119
|
+
const onDismiss = vi.fn()
|
|
120
|
+
const onNestedOpenChange = vi.fn()
|
|
121
|
+
render(
|
|
122
|
+
<Modal
|
|
123
|
+
open
|
|
124
|
+
onClose={() => {}}
|
|
125
|
+
onDismiss={onDismiss}
|
|
126
|
+
title="Post details"
|
|
127
|
+
description="Edit post details"
|
|
128
|
+
>
|
|
129
|
+
<p>Body</p>
|
|
130
|
+
</Modal>,
|
|
131
|
+
)
|
|
132
|
+
render(<NestedDialog onOpenChange={onNestedOpenChange} />)
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
134
|
+
onDismiss.mockClear()
|
|
135
|
+
onNestedOpenChange.mockClear()
|
|
136
|
+
|
|
137
|
+
fireEvent.keyDown(screen.getByRole('dialog', { name: 'Media picker' }), { key: 'Escape' })
|
|
138
|
+
|
|
139
|
+
expect(onNestedOpenChange).toHaveBeenCalledWith(false)
|
|
140
|
+
expect(screen.queryByRole('dialog', { name: 'Media picker' })).toBeNull()
|
|
141
|
+
expect(onDismiss).not.toHaveBeenCalled()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('isNestedDialogEvent (UpChat #27)', () => {
|
|
146
|
+
it('detects events that belong to a different dialog than the Modal content', () => {
|
|
147
|
+
const parent = document.createElement('div')
|
|
148
|
+
parent.setAttribute('role', 'dialog')
|
|
149
|
+
const nested = document.createElement('div')
|
|
150
|
+
nested.setAttribute('role', 'dialog')
|
|
151
|
+
const button = document.createElement('button')
|
|
152
|
+
nested.appendChild(button)
|
|
153
|
+
document.body.append(parent, nested)
|
|
154
|
+
|
|
155
|
+
// MediaPicker / ConfirmDialog portals sit outside this Modal's content —
|
|
156
|
+
// their clicks must not count as "interact outside" for dismiss.
|
|
157
|
+
expect(isNestedDialogEvent(button, parent)).toBe(true)
|
|
158
|
+
expect(isNestedDialogEvent(button, nested)).toBe(false)
|
|
159
|
+
expect(isNestedDialogEvent(parent, parent)).toBe(false)
|
|
160
|
+
expect(isNestedDialogEvent(null, parent)).toBe(false)
|
|
161
|
+
|
|
162
|
+
parent.remove()
|
|
163
|
+
nested.remove()
|
|
164
|
+
})
|
|
57
165
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
4
|
import { X } from 'lucide-react'
|
|
5
|
-
import { type ReactNode, type RefObject } from 'react'
|
|
5
|
+
import { type ReactNode, type RefObject, useRef } from 'react'
|
|
6
6
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
7
7
|
|
|
8
8
|
const MODAL_SIZE_CLASS = {
|
|
@@ -41,6 +41,22 @@ export interface ModalProps {
|
|
|
41
41
|
onDismiss?: () => void
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* True when the event originated inside a *different* `[role="dialog"]` than
|
|
46
|
+
* `content` (e.g. MediaPickerModal / ConfirmDialog portaled above this Modal).
|
|
47
|
+
* Nested dialogs must own their own dismiss; treating their clicks as "outside"
|
|
48
|
+
* would discard the parent form before the nested selection can apply
|
|
49
|
+
* (UpChat #27 / featured-image picker).
|
|
50
|
+
*/
|
|
51
|
+
export function isNestedDialogEvent(
|
|
52
|
+
target: EventTarget | null,
|
|
53
|
+
content: EventTarget | null,
|
|
54
|
+
): boolean {
|
|
55
|
+
if (!(target instanceof Element) || !(content instanceof Element)) return false
|
|
56
|
+
const owningDialog = target.closest('[role="dialog"]')
|
|
57
|
+
return Boolean(owningDialog && owningDialog !== content)
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* The canonical admin modal. Built on Radix `Dialog`, which provides the
|
|
46
62
|
* accessibility contract for free: focus is trapped while open and restored to
|
|
@@ -64,6 +80,7 @@ export function Modal({
|
|
|
64
80
|
onDismiss,
|
|
65
81
|
}: ModalProps) {
|
|
66
82
|
const scrollBody = size === 'xl'
|
|
83
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
67
84
|
|
|
68
85
|
function handleDismissAttempt() {
|
|
69
86
|
if (onDismiss) onDismiss()
|
|
@@ -80,8 +97,15 @@ export function Modal({
|
|
|
80
97
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
81
98
|
<Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
|
|
82
99
|
<Dialog.Content
|
|
100
|
+
ref={contentRef}
|
|
83
101
|
className={`bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`}
|
|
84
102
|
onInteractOutside={(event) => {
|
|
103
|
+
// Radix dispatches this custom event on the outside target, so
|
|
104
|
+
// `event.currentTarget` is not the parent Dialog.Content.
|
|
105
|
+
if (isNestedDialogEvent(event.target, contentRef.current)) {
|
|
106
|
+
event.preventDefault()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
85
109
|
if (!onDismiss) return
|
|
86
110
|
event.preventDefault()
|
|
87
111
|
handleDismissAttempt()
|