Skip to content

OSelect ​

Choose stable IDs from local objects or a paginated asynchronous source, while retaining selected profiles independently of the visible page.

Usage ​

Step by step ​

  1. Import OSelect and define a typed options collection.
  2. Provide getOptionValue and getOptionLabel so the component can preserve values while presenting readable labels.
  3. Bind the selected value with v-model; add multiple, searchable, or loadOptions only when the use case needs them.

Example ​

The rendered example and its source come from the same Vue single-file component.

No selection.

Import ooko-ui/style.css globally before consumer overrides. The root named export and full default Ooko plugin also provide OSelect; the full plugin includes every component. The packer currently combines all component CSS into one stylesheet, so JavaScript tree-shaking does not imply isolated component CSS.

Interactive playground ​

Change the label, size, searchable and multiple modes, guidance, and control states. Open the select to inspect the result.

No selection.

Helpful context appears here.

Props ​

PropTypeDefaultDescription
sizeOokoFormControlSize"md"Controls the select's form-control scale.
labelstringundefinedVisible or visually hidden accessible label.
optionsreadonly T[]undefinedLocal options; with loadOptions, only the initially selected objects (pass [] when nothing is selected).
getOptionValue(option: T) => VundefinedReturns the stable, unique string or number ID for an option.
getOptionLabel(option: T) => stringundefinedReturns the plain-text accessible label for an option.
searchablebooleanfalseDisplays a search field inside the popup.
loadOptionsOSelectLoader<T>undefinedLoads a remote page when the popup, query, or page changes.
pageSizenumber20Sets the local slice size and remote request page size.
debouncenumber250Sets the remote search delay in milliseconds.
placeholderstring"Select…"Content displayed when no value is selected.
idstringundefinedTrigger identifier; a hydration-safe identifier is generated when omitted.
namestringundefinedNative form field name and OForm validation key.
hintstringundefinedPersistent field guidance.
errorstringundefinedControlled validation error, separate from request errors.
validationOokoValidationBindingundefinedExplicit binding created by useOokoValidation.
hideLabelbooleanfalseVisually hides the label while preserving its accessible name.
requiredbooleanfalseRequires at least one selected value.
disabledbooleanfalsePrevents interaction and native form submission.
readonlybooleanfalseKeeps the trigger focusable while preventing selection changes.
labelsPartial<OSelectLabels>{}Replaces some or all built-in English texts, such as screen reader announcements.
glassbooleanfalseUses a translucent, blurred popup with a thin border. Defaults to the app's glass option.
multiplebooleanfalseUses single-selection mode. Uses multiple-selection mode.
modelValueV | readonly V[] | nullundefinedControlled selected ID in single-selection mode. Controlled selected IDs in multiple-selection mode.

Events ​

EventPayloadDescription
update:modelValueV | null | V[]Emits a selected ID or a new array of selected IDs.

Slots ​

