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='flex flex-col gap-4'>
<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}}
@intent='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='flex flex-col gap-4'>
<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}}
@intent='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}}
@appearance='faded'
@disabledKeys={{this.disabledKeys}}
as |l|
>
<l.Item
@key='view'
@description='View in read-only mode'
@shortcut='⌘O'
>
<:start><ViewIcon /></:start>
<:default>View Details</:default>
</l.Item>
<l.Item @key='edit' @description='Make changes' @shortcut='⌘E'>
<:start><EditIcon /></:start>
<:default>Edit</:default>
</l.Item>
<l.Item
@key='share'
@description='Share with team'
@shortcut='⌘⇧S'
@withDivider={{true}}
>
<:start><ShareIcon /></:start>
<:default>Share</:default>
</l.Item>
<l.Item
@key='delete'
@description='Permanently delete'
@intent='danger'
@class='text-danger'
@shortcut='⌘⌫'
>
<: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='⌘S'>Save</l.Item>
<l.Item @key='save-as' @shortcut='⌘⇧S' @withDivider={{true}}>
Save As...
</l.Item>
<l.Item @key='export'>Export</l.Item>
<l.Item @key='print' @shortcut='⌘P'>Print</l.Item>
</Listbox>
</div>
</template>
}
Control the visual style with the @appearance 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 Appearances extends Component {
@tracked appearance = 'default';
@tracked selectedKeys: string[] = ['option2'];
options = ['option1', 'option2', 'option3', 'option4'];
@action
setAppearance(appearance: string) {
this.appearance = appearance;
}
@action
onSelectionChange(keys: string[]) {
this.selectedKeys = keys;
}
isSelected = (type: string) => {
return this.appearance === type;
};
<template>
<div class='flex flex-col gap-4'>
<ButtonGroup @size='xs' @intent='primary' as |g|>
<g.ToggleButton
@isSelected={{this.isSelected 'default'}}
@onChange={{fn this.setAppearance 'default'}}
>
Default
</g.ToggleButton>
<g.ToggleButton
@isSelected={{this.isSelected 'outlined'}}
@onChange={{fn this.setAppearance 'outlined'}}
>
Outlined
</g.ToggleButton>
<g.ToggleButton
@isSelected={{this.isSelected 'faded'}}
@onChange={{fn this.setAppearance 'faded'}}
>
Faded
</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}}
@appearance={{this.appearance}}
@intent='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}} @appearance='faded' as |l|>
<l.Item @key='default'>Default Color</l.Item>
<l.Item @key='primary' @intent='primary'>Primary Color</l.Item>
<l.Item @key='secondary' @intent='secondary'>Secondary Color</l.Item>
<l.Item @key='tertiary' @intent='tertiary'>Tertiary Color</l.Item>
<l.Item @key='success' @intent='success'>Success Color</l.Item>
<l.Item @key='warning' @intent='warning'>Warning Color</l.Item>
<l.Item @key='danger' @intent='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='flex flex-col gap-4'>
<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}}
@intent='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='flex flex-col gap-4'>
<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}}
@intent='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' @intent='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='flex flex-col gap-4'>
<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}}
@intent='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>
}
| 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 menuitem), aria-labelledby pointing at the item's label |
aria-selected reflecting selection — options only, since it is invalid on a plain menuitem |
|
aria-disabled="true" for keys in @disabledKeys |
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 worth knowing. 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.
Element: <span class="hljs-title class_">HTMLUListElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
allowEmpty
|
boolean
|
- | |
appearance
|
enum
|
'default'
|
The appearance of each item |
autoActivateMode
|
enum
|
- | |
class
|
string
|
- | |
disabledKeys
|
Array
|
- | |
elementToAddKeyboardEvents
|
HTMLElement
|
null
|
The element to add keyboard events to. This does not respect the option |
intent
|
enum
|
- | The intent of each item |
isKeyboardEventsEnabled
|
boolean
|
- | |
items
|
Array
|
- | |
onAction
|
function
|
- | |
onActiveItemChange
|
function
|
- | |
onSelectionChange
|
function
|
- | |
selectedKeys
|
Array
|
- | |
selectionMode
|
enum
|
- | |
type
|
enum
|
- |
| Name | Type | Default | Description |
|---|---|---|---|
item
*
|
Array
|
- | |
default
*
|
Array
|
- |
Element: <span class="hljs-title class_">HTMLLIElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
key
*
|
string
|
- | |
manager
*
|
ListManager
|
- | |
appearance
|
enum
|
'default'
|
The appearance of each item |
class
|
string
|
- | |
description
|
string
|
- | |
intent
|
enum
|
- | The intent of each item |
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
|
- | |
textValue
|
string
|
- | |
type
|
enum
|
- | |
withDivider
|
boolean
|
- |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | |
selectedIcon
*
|
Array
|
- | |
start
*
|
Array
|
- | |
end
*
|
Array
|
- |