@atlaskit/top-layer 0.2.0 → 0.4.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,831 @@
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 popover show only. toggle: async task, never 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
+ * - Auto popovers: maintain an open stack, perform light-dismiss on document mousedown
15
+ * outside the open chain, and dismiss the topmost on Escape.
16
+ * - On open, removes any inline `opacity: 0` from the popover element so callers that
17
+ * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-positioning`) become
18
+ * visible in jsdom, where layout-driven RO callbacks never fire.
19
+ *
20
+ * Companion `toBeVisible` patch lives at `./to-be-visible`.
21
+ *
22
+ * Importing this module has the side-effect of patching the relevant
23
+ * prototypes. It is safe to import multiple times; each branch is guarded
24
+ * to avoid overwriting native implementations or re-patching.
25
+ */
26
+
27
+ import { bind } from 'bind-event-listener';
28
+ /**
29
+ * e.g. `<div#my-id.class-a.class-b>`
30
+ */
31
+ function describeElement({
32
+ element
33
+ }) {
34
+ const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown';
35
+ const id = element.id ? `#${element.id}` : '';
36
+ const classes = element.classList && element.classList.length > 0 ? `.${Array.from(element.classList).join('.')}` : '';
37
+ return `<${tag}${id}${classes}>`;
38
+ }
39
+ function createToggleEvent({
40
+ type,
41
+ oldState,
42
+ newState,
43
+ cancelable
44
+ }) {
45
+ if (typeof globalThis.ToggleEvent === 'function') {
46
+ // ToggleEvent is not in lib.dom.d.ts at the time of writing; cast to any for construction.
47
+ const ToggleEventCtor = globalThis.ToggleEvent;
48
+ return new ToggleEventCtor(type, {
49
+ oldState,
50
+ newState,
51
+ cancelable,
52
+ bubbles: false
53
+ });
54
+ }
55
+ const event = new Event(type, {
56
+ cancelable,
57
+ bubbles: false
58
+ });
59
+ event.oldState = oldState;
60
+ event.newState = newState;
61
+ return event;
62
+ }
63
+ function fireToggleEvent({
64
+ element,
65
+ type,
66
+ oldState,
67
+ newState,
68
+ cancelable
69
+ }) {
70
+ return element.dispatchEvent(createToggleEvent({
71
+ type,
72
+ oldState,
73
+ newState,
74
+ cancelable
75
+ }));
76
+ }
77
+ function oppositeState({
78
+ state
79
+ }) {
80
+ if (state === 'open') {
81
+ return 'closed';
82
+ }
83
+ return 'open';
84
+ }
85
+
86
+ // Coalesces rapid show/hide into one toggle event per element.
87
+ // Uses setTimeout (task) not Promise.resolve (microtask) to match browser timing.
88
+ function createToggleScheduler() {
89
+ const pending = new Map();
90
+ return function schedule({
91
+ element,
92
+ newState
93
+ }) {
94
+ const existing = pending.get(element);
95
+ if (existing) {
96
+ // Coalesce: keep the original oldState, update newState
97
+ pending.set(element, {
98
+ oldState: existing.oldState,
99
+ newState
100
+ });
101
+ return;
102
+ }
103
+ pending.set(element, {
104
+ oldState: oppositeState({
105
+ state: newState
106
+ }),
107
+ newState
108
+ });
109
+
110
+ // Task queue (setTimeout) — browser fires toggle after microtasks drain, not during
111
+ setTimeout(() => {
112
+ const entry = pending.get(element);
113
+ if (!entry) {
114
+ return;
115
+ }
116
+ pending.delete(element);
117
+ fireToggleEvent({
118
+ element,
119
+ type: 'toggle',
120
+ oldState: entry.oldState,
121
+ newState: entry.newState,
122
+ cancelable: false
123
+ });
124
+ }, 0);
125
+ };
126
+ }
127
+
128
+ // Like createToggleScheduler but also fires a close event as a separate task after toggle.
129
+ function createDialogToggleScheduler() {
130
+ const pending = new Map();
131
+ return function schedule({
132
+ dialog,
133
+ newState,
134
+ shouldFireCloseEvent
135
+ }) {
136
+ const existing = pending.get(dialog);
137
+ if (existing) {
138
+ // Coalesce: keep the original oldState, merge shouldFireCloseEvent
139
+ pending.set(dialog, {
140
+ oldState: existing.oldState,
141
+ newState,
142
+ shouldFireCloseEvent: existing.shouldFireCloseEvent || shouldFireCloseEvent
143
+ });
144
+ return;
145
+ }
146
+ pending.set(dialog, {
147
+ oldState: oppositeState({
148
+ state: newState
149
+ }),
150
+ newState,
151
+ shouldFireCloseEvent
152
+ });
153
+
154
+ // Browser fires toggle as a task, not synchronously or as a microtask
155
+ setTimeout(() => {
156
+ const entry = pending.get(dialog);
157
+ if (!entry) {
158
+ return;
159
+ }
160
+ pending.delete(dialog);
161
+ fireToggleEvent({
162
+ element: dialog,
163
+ type: 'toggle',
164
+ oldState: entry.oldState,
165
+ newState: entry.newState,
166
+ cancelable: false
167
+ });
168
+ if (entry.shouldFireCloseEvent) {
169
+ // Browser fires close as a separate task after toggle
170
+ setTimeout(() => {
171
+ dialog.dispatchEvent(new Event('close', {
172
+ bubbles: false,
173
+ cancelable: false
174
+ }));
175
+ }, 0);
176
+ }
177
+ }, 0);
178
+ };
179
+ }
180
+
181
+ // ─── Popover API ────────────────────────────────────────────────────────────
182
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html
183
+
184
+ // `popover` attribute valid values. Empty / "auto" -> "auto"; unknown -> "manual".
185
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#attr-popover
186
+
187
+ function getPopoverMode({
188
+ element
189
+ }) {
190
+ if (!element.hasAttribute('popover')) {
191
+ return null;
192
+ }
193
+ const raw = element.getAttribute('popover');
194
+ if (raw === null || raw === '' || raw === 'auto') {
195
+ return 'auto';
196
+ }
197
+ if (raw === 'hint') {
198
+ return 'hint';
199
+ }
200
+ return 'manual';
201
+ }
202
+
203
+ // IDL property: `el.popover` reflects the popover content attribute. Setter
204
+ // writes verbatim; getter canonicalises (unknown -> "manual", empty -> "auto").
205
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#dom-popover
206
+
207
+ if (typeof HTMLElement !== 'undefined' && !Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'popover')) {
208
+ Object.defineProperty(HTMLElement.prototype, 'popover', {
209
+ configurable: true,
210
+ enumerable: true,
211
+ get() {
212
+ return getPopoverMode({
213
+ element: this
214
+ });
215
+ },
216
+ set(value) {
217
+ if (value === null) {
218
+ this.removeAttribute('popover');
219
+ return;
220
+ }
221
+ // Per spec, the IDL setter writes the value verbatim; the getter then
222
+ // canonicalises (so setting an unknown value is reflected as "manual"
223
+ // only when read back, but the attribute keeps the raw string).
224
+ this.setAttribute('popover', String(value));
225
+ }
226
+ });
227
+ }
228
+
229
+ // Open auto popovers in open-order. Used for light-dismiss, Escape, and
230
+ // hide-all-popovers-until on new auto opens.
231
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#showing-auto-popover-list
232
+ const openAutoStack = [];
233
+
234
+ // Open hint popovers in open-order. Hints have their own stack: hint closes
235
+ // other hints, auto closes all hints, hint never closes autos.
236
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#showing-hint-popover-list
237
+ const openHintStack = [];
238
+ function isPopoverOpen({
239
+ element
240
+ }) {
241
+ return element.hasAttribute('data-popover-open');
242
+ }
243
+
244
+ // Stack lookup: returns the stack the element belongs to (or null for manual /
245
+ // non-popovers). Used by hide-handlers to remove from the correct stack and by
246
+ // dismissal logic to find the topmost relevant popover.
247
+ function stackForMode({
248
+ mode
249
+ }) {
250
+ if (mode === 'auto') {
251
+ return openAutoStack;
252
+ }
253
+ if (mode === 'hint') {
254
+ return openHintStack;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ // Polyfill-only. Real browsers reveal the popover once `ResizeObserver`
260
+ // callbacks fire after the first layout pass. jsdom has no layout, so we clear
261
+ // any inline `opacity: 0` (set by callers like `use-anchor-positioning`) once
262
+ // the open toggle has fired.
263
+ function clearOpacityOnOpenToggle({
264
+ element
265
+ }) {
266
+ const unbind = bind(element, {
267
+ type: 'toggle',
268
+ listener: event => {
269
+ const toggleEvent = event;
270
+ if (toggleEvent.newState !== 'open') {
271
+ return;
272
+ }
273
+ element.style.removeProperty('opacity');
274
+ unbind();
275
+ }
276
+ });
277
+ }
278
+
279
+ // Spec: "topmost popover ancestor" walks both DOM ancestry AND the invoker
280
+ // chain (an invoker is a button whose `popovertarget` references an open
281
+ // popover). When opening popover B from a button inside popover A, A is
282
+ // considered B's ancestor and stays open. This walks ancestors of the target,
283
+ // hopping through any invoker references discovered along the way.
284
+ // Find a popover's invoker: an element with `popovertarget=<popover.id>`.
285
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#popover-target-element
286
+ function findInvokerForPopover({
287
+ popover
288
+ }) {
289
+ if (!popover.id || !popover.ownerDocument) {
290
+ return null;
291
+ }
292
+ const escaped = (typeof CSS !== 'undefined' && CSS.escape ? CSS.escape : String)(popover.id);
293
+ const selector = `[popovertarget="${escaped}"]`;
294
+ return popover.ownerDocument.querySelector(selector);
295
+ }
296
+
297
+ // True if `candidate` is reachable from `start` via DOM ancestry plus invoker
298
+ // hops. Spec: https://html.spec.whatwg.org/multipage/popover.html#topmost-popover-ancestor
299
+ function reachesViaAncestryOrInvoker({
300
+ start,
301
+ candidate
302
+ }) {
303
+ const found = walkAncestorChain({
304
+ start,
305
+ visitor: ({
306
+ node
307
+ }) => {
308
+ if (node === candidate) {
309
+ return true;
310
+ }
311
+ // Hop into the invoked popover's chain when this node is an invoker.
312
+ if (node instanceof HTMLElement) {
313
+ const popovertarget = node.getAttribute('popovertarget');
314
+ if (popovertarget && node.ownerDocument) {
315
+ const referenced = node.ownerDocument.getElementById(popovertarget);
316
+ if (referenced && referenced !== node) {
317
+ if (reachesViaAncestryOrInvoker({
318
+ start: referenced,
319
+ candidate
320
+ })) {
321
+ return true;
322
+ }
323
+ }
324
+ }
325
+ }
326
+ return null;
327
+ }
328
+ });
329
+ return found === true;
330
+ }
331
+
332
+ // Is `candidate` an ancestor of `target` via DOM nesting OR invoker chain?
333
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#topmost-popover-ancestor
334
+ function isInChainTo({
335
+ candidate,
336
+ target
337
+ }) {
338
+ if (!(target instanceof Node)) {
339
+ return false;
340
+ }
341
+ if (reachesViaAncestryOrInvoker({
342
+ start: target,
343
+ candidate
344
+ })) {
345
+ return true;
346
+ }
347
+ // If target is itself a popover, also walk its invoker's chain (a button
348
+ // elsewhere in the DOM that has `popovertarget=<target.id>`).
349
+ if (target instanceof HTMLElement && target.hasAttribute('popover')) {
350
+ const invoker = findInvokerForPopover({
351
+ popover: target
352
+ });
353
+ if (invoker && reachesViaAncestryOrInvoker({
354
+ start: invoker,
355
+ candidate
356
+ })) {
357
+ return true;
358
+ }
359
+ }
360
+ return false;
361
+ }
362
+
363
+ // Hide every popover in `stack` that is not an ancestor of `target`. With a
364
+ // null target, closes the entire stack.
365
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#hide-all-popovers-until
366
+ function hidePopoversInStackNotInChainTo({
367
+ stack,
368
+ target
369
+ }) {
370
+ const snapshot = stack.slice();
371
+ for (let i = snapshot.length - 1; i >= 0; i -= 1) {
372
+ const popover = snapshot[i];
373
+ if (isInChainTo({
374
+ candidate: popover,
375
+ target
376
+ })) {
377
+ break;
378
+ }
379
+ popover.hidePopover();
380
+ }
381
+ }
382
+
383
+ // Two-phase light-dismiss: pointerdown stores the nearest open popover for
384
+ // the target; pointerup dismisses popovers that are not in that popover's
385
+ // chain when the same target is hit. Drag-out / drag-in does not dismiss.
386
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#light-dismiss-open-popovers
387
+ const pointerdownState = {
388
+ target: null
389
+ };
390
+
391
+ // If `node` is an invoker for an open popover, return that popover.
392
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#popover-target-element
393
+ function invokedOpenPopover({
394
+ node
395
+ }) {
396
+ if (!(node instanceof HTMLElement)) {
397
+ return null;
398
+ }
399
+ const popovertarget = node.getAttribute('popovertarget');
400
+ if (!popovertarget || !node.ownerDocument) {
401
+ return null;
402
+ }
403
+ const referenced = node.ownerDocument.getElementById(popovertarget);
404
+ if (!referenced || referenced === node || !isPopoverOpen({
405
+ element: referenced
406
+ })) {
407
+ return null;
408
+ }
409
+ return referenced;
410
+ }
411
+
412
+ // Cycle-safe ancestor walker. Returns the first non-null `visitor` result.
413
+ function walkAncestorChain({
414
+ start,
415
+ visitor
416
+ }) {
417
+ const seen = new Set();
418
+ function step(node) {
419
+ if (!node || seen.has(node)) {
420
+ return null;
421
+ }
422
+ seen.add(node);
423
+ const result = visitor({
424
+ node
425
+ });
426
+ if (result !== null) {
427
+ return result;
428
+ }
429
+ return step(node.parentNode);
430
+ }
431
+ return step(start);
432
+ }
433
+
434
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#nearest-inclusive-open-popover
435
+ function nearestOpenPopover({
436
+ target
437
+ }) {
438
+ return walkAncestorChain({
439
+ start: target,
440
+ visitor: ({
441
+ node
442
+ }) => {
443
+ if (node instanceof HTMLElement && isPopoverOpen({
444
+ element: node
445
+ })) {
446
+ return node;
447
+ }
448
+ return invokedOpenPopover({
449
+ node
450
+ });
451
+ }
452
+ });
453
+ }
454
+ function handleDocumentPointerdown(event) {
455
+ if (openAutoStack.length === 0 && openHintStack.length === 0) {
456
+ return;
457
+ }
458
+ const target = event.target instanceof Node ? event.target : null;
459
+ pointerdownState.target = nearestOpenPopover({
460
+ target
461
+ });
462
+ }
463
+ function handleDocumentPointerup(event) {
464
+ if (openAutoStack.length === 0 && openHintStack.length === 0) {
465
+ pointerdownState.target = null;
466
+ return;
467
+ }
468
+ const target = event.target instanceof Node ? event.target : null;
469
+ const ancestor = nearestOpenPopover({
470
+ target
471
+ });
472
+ const sameTarget = ancestor === pointerdownState.target;
473
+ pointerdownState.target = null;
474
+ if (!sameTarget) {
475
+ // User pointerdowned inside a popover and released elsewhere (or vice
476
+ // versa). Treat as a drag and do not dismiss per spec.
477
+ return;
478
+ }
479
+ // Hints are dismissed first (they sit "above" autos for dismissal purposes
480
+ // even though they live in their own stack), then autos. When ancestor is a
481
+ // hint, autos are unaffected; when ancestor is an auto or null, hints close
482
+ // entirely (because no hint is the target's ancestor).
483
+ const ancestorMode = ancestor ? getPopoverMode({
484
+ element: ancestor
485
+ }) : null;
486
+ if (ancestorMode === 'hint') {
487
+ hidePopoversInStackNotInChainTo({
488
+ stack: openHintStack,
489
+ target
490
+ });
491
+ return;
492
+ }
493
+ hidePopoversInStackNotInChainTo({
494
+ stack: openHintStack,
495
+ target: null
496
+ });
497
+ hidePopoversInStackNotInChainTo({
498
+ stack: openAutoStack,
499
+ target
500
+ });
501
+ }
502
+
503
+ // Topmost still-connected popover in `stack`, pruning disconnected entries
504
+ // (test pollution where a popover was removed from the DOM without hidePopover).
505
+ // Polyfill-only defensive cleanup; not in the spec.
506
+ function topmostConnected({
507
+ stack
508
+ }) {
509
+ while (stack.length > 0) {
510
+ const candidate = stack[stack.length - 1];
511
+ if (candidate.isConnected) {
512
+ return candidate;
513
+ }
514
+ stack.pop();
515
+ }
516
+ return null;
517
+ }
518
+
519
+ // Escape closes the topmost open popover. Hints are dismissed before autos.
520
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#close-watcher
521
+ function handleDocumentKeydown(event) {
522
+ if (event.key !== 'Escape') {
523
+ return;
524
+ }
525
+ const topHint = topmostConnected({
526
+ stack: openHintStack
527
+ });
528
+ if (topHint) {
529
+ topHint.hidePopover();
530
+ return;
531
+ }
532
+ const topAuto = topmostConnected({
533
+ stack: openAutoStack
534
+ });
535
+ if (topAuto) {
536
+ topAuto.hidePopover();
537
+ }
538
+ }
539
+ if (typeof HTMLElement !== 'undefined') {
540
+ const schedulePopoverToggle = createToggleScheduler();
541
+
542
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#dom-showpopover
543
+ if (!HTMLElement.prototype.showPopover) {
544
+ HTMLElement.prototype.showPopover = function showPopover() {
545
+ if (!this.hasAttribute('popover')) {
546
+ throw new DOMException(`Failed to execute 'showPopover' on 'HTMLElement': ${describeElement({
547
+ element: this
548
+ })} is not a popover element. Ensure the element has a 'popover' attribute (e.g. <div popover>).`, 'NotSupportedError');
549
+ }
550
+ if (!this.isConnected) {
551
+ throw new DOMException(`Failed to execute 'showPopover' on 'HTMLElement': ${describeElement({
552
+ element: this
553
+ })} 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');
554
+ }
555
+ if (isPopoverOpen({
556
+ element: this
557
+ })) {
558
+ return; // already showing — no-op
559
+ }
560
+
561
+ // sync, cancelable on show only
562
+ const allowed = fireToggleEvent({
563
+ element: this,
564
+ type: 'beforetoggle',
565
+ oldState: 'closed',
566
+ newState: 'open',
567
+ cancelable: true
568
+ });
569
+ if (!allowed) {
570
+ return;
571
+ }
572
+
573
+ // Spec-driven dismissal of popovers in other stacks before opening:
574
+ // - opening an `auto`: close all hints; close autos not in the new
575
+ // popover's ancestor chain.
576
+ // - opening a `hint`: close hints not in the new popover's chain;
577
+ // leave autos alone.
578
+ // - opening a `manual`: leave both stacks untouched.
579
+ const mode = getPopoverMode({
580
+ element: this
581
+ });
582
+ if (mode === 'auto') {
583
+ hidePopoversInStackNotInChainTo({
584
+ stack: openHintStack,
585
+ target: null
586
+ });
587
+ hidePopoversInStackNotInChainTo({
588
+ stack: openAutoStack,
589
+ target: this
590
+ });
591
+ } else if (mode === 'hint') {
592
+ hidePopoversInStackNotInChainTo({
593
+ stack: openHintStack,
594
+ target: this
595
+ });
596
+ }
597
+ this.setAttribute('data-popover-open', '');
598
+ const stack = stackForMode({
599
+ mode
600
+ });
601
+ if (stack && !stack.includes(this)) {
602
+ stack.push(this);
603
+ }
604
+ clearOpacityOnOpenToggle({
605
+ element: this
606
+ });
607
+ schedulePopoverToggle({
608
+ element: this,
609
+ newState: 'open'
610
+ });
611
+ };
612
+ }
613
+
614
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#dom-hidepopover
615
+ if (!HTMLElement.prototype.hidePopover) {
616
+ HTMLElement.prototype.hidePopover = function hidePopover() {
617
+ if (!this.hasAttribute('popover')) {
618
+ throw new DOMException(`Failed to execute 'hidePopover' on 'HTMLElement': ${describeElement({
619
+ element: this
620
+ })} is not a popover element. Ensure the element has a 'popover' attribute (e.g. <div popover>).`, 'NotSupportedError');
621
+ }
622
+ // Browser does not throw for disconnected elements on hide. No-op.
623
+ if (!this.isConnected || !isPopoverOpen({
624
+ element: this
625
+ })) {
626
+ return;
627
+ }
628
+
629
+ // not cancelable on hide
630
+ fireToggleEvent({
631
+ element: this,
632
+ type: 'beforetoggle',
633
+ oldState: 'open',
634
+ newState: 'closed',
635
+ cancelable: false
636
+ });
637
+ this.removeAttribute('data-popover-open');
638
+ const stack = stackForMode({
639
+ mode: getPopoverMode({
640
+ element: this
641
+ })
642
+ });
643
+ if (stack) {
644
+ const stackIndex = stack.indexOf(this);
645
+ if (stackIndex !== -1) {
646
+ stack.splice(stackIndex, 1);
647
+ }
648
+ }
649
+ schedulePopoverToggle({
650
+ element: this,
651
+ newState: 'closed'
652
+ });
653
+ };
654
+ }
655
+
656
+ // Spec: https://html.spec.whatwg.org/multipage/popover.html#dom-togglepopover
657
+ if (!HTMLElement.prototype.togglePopover) {
658
+ HTMLElement.prototype.togglePopover = function togglePopover(force) {
659
+ const wasOpen = isPopoverOpen({
660
+ element: this
661
+ });
662
+ if (typeof force === 'boolean') {
663
+ if (force && !wasOpen) {
664
+ this.showPopover();
665
+ } else if (!force && wasOpen) {
666
+ this.hidePopover();
667
+ }
668
+ } else if (wasOpen) {
669
+ this.hidePopover();
670
+ } else {
671
+ this.showPopover();
672
+ }
673
+
674
+ // Return the actual resulting state, which differs from `force` when
675
+ // `beforetoggle` was preventDefaulted on show.
676
+ return isPopoverOpen({
677
+ element: this
678
+ });
679
+ };
680
+ }
681
+
682
+ // Document-level light-dismiss for `popover="auto"` and `popover="hint"`.
683
+ // Spec uses pointerdown + pointerup with same-target check; we also wire
684
+ // `mousedown` + `mouseup` because jsdom does not fire pointer events for
685
+ // fireEvent.click() / userEvent.click() in older jsdom versions.
686
+
687
+ if (typeof document !== 'undefined' && !document.__atTopLayerLightDismiss) {
688
+ document.__atTopLayerLightDismiss = true;
689
+
690
+ // capture phase so application handlers cannot stop propagation before us
691
+ const pointerdownAdapter = event => handleDocumentPointerdown(event);
692
+ const pointerupAdapter = event => handleDocumentPointerup(event);
693
+ bind(document, {
694
+ type: 'pointerdown',
695
+ listener: pointerdownAdapter,
696
+ options: {
697
+ capture: true
698
+ }
699
+ });
700
+ bind(document, {
701
+ type: 'pointerup',
702
+ listener: pointerupAdapter,
703
+ options: {
704
+ capture: true
705
+ }
706
+ });
707
+ // Fallback for environments / fireEvent paths that fire mouse events
708
+ // only. The same handlers work for MouseEvent because we only read
709
+ // `target`. PointerEvent extends MouseEvent so the runtime cast is safe.
710
+ bind(document, {
711
+ type: 'mousedown',
712
+ listener: pointerdownAdapter,
713
+ options: {
714
+ capture: true
715
+ }
716
+ });
717
+ bind(document, {
718
+ type: 'mouseup',
719
+ listener: pointerupAdapter,
720
+ options: {
721
+ capture: true
722
+ }
723
+ });
724
+ bind(document, {
725
+ type: 'keydown',
726
+ listener: handleDocumentKeydown,
727
+ options: {
728
+ capture: true
729
+ }
730
+ });
731
+ }
732
+ }
733
+
734
+ // ─── HTMLDialogElement ──────────────────────────────────────────────────────
735
+ // Spec: https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
736
+
737
+ if (typeof HTMLDialogElement !== 'undefined') {
738
+ const proto = HTMLDialogElement.prototype;
739
+ const scheduleDialogToggle = createDialogToggleScheduler();
740
+ if (typeof proto.showModal !== 'function') {
741
+ proto.showModal = function showModal() {
742
+ if (!this.isConnected) {
743
+ throw new DOMException(`Failed to execute 'showModal' on 'HTMLDialogElement': ${describeElement({
744
+ element: this
745
+ })} 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');
746
+ }
747
+ if (this.hasAttribute('open')) {
748
+ if (this._isModal) {
749
+ return; // already modal — no-op
750
+ }
751
+ throw new DOMException(`Failed to execute 'showModal' on 'HTMLDialogElement': ${describeElement({
752
+ element: this
753
+ })} 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');
754
+ }
755
+ fireToggleEvent({
756
+ element: this,
757
+ type: 'beforetoggle',
758
+ oldState: 'closed',
759
+ newState: 'open',
760
+ cancelable: false
761
+ });
762
+ this.setAttribute('open', '');
763
+ this._isModal = true;
764
+ scheduleDialogToggle({
765
+ dialog: this,
766
+ newState: 'open',
767
+ shouldFireCloseEvent: false
768
+ });
769
+ };
770
+ }
771
+ if (typeof proto.show !== 'function') {
772
+ proto.show = function show() {
773
+ // Browser does not throw for disconnected elements on show — just no-op
774
+ if (!this.isConnected || this.hasAttribute('open')) {
775
+ return;
776
+ }
777
+ fireToggleEvent({
778
+ element: this,
779
+ type: 'beforetoggle',
780
+ oldState: 'closed',
781
+ newState: 'open',
782
+ cancelable: false
783
+ });
784
+ this.setAttribute('open', '');
785
+ this._isModal = false;
786
+ scheduleDialogToggle({
787
+ dialog: this,
788
+ newState: 'open',
789
+ shouldFireCloseEvent: false
790
+ });
791
+ };
792
+ }
793
+ if (typeof proto.close !== 'function') {
794
+ proto.close = function close(returnValue) {
795
+ if (!this.hasAttribute('open')) {
796
+ return; // already closed — no-op
797
+ }
798
+
799
+ // Sync beforetoggle (not cancelable) — matches browser behavior
800
+ fireToggleEvent({
801
+ element: this,
802
+ type: 'beforetoggle',
803
+ oldState: 'open',
804
+ newState: 'closed',
805
+ cancelable: false
806
+ });
807
+ this.removeAttribute('open');
808
+ this._isModal = false;
809
+ if (returnValue !== undefined) {
810
+ this.returnValue = returnValue;
811
+ }
812
+ scheduleDialogToggle({
813
+ dialog: this,
814
+ newState: 'closed',
815
+ shouldFireCloseEvent: true
816
+ });
817
+ };
818
+ }
819
+ if (!('returnValue' in proto)) {
820
+ Object.defineProperty(proto, 'returnValue', {
821
+ get() {
822
+ return this._returnValue === undefined ? '' : this._returnValue;
823
+ },
824
+ set(value) {
825
+ this._returnValue = String(value);
826
+ },
827
+ configurable: true,
828
+ enumerable: true
829
+ });
830
+ }
831
+ }