SlotPropsDescription
header{ /** Option count and page, or the loading or error message. */ status: string; }Replaces the list header text inside the popup's polite status region; defaults to the status.
option{ option: T; selected: boolean }Customizes an option while preserving its accessible label.
selection{ option: T | undefined; value: V }Customizes the rendered value for one selected ID.
TypeDefinition
OSelectValuetype OSelectValue = string | number;
OSelectCommonPropsinterface OSelectCommonProps<T extends object, V extends OSelectValue> { /** Controls the select's form-control scale. */ size?: OokoFormControlSize; /** Visible or visually hidden accessible label. */ label: string; /** Local options; with loadOptions, only the initially selected objects (pass []when nothing is selected). */ options: readonly T[]; /** Returns the stable, unique string or number ID for an option. */ getOptionValue: (option: T) => V; /** Returns the plain-text accessible label for an option. */ getOptionLabel: (option: T) => string; /** Displays a search field inside the popup. */ searchable?: boolean; /** Loads a remote page when the popup, query, or page changes. */ loadOptions?: OSelectLoader<T>; /** Sets the local slice size and remote request page size. */ pageSize?: number; /** Sets the remote search delay in milliseconds. */ debounce?: number; /** Content displayed when no value is selected. */ placeholder?: string; /** Trigger identifier; a hydration-safe identifier is generated when omitted. */ id?: string; /** Native form field name and OForm validation key. */ name?: string; /** Persistent field guidance. */ hint?: string; /** Controlled validation error, separate from request errors. */ error?: string; /** Explicit binding created by useOokoValidation. */ validation?: OokoValidationBinding; /** Visually hides the label while preserving its accessible name. */ hideLabel?: boolean; /** Requires at least one selected value. */ required?: boolean; /** Prevents interaction and native form submission. */ disabled?: boolean; /** Keeps the trigger focusable while preventing selection changes. */ readonly?: boolean; /** Replaces some or all built-in English texts, such as screen reader announcements. */ labels?: Partial<OSelectLabels>; /** Uses a translucent, blurred popup with a thin border. Defaults to the app'sglass option. */ glass?: boolean; }
OSelectLabelsinterface OSelectLabels { /** Announces the selection; receives the selected labels joined with ", ". Default: Selected: {labels}.*/ selected: (labels: string) => string; /** Announces an empty selection. Default:No selection.*/ noSelection: string; /** Announces a required select. Default:Required.*/ required: string; /** Announces a read-only select. Default:Read only.*/ readonly: string; /** Validation message when a required select is empty. Default:Select an option.*/ requiredError: string; /** Announces the number of listed options. Default: option(s).*/ optionCount: (count: number) => string; /** Announces the current page;pageCountis undefined when unknown. Default:Page {page} of {pageCount}.*/ page: (page: number, pageCount: number | undefined) => string; /** Announces a pending remote request. Default:Loading options…*/ loading: string; /** Announces a failed remote request. Default:Could not load options.*/ loadError: string; /** Shown when no option matches. Default:No options found.*/ noOptions: string; /** Text of the button that repeats a failed remote request. Default:Retry*/ retry: string; /** Names the search field; receives the select label. Default:Search*/ search: (label: string) => string; /** Names a selected chip's remove button; receives the option label. Default:Remove {label}*/ remove: (label: string) => string; /** Names the popup close button. Default:Close*/ close: string; /** Names the options pagination. Default:Options pagination */ pagination: string; }
OSelectLoadertype OSelectLoader<T> = (request: OSelectLoadRequest) => Promise<OSelectLoadResult<T>>;
OSelectLoadRequestinterface OSelectLoadRequest { query: string; page: number; pageSize: number; signal: AbortSignal; }
OSelectLoadResulttype OSelectLoadResult<T> = OSelectOptionPage<T> | OSelectLaravelPage<T> | OSelectLaravelResourcePage<T>;
OSelectOptionPageinterface OSelectOptionPage<T> { items: readonly T[]; hasNextPage: boolean; }
OSelectLaravelPageinterface OSelectLaravelPage<T> { data: readonly T[]; current_page: number; last_page?: number; next_page_url: string | null; }
OSelectLaravelResourcePageinterface OSelectLaravelResourcePage<T> { data: readonly T[]; links: { next: string | null; }; meta: { current_page: number; last_page?: number; }; }
OokoFormControlSizetype OokoFormControlSize = "sm" | "md";

CSS custom properties ​

Custom propertyDefaultDescription
--o-select-panel-width22remPreferred popup width, bounded by available viewport width and at least the trigger width when space permits.
--o-select-panel-radiusvar(--o-radius-surface)Popup corner radius.
--o-select-option-paddingvar(--o-space-2) var(--o-space-3)Option spacing.
--o-select-list-height16remMaximum scrollable list height.

Override example ​

css
.customer-select {
  --o-select-panel-width: 28rem;
  --o-select-list-height: 20rem;
  --o-select-option-padding: 0.75rem 1rem;
}

Accessibility ​

A native labelled button opens an ODropdown nonmodal dialog containing a header with a polite status and an icon close button, optional search, a listbox and, when results span several pages, pagination. It does not use aria-modal or trap focus. Pagination controls are outside the listbox, as required by the APG listbox interaction model. ODropdown owns positioning, dismissal and popup focus while OSelect owns selection behavior.

Enter, Space or Arrow Down on the trigger opens the popup. Search receives initial focus when present; otherwise the listbox does. Arrow Up/Down, Home and End move the active option; Enter/Space select or toggle it. Typing a character in the listbox cycles matching initial letters on the current page. Selection does not follow focus. Tab traverses the close button, search, listbox and enabled buttons in document order. A page change returns focus to the listbox and resets its active option. Escape or the close button (accessible name "Close") returns focus to the trigger; leaving the popup or clicking elsewhere closes it without stealing focus. Escape is stopped at this popup so an enclosing panel does not also close.

