feat(ui): add shadcn-vue Select components (#8205)

## Summary

Adds shadcn-vue Select components built on Reka UI primitives with
design system styling.

## Changes

**New Components** (`src/components/ui/select/`):
- `Select.vue` - Root wrapper using `SelectRoot` from Reka UI
- `SelectTrigger.vue` - Styled trigger with chevron icon
- `SelectContent.vue` - Dropdown content with scroll buttons, z-index
3000 for PrimeVue dialog compatibility
- `SelectItem.vue` - Individual option with check icon
- `SelectGroup.vue`, `SelectLabel.vue`, `SelectSeparator.vue` - Grouping
support
- `SelectScrollUpButton.vue`, `SelectScrollDownButton.vue` - Overflow
navigation
- `SelectValue.vue` - Placeholder/value display

**Styling**:
- Uses design tokens (`bg-secondary-background`,
`text-muted-foreground`, `border-border-default`)
- Iconify icons via `icon-[lucide--*]` classes
- Smooth transitions and focus states

**Documentation**:
- Comprehensive Storybook stories covering all variants
- `AGENTS.md` with component creation guidelines

## Testing

- [x] Storybook stories work correctly
- [x] Components build without errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8205-feat-ui-add-shadcn-vue-Select-components-2ef6d73d365081fd994ddb1123c063e7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Brown
2026-01-20 23:17:53 -08:00
committed by GitHub
parent e503482c6f
commit 7a1a2c1abb
12 changed files with 568 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# UI Component Guidelines
## Adding New Components
```bash
pnpm dlx shadcn-vue@latest add <component-name> --yes
```
After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories.
## Reka UI Wrapper Components
- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps<Props>()`
- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults
- Import siblings directly (`./Component.vue`), not from barrel (`'.'`)
- Use `cn()` for class merging with `className`
- Use Iconify icons: `<i class="icon-[lucide--check]" />`
- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default`
- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]`

View File

@@ -0,0 +1,261 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Select from './Select.vue'
import SelectContent from './SelectContent.vue'
import SelectGroup from './SelectGroup.vue'
import SelectItem from './SelectItem.vue'
import SelectLabel from './SelectLabel.vue'
import SelectSeparator from './SelectSeparator.vue'
import SelectTrigger from './SelectTrigger.vue'
import SelectValue from './SelectValue.vue'
const meta = {
title: 'Components/Select',
component: Select,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text',
description: 'Selected value'
},
disabled: {
control: 'boolean',
description: 'When true, disables the select'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref(args.modelValue || '')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const WithPlaceholder: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Choose an option..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const Disabled: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('apple')
return { value, args }
},
template: `
<Select v-model="value" disabled>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
`
})
}
export const WithGroups: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a model type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Checkpoints</SelectLabel>
<SelectItem value="sd15">SD 1.5</SelectItem>
<SelectItem value="sdxl">SDXL</SelectItem>
<SelectItem value="flux">Flux</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>LoRAs</SelectLabel>
<SelectItem value="lora-style">Style LoRA</SelectItem>
<SelectItem value="lora-character">Character LoRA</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="vae">VAE</SelectItem>
<SelectItem value="embedding">Embedding</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const Scrollable: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
const items = Array.from({ length: 20 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Option ${i + 1}`
}))
return { value, items, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<div class="space-y-4">
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-32">
<SelectValue placeholder="Small" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">A</SelectItem>
<SelectItem value="b">B</SelectItem>
<SelectItem value="c">C</SelectItem>
</SelectContent>
</Select>
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Full width select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
</div>
`
}),
args: {
disabled: false
}
}

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import SelectScrollDownButton from './SelectScrollDownButton.vue'
import SelectScrollUpButton from './SelectScrollUpButton.vue'
defineOptions({
inheritAttrs: false
})
const {
position = 'popper',
class: className,
...restProps
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => ({
position,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-3000 max-h-96 min-w-32 overflow-hidden',
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'scrollbar-custom flex flex-col gap-0',
position === 'popper' &&
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectGroupProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectItemProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectItem
v-bind="restProps"
:class="
cn(
'relative flex w-full cursor-pointer select-none items-center justify-between',
'gap-3 rounded px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)
"
>
<SelectItemText class="truncate">
<slot />
</SelectItemText>
<SelectItemIndicator class="flex shrink-0 items-center justify-center">
<i class="icon-[lucide--check] text-base-foreground" aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import { SelectLabel } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectLabelProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectLabel
v-bind="restProps"
:class="
cn(
'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground',
className
)
"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import { SelectScrollDownButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollDownButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-down]" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import { SelectScrollUpButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollUpButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-up]" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import { SelectSeparator } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectSeparator
v-bind="restProps"
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
/>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import { SelectIcon, SelectTrigger } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectTriggerProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectTrigger
v-bind="restProps"
:class="
cn(
'flex h-10 w-full cursor-pointer select-none items-center justify-between',
'rounded-lg px-4 py-2 text-sm',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus:border-node-component-border focus:outline-none',
'data-[placeholder]:text-muted-foreground',
'disabled:cursor-not-allowed disabled:opacity-60',
'[&>span]:truncate',
className
)
"
>
<slot />
<SelectIcon as-child>
<i class="icon-[lucide--chevron-down] shrink-0 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>