A menu activated by a button, representing a set of actions or displaying a list of options for user selection. Built on top of Popover and Listbox components with keyboard navigation support.
import { Dropdown } from 'frontile';
A feature-rich action menu showcasing icons, descriptions, shortcuts, dividers, and color intents.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
import {
ViewIcon,
EditIcon,
DuplicateIcon,
ShareIcon,
DownloadIcon,
ArchiveIcon,
DeleteIcon
} from 'site/components/icons';
export default class BasicDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action triggered:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>
Project Actions
</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='view' @description='Open in read-only mode' @shortcut='⌘O'>
<:start><ViewIcon /></:start>
<:default>View Details</:default>
</Item>
<Item @key='edit' @description='Make changes to project' @shortcut='⌘E'>
<:start><EditIcon /></:start>
<:default>Edit Project</:default>
</Item>
<Item
@key='duplicate'
@description='Create a copy'
@shortcut='⌘D'
@withDivider={{true}}
>
<:start><DuplicateIcon /></:start>
<:default>Duplicate</:default>
</Item>
<Item
@key='share'
@intent='primary'
@description='Invite team members'
@shortcut='⌘⇧S'
>
<:start><ShareIcon /></:start>
<:default>Share</:default>
</Item>
<Item @key='export' @intent='success' @description='Download as file'>
<:start><DownloadIcon /></:start>
<:default>Export</:default>
</Item>
<Item
@key='archive'
@intent='warning'
@description='Move to archived projects'
@withDivider={{true}}
>
<:start><ArchiveIcon /></:start>
<:default>Archive Project</:default>
</Item>
<Item
@key='delete'
@intent='danger'
@description='Permanently delete'
@class='text-danger'
@shortcut='⌘⌫'
>
<:start><DeleteIcon /></:start>
<:default>Delete Project</:default>
</Item>
</d.Menu>
</Dropdown>
</template>
}
Add descriptions and keyboard shortcuts to menu items for better UX.
Note: The
@shortcutargument is for display purposes only. You'll need to implement actual keyboard shortcut handling in your application using a library or custom implementation.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class DropdownWithDetails extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>Account</d.Trigger>
<d.Menu @onAction={{this.onAction}} @intent='primary' as |Item|>
<Item @key='profile' @description='View and edit your profile'>
My Profile
</Item>
<Item @key='settings' @description='Manage preferences' @shortcut='⌘,'>
Settings
</Item>
<Item @key='billing' @description='View billing details'>
Billing
</Item>
<Item
@key='team'
@description='Manage team members'
@withDivider={{true}}
>
Team
</Item>
<Item @key='logout' @intent='danger' @class='text-danger'>
Log Out
</Item>
</d.Menu>
</Dropdown>
</template>
}
Enable single or multiple selection mode for choosing options.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class SelectableDropdown extends Component {
@tracked selectedKeys = ['bold'];
@action
handleSelectionChange(keys: Set<string>) {
this.selectedKeys = Array.from(keys);
// eslint-disable-next-line
console.log('Selected:', this.selectedKeys);
}
<template>
<div class='flex flex-col gap-2'>
<Dropdown @closeOnItemSelect={{false}} as |d|>
<d.Trigger @size='sm'>Text Formatting</d.Trigger>
<d.Menu
@selectionMode='multiple'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
as |Item|
>
<Item @key='bold'>Bold</Item>
<Item @key='italic'>Italic</Item>
<Item @key='underline'>Underline</Item>
<Item @key='strikethrough'>Strikethrough</Item>
</d.Menu>
</Dropdown>
<div class='text-sm text-neutral-soft'>
Selected:
{{this.selectedKeys}}
</div>
</div>
</template>
}
Customize the trigger button appearance.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class TriggerStyles extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<div class='flex gap-2 flex-wrap'>
<Dropdown as |d|>
<d.Trigger @intent='default' @size='sm'>Default</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>Primary</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @intent='secondary' @size='sm'>Secondary</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @intent='success' @size='sm'>Success</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @intent='warning' @size='sm'>Warning</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @intent='danger' @size='sm'>Danger</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
</div>
</template>
}
Control where the menu appears relative to the trigger.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Dropdown, ButtonGroup } from 'frontile';
export default class MenuPositioning extends Component {
@tracked placement = 'bottom-start';
placements = [
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'right'
];
@action
setPlacement(placement: string) {
this.placement = placement;
}
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
isSelected = (p: string) => {
return p === this.placement;
};
<template>
<div class='flex flex-col gap-4'>
<div class='flex gap-2 flex-wrap'>
<ButtonGroup @size='xs' @intent='primary' as |g|>
{{#each this.placements as |p|}}
<g.ToggleButton
@isSelected={{this.isSelected p}}
@onChange={{fn this.setPlacement p}}
>
{{p}}
</g.ToggleButton>
{{/each}}
</ButtonGroup>
</div>
<div class='flex justify-center items-center h-32'>
<Dropdown @placement={{this.placement}} as |d|>
<d.Trigger @intent='primary' @size='sm'>
Menu ({{this.placement}})
</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
<Item @key='option3'>Option 3</Item>
</d.Menu>
</Dropdown>
</div>
</div>
</template>
}
Disable specific menu items.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class DisabledItems extends Component {
disabledKeys = ['share', 'delete'];
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>File Actions</d.Trigger>
<d.Menu
@onAction={{this.onAction}}
@disabledKeys={{this.disabledKeys}}
as |Item|
>
<Item @key='open'>Open</Item>
<Item @key='rename'>Rename</Item>
<Item @key='share'>Share (Coming Soon)</Item>
<Item @key='download'>Download</Item>
<Item @key='delete' @intent='danger' @class='text-danger'>
Delete (Unavailable)
</Item>
</d.Menu>
</Dropdown>
</template>
}
Prevent the menu from closing when items are selected.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class KeepOpenDropdown extends Component {
@tracked filters = ['recent'];
@action
handleSelectionChange(keys: Set<string>) {
this.filters = Array.from(keys);
// eslint-disable-next-line
console.log('Filters:', this.filters);
}
<template>
<div class='flex flex-col gap-2'>
<Dropdown @closeOnItemSelect={{false}} as |d|>
<d.Trigger @size='sm'>Filter Options</d.Trigger>
<d.Menu
@selectionMode='multiple'
@selectedKeys={{this.filters}}
@onSelectionChange={{this.handleSelectionChange}}
as |Item|
>
<Item @key='recent'>Recent</Item>
<Item @key='starred'>Starred</Item>
<Item @key='shared'>Shared with me</Item>
<Item @key='archived'>Archived</Item>
</d.Menu>
</Dropdown>
<div class='text-sm text-neutral-soft'>
Active filters:
{{this.filters}}
</div>
</div>
</template>
}
Organize menu items into logical groups.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class SectionedDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>More Options</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='new-file'>New File</Item>
<Item @key='new-folder'>New Folder</Item>
<Item @key='upload' @withDivider={{true}}>Upload</Item>
<Item @key='copy'>Copy</Item>
<Item @key='move'>Move</Item>
<Item @key='rename' @withDivider={{true}}>Rename</Item>
<Item @key='export'>Export</Item>
<Item @key='share'>Share</Item>
<Item @key='delete' @intent='danger' @class='text-danger'>
Delete
</Item>
</d.Menu>
</Dropdown>
</template>
}
Use individual click handlers for specific items.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class CustomHandlers extends Component {
@action
handleEdit() {
alert('Edit clicked');
}
@action
handleDelete() {
if (confirm('Are you sure you want to delete?')) {
alert('Deleted!');
}
}
@action
handleDownload() {
alert('Downloading...');
}
<template>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>Actions</d.Trigger>
<d.Menu as |Item|>
<Item @key='view'>View Details</Item>
<Item @key='edit' @onClick={{this.handleEdit}}>Edit</Item>
<Item @key='download' @onClick={{this.handleDownload}}>
Download
</Item>
<Item
@key='delete'
@intent='danger'
@class='text-danger'
@onClick={{this.handleDelete}}
@withDivider={{true}}
>
Delete
</Item>
</d.Menu>
</Dropdown>
</template>
}
Control the backdrop appearance behind the dropdown menu.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Dropdown, ButtonGroup } from 'frontile';
export default class DropdownBackdrop extends Component {
@tracked backdrop = 'none';
@action
setBackdrop(type: string) {
this.backdrop = type;
}
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
isActiveBackdrop = (type: string) => {
return this.backdrop === type;
};
<template>
<div class='flex flex-col gap-4'>
<ButtonGroup @size='xs' @intent='primary' as |g|>
<g.ToggleButton
@isSelected={{this.isActiveBackdrop 'none'}}
@onChange={{fn this.setBackdrop 'none'}}
>
No Backdrop
</g.ToggleButton>
<g.ToggleButton
@isSelected={{this.isActiveBackdrop 'faded'}}
@onChange={{fn this.setBackdrop 'faded'}}
>
Faded
</g.ToggleButton>
<g.ToggleButton
@isSelected={{this.isActiveBackdrop 'blur'}}
@onChange={{fn this.setBackdrop 'blur'}}
>
Blur
</g.ToggleButton>
</ButtonGroup>
<Dropdown as |d|>
<d.Trigger @intent='primary' @size='sm'>
Open Menu ({{this.backdrop}})
</d.Trigger>
<d.Menu
@backdrop={{this.backdrop}}
@onAction={{this.onAction}}
as |Item|
>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
<Item @key='option3'>Option 3</Item>
</d.Menu>
</Dropdown>
</div>
</template>
}
Execute a callback when the dropdown closes.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class DropdownWithCallback extends Component {
@action
handleDidClose() {
// eslint-disable-next-line
console.log('Dropdown closed');
}
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown @didClose={{this.handleDidClose}} as |d|>
<d.Trigger @intent='primary' @size='sm'>Dropdown</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
<Item @key='option3'>Option 3</Item>
</d.Menu>
</Dropdown>
</template>
}
Dropdown is a Popover wrapping a Listbox with @type="menu", and inherits from both.
The trigger — a real <button>, so it is focusable and activates on Enter and Space —
carries aria-haspopup="true", aria-controls pointing at the menu, and aria-expanded
kept in sync. The menu itself is role="menu" and its items are role="menuitem" with
aria-labelledby, plus aria-disabled="true" for keys in @disabledKeys.
Menu items deliberately carry no aria-selected: it is invalid on a plain menuitem, which
conveys state through aria-checked and only as menuitemcheckbox or menuitemradio. A
menu is a list of commands rather than a set of choices.
Keys are handled in two places, which is worth knowing when debugging one that doesn't fire:
| Focus is on the trigger | Behavior |
|---|---|
Enter / Space |
Opens the menu, on key release |
ArrowDown / ArrowUp |
Opens the menu |
| any letter | Opens the menu (not with Cmd/Ctrl/Alt held) |
Escape |
Closes |
Tab |
Closes and moves on, without pulling focus back |
Once open, focus moves into the menu and the list takes over:
| Focus is in the menu | Behavior |
|---|---|
ArrowDown / ArrowUp |
Move the active item |
Home / PageUp, End / PageDown |
First / last item |
Enter, Space |
Runs the active item's action |
| any single character | Type-ahead to a matching item |
Escape |
Closes — handled by the underlying Overlay, and focus returns to the trigger |
Because the content is inside an Overlay with a focus trap, Tab from within the menu cycles
inside it rather than leaving. @autoActivateMode="none" means no item is active when the
menu opens, so the first ArrowDown lands on the first item rather than the second.
Element: <span class="hljs-title class_">HTMLUListElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
closeOnItemSelect
|
boolean
|
true
|
Whether the dropdown should close upon selecting an item. |
didClose
|
function
|
- | Callback when closing has finished, including any exit transition. |
flipOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean | 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' | 'initialPlacement'; fallbackAxisSideDirection?: 'none' | ... 1 more ... | 'start'; ... 4 more ...; boundary?: Boundary; }
|
- | Options for the floating-ui flip middleware, which moves the content to the opposite side when it would overflow the viewport. |
middleware
|
Array
|
- |
Additional floating-ui middleware, for positioning behavior beyond what
placement, offsetOptions, flipOptions, and shiftOptions cover.
|
offsetOptions
|
enum
|
5
|
|
placement
|
enum
|
'bottom-start'
|
Placement of the menu when open |
shiftOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; limiter?: { ...; }; boundary?: Boundary; }
|
- | Options for the floating-ui shift middleware, which nudges the content along its axis to keep it in view. |
strategy
|
enum
|
'absolute'
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |