diff --git a/fixtures/attribute-behavior/src/attributes.js b/fixtures/attribute-behavior/src/attributes.js index 9427cf1699837..02a20b097256f 100644 --- a/fixtures/attribute-behavior/src/attributes.js +++ b/fixtures/attribute-behavior/src/attributes.js @@ -357,6 +357,21 @@ const attributes = [ }, {name: 'cols', tagName: 'textarea'}, {name: 'colSpan', containerTagName: 'tr', tagName: 'td'}, + {name: 'command', tagName: 'button', overrideStringValue: 'show-popover'}, + { + name: 'commandFor', + read: element => { + document.body.appendChild(element); + try { + // trigger and target need to be connected for `commandForElement` to read the actual value. + return element.commandForElement; + } finally { + document.body.removeChild(element); + } + }, + overrideStringValue: 'popover-target', + tagName: 'button', + }, {name: 'content', containerTagName: 'head', tagName: 'meta'}, {name: 'contentEditable'}, { diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 48b4d9472fb5d..c280818130a7c 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -82,6 +82,7 @@ let didWarnFormActionTarget = false; let didWarnFormActionMethod = false; let didWarnForNewBooleanPropsWithEmptyValue: {[string]: boolean}; let didWarnPopoverTargetObject = false; +let didWarnCommandForObject = false; if (__DEV__) { didWarnForNewBooleanPropsWithEmptyValue = {}; } @@ -875,6 +876,21 @@ function setProp( ); } } + break; + case 'commandFor': + if (__DEV__) { + if ( + !didWarnCommandForObject && + value != null && + typeof value === 'object' + ) { + didWarnCommandForObject = true; + console.error( + 'The `commandFor` prop expects the ID of an Element as a string. Received %s instead.', + value, + ); + } + } // Fall through default: { if ( @@ -3077,6 +3093,10 @@ export function hydrateProperties( } } + if (props.onCommand != null) { + listenToNonDelegatedEvent('command', domElement); + } + if (props.onClick != null) { // TODO: This cast may not be sound for SVG, MathML or custom elements. trapClickOnNonInteractiveElement(((domElement: any): HTMLElement)); diff --git a/packages/react-dom-bindings/src/events/DOMEventNames.js b/packages/react-dom-bindings/src/events/DOMEventNames.js index 73f0e2f632c55..6bae97423db4b 100644 --- a/packages/react-dom-bindings/src/events/DOMEventNames.js +++ b/packages/react-dom-bindings/src/events/DOMEventNames.js @@ -26,6 +26,7 @@ export type DOMEventName = | 'change' | 'click' | 'close' + | 'command' | 'compositionend' | 'compositionstart' | 'compositionupdate' diff --git a/packages/react-dom-bindings/src/events/DOMEventProperties.js b/packages/react-dom-bindings/src/events/DOMEventProperties.js index 534cae3045e37..2b40548babe46 100644 --- a/packages/react-dom-bindings/src/events/DOMEventProperties.js +++ b/packages/react-dom-bindings/src/events/DOMEventProperties.js @@ -46,6 +46,7 @@ const simpleEventPluginEvents = [ 'canPlayThrough', 'click', 'close', + 'command', 'contextMenu', 'copy', 'cut', diff --git a/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js b/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js index b4733c7781f8a..88051fbe8ea3c 100644 --- a/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js +++ b/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js @@ -236,6 +236,7 @@ export const nonDelegatedEvents: Set = new Set([ 'beforetoggle', 'cancel', 'close', + 'command', 'invalid', 'load', 'scroll', diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js index 243042ba6fde4..f4f308d62536f 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js @@ -294,6 +294,7 @@ export function getEventPriority(domEventName: DOMEventName): EventPriority { case 'cancel': case 'click': case 'close': + case 'command': case 'contextmenu': case 'copy': case 'cut': diff --git a/packages/react-dom-bindings/src/events/SyntheticEvent.js b/packages/react-dom-bindings/src/events/SyntheticEvent.js index 2a1c45dda3770..985e878cb72c4 100644 --- a/packages/react-dom-bindings/src/events/SyntheticEvent.js +++ b/packages/react-dom-bindings/src/events/SyntheticEvent.js @@ -600,3 +600,12 @@ const ToggleEventInterface = { }; export const SyntheticToggleEvent: $FlowFixMe = createSyntheticEvent(ToggleEventInterface); + +const CommandEventInterface = { + ...EventInterface, + source: 0, + command: 0, +}; +export const SyntheticCommandEvent: $FlowFixMe = createSyntheticEvent( + CommandEventInterface, +); diff --git a/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js index 8c983b3f5dfcc..06c535ce335da 100644 --- a/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js @@ -28,6 +28,7 @@ import { SyntheticClipboardEvent, SyntheticPointerEvent, SyntheticToggleEvent, + SyntheticCommandEvent, } from '../../events/SyntheticEvent'; import { @@ -167,6 +168,9 @@ function extractEvents( // MDN claims
should not receive ToggleEvent contradicting the spec: https://html.spec.whatwg.org/multipage/indices.html#event-toggle SyntheticEventCtor = SyntheticToggleEvent; break; + case 'command': + SyntheticEventCtor = SyntheticCommandEvent; + break; default: // Unknown event. This is used by createEventHandle. break; diff --git a/packages/react-dom-bindings/src/shared/possibleStandardNames.js b/packages/react-dom-bindings/src/shared/possibleStandardNames.js index 369b87110b235..5b3339b39fc69 100644 --- a/packages/react-dom-bindings/src/shared/possibleStandardNames.js +++ b/packages/react-dom-bindings/src/shared/possibleStandardNames.js @@ -38,6 +38,8 @@ const possibleStandardNames = { classname: 'className', cols: 'cols', colspan: 'colSpan', + command: 'command', + commandfor: 'commandFor', content: 'content', contenteditable: 'contentEditable', contextmenu: 'contextMenu', diff --git a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js index ef09e49bf36c1..537f9a8865606 100644 --- a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js +++ b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js @@ -1346,6 +1346,34 @@ describe('DOMPropertyOperations', () => { ); }); }); + + it('warns when using commandFor={HTMLElement}', async () => { + const popoverTarget = document.createElement('div'); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( + , + ); + }); + + assertConsoleErrorDev([ + 'The `commandFor` prop expects the ID of an Element as a string. Received HTMLDivElement {} instead.\n' + + ' in button (at **)', + ]); + + // Dedupe warning + await act(() => { + root.render( + , + ); + }); + }); }); describe('deleteValueForProperty', () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js b/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js index ebd3f9a540115..c5bed757567bb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js @@ -1334,6 +1334,22 @@ describe('ReactDOMEventListener', () => { }); }); + it('onCommand', async () => { + await testEmulatedBubblingEvent({ + type: 'div', + reactEvent: 'onCommand', + reactEventType: 'command', + nativeEvent: 'command', + dispatch(node) { + const e = new Event('command', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + it('onVolumeChange', async () => { await testEmulatedBubblingEvent({ type: 'video',