GitHub

Dropdown

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

import { Dropdown } from 'frontile';

Usage

Basic Dropdown

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>
}

With Descriptions and Shortcuts

Add descriptions and keyboard shortcuts to menu items for better UX.

Note: The @shortcut argument 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>
}

With Selection

Enable single or multiple selection mode for choosing options.

Selected: bold
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>
}

Different Trigger Styles

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>
}

Disabled Items

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>
}

Keep Open on Select

Prevent the menu from closing when items are selected.

Active filters: recent
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>
}

With Dividers and Sections

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>
}

Custom Click Handlers

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>
}

With Backdrop

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>
}

With Close Callback

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>
}

Accessibility

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.

API

Dropdown

Element: <span class="hljs-title class_">HTMLUListElement</span>

Arguments

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'

Blocks

Name Type Default Description
default * Array -
Released under MIT License - Created by Josemar Luedke