fix(packages): scope menu data attributes (#1628)

This commit is contained in:
Sam Potts
2026-05-29 16:26:47 +10:00
committed by GitHub
parent e7aa0a6669
commit 01a2115aab
24 changed files with 237 additions and 79 deletions
@@ -1,4 +1,4 @@
import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom';
import { applyElementProps } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -57,7 +57,5 @@ export class MenuBackElement extends MediaElement {
role: 'button',
'aria-label': this.label,
});
if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
}
}
@@ -1,4 +1,4 @@
import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom';
import { applyElementProps } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -72,7 +72,5 @@ export class MenuCheckboxItemElement extends MediaElement {
'aria-checked': String(this.checked),
'aria-disabled': this.disabled ? 'true' : undefined,
});
applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
}
}
@@ -282,6 +282,9 @@ export class MenuElement extends MediaElement {
// Apply base submenu attributes regardless of phase.
const transitionState = this.#menuViewTransition.input.current;
this.removeAttribute(MenuDataAttrs.side);
this.removeAttribute(MenuDataAttrs.align);
applyElementProps(this, {
...getMenuViewTransitionAttrs(transitionState),
role: 'menu',
@@ -1,9 +1,7 @@
import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom';
import { applyElementProps } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { menuContext } from './context';
export class MenuGroupElement extends MediaElement {
static readonly tagName = 'media-menu-group';
@@ -14,8 +12,6 @@ export class MenuGroupElement extends MediaElement {
label: string | undefined = undefined;
readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true });
protected override update(_changed: PropertyValues): void {
super.update(_changed);
@@ -23,8 +19,5 @@ export class MenuGroupElement extends MediaElement {
role: 'group',
'aria-label': this.label,
});
const ctx = this.#ctx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
}
}
@@ -1,4 +1,4 @@
import { applyElementProps, applyStateDataAttrs, completeMenuItemSelection } from '@videojs/core/dom';
import { applyElementProps, completeMenuItemSelection } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -100,7 +100,5 @@ export class MenuItemElement extends MediaElement {
'data-has-submenu': '',
}),
});
applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
}
}
@@ -1,11 +1,5 @@
import type { MenuState } from '@videojs/core';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { ContextPartElement } from '../context-part-element';
import { menuContext } from './context';
export class MenuLabelElement extends ContextPartElement<MenuState> {
export class MenuLabelElement extends MediaElement {
static readonly tagName = 'media-menu-label';
protected readonly consumer = new ContextConsumer(this, { context: menuContext, subscribe: true });
}
@@ -1,9 +1,9 @@
import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom';
import { applyElementProps } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer, ContextProvider } from '@videojs/element/context';
import { ContextProvider } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { menuContext, menuRadioGroupContext } from './context';
import { menuRadioGroupContext } from './context';
export class MenuRadioGroupElement extends MediaElement {
static readonly tagName: string = 'media-menu-radio-group';
@@ -16,7 +16,6 @@ export class MenuRadioGroupElement extends MediaElement {
value = '';
label: string | undefined = undefined;
readonly #menuCtx = new ContextConsumer(this, { context: menuContext, subscribe: true });
readonly #provider = new ContextProvider(this, { context: menuRadioGroupContext });
protected override update(_changed: PropertyValues): void {
@@ -26,10 +25,6 @@ export class MenuRadioGroupElement extends MediaElement {
role: 'group',
'aria-label': this.label,
});
const ctx = this.#menuCtx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
this.#provider.setValue({
value: this.value,
onValueChange: (next: string) => {
@@ -1,4 +1,4 @@
import { applyElementProps, applyStateDataAttrs, completeMenuItemSelection } from '@videojs/core/dom';
import { applyElementProps, completeMenuItemSelection } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -77,7 +77,5 @@ export class MenuRadioItemElement extends MediaElement {
'aria-checked': String(checked),
'aria-disabled': this.disabled ? 'true' : undefined,
});
applyStateDataAttrs(this, menuCtx.state, menuCtx.stateAttrMap);
}
}
@@ -1,21 +1,14 @@
import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom';
import { applyElementProps } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { menuContext } from './context';
export class MenuSeparatorElement extends MediaElement {
static readonly tagName = 'media-menu-separator';
readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true });
protected override update(_changed: PropertyValues): void {
super.update(_changed);
applyElementProps(this, { role: 'separator' });
const ctx = this.#ctx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
}
}
@@ -7,9 +7,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { ControlsElement } from '../../controls/controls-element';
import { MediaElement } from '../../media-element';
import { MenuBackElement } from '../menu-back-element';
import { MenuCheckboxItemElement } from '../menu-checkbox-item-element';
import { MenuElement } from '../menu-element';
import { MenuGroupElement } from '../menu-group-element';
import { MenuItemElement } from '../menu-item-element';
import { MenuItemIndicatorElement } from '../menu-item-indicator-element';
import { MenuLabelElement } from '../menu-label-element';
import { MenuRadioGroupElement } from '../menu-radio-group-element';
import { MenuRadioItemElement } from '../menu-radio-item-element';
import { MenuSeparatorElement } from '../menu-separator-element';
import { MenuViewElement } from '../menu-view-element';
let tagCounter = 0;
@@ -91,11 +98,108 @@ async function waitForAssertion(assertion: () => void): Promise<void> {
throw error;
}
const menuStateAttrs = ['data-open', 'data-side', 'data-align', 'data-starting-style', 'data-ending-style'] as const;
function expectNoMenuStateAttrs(element: HTMLElement): void {
for (const attr of menuStateAttrs) {
expect(element.hasAttribute(attr), `${element.localName} should not have ${attr}`).toBe(false);
}
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('MenuElement', () => {
it('scopes menu state data attributes to menu elements', async () => {
const root = createElement(MenuElement);
const label = createElement(MenuLabelElement);
const group = createElement(MenuGroupElement);
const item = createElement(MenuItemElement);
const checkboxItem = createElement(MenuCheckboxItemElement);
const radioGroup = createElement(MenuRadioGroupElement);
const radioItem = createElement(MenuRadioItemElement);
const indicator = createElement(MenuItemIndicatorElement);
const separator = createElement(MenuSeparatorElement);
const rootView = createElement(MenuViewElement);
const trigger = createElement(MenuItemElement);
const child = createElement(MenuElement);
const back = createElement(MenuBackElement);
const childItem = createElement(MenuItemElement);
root.open = true;
root.side = 'top';
root.align = 'end';
label.textContent = 'Playback';
group.label = 'Playback';
item.textContent = 'Copy link';
checkboxItem.textContent = 'Autoplay';
radioGroup.label = 'Quality';
radioGroup.value = 'auto';
radioItem.value = 'auto';
radioItem.textContent = 'Auto';
indicator.checked = true;
trigger.id = 'child-trigger';
trigger.commandfor = 'child-menu';
trigger.textContent = 'Quality';
child.id = 'child-menu';
back.textContent = 'Back';
childItem.textContent = 'Auto';
radioItem.append(indicator);
radioGroup.append(radioItem);
group.append(item, checkboxItem, radioGroup);
rootView.append(trigger);
child.append(back, childItem);
root.append(label, group, separator, rootView, child);
document.body.append(root);
await root.updateComplete;
await label.updateComplete;
await group.updateComplete;
await item.updateComplete;
await checkboxItem.updateComplete;
await radioGroup.updateComplete;
await radioItem.updateComplete;
await indicator.updateComplete;
await separator.updateComplete;
await rootView.updateComplete;
await trigger.updateComplete;
await child.updateComplete;
await back.updateComplete;
await childItem.updateComplete;
expect(root.hasAttribute('data-open')).toBe(true);
expect(root.getAttribute('data-side')).toBe('top');
expect(root.getAttribute('data-align')).toBe('end');
for (const element of [label, group, separator, item, checkboxItem, radioGroup, radioItem, indicator, trigger]) {
expectNoMenuStateAttrs(element);
}
expect(item.hasAttribute('data-item')).toBe(true);
expect(checkboxItem.hasAttribute('data-item')).toBe(true);
expect(radioItem.hasAttribute('data-item')).toBe(true);
expect(trigger.hasAttribute('data-item')).toBe(true);
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await root.updateComplete;
await child.updateComplete;
await back.updateComplete;
await waitForAssertion(() => {
expect(child.getAttribute('data-menu-view-state')).toBe('active');
});
expect(child.hasAttribute('data-submenu')).toBe(true);
expect(child.hasAttribute('data-menu-view')).toBe(true);
expect(child.hasAttribute('data-open')).toBe(true);
expect(child.hasAttribute('data-side')).toBe(false);
expect(child.hasAttribute('data-align')).toBe(false);
expectNoMenuStateAttrs(back);
expectNoMenuStateAttrs(childItem);
});
it('marks root and nested menu views with generic view attributes', async () => {
const root = createElement(MenuElement);
const rootView = createElement(MenuViewElement);
-1
View File
@@ -33,7 +33,6 @@ export const MenuBack = forwardRef<HTMLButtonElement, MenuBackProps>(function Me
{ render, className, style },
{
state: parentMenu?.state ?? ({} as MenuState),
stateAttrMap: parentMenu?.stateAttrMap,
ref: forwardedRef,
props: [
{
@@ -21,7 +21,7 @@ export const MenuCheckboxItem = forwardRef<HTMLDivElement, MenuCheckboxItemProps
{ render, className, style, checked, onCheckedChange, disabled, onClick, ...elementProps },
forwardedRef
) {
const { menu, state, stateAttrMap } = useMenuContext();
const { menu, state } = useMenuContext();
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -50,7 +50,6 @@ export const MenuCheckboxItem = forwardRef<HTMLDivElement, MenuCheckboxItemProps
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef, elementRef],
props: [
{
@@ -332,7 +332,6 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
{ render, className, style },
{
state,
stateAttrMap,
ref: menuViewComposedRef,
props: [
{
+1 -2
View File
@@ -17,14 +17,13 @@ export const MenuGroup = forwardRef<HTMLDivElement, MenuGroupProps>(function Men
{ render, className, style, label, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap } = useMenuContext();
const { state } = useMenuContext();
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef],
props: [{ role: 'group' as const, 'aria-label': label }, elementProps],
}
@@ -19,7 +19,7 @@ export const MenuItemIndicator = forwardRef<HTMLSpanElement, MenuItemIndicatorPr
{ render, className, style, checked, forceMount = false, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap } = useMenuContext();
const { state } = useMenuContext();
if (!checked && !forceMount) return null;
@@ -28,7 +28,6 @@ export const MenuItemIndicator = forwardRef<HTMLSpanElement, MenuItemIndicatorPr
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef],
props: [{ 'aria-hidden': true }, elementProps],
}
+1 -2
View File
@@ -20,7 +20,7 @@ export const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(function MenuI
{ render, className, style, onSelect, disabled, onClick, ...elementProps },
forwardedRef
) {
const { menu, state, stateAttrMap } = useMenuContext();
const { menu, state } = useMenuContext();
const subMenuCtx = useSubMenuContext();
const parentMenu = subMenuCtx?.parentMenu.menu ?? null;
const elementRef = useRef<HTMLDivElement>(null);
@@ -52,7 +52,6 @@ export const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(function MenuI
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef, elementRef],
props: [
{
+1 -2
View File
@@ -14,14 +14,13 @@ export const MenuLabel = forwardRef<HTMLDivElement, MenuLabelProps>(function Men
{ render, className, style, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap } = useMenuContext();
const { state } = useMenuContext();
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef],
props: [elementProps],
}
@@ -21,7 +21,7 @@ export const MenuRadioGroup = forwardRef<HTMLDivElement, MenuRadioGroupProps>(fu
{ render, className, style, value, onValueChange, label, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap } = useMenuContext();
const { state } = useMenuContext();
return (
<MenuRadioGroupContextProvider value={{ value, onValueChange }}>
@@ -30,7 +30,6 @@ export const MenuRadioGroup = forwardRef<HTMLDivElement, MenuRadioGroupProps>(fu
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef],
props: [{ role: 'group' as const, 'aria-label': label }, elementProps],
}
@@ -20,7 +20,7 @@ export const MenuRadioItem = forwardRef<HTMLDivElement, MenuRadioItemProps>(func
{ render, className, style, value, disabled, onClick, ...elementProps },
forwardedRef
) {
const { menu, state, stateAttrMap } = useMenuContext();
const { menu, state } = useMenuContext();
const { value: groupValue, onValueChange } = useMenuRadioGroupContext();
const subMenuCtx = useSubMenuContext();
const parentMenu = subMenuCtx?.parentMenu.menu ?? null;
@@ -54,7 +54,6 @@ export const MenuRadioItem = forwardRef<HTMLDivElement, MenuRadioItemProps>(func
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef, elementRef],
props: [
{
@@ -14,14 +14,13 @@ export const MenuSeparator = forwardRef<HTMLDivElement, MenuSeparatorProps>(func
{ render, className, style, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap } = useMenuContext();
const { state } = useMenuContext();
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef],
props: [{ role: 'separator' as const }, elementProps],
}
+1 -3
View File
@@ -49,7 +49,7 @@ export const MenuTrigger = forwardRef<HTMLButtonElement | HTMLDivElement, MenuTr
{ render, className, style, disabled, onClick, onKeyDown, ...elementProps },
forwardedRef
) {
const { core, menu, state, stateAttrMap, anchorName, contentId } = useMenuContext();
const { core, menu, state, anchorName, contentId } = useMenuContext();
const subMenuCtx = useSubMenuContext();
const isSubMenuTrigger = subMenuCtx !== null;
@@ -119,7 +119,6 @@ export const MenuTrigger = forwardRef<HTMLButtonElement | HTMLDivElement, MenuTr
{ render, className, style },
{
state: parentState,
stateAttrMap,
ref: [forwardedRef as React.Ref<HTMLDivElement>, elementRef as React.Ref<HTMLDivElement>],
props: [
{
@@ -145,7 +144,6 @@ export const MenuTrigger = forwardRef<HTMLButtonElement | HTMLDivElement, MenuTr
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef as React.Ref<HTMLButtonElement>, triggerRef],
props: [
{ type: 'button' as const, ...core.getTriggerAttrs(state, contentId) },
@@ -6,8 +6,14 @@ import { ControlsContextProvider } from '../../controls/context';
import { MenuBack } from '../menu-back';
import { MenuCheckboxItem } from '../menu-checkbox-item';
import { MenuContent } from '../menu-content';
import { MenuGroup } from '../menu-group';
import { MenuItem } from '../menu-item';
import { MenuItemIndicator } from '../menu-item-indicator';
import { MenuLabel } from '../menu-label';
import { MenuRadioGroup } from '../menu-radio-group';
import { MenuRadioItem } from '../menu-radio-item';
import { MenuRoot } from '../menu-root';
import { MenuSeparator } from '../menu-separator';
import { MenuTrigger } from '../menu-trigger';
import { MenuView } from '../menu-view';
@@ -263,7 +269,93 @@ function FocusOutFixture({ onRootOpenChange }: { onRootOpenChange: NonNullable<M
);
}
const menuStateAttrs = ['data-open', 'data-side', 'data-align', 'data-starting-style', 'data-ending-style'] as const;
function expectNoMenuStateAttrs(element: HTMLElement): void {
for (const attr of menuStateAttrs) {
expect(element.hasAttribute(attr), `${element.dataset.testid ?? element.tagName} should not have ${attr}`).toBe(
false
);
}
}
describe('MenuContent', () => {
it('scopes menu state data attributes to content elements', async () => {
render(
<MenuRoot defaultOpen side="top" align="end">
<MenuTrigger data-testid="trigger">Settings</MenuTrigger>
<MenuContent data-testid="root-content">
<MenuLabel data-testid="label">Playback</MenuLabel>
<MenuGroup data-testid="group" label="Playback">
<MenuItem data-testid="item">Copy link</MenuItem>
<MenuCheckboxItem data-testid="checkbox-item" checked={false} onCheckedChange={vi.fn()}>
Autoplay
</MenuCheckboxItem>
<MenuRadioGroup data-testid="radio-group" value="auto" onValueChange={vi.fn()} label="Quality">
<MenuRadioItem data-testid="radio-item" value="auto">
Auto
<MenuItemIndicator data-testid="indicator" checked>
Checked
</MenuItemIndicator>
</MenuRadioItem>
</MenuRadioGroup>
</MenuGroup>
<MenuSeparator data-testid="separator" />
<MenuView data-testid="root-view">
<MenuRoot>
<MenuTrigger data-testid="submenu-trigger">Quality</MenuTrigger>
<MenuContent data-testid="submenu-content">
<MenuBack data-testid="back">Back</MenuBack>
<MenuItem data-testid="submenu-item">Auto</MenuItem>
</MenuContent>
</MenuRoot>
</MenuView>
</MenuContent>
</MenuRoot>
);
const rootContent = screen.getByTestId('root-content');
expect(rootContent.hasAttribute('data-open')).toBe(true);
expect(rootContent.getAttribute('data-side')).toBe('top');
expect(rootContent.getAttribute('data-align')).toBe('end');
for (const testId of [
'trigger',
'label',
'group',
'separator',
'item',
'checkbox-item',
'radio-group',
'radio-item',
'indicator',
'submenu-trigger',
]) {
expectNoMenuStateAttrs(screen.getByTestId(testId));
}
expect(screen.getByTestId('item').hasAttribute('data-item')).toBe(true);
expect(screen.getByTestId('radio-item').hasAttribute('data-item')).toBe(true);
expect(screen.getByTestId('checkbox-item').hasAttribute('data-item')).toBe(true);
expect(screen.getByTestId('submenu-trigger').hasAttribute('data-item')).toBe(true);
fireEvent.click(screen.getByTestId('submenu-trigger'));
await waitFor(() => {
expect(screen.getByTestId('submenu-content').getAttribute('data-menu-view-state')).toBe('active');
});
const submenuContent = screen.getByTestId('submenu-content');
expect(submenuContent.hasAttribute('data-submenu')).toBe(true);
expect(submenuContent.hasAttribute('data-menu-view')).toBe(true);
expect(submenuContent.hasAttribute('data-open')).toBe(true);
expect(submenuContent.hasAttribute('data-side')).toBe(false);
expect(submenuContent.hasAttribute('data-align')).toBe(false);
expectNoMenuStateAttrs(screen.getByTestId('back'));
});
it('marks the root view inactive while a submenu view is active', async () => {
render(<SubmenuFixture />);
@@ -419,21 +419,14 @@ describe('renderElement', () => {
});
describe('state data attributes', () => {
it('generates data-* attributes from state boolean true values', () => {
it('does not generate data-* attributes without an explicit mapping', () => {
const { container } = render(<TestComponent active />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-active')).toBe('');
});
it('does not generate data-* attributes from state boolean false values', () => {
const { container } = render(<TestComponent active={false} />);
const element = container.firstElementChild;
expect(element?.hasAttribute('data-active')).toBe(false);
});
it('generates data-* attributes from state with multiple properties', () => {
it('generates mapped data-* attributes from state with multiple properties', () => {
interface MultiState {
paused: boolean;
ended: boolean;
@@ -454,8 +447,17 @@ describe('renderElement', () => {
...elementProps
} = props;
const state: MultiState = { paused, ended, volume };
const stateAttrMap = {
paused: 'data-paused',
ended: 'data-ended',
volume: 'data-volume',
} as const;
return renderElement('div', { className, style, render: renderProp }, { state, ref, props: [elementProps] });
return renderElement(
'div',
{ className, style, render: renderProp },
{ state, ref, props: [elementProps], stateAttrMap }
);
});
const { container } = render(<MultiStateComponent paused ended={false} volume={0.5} />);
@@ -466,7 +468,7 @@ describe('renderElement', () => {
expect(element?.getAttribute('data-volume')).toBe('0.5');
});
it('converts state keys to lowercase for data attributes', () => {
it('does not expose unmapped state keys as data attributes', () => {
interface CamelCaseState {
isPaused: boolean;
}
@@ -484,7 +486,7 @@ describe('renderElement', () => {
const { container } = render(<CamelCaseComponent isPaused />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-ispaused')).toBe('');
expect(element?.hasAttribute('data-ispaused')).toBe(false);
});
it('supports explicit state attribute mapping', () => {
@@ -534,6 +536,7 @@ describe('renderElement', () => {
{
state,
ref,
stateAttrMap: { active: 'data-active' },
// Explicit prop comes after state in merge order, so it wins
props: [elementProps],
}
+3 -2
View File
@@ -85,8 +85,9 @@ export function renderElement<
const className = resolveClassName(classNameProp, state);
const style = resolveStyle(styleProp, state);
// Generate data attributes from state
const stateDataAttrs = getStateDataAttrs(state, stateAttrMap);
// Generate data attributes only when a component explicitly opts in with a
// mapping. State is still passed to render/className/style callbacks.
const stateDataAttrs = stateAttrMap ? getStateDataAttrs(state, stateAttrMap) : {};
// Merge: state data attrs first, then props (so props can override)
const propsArray = Array.isArray(props) ? props : props ? [props] : [];