React DOM: Add support for Popover API (#27981)
Sebastian Silbermann committed
May 20, 2024 at 13:01 UTC
6f903651283861af4e5a8838b3b74d2a953a0acc
16 files changed
+332
-6
fixtures/attribute-behavior/AttributeTableSnapshot.md
+75
@@ -8448,6 +8448,81 @@
8448
| `pointsAtZ=(null)`| (initial)| `<number: 0>` |
8449
| `pointsAtZ=(undefined)`| (initial)| `<number: 0>` |
8450
8451
+## `popover` (on `<div>` inside `<div>`)
8452
+| Test Case | Flags | Result |
8453
+| --- | --- | --- |
8454
+| `popover=(string)`| (changed)| `"manual"` |
8455
+| `popover=(empty string)`| (changed)| `"auto"` |
8456
+| `popover=(array with string)`| (changed)| `"manual"` |
8457
+| `popover=(empty array)`| (changed)| `"auto"` |
8458
+| `popover=(object)`| (changed)| `"manual"` |
8459
+| `popover=(numeric string)`| (changed)| `"manual"` |
8460
+| `popover=(-1)`| (changed)| `"manual"` |
8461
+| `popover=(0)`| (changed)| `"manual"` |
8462
+| `popover=(integer)`| (changed)| `"manual"` |
8463
+| `popover=(NaN)`| (changed, warning)| `"manual"` |
8464
+| `popover=(float)`| (changed)| `"manual"` |
8465
+| `popover=(true)`| (initial, warning)| `<null>` |
8466
+| `popover=(false)`| (initial, warning)| `<null>` |
8467
+| `popover=(string 'true')`| (changed)| `"manual"` |
8468
+| `popover=(string 'false')`| (changed)| `"manual"` |
8469
+| `popover=(string 'on')`| (changed)| `"manual"` |
8470
+| `popover=(string 'off')`| (changed)| `"manual"` |
8471
+| `popover=(symbol)`| (initial, warning)| `<null>` |
8472
+| `popover=(function)`| (initial, warning)| `<null>` |
8473
+| `popover=(null)`| (initial)| `<null>` |
8474
+| `popover=(undefined)`| (initial)| `<null>` |
8475
+
8476
+## `popoverTarget` (on `<button>` inside `<div>`)
8477
+| Test Case | Flags | Result |
8478
+| --- | --- | --- |
8479
+| `popoverTarget=(string)`| (changed)| `<HTMLDivElement>` |
8480
+| `popoverTarget=(empty string)`| (initial)| `<null>` |
8481
+| `popoverTarget=(array with string)`| (changed, warning, ssr warning)| `<HTMLDivElement>` |
8482
+| `popoverTarget=(empty array)`| (initial, warning, ssr warning)| `<null>` |
8483
+| `popoverTarget=(object)`| (initial, warning, ssr warning)| `<null>` |
8484
+| `popoverTarget=(numeric string)`| (initial)| `<null>` |
8485
+| `popoverTarget=(-1)`| (initial)| `<null>` |
8486
+| `popoverTarget=(0)`| (initial)| `<null>` |
8487
+| `popoverTarget=(integer)`| (initial)| `<null>` |
8488
+| `popoverTarget=(NaN)`| (initial, warning)| `<null>` |
8489
+| `popoverTarget=(float)`| (initial)| `<null>` |
8490
+| `popoverTarget=(true)`| (initial, warning)| `<null>` |
8491
+| `popoverTarget=(false)`| (initial, warning)| `<null>` |
8492
+| `popoverTarget=(string 'true')`| (initial)| `<null>` |
8493
+| `popoverTarget=(string 'false')`| (initial)| `<null>` |
8494
+| `popoverTarget=(string 'on')`| (initial)| `<null>` |
8495
+| `popoverTarget=(string 'off')`| (initial)| `<null>` |
8496
+| `popoverTarget=(symbol)`| (initial, warning)| `<null>` |
8497
+| `popoverTarget=(function)`| (initial, warning)| `<null>` |
8498
+| `popoverTarget=(null)`| (initial)| `<null>` |
8499
+| `popoverTarget=(undefined)`| (initial)| `<null>` |
8500
+
8501
+## `popoverTargetAction` (on `<button>` inside `<div>`)
8502
+| Test Case | Flags | Result |
8503
+| --- | --- | --- |
8504
+| `popoverTargetAction=(string)`| (changed)| `"show"` |
8505
+| `popoverTargetAction=(empty string)`| (initial)| `"toggle"` |
8506
+| `popoverTargetAction=(array with string)`| (changed)| `"show"` |
8507
+| `popoverTargetAction=(empty array)`| (initial)| `"toggle"` |
8508
+| `popoverTargetAction=(object)`| (initial)| `"toggle"` |
8509
+| `popoverTargetAction=(numeric string)`| (initial)| `"toggle"` |
8510
+| `popoverTargetAction=(-1)`| (initial)| `"toggle"` |
8511
+| `popoverTargetAction=(0)`| (initial)| `"toggle"` |
8512
+| `popoverTargetAction=(integer)`| (initial)| `"toggle"` |
8513
+| `popoverTargetAction=(NaN)`| (initial, warning)| `"toggle"` |
8514
+| `popoverTargetAction=(float)`| (initial)| `"toggle"` |
8515
+| `popoverTargetAction=(true)`| (initial, warning)| `"toggle"` |
8516
+| `popoverTargetAction=(false)`| (initial, warning)| `"toggle"` |
8517
+| `popoverTargetAction=(string 'true')`| (initial)| `"toggle"` |
8518
+| `popoverTargetAction=(string 'false')`| (initial)| `"toggle"` |
8519
+| `popoverTargetAction=(string 'on')`| (initial)| `"toggle"` |
8520
+| `popoverTargetAction=(string 'off')`| (initial)| `"toggle"` |
8521
+| `popoverTargetAction=(symbol)`| (initial, warning)| `"toggle"` |
8522
+| `popoverTargetAction=(function)`| (initial, warning)| `"toggle"` |
8523
+| `popoverTargetAction=(null)`| (initial)| `"toggle"` |
8524
+| `popoverTargetAction=(undefined)`| (initial)| `"toggle"` |
8525
+
8526
## `poster` (on `<video>` inside `<div>`)
8527
| Test Case | Flags | Result |
8528
| --- | --- | --- |
fixtures/attribute-behavior/public/index.html
+1
@@ -26,6 +26,7 @@
26
You need to enable JavaScript to run this app.
27
</noscript>
28
<div id="root"></div>
29
+ <div id="popover-target" popover="auto"></div>
30
<!--
31
This HTML file is a template.
32
If you open it directly in the browser, you will see an empty page.
fixtures/attribute-behavior/src/attributes.js
+16
@@ -1447,6 +1447,22 @@ const attributes = [
1447
containerTagName: 'svg',
1448
tagName: 'feSpotLight',
1449
},
1450
+ {name: 'popover', overrideStringValue: 'manual'},
1451
+ {
1452
+ name: 'popoverTarget',
1453
+ read: element => {
1454
+ document.body.appendChild(element);
1455
+ try {
1456
+ // trigger and target need to be connected for `popoverTargetElement` to read the actual value.
1457
+ return element.popoverTargetElement;
1458
+ } finally {
1459
+ document.body.removeChild(element);
1460
+ }
1461
+ },
1462
+ overrideStringValue: 'popover-target',
1463
+ tagName: 'button',
1464
+ },
1465
+ {name: 'popoverTargetAction', overrideStringValue: 'show', tagName: 'button'},
1466
{
1467
name: 'poster',
1468
tagName: 'video',
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+27
@@ -82,6 +82,7 @@ let didWarnFormActionName = false;
82
let didWarnFormActionTarget = false;
83
let didWarnFormActionMethod = false;
84
let didWarnForNewBooleanPropsWithEmptyValue: {[string]: boolean};
85
+let didWarnPopoverTargetObject = false;
86
let canDiffStyleForHydrationWarning;
87
if (__DEV__) {
88
didWarnForNewBooleanPropsWithEmptyValue = {};
@@ -770,6 +771,11 @@ function setProp(
771
}
772
break;
773
}
774
+ case 'popover':
775
+ listenToNonDelegatedEvent('beforetoggle', domElement);
776
+ listenToNonDelegatedEvent('toggle', domElement);
777
+ setValueForAttribute(domElement, 'popover', value);
778
+ break;
779
case 'xlinkActuate':
780
setValueForNamespacedAttribute(
781
domElement,
@@ -861,6 +867,20 @@ function setProp(
867
case 'innerText':
868
case 'textContent':
869
break;
870
+ case 'popoverTarget':
871
+ if (__DEV__) {
872
+ if (
873
+ !didWarnPopoverTargetObject &&
874
+ value != null &&
875
+ typeof value === 'object'
876
+ ) {
877
+ didWarnPopoverTargetObject = true;
878
+ console.error(
879
+ 'The `popoverTarget` prop expects the ID of an Element as a string. Received %s instead.',
880
+ value,
881
+ );
882
+ }
883
+ }
884
// Fall through
885
default: {
886
if (
@@ -2953,6 +2973,13 @@ export function hydrateProperties(
2973
}
2974
}
2975
2976
+ if (props.popover != null) {
2977
+ // We listen to this event in case to ensure emulated bubble
2978
+ // listeners still fire for the toggle event.
2979
+ listenToNonDelegatedEvent('beforetoggle', domElement);
2980
+ listenToNonDelegatedEvent('toggle', domElement);
2981
+ }
2982
+
2983
if (props.onScroll != null) {
2984
listenToNonDelegatedEvent('scroll', domElement);
2985
}
packages/react-dom-bindings/src/events/DOMEventNames.js
+1
@@ -18,6 +18,7 @@ export type DOMEventName =
18
// 'animationstart' |
19
| 'beforeblur' // Not a real event. This is used by event experiments.
20
| 'beforeinput'
21
+ | 'beforetoggle'
22
| 'blur'
23
| 'canplay'
24
| 'canplaythrough'
packages/react-dom-bindings/src/events/DOMEventProperties.js
+1
@@ -37,6 +37,7 @@ export const topLevelEventsToReactNames: Map<DOMEventName, string | null> =
37
const simpleEventPluginEvents = [
38
'abort',
39
'auxClick',
40
+ 'beforeToggle',
41
'cancel',
42
'canPlay',
43
'canPlayThrough',
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+1
@@ -214,6 +214,7 @@ export const mediaEventTypes: Array<DOMEventName> = [
214
// set them on the actual target element itself. This is primarily
215
// because these events do not consistently bubble in the DOM.
216
export const nonDelegatedEvents: Set<DOMEventName> = new Set([
217
+ 'beforetoggle',
218
'cancel',
219
'close',
220
'invalid',
packages/react-dom-bindings/src/events/ReactDOMEventListener.js
+1
@@ -345,6 +345,7 @@ export function getEventPriority(domEventName: DOMEventName): EventPriority {
345
case 'select':
346
case 'selectstart':
347
return DiscreteEventPriority;
348
+ case 'beforetoggle':
349
case 'drag':
350
case 'dragenter':
351
case 'dragexit':
packages/react-dom-bindings/src/events/SyntheticEvent.js
+8
@@ -592,3 +592,11 @@ const WheelEventInterface = {
592
};
593
export const SyntheticWheelEvent: $FlowFixMe =
594
createSyntheticEvent(WheelEventInterface);
595
+
596
+const ToggleEventInterface = {
597
+ ...EventInterface,
598
+ newState: 0,
599
+ oldState: 0,
600
+};
601
+export const SyntheticToggleEvent: $FlowFixMe =
602
+ createSyntheticEvent(ToggleEventInterface);
packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js
+6
@@ -27,6 +27,7 @@ import {
27
SyntheticWheelEvent,
28
SyntheticClipboardEvent,
29
SyntheticPointerEvent,
30
+ SyntheticToggleEvent,
31
} from '../../events/SyntheticEvent';
32
33
import {
@@ -161,6 +162,11 @@ function extractEvents(
162
case 'pointerup':
163
SyntheticEventCtor = SyntheticPointerEvent;
164
break;
165
+ case 'toggle':
166
+ case 'beforetoggle':
167
+ // MDN claims <details> should not receive ToggleEvent contradicting the spec: https://html.spec.whatwg.org/multipage/indices.html#event-toggle
168
+ SyntheticEventCtor = SyntheticToggleEvent;
169
+ break;
170
default:
171
// Unknown event. This is used by createEventHandle.
172
break;
packages/react-dom-bindings/src/shared/possibleStandardNames.js
+3
@@ -344,6 +344,9 @@ const possibleStandardNames = {
344
pointsatx: 'pointsAtX',
345
pointsaty: 'pointsAtY',
346
pointsatz: 'pointsAtZ',
347
+ popover: 'popover',
348
+ popovertarget: 'popoverTarget',
349
+ popovertargetaction: 'popoverTargetAction',
350
prefix: 'prefix',
351
preservealpha: 'preserveAlpha',
352
preserveaspectratio: 'preserveAspectRatio',
packages/react-dom/src/__tests__/DOMPropertyOperations-test.js
+29
-1
@@ -16,12 +16,13 @@ describe('DOMPropertyOperations', () => {
16
let React;
17
let ReactDOMClient;
18
let act;
19
+ let assertConsoleErrorDev;
20
21
beforeEach(() => {
22
jest.resetModules();
23
React = require('react');
24
ReactDOMClient = require('react-dom/client');
24
- ({act} = require('internal-test-utils'));
25
+ ({act, assertConsoleErrorDev} = require('internal-test-utils'));
26
});
27
28
// Sets a value in a way that React doesn't see,
@@ -1317,6 +1318,33 @@ describe('DOMPropertyOperations', () => {
1318
});
1319
expect(customElement.foo).toBe(undefined);
1320
});
1321
+
1322
+ it('warns when using popoverTarget={HTMLElement}', async () => {
1323
+ const popoverTarget = document.createElement('div');
1324
+ const container = document.createElement('div');
1325
+ const root = ReactDOMClient.createRoot(container);
1326
+
1327
+ await act(() => {
1328
+ root.render(
1329
+ <button key="one" popoverTarget={popoverTarget}>
1330
+ Toggle popover
1331
+ </button>,
1332
+ );
1333
+ });
1334
+
1335
+ assertConsoleErrorDev([
1336
+ 'The `popoverTarget` prop expects the ID of an Element as a string. Received [object HTMLDivElement] instead.',
1337
+ ]);
1338
+
1339
+ // Dedupe warning
1340
+ await act(() => {
1341
+ root.render(
1342
+ <button key="two" popoverTarget={popoverTarget}>
1343
+ Toggle popover
1344
+ </button>,
1345
+ );
1346
+ });
1347
+ });
1348
});
1349
1350
describe('deleteValueForProperty', () => {
packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js
+41
-5
@@ -1268,6 +1268,40 @@ describe('ReactDOMEventListener', () => {
1268
});
1269
});
1270
1271
+ it('onBeforeToggle Popover API', async () => {
1272
+ await testEmulatedBubblingEvent({
1273
+ type: 'div',
1274
+ targetProps: {popover: 'any'},
1275
+ reactEvent: 'onBeforeToggle',
1276
+ reactEventType: 'beforetoggle',
1277
+ nativeEvent: 'beforetoggle',
1278
+ dispatch(node) {
1279
+ const e = new Event('beforetoggle', {
1280
+ bubbles: false,
1281
+ cancelable: true,
1282
+ });
1283
+ node.dispatchEvent(e);
1284
+ },
1285
+ });
1286
+ });
1287
+
1288
+ it('onToggle Popover API', async () => {
1289
+ await testEmulatedBubblingEvent({
1290
+ type: 'div',
1291
+ targetProps: {popover: 'any'},
1292
+ reactEvent: 'onToggle',
1293
+ reactEventType: 'toggle',
1294
+ nativeEvent: 'toggle',
1295
+ dispatch(node) {
1296
+ const e = new Event('toggle', {
1297
+ bubbles: false,
1298
+ cancelable: true,
1299
+ });
1300
+ node.dispatchEvent(e);
1301
+ },
1302
+ });
1303
+ });
1304
+
1305
it('onVolumeChange', async () => {
1306
await testEmulatedBubblingEvent({
1307
type: 'video',
@@ -1969,6 +2003,7 @@ describe('ReactDOMEventListener', () => {
2003
type={eventConfig.type}
2004
targetRef={targetRef}
2005
targetProps={{
2006
+ ...eventConfig.targetProps,
2007
[eventConfig.reactEvent]: e => {
2008
log.push('---- inner');
2009
},
@@ -2135,11 +2170,10 @@ describe('ReactDOMEventListener', () => {
2170
<Fixture
2171
type={eventConfig.type}
2172
targetRef={targetRef}
2138
- targetProps={
2139
- {
2140
- // No listener on the target itself.
2141
- }
2142
- }
2173
+ targetProps={{
2174
+ ...eventConfig.targetProps,
2175
+ // No listener on the target itself.
2176
+ }}
2177
parentProps={{
2178
[eventConfig.reactEvent]: e => {
2179
log.push('--- inner parent');
@@ -2368,6 +2402,7 @@ describe('ReactDOMEventListener', () => {
2402
type={eventConfig.type}
2403
targetRef={targetRef}
2404
targetProps={{
2405
+ ...eventConfig.targetProps,
2406
[eventConfig.reactEvent]: e => {
2407
e.stopPropagation(); // <---------
2408
log.push('---- inner');
@@ -2705,6 +2740,7 @@ describe('ReactDOMEventListener', () => {
2740
}
2741
}}
2742
targetProps={{
2743
+ ...eventConfig.targetProps,
2744
[eventConfig.reactEvent]: e => {
2745
log.push('---- inner');
2746
},
packages/react-dom/src/__tests__/ReactTestUtils-test.js
+1
@@ -45,6 +45,7 @@ describe('ReactTestUtils', () => {
45
"animationStart",
46
"auxClick",
47
"beforeInput",
48
+ "beforeToggle",
49
"blur",
50
"canPlay",
51
"canPlayThrough",
packages/react-dom/src/events/plugins/__tests__/SimpleEventPlugin-test.js
+120
@@ -9,6 +9,15 @@
9
10
'use strict';
11
12
+// polyfill missing JSDOM support
13
+class ToggleEvent extends Event {
14
+ constructor(type, eventInit) {
15
+ super(type, eventInit);
16
+ this.newState = eventInit.newState;
17
+ this.oldState = eventInit.oldState;
18
+ }
19
+}
20
+
21
describe('SimpleEventPlugin', function () {
22
let React;
23
let ReactDOMClient;
@@ -469,5 +478,116 @@ describe('SimpleEventPlugin', function () {
478
'wheel',
479
]);
480
});
481
+
482
+ it('dispatches synthetic toggle events when the Popover API is used', async () => {
483
+ container = document.createElement('div');
484
+
485
+ const onToggle = jest.fn();
486
+ const root = ReactDOMClient.createRoot(container);
487
+ await act(() => {
488
+ root.render(
489
+ <>
490
+ <button popoverTarget="popover">Toggle popover</button>
491
+ <div id="popover" popover="" onToggle={onToggle}>
492
+ popover content
493
+ </div>
494
+ </>,
495
+ );
496
+ });
497
+
498
+ const target = container.querySelector('#popover');
499
+ target.dispatchEvent(
500
+ new ToggleEvent('toggle', {
501
+ bubbles: false,
502
+ cancelable: true,
503
+ oldState: 'closed',
504
+ newState: 'open',
505
+ }),
506
+ );
507
+
508
+ expect(onToggle).toHaveBeenCalledTimes(1);
509
+ let event = onToggle.mock.calls[0][0];
510
+ expect(event).toEqual(
511
+ expect.objectContaining({
512
+ oldState: 'closed',
513
+ newState: 'open',
514
+ }),
515
+ );
516
+
517
+ target.dispatchEvent(
518
+ new ToggleEvent('toggle', {
519
+ bubbles: false,
520
+ cancelable: true,
521
+ oldState: 'open',
522
+ newState: 'closed',
523
+ }),
524
+ );
525
+
526
+ expect(onToggle).toHaveBeenCalledTimes(2);
527
+ event = onToggle.mock.calls[1][0];
528
+ expect(event).toEqual(
529
+ expect.objectContaining({
530
+ oldState: 'open',
531
+ newState: 'closed',
532
+ }),
533
+ );
534
+ });
535
+
536
+ it('dispatches synthetic toggle events when <details> is used', async () => {
537
+ // This test just replays browser behavior.
538
+ // The real test would be if browsers dispatch ToggleEvent on <details>.
539
+ // This case only exists because MDN claims <details> doesn't receive ToggleEvent.
540
+ // However, Chrome dispatches ToggleEvent on <details> and the spec confirms that behavior: https://html.spec.whatwg.org/multipage/indices.html#event-toggle
541
+
542
+ container = document.createElement('div');
543
+
544
+ const onToggle = jest.fn();
545
+ const root = ReactDOMClient.createRoot(container);
546
+ await act(() => {
547
+ root.render(
548
+ <details id="details" onToggle={onToggle}>
549
+ <summary>Summary</summary>
550
+ Details
551
+ </details>,
552
+ );
553
+ });
554
+
555
+ const target = container.querySelector('#details');
556
+ target.dispatchEvent(
557
+ new ToggleEvent('toggle', {
558
+ bubbles: false,
559
+ cancelable: true,
560
+ oldState: 'closed',
561
+ newState: 'open',
562
+ }),
563
+ );
564
+
565
+ expect(onToggle).toHaveBeenCalledTimes(1);
566
+ let event = onToggle.mock.calls[0][0];
567
+ expect(event).toEqual(
568
+ expect.objectContaining({
569
+ oldState: 'closed',
570
+ newState: 'open',
571
+ }),
572
+ );
573
+
574
+ target.dispatchEvent(
575
+ new ToggleEvent('toggle', {
576
+ bubbles: false,
577
+ cancelable: true,
578
+ oldState: 'open',
579
+ newState: 'closed',
580
+ }),
581
+ );
582
+
583
+ expect(onToggle).toHaveBeenCalledTimes(2);
584
+ event = onToggle.mock.calls[1][0];
585
+ expect(event).toEqual(
586
+ expect.objectContaining({
587
+ oldState: 'open',
588
+ newState: 'closed',
589
+ }),
590
+ );
591
+ });
592
});
593
});
packages/react-dom/src/test-utils/ReactTestUtilsFB.js
+1
@@ -850,6 +850,7 @@ const simulatedEventTypes = [
850
'change',
851
'select',
852
'beforeInput',
853
+ 'beforeToggle',
854
'compositionEnd',
855
'compositionStart',
856
'compositionUpdate',