Select
A single-select field opening a dropdown (MD) or picker sheet (iOS).
A single-select form control. The trigger renders as an input-like field; opening reveals a platform-appropriate list — an anchored dropdown on Material (flipping above when short on room) and a bottom-sheet picker on iOS.
Installation
npx touchcn add select
Usage
<tcn-select [(value)]="fruit" label="Favorite fruit" placeholder="Choose a fruit">
<tcn-select-option value="apple">Apple</tcn-select-option>
<tcn-select-option value="banana">Banana</tcn-select-option>
</tcn-select>import { TcnSelect, TcnSelectOption } from '@/components/ui/select';
<TcnSelect value={fruit} onValueChange={setFruit} label="Favorite fruit">
<TcnSelectOption value="apple">Apple</TcnSelectOption>
<TcnSelectOption value="banana">Banana</TcnSelectOption>
</TcnSelect><script setup lang="ts">
import { TcnSelect, TcnSelectOption } from '@/components/ui/select';
</script>
<template>
<TcnSelect v-model="fruit" label="Favorite fruit">
<TcnSelectOption value="apple">Apple</TcnSelectOption>
<TcnSelectOption value="banana">Banana</TcnSelectOption>
</TcnSelect>
</template>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string | null |
null |
Selected value (two-way on Angular). |
onValueChange |
(value: string) => void |
— | Selection callback (React). |
label |
string |
'' |
Field label. |
placeholder |
string |
'Select…' |
Shown when nothing is selected. |
disabled |
boolean |
false |
Disable the control. |
Source · Angular
export * from './tcn-select';import {
booleanAttribute,
Component,
computed,
contentChildren,
ElementRef,
inject,
input,
model,
} from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';
import { TcnAnchoredPositionDirective } from '@touchcn/angular/positioning';
import { TcnSelectListboxDirective } from '@touchcn/angular/select';
/**
* Single-select form control. The trigger renders as an input-like field
* (label · value · chevron). Opening reveals a platform-appropriate list: an
* anchored dropdown on MD (flips above when short on room) and a bottom-sheet
* picker on iOS — one panel, forked by the cascade. Behaviour is composed from
* the shared overlay primitive (focus trap, scroll lock, Escape, backdrop) plus
* the engine listbox directive (placement measurement, arrow-key roving).
*
* Options are projected `tcn-select-option`s; the selected value is a two-way
* `value` model.
*/
@Component({
selector: 'tcn-select',
imports: [TcnOverlayDirective, TcnAnchoredPositionDirective, TcnSelectListboxDirective],
template: `
<div class="tcn-select relative block">
@if (label()) {
<span class="tcn-select-label" [id]="labelId">{{ label() }}</span>
}
<button
#trigger
type="button"
class="tcn-select-trigger"
[attr.aria-expanded]="open()"
[attr.aria-labelledby]="label() ? labelId : null"
aria-haspopup="listbox"
[disabled]="disabled()"
(click)="toggle()"
>
<span class="tcn-select-value" [class.tcn-select-placeholder]="!selectedLabel()">
{{ selectedLabel() || placeholder() }}
</span>
<svg class="tcn-select-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="tcn-select-overlay" [class.pointer-events-none]="!open()" [attr.data-state]="state()">
<div class="tcn-overlay-backdrop tcn-select-backdrop fixed inset-0 z-40" (click)="overlay.dismiss()"></div>
<div
#overlay="tcnOverlay"
tcnOverlay
tcnAnchoredPosition
[(open)]="open"
[trigger]="trigger"
class="tcn-overlay-panel tcn-select-panel z-50"
>
<div
tcnSelectListbox
role="listbox"
[attr.aria-labelledby]="label() ? labelId : null"
>
<ng-content />
</div>
</div>
</div>
</div>
`,
host: { class: 'block' },
})
export class TcnSelect {
readonly value = model<string | null>(null);
readonly placeholder = input('Select…');
readonly label = input('');
readonly disabled = input(false, { transform: booleanAttribute });
readonly open = model(false);
protected readonly labelId = `tcn-select-label-${nextId++}`;
private readonly options = contentChildren(TcnSelectOption);
protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));
protected readonly selectedLabel = computed(() => {
const selected = this.options().find((option) => option.value() === this.value());
return selected?.text() ?? '';
});
select(value: string): void {
this.value.set(value);
this.open.set(false);
}
protected toggle(): void {
this.open.update((open) => !open);
}
}
let nextId = 0;
/** A single option projected into a `tcn-select`. */
@Component({
selector: 'tcn-select-option',
template: `
<button
type="button"
role="option"
class="tcn-select-option"
[attr.aria-selected]="selected()"
[attr.aria-disabled]="disabled() || null"
[disabled]="disabled()"
(click)="pick()"
>
<span class="tcn-select-option-label"><ng-content /></span>
<svg class="tcn-select-option-check" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12l5 5L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
`,
host: { class: 'block' },
})
export class TcnSelectOption {
private readonly select = inject(TcnSelect);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly value = input.required<string>();
readonly disabled = input(false, { transform: booleanAttribute });
protected readonly selected = computed(() => this.select.value() === this.value());
/** The option's visible text, used by the trigger to show the selected label. */
text(): string {
return this.host.nativeElement.textContent?.trim() ?? '';
}
protected pick(): void {
if (!this.disabled()) {
this.select.select(this.value());
}
}
}Source · React
export * from './tcn-select';import { Children, createContext, isValidElement, useContext, useId, useRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { useListboxKeyNav, useMenuPosition, useOverlayPresence } from '@touchcn/react';
interface SelectContextValue {
value: string | null;
onSelect(value: string): void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
export interface TcnSelectProps {
value: string | null;
onValueChange(value: string): void;
placeholder?: string;
label?: string;
disabled?: boolean;
children?: ReactNode;
}
interface TcnSelectOptionProps {
value: string;
disabled?: boolean;
children?: ReactNode;
}
/**
* Single-select form control. The trigger renders as an input-like field
* (label · value · chevron). Opening reveals a platform-appropriate list: an
* anchored dropdown on MD (flips above when short on room) and a bottom-sheet
* picker on iOS — one panel, forked by the cascade.
*
* Hand-rolled on the engine overlay/presence primitives rather than
* `@radix-ui/react-select`: Radix Select owns a single popper presentation and
* its own trigger/positioning contract, which fights the dual iOS-sheet / MD-
* dropdown markup this component forks with `theme.css`. `useOverlayPresence`
* (mount/animate), `useMenuPosition` (flip) and `useListboxKeyNav` (roving
* focus + Escape) keep the copied markup identical to the Angular component.
*/
export function TcnSelect({
value,
onValueChange,
placeholder = 'Select…',
label,
disabled,
children,
}: TcnSelectProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const { present, active, panelRef } = useOverlayPresence(open);
const placement = useMenuPosition(triggerRef, active);
const close = () => setOpen(false);
const { onKeyDown } = useListboxKeyNav(panelRef, active, close);
const labelId = useId();
const selected = Children.toArray(children).find(
(child): child is ReactElement<TcnSelectOptionProps> =>
isValidElement<TcnSelectOptionProps>(child) && child.props.value === value,
);
const selectedLabel = selected?.props.children;
const contextValue: SelectContextValue = {
value,
onSelect: (next) => {
onValueChange(next);
setOpen(false);
},
};
return (
<div className="tcn-select relative block">
{label && (
<span className="tcn-select-label" id={labelId}>
{label}
</span>
)}
<button
ref={triggerRef}
type="button"
className="tcn-select-trigger"
aria-expanded={open}
aria-labelledby={label ? labelId : undefined}
aria-haspopup="listbox"
disabled={disabled}
onClick={() => setOpen((prev) => !prev)}
>
<span className={`tcn-select-value${selectedLabel ? '' : ' tcn-select-placeholder'}`}>
{selectedLabel || placeholder}
</span>
<svg className="tcn-select-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{present && (
<div className="tcn-select-overlay" data-state={active ? 'open' : 'closed'}>
<div className="tcn-overlay-backdrop tcn-select-backdrop fixed inset-0 z-40" onClick={close} />
<div
ref={panelRef}
role="listbox"
aria-labelledby={label ? labelId : undefined}
data-placement={placement}
className="tcn-overlay-panel tcn-select-panel z-50"
onKeyDown={onKeyDown}
>
<SelectContext.Provider value={contextValue}>{children}</SelectContext.Provider>
</div>
</div>
)}
</div>
);
}
/** A single option projected into a `TcnSelect`. */
export function TcnSelectOption({ value, disabled, children }: TcnSelectOptionProps) {
const context = useContext(SelectContext);
const selected = context?.value === value;
return (
<button
type="button"
role="option"
className="tcn-select-option"
aria-selected={selected}
aria-disabled={disabled || undefined}
disabled={disabled}
onClick={() => !disabled && context?.onSelect(value)}
>
<span className="tcn-select-option-label">{children}</span>
<svg className="tcn-select-option-check" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12l5 5L19 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}Source · Vue
<script setup lang="ts">
import { computed, defineComponent, provide, ref, useId, useSlots } from 'vue';
import { resolveSelectedLabel, useListboxKeyNav, useMenuPosition, useOverlayPresence } from '@touchcn/vue';
import { TcnSelectKey } from './context';
withDefaults(defineProps<{ placeholder?: string; label?: string; disabled?: boolean }>(), {
placeholder: 'Select…',
});
/** Two-way bound selected value (`v-model`). */
const model = defineModel<string | null>({ default: null });
/**
* Single-select form control. The trigger renders as an input-like field
* (label · value · chevron). Opening reveals a platform-appropriate list: an
* anchored dropdown on MD (flips above when short on room) and a bottom-sheet
* picker on iOS — one panel, forked by the cascade.
*
* Hand-rolled on the engine overlay/presence primitives rather than a reka-ui
* Select: reka's select owns a single popper presentation and its own
* trigger/positioning contract, which fights the dual iOS-sheet / MD-dropdown
* markup this component forks with `theme.css`. `useOverlayPresence`
* (mount/animate), `useMenuPosition` (flip) and `useListboxKeyNav` (roving focus
* + Escape) keep the copied markup identical to the React / Angular component.
*/
const slots = useSlots();
const open = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const { present, active, setPanel } = useOverlayPresence(open);
const placement = useMenuPosition(triggerRef, active);
const close = (): void => {
open.value = false;
};
const { onKeyDown } = useListboxKeyNav(panelRef, active, close);
const labelId = useId();
const setPanelBoth = (el: unknown): void => {
const node = el instanceof HTMLElement ? el : null;
setPanel(node);
panelRef.value = node;
};
const selectedLabel = computed(() => resolveSelectedLabel(slots.default?.(), model.value));
const SelectedLabel = defineComponent({ name: 'TcnSelectValue', render: () => selectedLabel.value });
provide(TcnSelectKey, {
value: computed(() => model.value),
select: (next: string) => {
model.value = next;
open.value = false;
},
});
</script>
<template>
<div class="tcn-select relative block">
<span v-if="label" class="tcn-select-label" :id="labelId">{{ label }}</span>
<button
ref="triggerRef"
type="button"
class="tcn-select-trigger"
:aria-expanded="open"
:aria-labelledby="label ? labelId : undefined"
aria-haspopup="listbox"
:disabled="disabled"
@click="open = !open"
>
<span :class="['tcn-select-value', !selectedLabel && 'tcn-select-placeholder']">
<SelectedLabel v-if="selectedLabel" />
<template v-else>{{ placeholder }}</template>
</span>
<svg class="tcn-select-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div v-if="present" class="tcn-select-overlay" :data-state="active ? 'open' : 'closed'">
<div class="tcn-overlay-backdrop tcn-select-backdrop fixed inset-0 z-40" @click="close" />
<div
:ref="setPanelBoth"
role="listbox"
:aria-labelledby="label ? labelId : undefined"
:data-placement="placement"
class="tcn-overlay-panel tcn-select-panel z-50"
@keydown="onKeyDown"
>
<slot />
</div>
</div>
</div>
</template><script setup lang="ts">
import { computed, inject } from 'vue';
import { TcnSelectKey } from './context';
const props = defineProps<{ value: string; disabled?: boolean }>();
/** A single option projected into a `TcnSelect`. */
const context = inject(TcnSelectKey, null);
const selected = computed(() => context?.value.value === props.value);
const onClick = (): void => {
if (!props.disabled) {
context?.select(props.value);
}
};
</script>
<template>
<button
type="button"
role="option"
class="tcn-select-option"
:aria-selected="selected"
:aria-disabled="disabled || undefined"
:disabled="disabled"
@click="onClick"
>
<span class="tcn-select-option-label"><slot /></span>
<svg class="tcn-select-option-check" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12l5 5L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>import type { ComputedRef, InjectionKey } from 'vue';
export interface TcnSelectContext {
/** The currently selected value. */
value: ComputedRef<string | null>;
/** Commit a selection and close the menu. */
select(value: string): void;
}
/** Injection key shared between `TcnSelect` and its projected `TcnSelectOption`s. */
export const TcnSelectKey: InjectionKey<TcnSelectContext> = Symbol('tcn-select');export { default as TcnSelect } from './TcnSelect.vue';
export { default as TcnSelectOption } from './TcnSelectOption.vue';