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 { Listbox } from 'frontile';
A simple listbox with single selection mode.
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>
}
Enable users to select multiple items from the list.
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>
}
Define items explicitly with icons, descriptions, and shortcuts.
Note: The
@shortcutargument is for display purposes only. You'll need to implement actual keyboard shortcut handling in your application.
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>
}
Use selection mode "none" for action menus where items trigger actions rather than being selected.
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>
}
Control the visual style with the @variant argument.
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>
}
Apply color intents to individual items or the entire listbox.
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>
}
Prevent interaction with specific items using @disabledKeys.
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>
}
Render complex objects with custom templates.
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>
}
Organize items into logical groups using dividers.
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>
}
Control whether users can deselect all items.
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>
}
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.
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.
@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.
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>
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.
| 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 listbox → option
ownership chain stays intact. A group with no @title carries no
aria-labelledby.
Element: HTMLUListElement
| 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 |
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. |
| Name | Type | Default | Description |
|---|---|---|---|
item
*
|
Array
|
- | |
default
*
|
Array
|
- |
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.
| 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. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLLIElement
| 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 Renders |
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
|
- |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | |
selectedIcon
*
|
Array
|
- | |
start
*
|
Array
|
- | |
end
*
|
Array
|
- |