Frontile

Listbox

A listbox presents a list of options allowing users to select one or multiple items. It serves as the foundation for other components like Select and Dropdown menus, with full keyboard navigation and accessibility support.

Import

import { Listbox } from 'frontile';

Usage

Basic Listbox with Selection

A simple listbox with single selection mode.

  • cheetah
  • crocodile
  • elephant
  • giraffe
  • lion
  • panda
  • tiger
  • zebra
Selected: lion
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Listbox } from 'frontile';

export default class BasicListbox extends Component {
  @tracked selectedKeys: string[] = ['lion'];

  animals = [
    'cheetah',
    'crocodile',
    'elephant',
    'giraffe',
    'lion',
    'panda',
    'tiger',
    'zebra'
  ];

  @action
  onSelectionChange(keys: string[]) {
    this.selectedKeys = keys;
  }

  <template>
    <div class='demo-stack items-center'>
      <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
        <Listbox
          @isKeyboardEventsEnabled={{true}}
          @selectionMode='single'
          @items={{this.animals}}
          @selectedKeys={{this.selectedKeys}}
          @onSelectionChange={{this.onSelectionChange}}
          @color='primary'
        />
      </div>
      <div class='text-sm text-neutral-firm'>
        Selected:
        {{this.selectedKeys}}
      </div>
    </div>
  </template>
}

Multiple Selection

Enable users to select multiple items from the list.

  • cheetah
  • crocodile
  • elephant
  • giraffe
  • lion
  • panda
  • tiger
  • zebra
Selected: lion,tiger
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Listbox } from 'frontile';

export default class MultipleSelection extends Component {
  @tracked selectedKeys: string[] = ['lion', 'tiger'];

  animals = [
    'cheetah',
    'crocodile',
    'elephant',
    'giraffe',
    'lion',
    'panda',
    'tiger',
    'zebra'
  ];

  @action
  onSelectionChange(keys: string[]) {
    this.selectedKeys = keys;
  }

  <template>
    <div class='demo-stack items-center'>
      <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
        <Listbox
          @isKeyboardEventsEnabled={{true}}
          @allowEmpty={{true}}
          @selectionMode='multiple'
          @items={{this.animals}}
          @selectedKeys={{this.selectedKeys}}
          @onSelectionChange={{this.onSelectionChange}}
          @color='primary'
        />
      </div>
      <div class='text-sm text-neutral-firm'>
        Selected:
        {{this.selectedKeys}}
      </div>
    </div>
  </template>
}

Static Items with Rich Content

Define items explicitly with icons, descriptions, and shortcuts.

Note: The @shortcut argument is for display purposes only. You'll need to implement actual keyboard shortcut handling in your application.

  • View Details View in read-only mode
    Ctrl O
  • Edit Make changes
    Ctrl E
  • Share Share with team
    Ctrl Shift S
  • Delete Permanently delete
    Ctrl Backspace
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Listbox } from 'frontile';
import {
  ViewIcon,
  EditIcon,
  ShareIcon,
  DeleteIcon
} from 'site/components/icons';

export default class StaticItems extends Component {
  disabledKeys = ['delete'];

  @action
  onAction(key: string) {
    // eslint-disable-next-line
    console.log('Action:', key);
  }

  <template>
    <div class='w-[280px] border px-1 py-2 rounded border-neutral-subtle'>
      <Listbox
        @isKeyboardEventsEnabled={{true}}
        @onAction={{this.onAction}}
        @variant='subtle'
        @disabledKeys={{this.disabledKeys}}
        as |l|
      >
        <l.Item
          @key='view'
          @description='View in read-only mode'
          @shortcut='mod+o'
        >
          <:start><ViewIcon /></:start>
          <:default>View Details</:default>
        </l.Item>
        <l.Item @key='edit' @description='Make changes' @shortcut='mod+e'>
          <:start><EditIcon /></:start>
          <:default>Edit</:default>
        </l.Item>
        <l.Item
          @key='share'
          @description='Share with team'
          @shortcut='mod+shift+s'
          @withDivider={{true}}
        >
          <:start><ShareIcon /></:start>
          <:default>Share</:default>
        </l.Item>
        <l.Item
          @key='delete'
          @description='Permanently delete'
          @color='danger'
          @class='text-danger'
          @shortcut='mod+backspace'
        >
          <:start><DeleteIcon /></:start>
          <:default>Delete</:default>
        </l.Item>
      </Listbox>
    </div>
  </template>
}

Action Menu (No Selection)

Use selection mode "none" for action menus where items trigger actions rather than being selected.

  • New File
  • Open...
  • Save Ctrl S
  • Save As... Ctrl Shift S
  • Export
  • Print Ctrl P
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Listbox } from 'frontile';

export default class ActionMenu extends Component {
  @action
  onAction(key: string) {
    // eslint-disable-next-line
    alert(`Action triggered: ${key}`);
  }

  <template>
    <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
      <Listbox
        @isKeyboardEventsEnabled={{true}}
        @selectionMode='none'
        @onAction={{this.onAction}}
        as |l|
      >
        <l.Item @key='new'>New File</l.Item>
        <l.Item @key='open'>Open...</l.Item>
        <l.Item @key='save' @shortcut='mod+s'>Save</l.Item>
        <l.Item @key='save-as' @shortcut='mod+shift+s' @withDivider={{true}}>
          Save As...
        </l.Item>
        <l.Item @key='export'>Export</l.Item>
        <l.Item @key='print' @shortcut='mod+p'>Print</l.Item>
      </Listbox>
    </div>
  </template>
}

Different Variants

Control the visual style with the @variant argument.

  • option1
  • option2
  • option3
  • option4
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Listbox, ButtonGroup } from 'frontile';

export default class Variants extends Component {
  @tracked variant = 'solid';
  @tracked selectedKeys: string[] = ['option2'];

  options = ['option1', 'option2', 'option3', 'option4'];

  @action
  setVariant(variant: string) {
    this.variant = variant;
  }

  @action
  onSelectionChange(keys: string[]) {
    this.selectedKeys = keys;
  }

  isSelected = (type: string) => {
    return this.variant === type;
  };

  <template>
    <div class='demo-stack items-center'>
      <ButtonGroup @size='xs' @color='primary' as |g|>
        <g.ToggleButton
          @isSelected={{this.isSelected 'solid'}}
          @onChange={{fn this.setVariant 'solid'}}
        >
          Solid
        </g.ToggleButton>
        <g.ToggleButton
          @isSelected={{this.isSelected 'outline'}}
          @onChange={{fn this.setVariant 'outline'}}
        >
          Outline
        </g.ToggleButton>
        <g.ToggleButton
          @isSelected={{this.isSelected 'subtle'}}
          @onChange={{fn this.setVariant 'subtle'}}
        >
          Subtle
        </g.ToggleButton>
      </ButtonGroup>

      <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
        <Listbox
          @isKeyboardEventsEnabled={{true}}
          @selectionMode='single'
          @items={{this.options}}
          @selectedKeys={{this.selectedKeys}}
          @onSelectionChange={{this.onSelectionChange}}
          @variant={{this.variant}}
          @color='primary'
        />
      </div>
    </div>
  </template>
}

Different Colors

Apply color intents to individual items or the entire listbox.

  • Default Color
  • Primary Color
  • Secondary Color
  • Tertiary Color
  • Success Color
  • Warning Color
  • Danger Color
import Component from '@glimmer/component';
import { Listbox } from 'frontile';

export default class IntentColors extends Component {
  <template>
    <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
      <Listbox @isKeyboardEventsEnabled={{true}} @variant='subtle' as |l|>
        <l.Item @key='default'>Default Color</l.Item>
        <l.Item @key='primary' @color='primary'>Primary Color</l.Item>
        <l.Item @key='secondary' @color='secondary'>Secondary Color</l.Item>
        <l.Item @key='tertiary' @color='tertiary'>Tertiary Color</l.Item>
        <l.Item @key='success' @color='success'>Success Color</l.Item>
        <l.Item @key='warning' @color='warning'>Warning Color</l.Item>
        <l.Item @key='danger' @color='danger'>Danger Color</l.Item>
      </Listbox>
    </div>
  </template>
}

Disabled Items

Prevent interaction with specific items using @disabledKeys.

  • feature1
  • feature2
  • feature3
  • feature4
  • feature5
Items "feature3" and "feature4" are disabled
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Listbox } from 'frontile';

export default class DisabledItems extends Component {
  @tracked selectedKeys: string[] = ['feature1'];

  disabledKeys = ['feature3', 'feature4'];

  features = ['feature1', 'feature2', 'feature3', 'feature4', 'feature5'];

  @action
  onSelectionChange(keys: string[]) {
    this.selectedKeys = keys;
  }

