Skip to content

RegionPicker

createRegionPicker reads its rows from a RegionList and holds the open state, the keyboard cursor, and the selected option. Every change emits a RegionPickerState snapshot. Bound to a PhoneInput, the phone owns the selected region and the picker follows it. No emit fires at construction. Read the initial state with getState.

Two functions connect the widget to the DOM. bindRegionPicker wires the events and leaves the rendering to the caller, and attachRegionPicker renders the rows as well.

function createRegionPicker<T = undefined>(options: RegionPickerOptions<T>): RegionPicker<T>;
const regions = createRegionList({ prioritize: ['US', 'CA', 'GB'] });
const picker = createRegionPicker({ regions, phone });
picker.open();
picker.search('can');
picker.getState().active; // 'CA'
picker.select('CA'); // phone.setRegion('CA')
picker.close();
Field Type Meaning
regions RegionList<T> The list the picker reads its rows from. The picker never destroys it.
phone PhoneInput The phone input that owns the selected region. See Bound to a phone input.
type RegionPickerState<T = undefined> = {
readonly open: boolean;
readonly selected: RegionOption<T> | null;
readonly active: RegionCode | null;
readonly options: readonly RegionOption<T>[];
readonly searchQuery: string;
};

selected is null until a region is selected. It is the option from the list’s base set, the same object the list renders. It follows localize and refresh. active is the row under the keyboard cursor, null when there is none. options and searchQuery mirror the list’s state.

With a phone, the phone input is the single source of the selected region. The picker subscribes to it and takes selected from the phone’s resolved region, keeping the last region while the phone reports null. select hands the region to setRegion instead of storing it. Undo on the phone therefore moves the picker as well. A select that round-trips through the phone emits once.

The picker writes only the search query to regions and never touches its filters. Give the list and the phone the same regionFilter and numberTypeFilter when the rows must match what the phone accepts.

subscribe(listener: RegionPickerListener<T>): () => void;

Subscribes to state changes. Returns an unsubscribe function. RegionPickerListener<T> is (state: RegionPickerState<T>) => void.

getState(): RegionPickerState<T>;

The current state, without subscribing.

open(): void;

Shows the list with an empty query and the cursor on the selected row, or on the first row when the selection is filtered out. A no-op while open.

close(): void;

Hides the list and clears the cursor. A no-op while closed.

toggle(): void;

open while closed, close while open.

search(query: string): void;

Updates the query on regions. A new row set moves the cursor to the first row, which lets Enter pick the top match. An unchanged query is a no-op.

setActive(region: RegionCode | null): void;

Puts the cursor on a row, or clears it with null. A region outside options clears it.

moveActive(step: number): void;

Moves the cursor by step rows, wrapping at both ends. Without a cursor, a positive step starts at the first row and a negative one at the last.

select(region: RegionCode): void;

Selects a region. Bound to a phone, the phone receives the region; on its own, the picker keeps it. A region the engine does not know changes nothing. Closing the list after a pick stays with the caller.

destroy(): void;

Unsubscribes from regions and the phone and clears all listeners. Idempotent. The list and the phone stay alive. Destroy the picker before or together with the phone; a select after the phone is gone has nothing to write to.

function bindRegionPicker<T = undefined>(options: BindRegionPickerOptions<T>): RegionPickerBinding;

Wires a trigger, a popup with an optional search field, and a listbox to a picker. Events flow in, while the rendering belongs to the caller, which suits a framework whose template already turns the picker’s state into elements. attachRegionPicker adds the rendering.

What the binding wires:

  • A click on the trigger toggles the list and takes focus to the trigger, which Safari never does on its own. A caller that moves focus into the popup on open keeps it, because the binding focuses the trigger before it toggles the list. On the trigger and on the search field, ArrowDown and ArrowUp open a closed list and move the cursor in an open one. Enter picks the cursor’s row, moves focus to returnFocusTo, and closes. While the list is open, Enter never submits a surrounding form. Escape returns focus to the trigger and closes. Focus moves before the list closes, which never strands it inside a hidden popup. Keys pressed during IME composition are ignored.
  • Typing in the search field searches the list.
  • A click on a row picks it. A moving pointer moves the cursor. A list scrolling under a resting pointer leaves the cursor in place. The cursor scrolls into view on the arrow keys.
  • A press outside the trigger and the popup closes the list, as does focus leaving them. The press is seen in the capture phase, before any handler on the pressed element. A press inside a shadow root is followed to the element it really landed on.
  • The attributes that describe the parts, written once when the binding is made. On the trigger, type="button" when the attribute is absent, aria-haspopup, and aria-controls. On the search field, or on the trigger without one, role="combobox" and aria-controls, plus aria-autocomplete="list" on the search field. On the list, an id, role="listbox", and tabindex="-1", which keeps a scrollable list out of the tab order. The binding writes no other attribute.

The rows and everything that follows the state stay with the caller:

What the caller writes Where From
the rows the listbox state.options
hidden the popup state.open
aria-expanded the trigger and the combobox host state.open
aria-selected every row state.selected
data-active the row under the cursor state.active
aria-activedescendant the combobox host the cursor row’s id
value the search field state.searchQuery
focus on open the search field the edge from closed to open

