diff --git a/packages/ui-library/src/components/index.ts b/packages/ui-library/src/components/index.ts index 76e94ddd..0f28050e 100644 --- a/packages/ui-library/src/components/index.ts +++ b/packages/ui-library/src/components/index.ts @@ -41,7 +41,7 @@ import RuiLogo, { type Props as LogoProps } from '@/components/logos/RuiLogo.vue import RuiBadge, { type Props as BadgeProps } from '@/components/overlays/badge/RuiBadge.vue'; import RuiBottomSheet, { type BottomSheetProps } from '@/components/overlays/bottom-sheet/RuiBottomSheet.vue'; import RuiDialog, { type DialogProps, type RuiDialogClassNames } from '@/components/overlays/dialog/RuiDialog.vue'; -import RuiMenu, { type MenuProps, type RuiMenuClassNames } from '@/components/overlays/menu/RuiMenu.vue'; +import RuiMenu, { type MenuProps, type RuiMenuClassNames, type RuiMenuRole } from '@/components/overlays/menu/RuiMenu.vue'; import RuiNavigationDrawer, { type NavigationDrawerProps, type RuiNavigationDrawerClassNames } from '@/components/overlays/navigation-drawer/RuiNavigationDrawer.vue'; import RuiNotification, { type NotificationProps } from '@/components/overlays/notification/RuiNotification.vue'; import RuiTooltip, { type RuiTooltipClassNames, type Props as TooltipProps } from '@/components/overlays/tooltip/RuiTooltip.vue'; @@ -105,6 +105,7 @@ export type { RuiDateTimePickerProps, RuiDialogClassNames, RuiMenuClassNames, + RuiMenuRole, RuiMenuSelectClassNames, RuiNavigationDrawerClassNames, RuiSliderClassNames, diff --git a/packages/ui-library/src/components/overlays/menu/RuiMenu.spec.ts b/packages/ui-library/src/components/overlays/menu/RuiMenu.spec.ts index 06df0e32..5f32b82e 100644 --- a/packages/ui-library/src/components/overlays/menu/RuiMenu.spec.ts +++ b/packages/ui-library/src/components/overlays/menu/RuiMenu.spec.ts @@ -290,6 +290,42 @@ describe('components/overlays/menu/RuiMenu.vue', () => { expect(activatorWrapper.attributes('aria-expanded')).toBe('false'); }); + describe('role', () => { + it('should default the popover to role="menu"', async () => { + wrapper = createWrapper(); + + await wrapper.find('#trigger').trigger('click'); + await vi.runAllTimersAsync(); + + expect(queryByRole('menu')).toBeTruthy(); + }); + + it('should apply a custom role to the popover', async () => { + wrapper = createWrapper({ + props: { + role: 'listbox', + }, + }); + + await wrapper.find('#trigger').trigger('click'); + await vi.runAllTimersAsync(); + + expect(queryByRole('listbox')).toBeTruthy(); + expect(queryByRole('menu')).toBeFalsy(); + }); + + it('should match aria-haspopup on the activator wrapper to the role', () => { + wrapper = createWrapper({ + props: { + role: 'listbox', + }, + }); + + const activatorWrapper = wrapper.find('[data-menu-disabled]'); + expect(activatorWrapper.attributes('aria-haspopup')).toBe('listbox'); + }); + }); + it('should not close when persistent and clicking outside', async () => { wrapper = createWrapper({ props: { diff --git a/packages/ui-library/src/components/overlays/menu/RuiMenu.stories.ts b/packages/ui-library/src/components/overlays/menu/RuiMenu.stories.ts index d764a796..522100ca 100644 --- a/packages/ui-library/src/components/overlays/menu/RuiMenu.stories.ts +++ b/packages/ui-library/src/components/overlays/menu/RuiMenu.stories.ts @@ -48,6 +48,10 @@ const meta = preview.meta({ openOnHover: { control: 'boolean' }, persistOnActivatorClick: { control: 'boolean' }, popper: { control: 'object' }, + role: { + control: 'select', + options: ['menu', 'listbox', 'tree', 'grid', 'dialog'], + }, }, component: RuiMenu, parameters: { diff --git a/packages/ui-library/src/components/overlays/menu/RuiMenu.vue b/packages/ui-library/src/components/overlays/menu/RuiMenu.vue index c22bf5b8..f1a72592 100644 --- a/packages/ui-library/src/components/overlays/menu/RuiMenu.vue +++ b/packages/ui-library/src/components/overlays/menu/RuiMenu.vue @@ -18,6 +18,12 @@ export interface RuiMenuClassNames { content?: VueClassValue; } +/** + * Roles a popover container may take. These are the values `aria-haspopup` + * accepts, which keeps the activator and the popover in agreement. + */ +export type RuiMenuRole = 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'; + export interface MenuProps { openOnHover?: boolean; fullWidth?: boolean; @@ -47,6 +53,13 @@ export interface MenuProps { * against this element. */ anchorEl?: HTMLElement; + /** + * Role of the teleported popover. Defaults to `menu`, which is correct when + * the content is made of `menuitem`s. Set it to `listbox` (or `tree`, `grid`, + * `dialog`) when the content follows a different ARIA model, e.g. a selection + * list opened by a `combobox`. The activator's `aria-haspopup` follows it. + */ + role?: RuiMenuRole; } defineOptions({ @@ -76,6 +89,7 @@ const { persistent = false, disableAutoFocus = false, anchorEl, + role = 'menu', } = defineProps(); defineSlots<{ @@ -146,6 +160,10 @@ const ui = computed>(() => menuStyles({ dense, })); +// `aria-haspopup="true"` is equivalent to `"menu"`; keep emitting `true` for +// the default so nothing changes for existing callers. +const ariaHasPopup = computed(() => role === 'menu' ? 'true' : role); + // NOTE: both computed functions must return the *same* object shape from // every branch — otherwise vue-tsc infers the slot's `attrs` type as a // discriminated union where each key is either "all defined" or "all @@ -262,7 +280,7 @@ onClickOutside(menu, () => { ref="activator" :class="ui.wrapper({ class: cn(classNames?.wrapper) ?? cn(wrapperClass as VueClassValue) })" :data-menu-disabled="disabled" - aria-haspopup="true" + :aria-haspopup="ariaHasPopup" :aria-expanded="open" > { v-if="visible" ref="menu" :class="ui.popover({ class: cn(classNames?.menu) ?? cn(menuClass as VueClassValue) })" - role="menu" + :role="role" :data-placement="currentPlacement" @click="closeOnContentClick ? onLeave() : undefined" @keydown.esc.stop="onLeave()"