Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions fixtures/attribute-behavior/AttributeTableSnapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -2023,6 +2023,56 @@
| `colSpan=(null)`| (initial, ssr error, ssr mismatch)| `<number: 1>` |
| `colSpan=(undefined)`| (initial, ssr error, ssr mismatch)| `<number: 1>` |

## `commandFor` (on `<button>` inside `<div>`)
| Test Case | Flags | Result |
| --- | --- | --- |
| `commandFor=(string)`| (changed)| `<HTMLDivElement>` |
| `commandFor=(empty string)`| (initial)| `<null>` |
| `commandFor=(array with string)`| (changed, warning, ssr warning)| `<HTMLDivElement>` |
| `commandFor=(empty array)`| (initial, warning, ssr warning)| `<null>` |
| `commandFor=(object)`| (initial, warning, ssr warning)| `<null>` |
| `commandFor=(numeric string)`| (initial)| `<null>` |
| `commandFor=(-1)`| (initial)| `<null>` |
| `commandFor=(0)`| (initial)| `<null>` |
| `commandFor=(integer)`| (initial)| `<null>` |
| `commandFor=(NaN)`| (initial, warning)| `<null>` |
| `commandFor=(float)`| (initial)| `<null>` |
| `commandFor=(true)`| (initial, warning)| `<null>` |
| `commandFor=(false)`| (initial, warning)| `<null>` |
| `commandFor=(string 'true')`| (initial)| `<null>` |
| `commandFor=(string 'false')`| (initial)| `<null>` |
| `commandFor=(string 'on')`| (initial)| `<null>` |
| `commandFor=(string 'off')`| (initial)| `<null>` |
| `commandFor=(symbol)`| (initial, warning)| `<null>` |
| `commandFor=(function)`| (initial, warning)| `<null>` |
| `commandFor=(null)`| (initial)| `<null>` |
| `commandFor=(undefined)`| (initial)| `<null>` |

## `command` (on `<button>` inside `<div>`)
| Test Case | Flags | Result |
| --- | --- | --- |
| `command=(string)`| (changed)| `"toggle-popover"` |
| `command=(empty string)`| (initial)| `""` |
| `command=(array with string)`| (changed)| `"toggle-popover"` |
| `command=(empty array)`| (initial)| `""` |
| `command=(object)`| (initial)| `""` |
| `command=(numeric string)`| (initial)| `""` |
| `command=(-1)`| (initial)| `""` |
| `command=(0)`| (initial)| `""` |
| `command=(integer)`| (initial)| `""` |
| `command=(NaN)`| (initial, warning)| `""` |
| `command=(float)`| (initial)| `""` |
| `command=(true)`| (initial, warning)| `""` |
| `command=(false)`| (initial, warning)| `""` |
| `command=(string 'true')`| (initial)| `""` |
| `command=(string 'false')`| (initial)| `""` |
| `command=(string 'on')`| (initial)| `""` |
| `command=(string 'off')`| (initial)| `""` |
| `command=(symbol)`| (initial, warning)| `""` |
| `command=(function)`| (initial, warning)| `""` |
| `command=(null)`| (initial)| `""` |
| `command=(undefined)`| (initial)| `""` |

