GrDropdownMenu
A ready-made actions menu for a button, link or context invocation.
Overview
When to take it
- actions over an object — the ”⋯” of a table row, of a card, of a file: the typical menu is assembled with the
itemsprop; - there are many items and they are heterogeneous — groups, headings, separators and columns are already there;
- some of the items are unavailable — a
disableditem stays in the keyboard walk and is announced rather than disappearing; - markup is needed without assembling it by hand — the layer, the roles and the keyboard arrive from
GrDropdownas a whole.
When to take something else
| Need | Take |
|---|---|
| The items are non-standard, you write the markup | GrDropdown |
| There is a form or a filter inside | GrPopover |
| There are two or three actions and they fit in a row | GrButtonGroup |
| Navigation across the sections of the application | GrSidebar |
| A search across the commands of the whole application | GrCommandPalette |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrDropdownMenu placement="bottom-start" :items="items" />Examples 4 each with a live demo and its code
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
placement | Placement | undefined | "bottom-end" | The placement of the panel relative to the trigger; the flip when there is not enough room still applies. |
offset | number | undefined | 8 | The gap between the trigger and the panel, in px. |
disabled | boolean | undefined | false | The menu is opened by nothing; the trigger stays focusable. |
openv-model | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`) — passed into `GrDropdown`. Without the prop the menu runs itself (uncontrolled). |
items | GrDropdownMenuEntry[] | undefined | undefined | A declarative menu: items, groups and separators as an array. The default slot is stronger — it is for a menu that is not assembled from a model. |
trigger | GrDropdownTrigger | undefined | "click" | What opens the panel. The click and the keyboard work in any mode. |
teleportTo | string | HTMLElement | undefined | undefined | A pointed override of the mounting point; by default — the shared portal. |
contentClass | string | undefined | "" | Extra classes for the content container. |
listClass | string | undefined | "" | Extra classes for the wrapper of the list. |
dividers | boolean | undefined | false | Separators between the items. |
width | GrDropdownWidth | undefined | "12rem" | The width of the panel: a number is pixels, a string is a CSS length, `auto` is by the content. |
openDelay | number | undefined | 120 | The delay before opening on hover, in ms. |
closeDelay | number | undefined | 160 | The delay before closing after the cursor leaves, in ms. |
closeOnContentClick | boolean | undefined | true | Close on a click inside the content. |
borderTop | boolean | undefined | false | The top border of the container of the list. |
borderBottom | boolean | undefined | false | The bottom border of the container of the list. |
Slots
| Slot | Type | Description |
|---|---|---|
default | { close: () => void; } | The items of the menu. The slot props are passed from `GrDropdown` as they are. |
trigger |
| The trigger of the panel. `triggerProps` are obliged to land on the interactive element itself rather than on a wrapper around it: `aria-expanded` and `aria-controls` are read from the node that gets the focus. |
Events
| Event | Signature | Description |
|---|---|---|
update:open | (event: "update:open", value: boolean): void | — |
select | (event: "select", item: GrDropdownMenuAction): void | — |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrDropdownMenuAction, GrDropdownMenuSeparator, GrDropdownMenuSection } from '@feugene/granularity/components/GrDropdownMenu'Filedist/types/components/GrDropdownMenu/config.d.ts
const grDropdownMenuConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDropdownMenu">;Filedist/types/components/GrDropdownMenu/safelist.d.ts
const grDropdownMenuSafelist: string[];Filedist/types/components/GrDropdownMenu/menuModel.d.ts
interface GrDropdownMenuAction {
key: string;
label: string;
icon?: Component;
shortcut?: string;
disabled?: boolean;
variant?: GrDropdownMenuItemVariant;
role?: GrDropdownMenuItemRole;
checked?: boolean;
href?: string;
target?: string;
rel?: string;
external?: boolean;
as?: string | Component;
align?: GrDropdownMenuItemAlign;
children?: GrDropdownMenuEntry[];
}interface GrDropdownMenuSeparator {
type: 'divider';
inset?: boolean;
}interface GrDropdownMenuSection {
type: 'group';
title?: string;
titleAlign?: GrDropdownMenuHeaderAlign;
dividers?: boolean;
uppercase?: boolean;
items: GrDropdownMenuAction[];
}type GrDropdownMenuEntry = GrDropdownMenuAction | GrDropdownMenuSeparator | GrDropdownMenuSection;Filedist/types/components/GrDropdownMenu/GrDropdownMenu.vue.d.ts
interface GrDropdownMenuProps {
placement?: UseFloatingPlacement;
offset?: number;
width?: GrDropdownWidth;
trigger?: GrDropdownTrigger;
openDelay?: number;
closeDelay?: number;
disabled?: boolean;
teleportTo?: string | HTMLElement;
closeOnContentClick?: boolean;
contentClass?: string;
items?: GrDropdownMenuEntry[];
dividers?: boolean;
borderTop?: boolean;
borderBottom?: boolean;
listClass?: string;
open?: boolean;
}interface GrDropdownMenuEmits {
(e: 'select', item: GrDropdownMenuAction): void;
(e: 'update:open', value: boolean): void;
}Filedist/types/components/GrDropdownMenu/grDropdownMenuStyles.d.ts
type GrDropdownMenuItemAlign = 'left' | 'center' | 'right';type GrDropdownMenuItemVariant = 'default' | 'danger';type GrDropdownMenuHeaderAlign = GrDropdownMenuItemAlign;type GrDropdownMenuColumnAlign = GrDropdownMenuItemAlign;Filedist/types/components/GrDropdownMenu/GrDropdownMenuColumn.vue.d.ts
interface GrDropdownMenuColumnProps {
align?: GrDropdownMenuColumnAlign;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuColumns.vue.d.ts
interface GrDropdownMenuColumnsProps {
cols?: GrDropdownMenuColumnsCount;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuDivider.vue.d.ts
interface GrDropdownMenuDividerProps {
inset?: boolean;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuGroup.vue.d.ts
interface GrDropdownMenuGroupProps {
title?: string;
titleAlign?: GrDropdownMenuHeaderAlign;
dividers?: boolean;
uppercase?: boolean;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuHeader.vue.d.ts
interface GrDropdownMenuHeaderProps {
title?: string;
align?: GrDropdownMenuHeaderAlign;
uppercase?: boolean;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuItem.vue.d.ts
type GrDropdownMenuItemRole = 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';interface GrDropdownMenuItemProps {
as?: string | Component;
disabled?: boolean;
href?: string;
target?: string;
rel?: string;
external?: boolean;
align?: GrDropdownMenuItemAlign;
variant?: GrDropdownMenuItemVariant;
role?: GrDropdownMenuItemRole;
checked?: boolean;
icon?: Component;
shortcut?: string;
expanded?: boolean;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuList.vue.d.ts
interface GrDropdownMenuListProps {
dividers?: boolean;
borderTop?: boolean;
borderBottom?: boolean;
}Filedist/types/components/GrDropdownMenu/GrDropdownMenuSub.vue.d.ts
interface GrDropdownMenuSubProps {
label?: string;
icon?: Component;
disabled?: boolean;
variant?: GrDropdownMenuItemVariant;
align?: GrDropdownMenuItemAlign;
placement?: UseFloatingPlacement;
offset?: number;
minWidth?: number | string;
openDelay?: number;
closeDelay?: number;
dividers?: boolean;
contentClass?: string;
listClass?: string;
}Filedist/types/components/GrDropdownMenu/index.d.ts
type GrDropdownMenuSubInstance = ComponentExposed<typeof GrDropdownMenuSubComponent>;Accessibility
APG pattern
menu
Keys
SpaceEscTab
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The roles: why they cannot be skipped
The panel of GrDropdown declares role="menu", and that role makes all of its
descendants presentational. The roles here are therefore not an ornament but the
condition on which the menu exists for a screen reader at all:
- an item is
role="menuitem"(ormenuitemcheckbox/menuitemradio); - a separator is
role="separator"; - a group is
role="group"with a name from its heading througharia-labelledby; - the heading of a group is
role="presentation"; - the list, the columns and a column are
role="none": a wrapper betweenmenuandmenuitembreaksaria-required-children.
The focus and the disabled items
The items are not tabbable (tabindex="-1"): in the menu pattern it is the trigger that
stays tabbable, and inside the panel the focus is walked by the arrows — that is disposed
of by GrDropdown. Otherwise Tab would walk the items and the menu would be a mere
list of buttons.
disabled is dimmed with aria-disabled rather than with the native disabled: the item
stays focusable and enters the walk with the arrows, so the user learns that the action
exists but is currently unavailable (a recommendation of the WAI-ARIA APG). The click and
Enter are intercepted in the process, and the background and the text come from the
disabled tokens rather than from opacity.
Link items
href makes an item a link by itself — as="a" is not needed for that. target and rel
are set explicitly, and external is a shortcut for target="_blank" with
rel="noopener noreferrer".
On a disabled link the href is removed: intercepting the click does not save you
from the middle mouse button and from “open in a new tab” in the browser’s context menu.
The same device is used in GrButton.
In the declarative model those are the href, target, rel and external fields of an
item.
Toggle items
role="menuitemcheckbox" and role="menuitemradio" require aria-checked — the component
sets it in both states, otherwise AT reads the item as an ordinary command. The room for
the mark is always taken when the item is toggleable: otherwise the “on” and “off” rows
shift horizontally against each other.
<GrDropdownMenuItem role="menuitemcheckbox" :checked="showArchived" @click="toggle">
Show the archive
</GrDropdownMenuItem>The icon and the keyboard shortcut are set with the icon / shortcut props or with the
#icon / #shortcut slots (a slot is stronger).
Styling
variant="danger" colours an item with the --gr-danger-text role rather than with a
saturated tone: a saturated tone as a text colour does not pass contrast. Disabled is
dimmed with a background (--gr-muted) rather than with opacity, and lets through
neither a click nor a keyboard activation — a handler on the item itself is stopped with
stopImmediatePropagation.
An item is rounded and fitted into the padding of the panel. The background of the
highlight lies on the item itself, and the panel of GrDropdown is rounded and does not
clip its content: a rectangle across the full width would fill the corner segments cut
out by its radius. The item therefore has a radius of its own, one step smaller than that
of the panel — the same device as in the options of GrSelect and GrAutocomplete.
Suppressing the padding of the panel with your own p-0 is not allowed for that: both
classes land in the same attribute with equal specificity, and the winner is chosen by the
order of the rules in the generated CSS rather than by the markup. If a panel without
padding is needed, that is a separate channel, not an override with a class.
The borderTop / borderBottom lines do not reach the edges — and that is not an
oversight. The list lies inside the padding of the panel: 1 px of border plus 4 px of
p-1. The corner of the panel is rounded by 16 px, that is, at a depth of 5 px the arc is
still running — a rule across the full width would run not into the vertical edge but into
it, and at every corner a wedge where the line and the border converge would be visible.
The line is therefore drawn with a pseudo-element inset by 8 px, the same as in
GrDropdownMenuDivider :inset. The items stay full width in the process: their width is
part of the same fitting of the highlight into the padding of the panel.
The rule is general for rounded surfaces: nothing full width comes closer to the edge of
a panel than its radius. The geometry is held by an e2e check
(apps/showcase/e2e/geometry.spec.ts) — in jsdom utilities do not turn into CSS, and
there is no seeing where the end of a line landed.
None of this concerns the separators between items: dividers and
GrDropdownMenuDivider lie far from the corners and read fine across the full width.
All of the classes of the catalogue live in grDropdownMenuStyles.ts and are declared in
the safelist: a .ts helper is moved by the bundler into a shared chunk, outside the scan
area of the component, and without the safelist an isolated consumer would lose the
alignment, the columns and the colours.
Control from the outside: v-model:open
The open prop and the update:open event are passed into the wrapped GrDropdown as
they are — the contract is the same as in GrDropdown.
Props in this example