  <template>
    <div class='demo-stack items-center'>
      <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
        <Listbox
          @isKeyboardEventsEnabled={{true}}
          @selectionMode='single'
          @items={{this.features}}
          @selectedKeys={{this.selectedKeys}}
          @disabledKeys={{this.disabledKeys}}
          @onSelectionChange={{this.onSelectionChange}}
          @color='primary'
        />
      </div>
      <div class='text-sm text-neutral-firm'>
        Items "feature3" and "feature4" are disabled
      </div>
    </div>
  </template>
}

Custom Item Rendering

Render complex objects with custom templates.

  • Alice Johnson (Admin) alice@example.com
  • Bob Smith (Developer) bob@example.com
  • Carol White (Designer) carol@example.com
  • David Brown (Manager) david@example.com
Selected: user-2
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Listbox } from 'frontile';

export default class CustomItems extends Component {
  @tracked selectedKeys: string[] = ['user-2'];

  users = [
    {
      id: 'user-1',
      name: 'Alice Johnson',
      role: 'Admin',
      email: 'alice@example.com'
    },
    {
      id: 'user-2',
      name: 'Bob Smith',
      role: 'Developer',
      email: 'bob@example.com'
    },
    {
      id: 'user-3',
      name: 'Carol White',
      role: 'Designer',
      email: 'carol@example.com'
    },
    {
      id: 'user-4',
      name: 'David Brown',
      role: 'Manager',
      email: 'david@example.com'
    }
  ];

  @action
  onSelectionChange(keys: string[]) {
    this.selectedKeys = keys;
  }

  <template>
    <div class='demo-stack items-center'>
      <div class='w-[320px] border px-1 py-2 rounded border-neutral-subtle'>
        <Listbox
          @isKeyboardEventsEnabled={{true}}
          @selectionMode='single'
          @items={{this.users}}
          @selectedKeys={{this.selectedKeys}}
          @onSelectionChange={{this.onSelectionChange}}
          @color='primary'
        >
          <:item as |o|>
            <o.Item @key={{o.item.id}} @description={{o.item.email}}>
              <:default>
                {{o.item.name}}
                <span
                  class='text-xs text-neutral-firm ml-2'
                >({{o.item.role}})</span>
              </:default>
            </o.Item>
          </:item>
        </Listbox>
      </div>
      <div class='text-sm text-neutral-firm'>
        Selected:
        {{this.selectedKeys}}
      </div>
    </div>
  </template>
}

With Dividers

Organize items into logical groups using dividers.

  • New File
  • New Folder
  • Copy
  • Paste
  • Cut
  • Rename
  • Delete
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Listbox } from 'frontile';

export default class WithDividers extends Component {
  @action
  onAction(key: string) {
    // eslint-disable-next-line
    console.log('Action:', key);
  }

  <template>
    <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
      <Listbox
        @isKeyboardEventsEnabled={{true}}
        @selectionMode='none'
        @onAction={{this.onAction}}
        as |l|
      >
        <l.Item @key='new-file'>New File</l.Item>
        <l.Item @key='new-folder' @withDivider={{true}}>New Folder</l.Item>
        <l.Item @key='copy'>Copy</l.Item>
        <l.Item @key='paste'>Paste</l.Item>
        <l.Item @key='cut' @withDivider={{true}}>Cut</l.Item>
        <l.Item @key='rename'>Rename</l.Item>
        <l.Item @key='delete' @color='danger'>Delete</l.Item>
      </Listbox>
    </div>
  </template>
}

Controlled Empty Selection

Control whether users can deselect all items.

  • option1
  • option2
  • option3
You can deselect all items by clicking the selected item.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Listbox, Button } from 'frontile';

export default class EmptySelection extends Component {
  @tracked allowEmpty = true;
  @tracked selectedKeys: string[] = ['option2'];

  options = ['option1', 'option2', 'option3'];

  @action
  toggleAllowEmpty() {
    this.allowEmpty = !this.allowEmpty;
  }

  @action
  onSelectionChange(keys: string[]) {
    this.selectedKeys = keys;
  }

  <template>
    <div class='demo-stack items-center'>
      <div class='flex items-center gap-2'>
        <Button @size='xs' @onPress={{this.toggleAllowEmpty}}>
          Toggle Allow Empty ({{if this.allowEmpty 'ON' 'OFF'}})
        </Button>
      </div>

      <div class='w-[260px] border px-1 py-2 rounded border-neutral-subtle'>
        <Listbox
          @isKeyboardEventsEnabled={{true}}
          @selectionMode='single'
          @allowEmpty={{this.allowEmpty}}
          @items={{this.options}}
          @selectedKeys={{this.selectedKeys}}
          @onSelectionChange={{this.onSelectionChange}}
          @color='primary'
        />
      </div>

