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);