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.
createRegionPicker
Section titled “createRegionPicker”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();RegionPickerOptions
Section titled “RegionPickerOptions”| 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. |
RegionPickerState
Section titled “RegionPickerState”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.
Bound to a phone input
Section titled “Bound to a phone input”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.
Members
Section titled “Members”subscribe
Section titled “subscribe”subscribe(listener: RegionPickerListener<T>): () => void;Subscribes to state changes. Returns an unsubscribe function.
RegionPickerListener<T> is (state: RegionPickerState<T>) => void.
getState
Section titled “getState”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
Section titled “toggle”toggle(): void;open while closed, close while open.
search
Section titled “search”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
Section titled “setActive”setActive(region: RegionCode | null): void;Puts the cursor on a row, or clears it with null. A region outside options clears it.
moveActive
Section titled “moveActive”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
Section titled “select”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
Section titled “destroy”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.
bindRegionPicker
Section titled “bindRegionPicker”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,
ArrowDownandArrowUpopen a closed list and move the cursor in an open one.Enterpicks the cursor’s row, moves focus toreturnFocusTo, and closes. While the list is open,Enternever submits a surrounding form.Escapereturns 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, andaria-controls. On the search field, or on the trigger without one,role="combobox"andaria-controls, plusaria-autocomplete="list"on the search field. On the list, an id,role="listbox", andtabindex="-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
Section titled “revealCursor”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
Section titled “destroy”destroy(): void;Removes every listener, the document-level one included. Idempotent. The elements and the attributes the binding wrote stay.
attachRegionPicker
Section titled “attachRegionPicker”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, anddata-region. Rows are cached per option object and reused across searches. A localized list yields new rows. - The popup’s
hidden,aria-expandedon the trigger and the combobox host,aria-selectedon the selected row,data-activeon the cursor’s row, andaria-activedescendanton 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: falseleaves 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
Section titled “destroy”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.