GitHub

NativeSelect

A select built on the browser's own <select> element, styled to match the rest of the form components. Reach for it when you want the platform's picker — the native dropdown on mobile, the OS list box on desktop — instead of the custom listbox that Select renders.

Import

import { NativeSelect } from 'frontile';

Usage

Pass a collection to @items and read the selection back from @onSelectionChange. Strings and numbers are used as both the key and the label.

Selected: Cheetah

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { NativeSelect } from 'frontile';

const animals = ['Cheetah', 'Crocodile', 'Elephant'];

export default class NativeSelectUsage extends Component {
  @tracked selectedKey: string | null = 'Cheetah';

  onSelectionChange = (key: string | null) => {
    this.selectedKey = key;
  };

  <template>
    <NativeSelect
      @label='Favorite animal'
      @items={{animals}}
      @selectedKey={{this.selectedKey}}
      @onSelectionChange={{this.onSelectionChange}}
    />
    <p class='mt-4 text-sm text-neutral'>Selected: {{this.selectedKey}}</p>
  </template>
}

The component is controlled. @selectedKey is what the user sees selected, so it has to be updated from @onSelectionChange — without that, the browser's change is reverted on the next render.

Items

Objects work too. The key comes from key or id, and the label from label, value, name or title. Anything else needs the :item block, which yields the raw item along with an Item component to render it with.

Selected:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { NativeSelect } from 'frontile';

const countries = [
  { code: 'br', country: 'Brazil' },
  { code: 'ca', country: 'Canada' },
  { code: 'jp', country: 'Japan' }
];

export default class NativeSelectItemBlock extends Component {
  @tracked selectedKey: string | null = null;

  onSelectionChange = (key: string | null) => {
    this.selectedKey = key;
  };

  <template>
    <NativeSelect
      @label='Country'
      @items={{countries}}
      @selectedKey={{this.selectedKey}}
      @onSelectionChange={{this.onSelectionChange}}
    >
      <:item as |o|>
        <o.Item @key={{o.item.code}}>{{o.item.country}}</o.Item>
      </:item>
    </NativeSelect>
    <p class='mt-4 text-sm text-neutral'>Selected: {{this.selectedKey}}</p>
  </template>
}

When the options are a fixed, hand-written list, drop @items entirely and use the default block, which yields the same Item component.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { array } from '@ember/helper';
import { NativeSelect } from 'frontile';

export default class NativeSelectStaticItems extends Component {
  @tracked selectedKey: string | null = 'standard';

  onSelectionChange = (key: string | null) => {
    this.selectedKey = key;
  };

  <template>
    <NativeSelect
      @label='Shipping'
      @selectedKey={{this.selectedKey}}
      @disabledKeys={{(array 'overnight')}}
      @onSelectionChange={{this.onSelectionChange}}
      as |l|
    >
      <l.Item @key='standard'>Standard — 5 business days</l.Item>
      <l.Item @key='express'>Express — 2 business days</l.Item>
      <l.Item @key='overnight'>Overnight — unavailable</l.Item>
    </NativeSelect>
  </template>
}

@disabledKeys renders those options as disabled: still listed, still announced, not selectable.

Empty Option

A native <select> always has something selected, so an initial "nothing chosen" state has to be a real option. @allowEmpty adds one, labeled with @placeholder, and picking it reports null.

Selected: none

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { NativeSelect } from 'frontile';

const roles = ['Owner', 'Editor', 'Viewer'];

export default class NativeSelectAllowEmpty extends Component {
  @tracked selectedKey: string | null = null;

  onSelectionChange = (key: string | null) => {
    this.selectedKey = key;
  };

  <template>
    <NativeSelect
      @label='Role'
      @items={{roles}}
      @allowEmpty={{true}}
      @placeholder='Select a role'
      @selectedKey={{this.selectedKey}}
      @onSelectionChange={{this.onSelectionChange}}
    />
    <p class='mt-4 text-sm text-neutral'>Selected:
      {{if this.selectedKey this.selectedKey 'none'}}</p>
  </template>
}

Multiple Selection

@selectionMode='multiple' renders the native multi-select list box. The state arguments change with it: use @selectedKeys instead of @selectedKey, and @onSelectionChange receives an array. Mixing them logs a warning.

Hold Cmd or Ctrl to select more than one.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { NativeSelect } from 'frontile';

const languages = ['Elixir', 'Go', 'Rust', 'TypeScript'];

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

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

  <template>
    <NativeSelect
      @label='Languages'
      @description='Hold Cmd or Ctrl to select more than one.'
      @selectionMode='multiple'
      @items={{languages}}
      @selectedKeys={{this.selectedKeys}}
      @onSelectionChange={{this.onSelectionChange}}
    />
  </template>
}

Sizes

@size accepts sm, md (the default) and lg, matching the other form controls.

import { array, concat } from '@ember/helper';
import { NativeSelect } from 'frontile';

const plans = ['Basic', 'Pro'];