## `content` (on `<meta>` inside `<head>`)
| Test Case | Flags | Result |
| --- | --- | --- |
Expand Down
15 changes: 15 additions & 0 deletions fixtures/attribute-behavior/src/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,21 @@ const attributes = [
},
{name: 'cols', tagName: 'textarea'},
{name: 'colSpan', containerTagName: 'tr', tagName: 'td'},
{
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: 'command', overrideStringValue: 'toggle-popover', tagName: 'button'},
{name: 'content', containerTagName: 'head', tagName: 'meta'},
{name: 'contentEditable'},
{
Expand Down
1 change: 1 addition & 0 deletions flow-typed/environments/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ type AnimationEventTypes =
| 'animationend'
| 'animationiteration';
type ClipboardEventTypes = 'clipboardchange' | 'cut' | 'copy' | 'paste';
type CommandEventTypes = 'command';
type TransitionEventTypes =
| 'transitionrun'
| 'transitionstart'
Expand Down
17 changes: 17 additions & 0 deletions flow-typed/environments/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ declare class ToggleEvent extends Event {
+newState: string;
}

type CommandEvent$Init = {
...Event$Init,
command?: string,
source?: Element | null,
...
};

// https://html.spec.whatwg.org/multipage/interaction.html#commandevent
declare class CommandEvent extends Event {
constructor(type: CommandEventTypes, eventInit?: CommandEvent$Init): void;
+command: string;
+source: Element | null;
}

// TODO: HTMLDocument
type FocusOptions = {preventScroll?: boolean, ...};

Expand Down Expand Up @@ -244,6 +258,7 @@ declare class HTMLElement extends Element {
ontimeupdate: ?Function;
ontoggle: ?Function;
onbeforetoggle: ?Function;
oncommand: ?Function;
onvolumechange: ?Function;
onwaiting: ?Function;
properties: any;
Expand Down Expand Up @@ -1141,6 +1156,8 @@ declare class HTMLButtonElement extends HTMLElement {
setCustomValidity(error: string): void;
popoverTargetElement: Element | null;
popoverTargetAction: 'toggle' | 'show' | 'hide';
commandForElement: Element | null;
command: string;
}

// https://w3c.github.io/html/sec-forms.html#the-textarea-element
Expand Down
39 changes: 39 additions & 0 deletions packages/react-dom-bindings/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ let didWarnFormActionTarget = false;
let didWarnFormActionMethod = false;
let didWarnForNewBooleanPropsWithEmptyValue: {[string]: boolean};
let didWarnPopoverTargetObject = false;
let didWarnCommandForObject = false;
if (__DEV__) {
didWarnForNewBooleanPropsWithEmptyValue = {};
}
Expand Down Expand Up @@ -634,6 +635,15 @@ function setProp(
}
return;
}
case 'onCommand': {
if (value != null) {
if (__DEV__ && typeof value !== 'function') {
warnForInvalidEventListener(key, value);
}
listenToNonDelegatedEvent('command', domElement);
}
return;
}
case 'dangerouslySetInnerHTML': {
if (value != null) {
if (typeof value !== 'object' || !('__html' in value)) {
Expand Down Expand Up @@ -956,6 +966,22 @@ function setProp(
}
}
// Fall through
case 'commandFor':
if (__DEV__) {
if (
key === 'commandFor' &&
!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 (
key.length > 2 &&
Expand Down Expand Up @@ -1049,6 +1075,15 @@ function setPropOnCustomElement(
}
return;
}
case 'onCommand': {
if (value != null) {
if (__DEV__ && typeof value !== 'function') {
warnForInvalidEventListener(key, value);
}
listenToNonDelegatedEvent('command', domElement);
}
return;
}
case 'onClick': {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
if (value != null) {
Expand Down Expand Up @@ -3256,6 +3291,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));
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom-bindings/src/events/DOMEventNames.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type DOMEventName =
| 'change'
| 'click'
| 'close'
| 'command'
| 'compositionend'
| 'compositionstart'
| 'compositionupdate'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const simpleEventPluginEvents = [
'canPlayThrough',
'click',
'close',
'command',
'contextMenu',
'copy',
'cut',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export const nonDelegatedEvents: Set<DOMEventName> = new Set([
'beforetoggle',
'cancel',
'close',
'command',
'invalid',
'load',
'scroll',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export function getEventPriority(domEventName: DOMEventName): EventPriority {
case 'cancel':
case 'click':
case 'close':
case 'command':
case 'contextmenu':
case 'copy':
case 'cut':
Expand Down
13 changes: 13 additions & 0 deletions packages/react-dom-bindings/src/events/SyntheticEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,16 @@ const ToggleEventInterface: EventInterfaceType = {
};
export const SyntheticToggleEvent: $FlowFixMe =
createSyntheticEvent(ToggleEventInterface);

/**
* @interface CommandEvent
* @see https://html.spec.whatwg.org/multipage/interaction.html#commandevent
*/
const CommandEventInterface: EventInterfaceType = {
...EventInterface,
command: 0,
source: 0,
};
export const SyntheticCommandEvent: $FlowFixMe = createSyntheticEvent(
CommandEventInterface,
);
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
SyntheticPointerEvent,
SyntheticSubmitEvent,
SyntheticToggleEvent,
SyntheticCommandEvent,
} from '../../events/SyntheticEvent';

import {
Expand Down Expand Up @@ -171,6 +172,9 @@ function extractEvents(
// MDN claims <details> 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;
Expand Down Expand Up @@ -210,7 +214,9 @@ function extractEvents(
// nonDelegatedEvents list in DOMPluginEventSystem.
// Then we can remove this special list.
// This is a breaking change that can wait until React 18.
(domEventName === 'scroll' || domEventName === 'scrollend');
(domEventName === 'scroll' ||
domEventName === 'scrollend' ||
domEventName === 'command');

const listeners = accumulateSinglePhaseListeners(
targetInst,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const possibleStandardNames = {
classname: 'className',
cols: 'cols',
colspan: 'colSpan',
command: 'command',
commandfor: 'commandFor',
content: 'content',
contenteditable: 'contentEditable',
contextmenu: 'contextMenu',
Expand Down
45 changes: 45 additions & 0 deletions packages/react-dom/src/__tests__/DOMPropertyOperations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,51 @@ describe('DOMPropertyOperations', () => {
);
});
});

it('sets commandFor attribute on button', async () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<button commandFor="demo-popover" command="show-popover">
show-popover
</button>,
);
});

const button = container.querySelector('button');
expect(button.getAttribute('commandfor')).toBe('demo-popover');
expect(button.getAttribute('command')).toBe('show-popover');
});

it('warns when using commandFor={HTMLElement}', async () => {
const commandTarget = document.createElement('div');
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<button key="one" commandFor={commandTarget}>
Invoke command
</button>,
);
});

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(
<button key="two" commandFor={commandTarget}>
Invoke command
</button>,
);
});
});
});

describe('deleteValueForProperty', () => {
Expand Down
27 changes: 27 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,33 @@ describe('ReactDOMEventListener', () => {
}
});

it('should not emulate bubbling of command events', async () => {
const container = document.createElement('div');
const ref = React.createRef();
const onCommand = jest.fn();
document.body.appendChild(container);
try {
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
<div onCommand={onCommand}>
<img ref={ref} alt="" onCommand={onCommand} />
</div>,
);
});
await act(() => {
ref.current.dispatchEvent(
new Event('command', {
bubbles: false,
}),
);
});
expect(onCommand).toHaveBeenCalledTimes(1);
} finally {
document.body.removeChild(container);
}
});

it('should bubble non-native bubbling cancel/close events', async () => {
const container = document.createElement('div');
const ref = React.createRef();
Expand Down
Loading