@samitouri / QOS-React-1 / commits / 6f90365128

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',