SwSelect

SwSelect is a dropdown selector: options are tucked into a popup and the trigger stays collapsed. Single- and multi-select live in one component (the multiple switch), with support for clearing (clearable), grouping (the option group field), searching (searchable), and create-on-type (creatable). It's a controlled v-model — a single value for single-select, an array for multi-select.

It's a thin shell over reka-ui: the default branch is built on the Select primitives; with searchable it internally switches to the Combobox primitives (the trigger becomes an input) while keeping the same props / v-model / slots. The popup is self-rendered (not the OS popup of a native <select>), so it's fully customizable; keyboard navigation, popup positioning, and a11y all work out of the box. Visuals follow a "minimal necessary baseline" — the trigger, popup, selected, highlighted, and disabled states are all distinguishable by default; colors, corner radius, etc. are left to the applier to override.

When to use: reach for this component (a dropdown) when there are many options and you need to save horizontal space. When you have few options (2–5), want them all visible, and want to save a click, use SwSegmented (laid out flat). The two are complementary — much like antd ships both Select and Segmented.

Single select

Single select (status filter)

加载中...
<script setup lang="ts">
const status = ref()
</script>

<template>
  <SwSelect
    v-model="status"
    :options="[
      { value: 'all', label: '全部状态' },
      { value: 'ongoing', label: '连载中' },
      { value: 'completed', label: '已完结' },
    ]"
    placeholder="选择状态"
    aria-label="状态筛选"
  />

  <!-- Disable the whole select: lock it while the parent form is pending / no permission -->
  <SwSelect v-model="status" :options="options" disabled />
</template>

Multi-select + clearable

multiple turns v-model into an array; clearable shows a one-click clear button when there's a selected value. In multi-select mode the trigger shows "已选 N 项" (N selected) by default; to render tags / chips, go through the #trigger slot:

Multi-select + clearable (trigger shows a count)

加载中...

Grouping

When options carry a group field, consecutive same-named ones are merged into one group and a group title is rendered at the head of the group; without group the list is flat:

Option grouping (municipalities / Zhejiang)

加载中...
<template>
  <SwSelect
    v-model="city"
    :options="[
      { value: 'bj', label: '北京', group: '直辖市' },
      { value: 'sh', label: '上海', group: '直辖市' },
      { value: 'hz', label: '杭州', group: '浙江' },
    ]"
  />
</template>

Searchable

searchable turns the trigger into an input: typing filters options locally by label (case-insensitive substring match; groups whose items are all filtered out are hidden), and ↑↓ + Enter selects. Use it when there are dozens or hundreds of options (cast & crew / tags / cities); when the set is too large to fetch at once (a media library / all users), use remote search below.

Searchable single select (type to filter)

加载中...

In single-select mode the input shows the selected label (after the popup opens, the list isn't filtered until you type — so the selection doesn't lock the list); in multi-select mode selected items render as chips (label + ✕ to remove; Backspace on an empty input removes the last one).

Remote search (remote + @search + loading)

Three pieces combine into "search one, pick one from a large set": remote turns off local label filtering (options is the remote result set — items the server matched by pinyin / alias / id may not contain the keyword in their label, and a second local pass would hide them); @search(term) hands the user's input to the business (debounced by searchDebounce, default 300ms; clearing emits '' immediately so you can reset the list); loading shows a "Loading…" row in the popup and suppresses the "no matches" empty state, keeping the current list in place (no flicker on every keystroke).

