feat(packages): update menu group labels (#1643)

This commit is contained in:
Sam Potts
2026-06-02 20:26:19 +10:00
committed by GitHub
parent 2f5e23e100
commit c5f75166fd
31 changed files with 449 additions and 128 deletions
+16
View File
@@ -60,6 +60,22 @@ export function useSubMenuContext(): SubMenuContextValue | null {
return useContext(SubMenuContext);
}
// ---------------------------------------------------------------------------
// Group context — shared by group-like parts and MenuGroupLabel
// ---------------------------------------------------------------------------
export interface MenuGroupContextValue {
registerLabel: (id: string) => () => void;
}
const MenuGroupContext = createContext<MenuGroupContextValue | null>(null);
export const MenuGroupContextProvider = MenuGroupContext.Provider;
export function useMenuGroupContext(): MenuGroupContextValue | null {
return useContext(MenuGroupContext);
}
// ---------------------------------------------------------------------------
// Radio group context — shared between MenuRadioGroup and MenuRadioItem
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -5,12 +5,12 @@ export {
} from './menu-checkbox-item';
export { MenuContent as Content, type MenuContentProps as ContentProps } from './menu-content';
export { MenuGroup as Group, type MenuGroupProps as GroupProps } from './menu-group';
export { MenuGroupLabel as GroupLabel, type MenuGroupLabelProps as GroupLabelProps } from './menu-group-label';
export { MenuItem as Item, type MenuItemProps as ItemProps } from './menu-item';
export {
MenuItemIndicator as ItemIndicator,
type MenuItemIndicatorProps as ItemIndicatorProps,
} from './menu-item-indicator';
export { MenuLabel as Label, type MenuLabelProps as LabelProps } from './menu-label';
export { MenuRadioGroup as RadioGroup, type MenuRadioGroupProps as RadioGroupProps } from './menu-radio-group';
export { MenuRadioItem as RadioItem, type MenuRadioItemProps as RadioItemProps } from './menu-radio-item';
export { MenuRoot as Root, type MenuRootProps as RootProps } from './menu-root';
@@ -0,0 +1,41 @@
'use client';
import type { MenuState } from '@videojs/core';
import { forwardRef, useLayoutEffect } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useSafeId } from '../../utils/use-safe-id';
import { useMenuContext, useMenuGroupContext } from './context';
export interface MenuGroupLabelProps extends UIComponentProps<'div', MenuState> {}
/** Non-interactive label for a group of items. Renders a `<div>`. */
export const MenuGroupLabel = forwardRef<HTMLDivElement, MenuGroupLabelProps>(function MenuGroupLabel(
{ render, className, style, id: idProp, ...elementProps },
forwardedRef
) {
const { state } = useMenuContext();
const group = useMenuGroupContext();
const generatedId = useSafeId('menu-group-label');
const id = idProp ?? generatedId;
useLayoutEffect(() => {
return group?.registerLabel(id);
}, [group, id]);
return renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [{ id }, elementProps],
}
);
});
export namespace MenuGroupLabel {
export type Props = MenuGroupLabelProps;
export type State = MenuState;
}
+17 -13
View File
@@ -6,27 +6,31 @@ import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useMenuContext } from './context';
import { getMenuGroupProps, MenuGroupProvider } from './use-menu-group';
export interface MenuGroupProps extends UIComponentProps<'div', MenuState> {
/** Accessible label for the group. */
label?: string;
}
export interface MenuGroupProps extends UIComponentProps<'div', MenuState> {}
/** Groups related menu items. Renders a `<div>` with `role="group"`. */
export const MenuGroup = forwardRef<HTMLDivElement, MenuGroupProps>(function MenuGroup(
{ render, className, style, label, ...elementProps },
{ render, className, style, ...elementProps },
forwardedRef
) {
const { state } = useMenuContext();
return renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [{ role: 'group' as const, 'aria-label': label }, elementProps],
}
return (
<MenuGroupProvider>
{(labelId) =>
renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [getMenuGroupProps(labelId, elementProps), elementProps],
}
)
}
</MenuGroupProvider>
);
});
-33
View File
@@ -1,33 +0,0 @@
'use client';
import type { MenuState } from '@videojs/core';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useMenuContext } from './context';
export interface MenuLabelProps extends UIComponentProps<'div', MenuState> {}
/** Non-interactive label for a group of items. Renders a `<div>`. */
export const MenuLabel = forwardRef<HTMLDivElement, MenuLabelProps>(function MenuLabel(
{ render, className, style, ...elementProps },
forwardedRef
) {
const { state } = useMenuContext();
return renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [elementProps],
}
);
});
export namespace MenuLabel {
export type Props = MenuLabelProps;
export type State = MenuState;
}
+16 -13
View File
@@ -6,35 +6,38 @@ import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { MenuRadioGroupContextProvider, useMenuContext } from './context';
import { getMenuGroupProps, MenuGroupProvider } from './use-menu-group';
export interface MenuRadioGroupProps extends UIComponentProps<'div', MenuState> {
/** The currently selected value. */
value: string;
/** Called when the user selects a radio item. */
onValueChange: (value: string) => void;
/** Accessible label for the group. */
label?: string;
}
/** A group of mutually exclusive radio items. Renders a `<div>` with `role="group"`. */
export const MenuRadioGroup = forwardRef<HTMLDivElement, MenuRadioGroupProps>(function MenuRadioGroup(
{ render, className, style, value, onValueChange, label, ...elementProps },
{ render, className, style, value, onValueChange, ...elementProps },
forwardedRef
) {
const { state } = useMenuContext();
return (
<MenuRadioGroupContextProvider value={{ value, onValueChange }}>
{renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [{ role: 'group' as const, 'aria-label': label }, elementProps],
}
<MenuGroupProvider>
{(labelId) => (
<MenuRadioGroupContextProvider value={{ value, onValueChange }}>
{renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [getMenuGroupProps(labelId, elementProps), elementProps],
}
)}
</MenuRadioGroupContextProvider>
)}
</MenuRadioGroupContextProvider>
</MenuGroupProvider>
);
});
+83 -4
View File
@@ -7,9 +7,9 @@ import { MenuBack } from '../menu-back';
import { MenuCheckboxItem } from '../menu-checkbox-item';
import { MenuContent } from '../menu-content';
import { MenuGroup } from '../menu-group';
import { MenuGroupLabel } from '../menu-group-label';
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';
@@ -253,6 +253,56 @@ function CheckboxFixture({
);
}
function GroupLabelFixture() {
return (
<MenuRoot defaultOpen>
<MenuTrigger>Settings</MenuTrigger>
<MenuContent>
<MenuGroup data-testid="group">
<MenuGroupLabel data-testid="label">Playback</MenuGroupLabel>
<MenuItem>Copy link</MenuItem>
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}
function RadioGroupLabelFixture() {
return (
<MenuRoot defaultOpen>
<MenuTrigger>Settings</MenuTrigger>
<MenuContent>
<MenuRadioGroup data-testid="group" value="auto" onValueChange={vi.fn()}>
<MenuGroupLabel data-testid="label">Quality</MenuGroupLabel>
<MenuRadioItem value="auto">Auto</MenuRadioItem>
</MenuRadioGroup>
</MenuContent>
</MenuRoot>
);
}
function ExplicitGroupLabelFixture() {
return (
<MenuRoot defaultOpen>
<MenuTrigger>Settings</MenuTrigger>
<MenuContent>
<MenuGroup data-testid="aria-label-group" aria-label="Playback">
<MenuGroupLabel data-testid="aria-label-label">Ignored</MenuGroupLabel>
</MenuGroup>
<MenuRadioGroup
data-testid="aria-labelledby-group"
aria-labelledby="external-label"
value="auto"
onValueChange={vi.fn()}
>
<MenuGroupLabel data-testid="aria-labelledby-label">Ignored</MenuGroupLabel>
<MenuRadioItem value="auto">Auto</MenuRadioItem>
</MenuRadioGroup>
</MenuContent>
</MenuRoot>
);
}
function FocusOutFixture({ onRootOpenChange }: { onRootOpenChange: NonNullable<MenuRoot.Props['onOpenChange']> }) {
return (
<>
@@ -285,13 +335,13 @@ describe('MenuContent', () => {
<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">
<MenuGroup data-testid="group">
<MenuGroupLabel data-testid="label">Playback</MenuGroupLabel>
<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">
<MenuRadioGroup data-testid="radio-group" aria-label="Quality" value="auto" onValueChange={vi.fn()}>
<MenuRadioItem data-testid="radio-item" value="auto">
Auto
<MenuItemIndicator data-testid="indicator" checked>
@@ -653,6 +703,35 @@ describe('MenuContent', () => {
});
});
it('wires GroupLabel to Group with aria-labelledby', async () => {
render(<GroupLabelFixture />);
await waitFor(() => {
expect(screen.getByTestId('group').getAttribute('aria-labelledby')).toBe(screen.getByTestId('label').id);
});
});
it('wires GroupLabel to RadioGroup with aria-labelledby', async () => {
render(<RadioGroupLabelFixture />);
await waitFor(() => {
expect(screen.getByTestId('group').getAttribute('aria-labelledby')).toBe(screen.getByTestId('label').id);
});
});
it('lets explicit group labels override generated aria-labelledby', async () => {
render(<ExplicitGroupLabelFixture />);
await waitFor(() => {
expect(screen.getByTestId('aria-label-label').id).not.toBe('');
expect(screen.getByTestId('aria-labelledby-label').id).not.toBe('');
});
expect(screen.getByTestId('aria-label-group').getAttribute('aria-label')).toBe('Playback');
expect(screen.getByTestId('aria-label-group').hasAttribute('aria-labelledby')).toBe(false);
expect(screen.getByTestId('aria-labelledby-group').getAttribute('aria-labelledby')).toBe('external-label');
});
it('keeps the menu open when a checkbox item is toggled', () => {
const onCheckedChange = vi.fn();
const onRootOpenChange = vi.fn();
@@ -0,0 +1,44 @@
'use client';
import { type ReactElement, useCallback, useMemo, useState } from 'react';
import { MenuGroupContextProvider } from './context';
interface MenuGroupProviderProps {
children: (labelId: string | undefined) => ReactElement | null;
}
interface MenuGroupElementProps {
'aria-label'?: unknown;
'aria-labelledby'?: unknown;
}
function hasExplicitLabel(elementProps: MenuGroupElementProps): boolean {
return elementProps['aria-label'] !== undefined || elementProps['aria-labelledby'] !== undefined;
}
export function getMenuGroupProps(
labelId: string | undefined,
elementProps: MenuGroupElementProps
): { role: 'group'; 'aria-labelledby'?: string | undefined } {
return {
role: 'group',
'aria-labelledby': hasExplicitLabel(elementProps) ? undefined : labelId,
};
}
export function MenuGroupProvider({ children }: MenuGroupProviderProps): ReactElement | null {
const [labelId, setLabelId] = useState<string>();
const registerLabel = useCallback((id: string) => {
setLabelId(id);
return () => {
setLabelId((current) => (current === id ? undefined : current));
};
}, []);
const value = useMemo(() => ({ registerLabel }), [registerLabel]);
return <MenuGroupContextProvider value={value}>{children(labelId)}</MenuGroupContextProvider>;
}
@@ -38,7 +38,7 @@ function PlaybackRateMenuItems(): ReactNode {
const { options, setValue, value } = usePlaybackRateMenu();
return (
<Menu.RadioGroup value={value} onValueChange={setValue} label="Playback rate">
<Menu.RadioGroup value={value} onValueChange={setValue} aria-label="Playback rate">
{options.map((option) => (
<Menu.RadioItem key={option.value} value={option.value} disabled={option.disabled}>
{option.label}