mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): update menu group labels (#1643)
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user