Selected state includes a check mark and aria-selected. The trigger's description includes selected labels, required and read-only states. Loading, errors and page results use a polite status in the list header. The header slot replaces its visible text and renders inside that status region, so custom content should stay short, plain text that reflects status. In multiple mode, each selected value renders as a small OChip whose close button has an individual accessible name. Disabled and read-only chips do not expose close buttons; read-only values remain submitted. Slots must not contain links, buttons or other interactive content. A field label and plain-text option names remain mandatory even with custom rendering.

These announcements, the empty-list text, the retry button, the required error, and the close, search, remove, and pagination names are English by default. Translate some or all of them with the labels prop, or once for the app through defaultProps.OSelect.labels; missing keys keep their English text:

ts
app.use(Ooko, {
  defaultProps: {
    OSelect: {
      placeholder: "Choisir…",
      labels: {
        selected: (labels) => `Sélection : ${labels}.`,
        noSelection: "Aucune sélection.",
        required: "Obligatoire.",
        optionCount: (count) => `${count} option${count > 1 ? "s" : ""}.`,
      },
    },
  },
});

Required validation is integrated with OForm and blur, not the browser's native required algorithm for buttons. Use OForm for blocked submission and first-invalid focus, or provide a controlled error/custom validation flow with native forms. Hidden inputs serialize IDs to strings; resetting a form resets validation, and the parent must reset the controlled model.

Keyboard and browser accessibility-tree inspection do not replace testing with a screen reader. No manual VoiceOver/NVDA speech validation is claimed. The popup fades and scales in and out, the trigger chevron rotates, and option hover and selection check marks animate briefly; prefers-reduced-motion: reduce removes all of these. Forced-colors focus/selection outlines and semantic theme colors are provided.

Behavior and edge cases ​

Single mode emits a scalar and closes; multiple mode emits a fresh array, stays open and displays each controlled value in a primary small chip. The selected display only changes when the parent updates the model. IDs must be unique and stable within a component; string "1" and number 1 are distinct. Supply every initial selected object in options, especially for edit forms. Unknown IDs display their string value and receive undefined in the selection slot until an object is supplied or loaded. Selected objects are cached per instance; unselected objects from previous remote pages are discarded. Updated supplied objects take precedence over cached objects.

Local search is a case-insensitive substring match on labels. Local results are sliced into pages and changing the query resets page one. Pagination renders only while a later or earlier page exists: it is hidden when all local results fit on one page or a remote first page reports no next page, and it stays visible while a later page loads. Remote requests start only when the popup opens, never during SSR or hydration. The loader receives { query, page, pageSize, signal }, with pages starting at one. It may resolve Ooko's { items, hasNextPage }, a Laravel paginator response with data and next_page_url, or a paginated API Resource with data, links and meta. Laravel's last_page is displayed when available; a total count is not required. Search is debounced, resets to page one, aborts the previous request immediately and ignores late results even if the loader ignores abort. Page changes replace displayed objects; selected profiles remain available. Closing or unmounting cancels timers and requests. Errors expose Retry for the same query/page; Previous remains available after a failed later page. Opening again retries the current query/page.

ts
import type { OSelectLaravelPage, OSelectLoader } from "ooko-ui/select";

const loadPeople: OSelectLoader<Person> = ({ query, page, pageSize, signal }) =>
  peopleApi.get<OSelectLaravelPage<Person>>("/api/people", {
    params: { search: query, page, per_page: pageSize },
    signal,
  });
// Editing Alice: modelValue = [1], multiple = true, options = [alice].
// Searching Bob or Claire never removes Alice's profile from the selected display.

Return the response body, not the surrounding HTTP client response. Laravel paginate and simplePaginate payloads work through next_page_url; standard paginated API Resources work through links.next. Cursor pagination still needs an application adapter because the loader contract navigates by numeric page.

The popup remains inside the field's DOM to preserve theme inheritance and natural tab order. It uses fixed positioning, flip/shift and available-space sizing; ancestor transforms/containment and overflow can still constrain it. It is not teleported to the document body or placed in the modal top layer. Custom parent overlays must respect handled Escape events and normal descendant focus. No Nuxt-version-specific integration is claimed.

App defaults may configure size, searchable, and placeholder; direct props take precedence. The resolved size is propagated to the internal OField.

The popup header, with its live option count and close button, is visually hidden: screen readers still announce the count, and the header appears only while keyboard focus reaches its close button. Escape and focus leaving the popup also close it. glass draws the popup as a translucent, blurred panel with a thin border, tuned by the --o-dropdown-glass-* properties; it follows the app's glass option unless set on the select.