<template>
  <div class='flex flex-col gap-4'>
    {{#each (array 'sm' 'md' 'lg') as |size|}}
      <NativeSelect
        @label={{concat 'Plan (' size ')'}}
        @size={{size}}
        @items={{plans}}
      />
    {{/each}}
  </div>
</template>

Description and Validation

NativeSelect is built on the same FormControl as the other form components, so @label, @description, @isRequired, @errors and @isInvalid behave identically. Errors are wired to the control through aria-describedby and set aria-invalid.

Where the build will be deployed.
Select an environment to continue.
import { array } from '@ember/helper';
import { NativeSelect } from 'frontile';

const environments = ['Development', 'Staging', 'Production'];

<template>
  <div class='flex flex-col gap-4'>
    <NativeSelect
      @label='Environment'
      @description='Where the build will be deployed.'
      @isRequired={{true}}
      @items={{environments}}
    />

    <NativeSelect
      @label='Environment'
      @errors={{(array 'Select an environment to continue.')}}
      @allowEmpty={{true}}
      @placeholder='Select an environment'
      @items={{environments}}
    />
  </div>
</template>

Start and End Content

The :startContent and :endContent named blocks place content inside the control. The chevron the component draws stays at the end, after whatever :endContent yields.

By default pointer events pass through end content to the select and are captured by start content; @startContentPointerEvents and @endContentPointerEvents flip that when the content itself needs to be clickable.

import { NativeSelect } from 'frontile';
import { SearchIcon } from 'site/components/icons';

const teams = ['Design', 'Engineering', 'Support'];

<template>
  <NativeSelect @label='Team' @items={{teams}}>
    <:startContent>
      <SearchIcon class='size-icon-md text-neutral' />
    </:startContent>
  </NativeSelect>
</template>

Accessibility

NativeSelect renders a real <select>, so keyboard behavior, type-ahead and the mobile picker are the browser's own and match whatever the user's platform does elsewhere. That is the main reason to choose it over Select.

Key Behavior
Space / Alt + Open the picker (browser dependent)
/ Move through options
Home / End First / last option
Printable characters Jump to the option starting with those characters
Enter / Esc Commit / dismiss the open picker
Cmd / Ctrl + click, Shift + / Extend the selection in multiple mode

The component's own responsibilities:

  • @label renders a <label> bound to the select by id. Always provide it — there is no visual affordance that substitutes for it, and an unlabeled select is announced only by its current value.
  • @description and @errors are referenced through aria-describedby, and an invalid control gets aria-invalid="true".
  • @disabledKeys sets the disabled attribute on the corresponding <option>, which screen readers announce as unavailable.
  • The chevron is decorative and is not exposed to assistive technology.

For multiple selection, say so in @description as the demo above does: nothing in the native list box announces that Cmd or Ctrl extends the selection, and it is the most commonly missed interaction in the component.

API

NativeSelect

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

Arguments

Name Type Default Description
allowEmpty boolean false Renders an extra empty option, letting the user clear the selection. Label it with the placeholder argument.
classes SlotsToClasses<'base' | 'input' | 'innerContainer' | 'startContent' | 'endContent' | 'icon'> - Per-slot class overrides: base, innerContainer, input, startContent, endContent and icon.
description string - Help text rendered between the label and the control, and referenced by the ids describedBy returns.
disabledKeys Array - Keys of the options that cannot be selected. Disabled options are still rendered and announced, they just cannot be picked.
endContentPointerEvents enum 'none' Controls pointer-events property of endContent. Defauled to none to pass the click event to the input. If your content needs to capture events, consider adding pointer-events-auto class to that element only.
errors enum - Validation messages for the field. A non-empty value also marks the control invalid, and an array is joined with ; when displayed.
id string - Id applied to the select element and referenced by its label. One is generated when omitted.
isDisabled boolean false Whether the field is disabled. FormControl passes this through for styling; the control it wraps is responsible for the disabled attribute.
isInvalid boolean false Marks the control invalid without supplying messages, for validation that is reported elsewhere.
isRequired boolean false Whether the field is required. Adds an asterisk to the label; it does not set the required attribute on the control itself.
items Array - Collection used to render the options. Strings and numbers become both the key and the label; objects use key or id for the key and label, value, name or title for the label. Use the :item block when your data does not follow those conventions.
label string - The label text rendered above the control and associated with it via for. Use the :label block instead when the label needs markup.
name string - Name of the select element, used when the surrounding form is submitted.
onAction function - Called with the key of an option whenever it is acted upon, including when the same option is picked again. Use onSelectionChange to track state.
onItemsChange function -
onSelectionChange function - Called with the newly selected key, or null when the selection is cleared through the allowEmpty option. Called with every selected key each time the selection changes.
placeholder string - Placeholder text used when allowEmpty is set to true.
selectedKey string - The selected key in single selection mode. The component is controlled: update this from onSelectionChange. Not available in multiple selection mode; use selectedKeys.
selectedKeys Array - Not available in single selection mode; use selectedKey. The selected keys in multiple selection mode. The component is controlled: update this from onSelectionChange.
selectionMode enum 'single' 'single' Whether one option or several can be selected. Multiple renders the native multi-select list box and switches to selectedKeys.
size enum 'md' The size of the control.
startContentPointerEvents enum 'auto' Controls pointer-events property of startContent. If you want to pass the click event to the input, set it to none.

Blocks

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

NativeSelectItem

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

Arguments

Name Type Default Description
key * string - Value of the rendered option, and the key reported by onSelectionChange, selectedKey or selectedKeys, and disabledKeys.
manager * ListManager - The ListManager that tracks selection for the parent select. It is bound for you on the yielded Item component.
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.
textValue string - Text representation of the option, used for matching. Defaults to the option's rendered text content.

Blocks

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