Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/ui-library/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,6 +105,7 @@ export type {
RuiDateTimePickerProps,
RuiDialogClassNames,
RuiMenuClassNames,
RuiMenuRole,
RuiMenuSelectClassNames,
RuiNavigationDrawerClassNames,
RuiSliderClassNames,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
22 changes: 20 additions & 2 deletions packages/ui-library/src/components/overlays/menu/RuiMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -76,6 +89,7 @@ const {
persistent = false,
disableAutoFocus = false,
anchorEl,
role = 'menu',
} = defineProps<MenuProps>();

defineSlots<{
Expand Down Expand Up @@ -146,6 +160,10 @@ const ui = computed<ReturnType<typeof menuStyles>>(() => menuStyles({
dense,
}));

// `aria-haspopup="true"` is equivalent to `"menu"`; keep emitting `true` for
// the default so nothing changes for existing callers.
const ariaHasPopup = computed<RuiMenuRole | 'true'>(() => 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
Expand Down Expand Up @@ -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"
>
<slot
Expand All @@ -278,7 +296,7 @@ onClickOutside(menu, () => {
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()"
Expand Down
Loading