feat(app): polish select-v2 component (#30446)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Aarav Sareen 2026-06-03 11:34:28 +05:30 committed by GitHub
parent 2538c0d083
commit 134a5c818a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 306 deletions

View File

@ -2,7 +2,7 @@ import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/select-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
@ -289,7 +289,7 @@ export const SettingsGeneralV2: Component = () => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
onSelect: (option: (typeof soundOptions)[number] | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
@ -310,8 +310,11 @@ export const SettingsGeneralV2: Component = () => {
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={languageOptions()}
placement="bottom-end"
gutter={6}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
@ -333,9 +336,12 @@ export const SettingsGeneralV2: Component = () => {
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
@ -505,9 +511,12 @@ export const SettingsGeneralV2: Component = () => {
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
@ -531,9 +540,12 @@ export const SettingsGeneralV2: Component = () => {
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
@ -671,6 +683,7 @@ export const SettingsGeneralV2: Component = () => {
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
@ -678,6 +691,8 @@ export const SettingsGeneralV2: Component = () => {
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
@ -686,6 +701,7 @@ export const SettingsGeneralV2: Component = () => {
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
@ -693,6 +709,8 @@ export const SettingsGeneralV2: Component = () => {
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
@ -701,6 +719,7 @@ export const SettingsGeneralV2: Component = () => {
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
@ -708,6 +727,8 @@ export const SettingsGeneralV2: Component = () => {
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>

View File

@ -153,14 +153,12 @@
width: 100%;
}
[data-component="settings-v2-dialog"] [data-component="select"][data-trigger-style="settings-v2"] {
[data-component="settings-v2-dialog"] [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
}
[data-component="settings-v2-dialog"]
[data-component="select"][data-trigger-style="settings-v2"]
[data-component="button-v2"] {
[data-component="settings-v2-dialog"] [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}

View File

@ -1,87 +0,0 @@
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"] {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
width: fit-content;
height: 24px;
padding: 4px 4px 4px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
cursor: pointer;
}
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger-value"] {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
height: 13px;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
}
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2.5px;
}
[data-component="select"][data-trigger-style="settings-v2"]
[data-slot="select-select-trigger"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="select"][data-trigger-style="settings-v2"]
[data-slot="select-select-trigger"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger-icon"] {
display: flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
[data-component="select"][data-trigger-style="settings-v2"]
[data-slot="select-select-trigger-icon"]
[data-slot="icon-svg"] {
margin-inline: -5px;
color: #3a3a3a;
}
[data-component="select-content"][data-trigger-style="settings-v2"] {
min-width: 160px;
border-radius: 8px;
padding: 0;
[data-slot="select-select-content-list"] {
padding: 4px;
}
[data-slot="select-select-item"] {
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
}
}

View File

@ -1,22 +0,0 @@
// @ts-nocheck
import * as mod from "./select-v2"
import { create } from "../storybook/scaffold"
const story = create({
title: "UI/SelectV2",
mod,
args: {
options: ["One", "Two", "Three"],
current: "One",
placeholder: "Choose...",
},
})
export default {
title: "UI/SelectV2",
id: "components-select-v2",
component: story.meta.component,
tags: ["autodocs"],
}
export const Basic = story.Basic

View File

@ -1,171 +0,0 @@
import { Select as Kobalte } from "@kobalte/core/select"
import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
import { pipe, groupBy, entries, map } from "remeda"
import { Icon as IconV2 } from "../v2/components/icon"
import { Icon } from "./icon"
import "./select-v2.css"
export type SelectV2Props<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & {
placeholder?: string
options: T[]
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
valueClass?: ComponentProps<"div">["class"]
onSelect?: (value: T | undefined) => void
onHighlight?: (value: T | undefined) => (() => void) | void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children?: (item: T | undefined) => JSX.Element
triggerStyle?: JSX.CSSProperties
triggerProps?: Record<string, string | number | boolean | undefined>
}
export function SelectV2<T>(props: SelectV2Props<T> & { disabled?: boolean }) {
const [local, others] = splitProps(props, [
"class",
"classList",
"placeholder",
"options",
"current",
"value",
"label",
"groupBy",
"valueClass",
"onSelect",
"onHighlight",
"onOpenChange",
"children",
"triggerStyle",
"triggerProps",
])
const state = {
key: undefined as string | undefined,
cleanup: undefined as (() => void) | void,
}
const stop = () => {
state.cleanup?.()
state.cleanup = undefined
state.key = undefined
}
const keyFor = (item: T) => (local.value ? local.value(item) : (item as string))
const move = (item: T | undefined) => {
if (!local.onHighlight) return
if (!item) {
stop()
return
}
const key = keyFor(item)
if (state.key === key) return
state.cleanup?.()
state.cleanup = local.onHighlight(item)
state.key = key
}
onCleanup(stop)
const grouped = createMemo(() => {
const result = pipe(
local.options,
groupBy((x) => (local.groupBy ? local.groupBy(x) : "")),
entries(),
map(([k, v]) => ({ category: k, options: v })),
)
return result
})
return (
// @ts-ignore
<Kobalte<T, { category: string; options: T[] }>
{...others}
data-component="select"
data-trigger-style="settings-v2"
placement="bottom-end"
gutter={4}
value={local.current}
options={grouped()}
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
optionGroupChildren="options"
placeholder={local.placeholder}
sectionComponent={(local) => (
<Kobalte.Section data-slot="select-section">{local.section.rawValue.category}</Kobalte.Section>
)}
itemComponent={(itemProps) => (
<Kobalte.Item
{...itemProps}
data-slot="select-select-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
onPointerEnter={() => move(itemProps.item.rawValue)}
onPointerMove={() => move(itemProps.item.rawValue)}
onFocus={() => move(itemProps.item.rawValue)}
>
<Kobalte.ItemLabel data-slot="select-select-item-label">
{local.children
? local.children(itemProps.item.rawValue)
: local.label
? local.label(itemProps.item.rawValue)
: (itemProps.item.rawValue as string)}
</Kobalte.ItemLabel>
<Kobalte.ItemIndicator data-slot="select-select-item-indicator">
<Icon name="check-small" size="small" />
</Kobalte.ItemIndicator>
</Kobalte.Item>
)}
onChange={(v) => {
local.onSelect?.(v ?? undefined)
stop()
}}
onOpenChange={(open) => {
local.onOpenChange?.(open)
if (!open) stop()
}}
>
<Kobalte.Trigger
{...local.triggerProps}
type="button"
disabled={props.disabled}
data-slot="select-select-trigger"
as="button"
style={local.triggerStyle}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Kobalte.Value<T> data-slot="select-select-trigger-value" class={local.valueClass}>
{(state) => {
const selected = state.selectedOption() ?? local.current
if (!selected) return local.placeholder || ""
if (local.label) return local.label(selected)
return selected as string
}}
</Kobalte.Value>
<Kobalte.Icon data-slot="select-select-trigger-icon">
<IconV2 name="chevron-down" class="-mx-[5px]" />
</Kobalte.Icon>
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
data-component="select-content"
data-trigger-style="settings-v2"
>
<Kobalte.Listbox data-slot="select-select-content-list" />
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View File

@ -1,7 +1,21 @@
@import "./menu-v2.css";
/* Select dropdown: slide down from trigger (no scale-from-corner). */
/* Above modal dialogs (z-index 50); matches legacy select-content. */
[data-popper-positioner]:has([data-slot="select-v2-content"]) {
z-index: 60;
}
/* Dropdown surface (Type=menu) — overrides shared menu-v2 defaults for selects only. */
[data-component="menu-v2-content"][data-slot="select-v2-content"] {
padding: 0;
min-width: 160px;
max-width: 23rem;
overflow: hidden;
z-index: 60;
pointer-events: auto;
background: var(--v2-background-bg-layer-01);
border-radius: 6px;
box-shadow: var(--v2-elevation-floating);
transform-origin: top center;
animation: select-v2-content-in 120ms ease-out;
}
@ -46,8 +60,9 @@
border-radius: 6px;
outline: 1px solid transparent;
outline-offset: 0;
background: linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%), var(--background-bg-base);
box-shadow: var(--elevation-button-neutral);
background:
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
flex: none;
align-self: stretch;
transition:
@ -65,18 +80,24 @@
[data-expanded]
) {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%), var(--background-bg-base);
linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
}
[data-component="select-v2"]:where(:focus-within):not([data-disabled], [data-invalid]),
[data-component="select-v2"]:where([data-expanded]):not([data-disabled], [data-invalid]) {
outline-color: var(--border-border-focus);
background:
linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
outline-color: transparent;
}
[data-component="select-v2"]:where(:focus-within):not([data-disabled], [data-invalid]):not([data-expanded]) {
outline-color: var(--v2-border-border-focus);
box-shadow: none;
}
[data-component="select-v2"]:where([data-invalid]):not([data-disabled]) {
outline-color: var(--state-fg-danger);
outline-color: var(--v2-state-fg-danger);
box-shadow: none;
}
@ -115,13 +136,13 @@
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
cursor: default;
}
[data-component="select-v2"] [data-slot="select-v2-value-text"][data-placeholder-shown] {
color: var(--text-text-faint);
color: var(--v2-text-text-faint);
}
[data-component="select-v2"][data-numeric] [data-slot="select-v2-value-text"] {
@ -129,7 +150,7 @@
}
[data-component="select-v2"]:where([data-invalid]):not([data-disabled]) [data-slot="select-v2-value-text"] {
color: var(--state-fg-danger);
color: var(--v2-state-fg-danger);
}
[data-component="select-v2"] [data-slot="select-v2-chevron"] {
@ -146,7 +167,7 @@
border: 0;
border-radius: 4px;
background: transparent;
color: var(--icon-icon-muted);
color: var(--v2-icon-icon-muted);
pointer-events: none;
}
@ -161,6 +182,49 @@
transform: rotate(0deg);
}
/* Compact trigger for settings rows and similar inline contexts. */
[data-component="select-v2"][data-appearance="inline"] {
width: fit-content;
max-width: 100%;
height: 24px;
padding: 4px 4px 4px 8px;
gap: 4px;
border-radius: 4px;
background: transparent;
box-shadow: none;
outline: none;
align-self: auto;
}
[data-component="select-v2"][data-appearance="inline"]:where(:hover):not([data-disabled], [data-invalid]):not(
:focus-within
):not([data-expanded]) {
background: var(--v2-overlay-simple-overlay-hover);
}
[data-component="select-v2"][data-appearance="inline"]:where([data-expanded]):not([data-disabled], [data-invalid]) {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
box-shadow: none;
}
[data-component="select-v2"][data-appearance="inline"]:where(:active):not([data-disabled], [data-invalid]):not(
[data-expanded]
) {
background: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="select-v2"][data-appearance="inline"] [data-slot="select-v2-value-text"] {
padding: 0;
font-weight: 530;
}
[data-component="select-v2"][data-appearance="inline"] [data-slot="select-v2-chevron"] {
width: 16px;
height: 16px;
padding: 0;
}
/* Listbox inside menu surface */
[data-component="menu-v2-content"][data-slot="select-v2-content"] [data-slot="select-v2-listbox"] {
box-sizing: border-box;
@ -168,13 +232,39 @@
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 0;
padding: 4px;
list-style: none;
min-width: 0;
width: 100%;
max-height: min(320px, 70vh);
max-height: 12rem;
overflow-x: hidden;
overflow-y: auto;
outline: none;
white-space: nowrap;
&:focus {
outline: none;
}
> *:not([role="presentation"]) + *:not([role="presentation"]) {
margin-top: 2px;
}
}
[data-slot="select-v2-listbox"] [data-component="menu-v2-item"],
[data-slot="select-v2-listbox"] [data-slot="menu-v2-group-label"] {
flex: none;
}
[data-slot="select-v2-listbox"] [data-component="menu-v2-item"] {
--menu-v2-fg: var(--v2-text-text-base);
--menu-v2-fg-muted: var(--v2-text-text-faint);
--menu-v2-fg-subtle: var(--v2-text-text-muted);
--menu-v2-icon: var(--v2-icon-icon-base);
--menu-v2-accent: var(--v2-text-text-accent);
--menu-v2-badge-bg: var(--v2-background-bg-layer-02);
--menu-v2-badge-border: var(--v2-border-border-base);
--menu-v2-hover: var(--v2-overlay-simple-overlay-hover);
}
/* Listbox uses data-selected; menu item CSS uses data-checked — mirror accent */

View File

@ -22,7 +22,8 @@ Single-select built on Kobalte with a **TextInput v2** trigger surface and **Men
- \`options\`, \`current\`, \`onSelect\`: controlled selection (\`current\` is the selected option object).
- \`value\` / \`label\`: accessors when options are not plain strings.
- \`groupBy\`: groups options; section headers use menu group label styling.
- \`appearance\`: \`base\` (28px) or \`large\` (32px).
- \`appearance\`: \`base\` (28px), \`large\` (32px), or \`inline\` (compact settings-row trigger).
- \`placement\`, \`gutter\`, \`sameWidth\`, \`flip\`, \`slide\`, \`fitViewport\`: forwarded to Kobalte popper (defaults match legacy \`Select\`: gutter 4, flip/slide on; inline uses \`bottom-end\` and \`sameWidth: false\`).
- \`invalid\`, \`disabled\`, \`numeric\`: match text input conventions.
`
@ -58,7 +59,7 @@ export default {
},
appearance: {
control: "select",
options: ["base", "large"],
options: ["base", "large", "inline"],
},
},
}

View File

@ -53,8 +53,8 @@ export type SelectV2Props<T> = Omit<
groupBy?: (x: T) => string
onSelect?: (value: T | null) => void
onHighlight?: (value: T | undefined) => void | (() => void)
/** Match TextInput v2 height. */
appearance?: "base" | "large"
/** `base` / `large` match text-input-v2; `inline` is a compact settings-row trigger. */
appearance?: "base" | "large" | "inline"
invalid?: boolean
numeric?: boolean
children?: (item: T) => JSX.Element
@ -80,8 +80,16 @@ export function SelectV2<T>(props: SelectV2Props<T>) {
"numeric",
"disabled",
"valueClass",
"placement",
"gutter",
"sameWidth",
"flip",
"slide",
"fitViewport",
])
const inline = () => (local.appearance ?? "base") === "inline"
const state: { key?: string; cleanup?: void | (() => void) } = {}
const stop = () => {
@ -115,8 +123,12 @@ export function SelectV2<T>(props: SelectV2Props<T>) {
multiple={false}
disabled={local.disabled}
data-component="select-v2-root"
gutter={6}
placement="bottom-start"
placement={local.placement ?? (inline() ? "bottom-end" : "bottom-start")}
gutter={local.gutter ?? 4}
sameWidth={local.sameWidth ?? !inline()}
flip={local.flip ?? true}
slide={local.slide ?? true}
fitViewport={local.fitViewport ?? false}
value={local.current}
options={grouped()}
optionValue={(x) => (local.value ? local.value(x) : String(x as string))}