      <div class='text-sm text-neutral-firm'>
        {{#if this.allowEmpty}}
          You can deselect all items by clicking the selected item.
        {{else}}
          At least one item must remain selected.
        {{/if}}
      </div>
    </div>
  </template>
}

Grouped Options

l.Group renders a labelled section of options. It yields its own Item, and @withDivider draws a separator after the group.

Grouping changes nothing about keyboard navigation: arrow keys traverse straight across group boundaries, because navigation order is derived from the document rather than from the nesting.

  • Suggestions
    • Calendar
    • Search Emoji
    • Calculator
  • Settings
    • Profile Ctrl P
    • Billing Ctrl B
import { Listbox } from 'frontile';
import { array } from '@ember/helper';

<template>
  <div class='demo-stack'>
    <Listbox
      @selectionMode='single'
      @disabledKeys={{array 'calculator'}}
      as |l|
    >
      <l.Group @title='Suggestions' @withDivider={{true}} as |g|>
        <g.Item @key='calendar'>Calendar</g.Item>
        <g.Item @key='emoji'>Search Emoji</g.Item>
        <g.Item @key='calculator'>Calculator</g.Item>
      </l.Group>
      <l.Group @title='Settings' as |g|>
        <g.Item @key='profile' @shortcut='mod+p'>Profile</g.Item>
        <g.Item @key='billing' @shortcut='mod+b'>Billing</g.Item>
      </l.Group>
    </Listbox>
  </div>
</template>

A group without @title still groups its options but renders no heading, and carries no aria-labelledby — there would be nothing for it to point at.

Keyboard Shortcuts

@shortcut is rendered by Kbd, so it understands named keys and resolves them per platform: 'mod+o' shows ⌘O on Apple and Ctrl+O elsewhere. A string with no +, such as '⌘⇧S', is shown exactly as given.

Keycaps default to the inherit variant, which takes its colour from the option, so a shortcut stays legible on an active or filled row. Use @shortcutVariant to change that for every item at once — Command sets 'plain' for its denser rows.

  • Save Ctrl S
  • Print Ctrl P
import { Listbox } from 'frontile';

<template>
  <div class='w-[280px] border px-1 py-2 rounded border-neutral-subtle'>
    <Listbox @selectionMode='none' @shortcutVariant='plain' as |l|>
      <l.Item @key='save' @shortcut='mod+s'>Save</l.Item>
      <l.Item @key='print' @shortcut='mod+p'>Print</l.Item>
    </Listbox>
  </div>
</template>

Anatomy

Listbox renders items from @items automatically, or yields an Item component for manual composition. Each item owns its option semantics, selection state, and optional start/end content.

Accessibility

Element What it exposes
The list role="listbox", or role="menu" with @type="menu"
aria-multiselectable="true" when @selectionMode="multiple" (listbox only)
Items role="option", or a menu role with @type="menu" — see below
aria-selected reflecting selection — options only, since it is invalid on any menu item
aria-checked reflecting selection — checkable menu items only
aria-disabled="true" for keys in @disabledKeys
a roving tabindex — exactly one option carries 0, every other one -1
aria-haspopup="menu", aria-expanded and aria-controls on an item with @hasSubmenu

With @type="menu" an item's role follows how the list selects, because a menu conveys state through aria-checked rather than aria-selected, and only on a checkable role: menuitemcheckbox with @selectionMode="multiple", menuitemradio with "single", and a plain menuitem with no aria-checked when the menu does not select. An item with @hasSubmenu stays a plain menuitem whatever the list selects — activating it opens a menu rather than toggling a value. The role follows @selectionMode if it changes at runtime.

An item that opens a nested menu takes three arguments: @hasSubmenu renders a trailing chevron (unless the item supplies its own :end block) and switches its ARIA to aria-haspopup="menu"; @isSubmenuOpen drives aria-expanded and a data-submenu-open resting state; @submenuId points aria-controls at the submenu's role="menu" element. An item with @hasSubmenu never selects, so @onAction and @onSelectionChange never fire for it, while it still takes part in arrow navigation, type-ahead and the roving tab stop like any other item. Dropdown's Sub sets all three of these automatically.

The options form a composite you step into once and then navigate with the arrow keys, so only one of them is ever in the tab order. That one is the active option; with nothing active it is the first selected option, and failing that the first option that is not disabled. A multiple-selection list with eight selections is therefore still a single stop for Tab, not eight.

Keyboard, handled on the list itself:

Key Behavior
ArrowDown / ArrowUp Move the active item
Home / PageUp Jump to the first item
End / PageDown Jump to the last item
Enter, Space Select the active item
any single character Type-ahead: jumps to the item whose text starts with what you typed

Two details. Type-ahead means Space selects only when no search is in progress, so a space typed mid-search is treated as part of the search string rather than as a selection. And @elementToAddKeyboardEvents moves the key handling onto another element — that is how Select and Autocomplete keep focus in their input while driving the list.

Groups render as role="group" labelled by their heading, with the list between the group and its options marked role="none" so the listboxoption ownership chain stays intact. A group with no @title carries no aria-labelledby.

API

Listbox

Element: HTMLUListElement

Arguments

Name Type Default Description
allowEmpty boolean -
appearance
Deprecated
enum -

Deprecated. Use `variant`. `default` is now `solid`, `outlined` is `outline`, and `faded` is `subtle`.

autoActivateMode enum 'first'
class string -
color enum - The color of each item
disabledKeys Array -
elementToAddKeyboardEvents HTMLElement null

The element to add keyboard events to.

This does not respect the option iskeyboardEventsEnabled.

intent
Deprecated
enum -

Deprecated. Use `color`. `default` is now `neutral`.

isKeyboardEventsEnabled boolean -
items Array -
onAction function -
onActiveItemChange function -
onSelectionChange function -
selectedKeys Array -
selectionMode enum -
shortcutVariant enum 'inherit' The appearance of the keycap rendered for each item's @shortcut.
type enum -
variant enum 'solid' The variant of each item.

Blocks

Name Type Default Description
item * Array -
default * Array -

Listbox::Group

ListboxGroup

Element: HTMLLIElement

A labelled section of options within a Listbox.

The markup nests the options one level deeper than an ungrouped listbox, so the intervening list is marked role="none" to keep the listbox -> option ownership chain intact.

Grouping deliberately adds no concepts to ListManager: navigation order is derived from the live DOM via compareDocumentPosition, so nesting options inside a group leaves keyboard traversal across group boundaries correct by construction.

Arguments

Name Type Default Description
manager * ListManager -
appearance
Deprecated
enum -

Deprecated. Use `variant`.

class string -
classes Object -
color enum -
intent enum -
shortcutVariant enum -
title string - The heading rendered above the group's options. When omitted, the group still groups its options but renders no heading — and carries no aria-labelledby, since there would be nothing to point at.
type enum -
variant enum -
withDivider boolean - Render a divider after the group. Groups in a palette are usually separated visually; the divider is a sibling of the group rather than a child so it does not sit inside the group's labelled region.

Blocks

Name Type Default Description
default * Array -

Listbox::Item

ListboxItem

Element: HTMLLIElement

Arguments

Name Type Default Description
key * string -
manager * ListManager -
appearance
Deprecated
enum -

Deprecated. Use `variant`. `default` is now `solid`, `outlined` is `outline`, and `faded` is `subtle`.

class string -
color enum - The color of each item
description string -
hasSubmenu boolean -

Marks this option as the trigger for a submenu.

It stops the option selecting: opening a submenu is not choosing anything, so onAction and onSelectionChange must not fire for it. The option still registers with the ListManager and so still takes part in arrow navigation, type-ahead and the roving tab stop.

Renders aria-haspopup="menu" and a trailing chevron, unless an :end block supplies its own trailing content.

intent
Deprecated
enum -

Deprecated. Use `color`. `default` is now `neutral`.

isSubmenuOpen boolean - Whether this option's submenu is currently open. Only meaningful alongside @hasSubmenu; drives aria-expanded and the highlighted resting state via data-submenu-open.
item unknown - The entry of @items this option renders, remembered on the registered list item so a selection can hand it back. Bound for you on the Item yielded from the :item block; block-form options have no such entry.
onClick function -
shortcut string - A keyboard shortcut shown at the end of the option, rendered by Kbd. Accepts named keys ("mod+k") or a literal string ("⌘K").
shortcutVariant enum 'inherit' The variant of the rendered shortcut. Defaults to inherit, so the keycap follows the option's own colour on active and filled rows.
submenuId string - The id of the role="menu" element this option opens, for aria-controls. Only meaningful alongside @hasSubmenu.
textValue string -
type enum -
variant enum 'solid' The variant of each item.
withDivider boolean -

Blocks

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