While results are stale (the user has typed but results haven't arrived: the debounce window ∪ loading), the current list stays in place but can't be selected (options disabled + dimmed) — staying prevents layout jumps, non-selectable prevents "clicking fast and picking an item unrelated to the current query"; the creatable "create" item is hidden for the same period. When options is swapped out, the selected item keeps its label even if it's absent from the new result set — the single-select echo and multi-select chips don't go blank. The component caches labels of values that were selected (only selected ones, so it stays bounded); if a value's label changes later, the latest one wins.

Two semantics belong to you: the '' from @search only means "the search term was cleared" — resetting / restoring the initial list / not searching is your call, it does not mean "search everything"; and with remote, the creatable exact-match check only catches duplicates within the loaded page — the real dedupe guarantee lives on the server (a unique constraint).

Remote search (simulated 500ms API, also matches by director name)

加载中...
<SwSelect v-model="mediaId" searchable remote :loading="pending" :options="mediaOptions" @search="onSearch" />
async function onSearch(term: string) {
  pending.value = true;
  mediaOptions.value = await api.media.options({ keyword: term }); // replace the result set
  pending.value = false;
}

Create-on-type (creatable, tag-picking scenario)

creatable (effective only with searchable) appends a "创建 "xxx"" (create "xxx") item at the end of the list when the input has no exact match (case-insensitive). Selecting it does not change v-model — it only emits @create (payload is the trimmed input text); the business creates the item on the backend, then pushes the new option into options + v-model itself. multiple + searchable + creatable together form the tag-input shape:

Tag picking: multi-select + search + create-on-type

加载中...
<script setup lang="ts">
async function onCreateTag(name: string) {
  const tag = await createTag(name) // business calls the backend
  tagOptions.value.push({ value: tag.id, label: tag.name })
  tagIds.value = [...tagIds.value, tag.id] // push into options + v-model
}
</script>

<template>
  <SwSelect v-model="tagIds" multiple searchable creatable :options="tagOptions" @create="onCreateTag" />
</template>

Props

NameTypeRequiredDefaultDescription
modelValue (v-model)string | number | (string | number)[]undefinedControlled value. A single value for single-select, an array for multi-select (multiple)
optionsSwSelectOption[]✓—Option list; each item is { value, label, disabled?, group? }
multiplebooleanfalseMulti-select mode (v-model is an array)
placeholderstring'请选择'Trigger placeholder text when nothing is selected (Chinese for "please select")
disabledbooleanfalseDisable the whole control
clearablebooleanfalseShow a one-click clear button when there's a selected value
searchablebooleanfalseSearchable shape: the trigger becomes an input; typing filters options locally by label (case-insensitive)
creatablebooleanfalseShow a "create "xxx"" item when the input has no exact match, emitting create; effective only with searchable
remotebooleanfalseRemote search: turns off local label filtering (options is the remote result set), pairs with @search; only meaningful with searchable
loadingbooleanfalseRemote search in flight: the popup shows "Loading…" and suppresses the empty state; the current list stays in place
searchDebouncenumber300Debounce for @search in milliseconds; 0 = no debounce

SwSelectOption: { value: string \| number; label: string; disabled?: boolean; group?: string } (importable from the package: import type { SwSelectOption } from '@swift-web/framework')

Events

NamePayloadDescription
update:modelValuevalue | value[] | undefinedSelection changed (on clear: undefined for single-select, [] for multi-select)
update:openopen: booleanPopup opened / closed
clear—Fired when the clear button is clicked
createlabel: string(searchable + creatable) Fired when the "create" item is selected; the component doesn't touch v-model — the business creates the item, then pushes it into options + v-model
searchterm: string(searchable) Fired as the user types, payload is the trimmed search term, debounced by searchDebounce; clearing emits '' immediately. Programmatic label echo on single-select does not fire it; it also fires without remote (local filtering still applies) for analytics / linkage

Slots

NameScopeDescription
trigger{ selectedLabel, selectedOptions, count, hasValue }Customize the trigger content. Renders plain text when omitted. Only effective in the non-searchable branch (with searchable the trigger is an input)
option{ option, selected }Customize a single option's visuals. Renders option.label when omitted

Styling Tokens

Brand the trigger and popup via --sw-select-* tokens (fallbacks are the default look). The popup portals to body, so override its tokens on :root (or any body ancestor) — no :global piercing needed:

TokenDefaultApplies to
--sw-select-trigger-min-width8remtrigger min-width
--sw-select-trigger-min-heightvar(--control-height)trigger min-height
--sw-select-trigger-padding0.25rem 0.75remtrigger padding
--sw-select-trigger-bordervar(--input)trigger border color
--sw-select-trigger-radiusvar(--radius)trigger radius
--sw-select-trigger-bgvar(--background)trigger background
--sw-select-trigger-colorvar(--foreground)trigger text
--sw-select-trigger-font-sizevar(--control-font-size)trigger font size
--sw-select-chip-bgvar(--muted)multi-select chip background (multiple + searchable)
--sw-select-chip-colorvar(--foreground)multi-select chip text
--sw-select-chip-radiuscalc(var(--radius) - 0.25rem)multi-select chip radius
--sw-select-content-max-height18rempopup max height
--sw-select-content-bgvar(--popover)popup background
--sw-select-content-bordervar(--border)popup border color
--sw-select-content-radiusvar(--radius)popup radius
--sw-select-content-shadowvar(--shadow-md)popup shadow
--sw-select-content-font-sizevar(--control-font-size)popup font size
--sw-select-content-z1100Popup stacking level (the fw#332 z knob). The popup is portalled to body — adjust this when it collides with another overlay

Default height / font size come from the form-control semantic tokens --control-height (32px) / --control-font-size (14px) — SwInput / SwSelect / SwSegmented align to the same height out of the box; override these two tokens to scale every control site-wide (component-level tokens still take precedence).

Caveats

  • The popup is teleported to body via a Portal: to change popup styles (e.g. font size), the applier must use a global / :global selector (a scoped :deep can't reach it), targeting .sw-select-content.
  • No built-in visual branding: the component ships only the minimal necessary baseline styles; colors, corner radius, etc. are left to the applier to override.
  • The multi-select trigger shows "已选 N 项" (N selected) by default: to render tags / chips, customize it via the #trigger slot.
  • searchable filters locally by default: only a case-insensitive substring match on label; remote search must add remote to turn it off, otherwise items the server matched by alias / pinyin get hidden by the local pass. The data source is still yours (the component only hands out the @search term and takes back options).
  • @create doesn't change v-model: selecting the "create" item only emits the event — the business must push the new option into options and update v-model, otherwise nothing visibly changes.
  • The #trigger slot has no effect with searchable: the searchable trigger is an input (chips + input in multi-select); there's no custom-trigger slot in that shape.
  • In multiple mode the v-model must be an array: initializing it as a single value (e.g. ref('')) for multi-select breaks the selected state and the "N selected" count and corrupts the type — use ref([]) for multiple, a single value for single-select.
  • Groups are formed by consecutive same-name runs: options sharing a group must be adjacent; a same-named group scattered across the list renders as multiple separate group blocks — sort options by group before passing them in.

See Also