@atlaskit/top-layer 0.2.0 → 0.3.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.
@@ -0,0 +1,351 @@
1
+ /* eslint-disable compat/compat */
2
+ /**
3
+ * Popover API & HTMLDialogElement polyfills for jsdom.
4
+ *
5
+ * jsdom does not implement the Popover API or the HTMLDialogElement methods
6
+ * (showModal, show, close). This polyfill provides faithful implementations,
7
+ * including timing semantics validated against Chromium via Playwright.
8
+ *
9
+ * Behaviour:
10
+ * - beforetoggle: sync, cancelable on show only. toggle: async task, not cancelable.
11
+ * - Rapid show/hide coalesces into one toggle event (originalOldState to finalNewState).
12
+ * - Dialog: showModal/show fire beforetoggle+toggle. close fires toggle then close event.
13
+ * - Popover open state tracked via `data-popover-open` (no `:popover-open` in jsdom).
14
+ *
15
+ * Companion `toBeVisible` patch lives at `./to-be-visible`.
16
+ *
17
+ * Importing this module has the side-effect of patching the relevant
18
+ * prototypes. It is safe to import multiple times; each branch is guarded
19
+ * to avoid overwriting native implementations or re-patching.
20
+ */
21
+
22
+ /**
23
+ * e.g. `<div#my-id.class-a.class-b>`
24
+ */
25
+ function describeElement({
26
+ element
27
+ }) {
28
+ const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown';
29
+ const id = element.id ? `#${element.id}` : '';
30
+ const classes = element.classList && element.classList.length > 0 ? `.${Array.from(element.classList).join('.')}` : '';
31
+ return `<${tag}${id}${classes}>`;
32
+ }
33
+ function createToggleEvent({
34
+ type,
35
+ oldState,
36
+ newState,
37
+ cancelable
38
+ }) {
39
+ if (typeof globalThis.ToggleEvent === 'function') {
40
+ // ToggleEvent is not in lib.dom.d.ts at the time of writing; cast to any for construction.
41
+ const ToggleEventCtor = globalThis.ToggleEvent;
42
+ return new ToggleEventCtor(type, {
43
+ oldState,
44
+ newState,
45
+ cancelable,
46
+ bubbles: false
47
+ });
48
+ }
49
+ const event = new Event(type, {
50
+ cancelable,
51
+ bubbles: false
52
+ });
53
+ event.oldState = oldState;
54
+ event.newState = newState;
55
+ return event;
56
+ }
57
+ function fireToggleEvent({
58
+ element,
59
+ type,
60
+ oldState,
61
+ newState,
62
+ cancelable
63
+ }) {
64
+ return element.dispatchEvent(createToggleEvent({
65
+ type,
66
+ oldState,
67
+ newState,
68
+ cancelable
69
+ }));
70
+ }
71
+ function oppositeState({
72
+ state
73
+ }) {
74
+ if (state === 'open') {
75
+ return 'closed';
76
+ }
77
+ return 'open';
78
+ }
79
+
80
+ // Coalesces rapid show/hide into one toggle event per element.
81
+ // Uses setTimeout (task) not Promise.resolve (microtask) to match browser timing.
82
+ function createToggleScheduler() {
83
+ const pending = new Map();
84
+ return function schedule({
85
+ element,
86
+ newState
87
+ }) {
88
+ const existing = pending.get(element);
89
+ if (existing) {
90
+ // Coalesce: keep the original oldState, update newState
91
+ pending.set(element, {
92
+ oldState: existing.oldState,
93
+ newState
94
+ });
95
+ return;
96
+ }
97
+ pending.set(element, {
98
+ oldState: oppositeState({
99
+ state: newState
100
+ }),
101
+ newState
102
+ });
103
+
104
+ // Task queue (setTimeout) — browser fires toggle after microtasks drain, not during
105
+ setTimeout(() => {
106
+ const entry = pending.get(element);
107
+ if (!entry) {
108
+ return;
109
+ }
110
+ pending.delete(element);
111
+ fireToggleEvent({
112
+ element,
113
+ type: 'toggle',
114
+ oldState: entry.oldState,
115
+ newState: entry.newState,
116
+ cancelable: false
117
+ });
118
+ }, 0);
119
+ };
120
+ }
121
+
122
+ // Like createToggleScheduler but also fires a close event as a separate task after toggle.
123
+ function createDialogToggleScheduler() {
124
+ const pending = new Map();
125
+ return function schedule({
126
+ dialog,
127
+ newState,
128
+ shouldFireCloseEvent
129
+ }) {
130
+ const existing = pending.get(dialog);
131
+ if (existing) {
132
+ // Coalesce: keep the original oldState, merge shouldFireCloseEvent
133
+ pending.set(dialog, {
134
+ oldState: existing.oldState,
135
+ newState,
136
+ shouldFireCloseEvent: existing.shouldFireCloseEvent || shouldFireCloseEvent
137
+ });
138
+ return;
139
+ }
140
+ pending.set(dialog, {
141
+ oldState: oppositeState({
142
+ state: newState
143
+ }),
144
+ newState,
145
+ shouldFireCloseEvent
146
+ });
147
+
148
+ // Browser fires toggle as a task, not synchronously or as a microtask
149
+ setTimeout(() => {
150
+ const entry = pending.get(dialog);
151
+ if (!entry) {
152
+ return;
153
+ }
154
+ pending.delete(dialog);
155
+ fireToggleEvent({
156
+ element: dialog,
157
+ type: 'toggle',
158
+ oldState: entry.oldState,
159
+ newState: entry.newState,
160
+ cancelable: false
161
+ });
162
+ if (entry.shouldFireCloseEvent) {
163
+ // Browser fires close as a separate task after toggle
164
+ setTimeout(() => {
165
+ dialog.dispatchEvent(new Event('close', {
166
+ bubbles: false,
167
+ cancelable: false
168
+ }));
169
+ }, 0);
170
+ }
171
+ }, 0);
172
+ };
173
+ }
174
+
175
+ // ─── Popover API ────────────────────────────────────────────────────────────
176
+
177
+ if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.showPopover) {
178
+ const schedulePopoverToggle = createToggleScheduler();
179
+ HTMLElement.prototype.showPopover = function showPopover() {
180
+ if (!this.hasAttribute('popover')) {
181
+ throw new DOMException(`Failed to execute 'showPopover' on 'HTMLElement': ${describeElement({
182
+ element: this
183
+ })} is not a popover element. Ensure the element has a 'popover' attribute (e.g. <div popover>).`, 'NotSupportedError');
184
+ }
185
+ if (!this.isConnected) {
186
+ throw new DOMException(`Failed to execute 'showPopover' on 'HTMLElement': ${describeElement({
187
+ element: this
188
+ })} is not connected to the document. Ensure the element has been added to the DOM (e.g. via render() or document.body.appendChild()) before calling showPopover().`, 'InvalidStateError');
189
+ }
190
+ if (this.hasAttribute('data-popover-open')) {
191
+ return; // already showing — no-op
192
+ }
193
+
194
+ // sync, cancelable on show only
195
+ const allowed = fireToggleEvent({
196
+ element: this,
197
+ type: 'beforetoggle',
198
+ oldState: 'closed',
199
+ newState: 'open',
200
+ cancelable: true
201
+ });
202
+ if (!allowed) {
203
+ return;
204
+ }
205
+ this.setAttribute('data-popover-open', '');
206
+ schedulePopoverToggle({
207
+ element: this,
208
+ newState: 'open'
209
+ });
210
+ };
211
+ HTMLElement.prototype.hidePopover = function hidePopover() {
212
+ if (!this.hasAttribute('popover')) {
213
+ throw new DOMException(`Failed to execute 'hidePopover' on 'HTMLElement': ${describeElement({
214
+ element: this
215
+ })} is not a popover element. Ensure the element has a 'popover' attribute (e.g. <div popover>).`, 'NotSupportedError');
216
+ }
217
+ // Browser does not throw for disconnected elements on hide — just no-op
218
+ if (!this.isConnected || !this.hasAttribute('data-popover-open')) {
219
+ return;
220
+ }
221
+
222
+ // not cancelable on hide
223
+ fireToggleEvent({
224
+ element: this,
225
+ type: 'beforetoggle',
226
+ oldState: 'open',
227
+ newState: 'closed',
228
+ cancelable: false
229
+ });
230
+ this.removeAttribute('data-popover-open');
231
+ schedulePopoverToggle({
232
+ element: this,
233
+ newState: 'closed'
234
+ });
235
+ };
236
+ HTMLElement.prototype.togglePopover = function togglePopover(force) {
237
+ const isOpen = this.hasAttribute('data-popover-open');
238
+ if (typeof force === 'boolean') {
239
+ if (force && !isOpen) {
240
+ this.showPopover();
241
+ } else if (!force && isOpen) {
242
+ this.hidePopover();
243
+ }
244
+ return force;
245
+ }
246
+ if (isOpen) {
247
+ this.hidePopover();
248
+ return false;
249
+ }
250
+ this.showPopover();
251
+ return true;
252
+ };
253
+ }
254
+
255
+ // ─── HTMLDialogElement ──────────────────────────────────────────────────────
256
+
257
+ if (typeof HTMLDialogElement !== 'undefined') {
258
+ const proto = HTMLDialogElement.prototype;
259
+ const scheduleDialogToggle = createDialogToggleScheduler();
260
+ if (typeof proto.showModal !== 'function') {
261
+ proto.showModal = function showModal() {
262
+ if (!this.isConnected) {
263
+ throw new DOMException(`Failed to execute 'showModal' on 'HTMLDialogElement': ${describeElement({
264
+ element: this
265
+ })} is not connected to the document. Ensure the element has been added to the DOM (e.g. via render() or document.body.appendChild()) before calling showModal().`, 'InvalidStateError');
266
+ }
267
+ if (this.hasAttribute('open')) {
268
+ if (this._isModal) {
269
+ return; // already modal — no-op
270
+ }
271
+ throw new DOMException(`Failed to execute 'showModal' on 'HTMLDialogElement': ${describeElement({
272
+ element: this
273
+ })} already has an 'open' attribute, and therefore cannot be opened modally. The dialog was likely opened non-modally with .show(). Call .close() first, then .showModal().`, 'InvalidStateError');
274
+ }
275
+ fireToggleEvent({
276
+ element: this,
277
+ type: 'beforetoggle',
278
+ oldState: 'closed',
279
+ newState: 'open',
280
+ cancelable: false
281
+ });
282
+ this.setAttribute('open', '');
283
+ this._isModal = true;
284
+ scheduleDialogToggle({
285
+ dialog: this,
286
+ newState: 'open',
287
+ shouldFireCloseEvent: false
288
+ });
289
+ };
290
+ }
291
+ if (typeof proto.show !== 'function') {
292
+ proto.show = function show() {
293
+ // Browser does not throw for disconnected elements on show — just no-op
294
+ if (!this.isConnected || this.hasAttribute('open')) {
295
+ return;
296
+ }
297
+ fireToggleEvent({
298
+ element: this,
299
+ type: 'beforetoggle',
300
+ oldState: 'closed',
301
+ newState: 'open',
302
+ cancelable: false
303
+ });
304
+ this.setAttribute('open', '');
305
+ this._isModal = false;
306
+ scheduleDialogToggle({
307
+ dialog: this,
308
+ newState: 'open',
309
+ shouldFireCloseEvent: false
310
+ });
311
+ };
312
+ }
313
+ if (typeof proto.close !== 'function') {
314
+ proto.close = function close(returnValue) {
315
+ if (!this.hasAttribute('open')) {
316
+ return; // already closed — no-op
317
+ }
318
+
319
+ // Sync beforetoggle (not cancelable) — matches browser behavior
320
+ fireToggleEvent({
321
+ element: this,
322
+ type: 'beforetoggle',
323
+ oldState: 'open',
324
+ newState: 'closed',
325
+ cancelable: false
326
+ });
327
+ this.removeAttribute('open');
328
+ this._isModal = false;
329
+ if (returnValue !== undefined) {
330
+ this.returnValue = returnValue;
331
+ }
332
+ scheduleDialogToggle({
333
+ dialog: this,
334
+ newState: 'closed',
335
+ shouldFireCloseEvent: true
336
+ });
337
+ };
338
+ }
339
+ if (!('returnValue' in proto)) {
340
+ Object.defineProperty(proto, 'returnValue', {
341
+ get() {
342
+ return this._returnValue === undefined ? '' : this._returnValue;
343
+ },
344
+ set(value) {
345
+ this._returnValue = String(value);
346
+ },
347
+ configurable: true,
348
+ enumerable: true
349
+ });
350
+ }
351
+ }
@@ -0,0 +1,85 @@
1
+ /* eslint-disable compat/compat */
2
+ /* global expect */
3
+ /**
4
+ * Patches `toBeVisible()` to understand popover and dialog visibility.
5
+ *
6
+ * jest-dom's `toBeVisible()` does not know about the Popover API. In real
7
+ * browsers the UA stylesheet hides `[popover]` with `display: none`, but
8
+ * jsdom has no such stylesheet — so closed popovers incorrectly appear visible.
9
+ *
10
+ * Raw jest-dom results in jsdom:
11
+ *
12
+ * closed popover / child → true (WRONG — should be false)
13
+ * open popover / child → true (correct)
14
+ * closed dialog / child → false (correct — jsdom natively hides <dialog>)
15
+ * open dialog / child → true (correct)
16
+ *
17
+ * We correct both false positives and false negatives:
18
+ * 1. Delegate to the original first.
19
+ * 2. If it says "visible" → check for closed top-layer hosts (false positive).
20
+ * 3. If it says "not visible" → check for open top-layer hosts (false negative).
21
+ *
22
+ * Must load via `setupFilesAfterEnv` (after jest-dom registers its matchers).
23
+ * Polyfills live in `./polyfill` (loaded via `setupFiles`).
24
+ */
25
+
26
+ // Delegating to jest-dom's matcher; types come from @testing-library/jest-dom.
27
+ // eslint-disable-next-line import/no-extraneous-dependencies
28
+ import { toBeVisible as originalToBeVisible } from '@testing-library/jest-dom/matchers';
29
+ /**
30
+ * Finds the nearest popover or dialog ancestor (including the element itself)
31
+ * and returns its state: 'open', 'closed', or null if no top-layer ancestor exists.
32
+ *
33
+ * Checking the *nearest* ancestor handles nesting correctly:
34
+ * a closed popover inside an open popover → nearest is closed → not visible.
35
+ */
36
+ function nearestTopLayerState(element) {
37
+ if (!element) {
38
+ return null;
39
+ }
40
+ if (element.hasAttribute('popover')) {
41
+ return element.hasAttribute('data-popover-open') ? 'open' : 'closed';
42
+ }
43
+ if (element.nodeName === 'DIALOG') {
44
+ return element.hasAttribute('open') ? 'open' : 'closed';
45
+ }
46
+ return nearestTopLayerState(element.parentElement);
47
+ }
48
+ function getNearestTopLayerType(element) {
49
+ if (!element.closest) {
50
+ return 'top-layer element';
51
+ }
52
+ if (element.closest('[popover]')) {
53
+ return 'popover';
54
+ }
55
+ return 'dialog';
56
+ }
57
+
58
+ // `expect` is registered globally by jest before this file runs.
59
+
60
+ expect.extend({
61
+ toBeVisible(element) {
62
+ const originalResult = originalToBeVisible.call(this, element);
63
+ const topLayerState = nearestTopLayerState(element);
64
+ if (originalResult.pass) {
65
+ // Original says visible — override if nearest top-layer host is closed.
66
+ if (topLayerState === 'closed') {
67
+ return {
68
+ pass: false,
69
+ message: () => `expected element to be visible, but it is inside a closed ${getNearestTopLayerType(element)}`
70
+ };
71
+ }
72
+ return originalResult;
73
+ }
74
+
75
+ // Original says not visible — override if nearest top-layer host is open.
76
+ // jsdom may hide [popover] elements even when they are logically open.
77
+ if (topLayerState === 'open') {
78
+ return {
79
+ pass: true,
80
+ message: () => `expected element not to be visible, but it is inside an open ${getNearestTopLayerType(element)}`
81
+ };
82
+ }
83
+ return originalResult;
84
+ }
85
+ });