Every row carries data-region with its region code, which is how the binding finds the row a click, a pointer, or the cursor belongs to. The combobox role requires aria-expanded. Without it a screen reader cannot tell whether the list is open.

const binding = bindRegionPicker({ picker, trigger, popup, search, listbox });
picker.subscribe((state) => {
popup.hidden = !state.open;
trigger.setAttribute('aria-expanded', String(state.open));
search.setAttribute('aria-expanded', String(state.open));
if (search.value !== state.searchQuery) search.value = state.searchQuery;
listbox.replaceChildren(
...state.options.map((option) => {
const row = document.createElement('li');
row.id = binding.ids.option(option.region);
row.dataset.region = option.region;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', String(option.region === state.selected?.region));
if (option.region === state.active) row.dataset.active = 'true';
row.textContent = `${option.displayName} +${option.callingCode}`;
return row;
}),
);
const cursor = state.active === null ? null : listbox.querySelector(`[data-region="${state.active}"]`);
if (cursor === null) search.removeAttribute('aria-activedescendant');
else search.setAttribute('aria-activedescendant', cursor.id);
// The rows reach the DOM here, one step after the state changed.
binding.revealCursor();
});
Field Type Meaning
picker RegionPicker<T> The picker to wire.
trigger HTMLButtonElement The button that opens the list.
popup HTMLElement Holds the search field and the listbox. Presses and focus inside it count as inside the picker. May live anywhere in the document.
listbox HTMLElement Holds the rows.
search HTMLInputElement The search field. Without it the trigger carries the combobox role.
returnFocusTo HTMLElement Focused after a pick. The trigger by default.
readonly ids: { readonly listbox: string; option(region: RegionCode): string };

The listbox id, plus a ready-made id for a row. The generated ids are unique per binding, which keeps two pickers on one page apart. A listbox that already carries an id keeps it, which lets a template own the id while the binding points aria-controls at it.

A row’s id is never read by the binding, which leaves it with the caller. A framework that renders rows on its first pass, before the binding exists, gives them ids of its own and points aria-activedescendant at those.

revealCursor(): void;

Scrolls the row under the cursor into view inside the listbox. Arrow keys call it already. Call it after your framework has rendered the rows for a new state. That also covers the list opening on a fresh row set.

destroy(): void;

Removes every listener, the document-level one included. Idempotent. The elements and the attributes the binding wrote stay.

function attachRegionPicker<T = undefined>(options: AttachRegionPickerOptions<T>): RegionPickerAttachment;
type AttachRegionPickerOptions<T = undefined> = BindRegionPickerOptions<T> & {
renderOption: (option: RegionOption<T>) => HTMLElement;
renderTrigger?: (selected: RegionOption<T> | null) => void;
renderEmpty?: () => HTMLElement;
autoFocus?: boolean;
};

bindRegionPicker with the rendering on top. Every event above flows in the same way. The adapter writes the state the binding leaves to the caller and builds no elements of its own. Rows come from renderOption and the empty element from renderEmpty. It leaves the trigger’s content, the styling, and the popup’s position alone.

What the adapter adds:

  • The rows, each with role="option", an id, and data-region. Rows are cached per option object and reused across searches. A localized list yields new rows.
  • The popup’s hidden, aria-expanded on the trigger and the combobox host, aria-selected on the selected row, data-active on the cursor’s row, and aria-activedescendant on the host.
  • A query set on the picker, written back to the search field.
  • The search field focused as the list opens, with the list scrolled to the top and the cursor revealed. autoFocus: false leaves focus where it is.
  • renderTrigger, called on attach and on every change of the selection.
<button class="picker-trigger"><span class="picker-code"></span></button>
<div class="picker-menu" hidden>
<input type="text" placeholder="Search" />
<ul></ul>
</div>
<input type="tel" class="phone" />
const code = document.querySelector<HTMLElement>('.picker-code')!;
const attachment = attachRegionPicker({
picker,
trigger: document.querySelector<HTMLButtonElement>('.picker-trigger')!,
popup: document.querySelector<HTMLElement>('.picker-menu')!,
search: document.querySelector<HTMLInputElement>('.picker-menu input')!,
listbox: document.querySelector<HTMLElement>('.picker-menu ul')!,
renderOption: (option) => {
const row = document.createElement('li');
row.textContent = `${option.displayName} +${option.callingCode}`;
return row;
},
renderTrigger: (selected) => {
code.textContent = selected === null ? '' : `+${selected.callingCode}`;
},
returnFocusTo: document.querySelector<HTMLInputElement>('.phone')!,
});
Field Type Meaning
renderOption (option: RegionOption<T>) => HTMLElement Builds a row. The adapter adds the option attributes itself.
renderTrigger (selected: RegionOption<T> | null) => void Called on attach and on every change of the selection.
renderEmpty () => HTMLElement Builds the element shown while the rows are empty.
autoFocus boolean Focus the search field when the list opens. true by default.

The elements come from BindRegionPickerOptions, which these options extend.

The trigger’s accessible name stays with the caller, as does its content. A stylesheet that sets display on the popup must keep [hidden] hidden.

destroy(): void;

Removes every listener the binding added and unsubscribes from the picker. Idempotent. The picker stays alive, as do the rendered elements and their attributes. Call it when the elements leave the page.