refactor: rewrite the Select root family without reka-ui

Root/Value/Label/Group/Separator/ItemText now share a small
provide/inject context. SelectValue's label lookup is backed by an
itemLabels map that SelectItem populates on mount (next commit) --
matching the existing behavior where the trigger's displayed label
only resolves once the dropdown has opened at least once.
This commit is contained in:
Matthew McPeak 2026-07-13 18:16:17 -04:00
parent e7d96cc3ba
commit f69a3b0c65
7 changed files with 76 additions and 69 deletions

View file

@ -1,19 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui" import { computed, provide, reactive, ref, useId } from 'vue'
import { SelectRoot, useForwardPropsEmits } from "reka-ui" import { useVModel } from '@vueuse/core'
import { SelectContextKey } from './context'
const props = defineProps<SelectRootProps>() const props = defineProps<{
const emits = defineEmits<SelectRootEmits>() modelValue?: string
defaultValue?: string
disabled?: boolean
open?: boolean
defaultOpen?: boolean
}>()
const forwarded = useForwardPropsEmits(props, emits) const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue,
})
const open = useVModel(props, 'open', emit, {
passive: true,
defaultValue: props.defaultOpen ?? false,
})
const disabled = computed(() => props.disabled ?? false)
const triggerRef = ref<HTMLElement | null>(null)
const itemLabels = reactive(new Map<string, string>())
provide(SelectContextKey, {
open,
modelValue,
disabled,
triggerRef,
contentId: `select-content-${useId()}`,
itemLabels,
registerLabel: (value: string, label: string) => { itemLabels.set(value, label) },
unregisterLabel: (value: string) => { itemLabels.delete(value) },
})
</script> </script>
<template> <template>
<SelectRoot <slot />
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template> </template>

View file

@ -1,21 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectGroupProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { cn } from '@/lib/utils'
import { reactiveOmit } from "@vueuse/core"
import { SelectGroup } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<SelectGroup <div data-slot="select-group" role="group" :class="cn('scroll-my-1 p-1', props.class)">
data-slot="select-group"
v-bind="delegatedProps"
:class="cn('scroll-my-1 p-1', props.class)"
>
<slot /> <slot />
</SelectGroup> </div>
</template> </template>

View file

@ -1,15 +1,3 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template> <template>
<SelectItemText <span data-slot="select-item-text"><slot /></span>
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template> </template>

View file

@ -1,17 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectLabelProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { cn } from '@/lib/utils'
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script> </script>
<template> <template>
<SelectLabel <div data-slot="select-label" :class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)">
data-slot="select-label"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
>
<slot /> <slot />
</SelectLabel> </div>
</template> </template>

View file

@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { cn } from '@/lib/utils'
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<SelectSeparator <div data-slot="select-separator" role="separator" :class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)" />
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
/>
</template> </template>

View file

@ -1,15 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectValueProps } from "reka-ui" import { inject } from 'vue'
import { SelectValue } from "reka-ui" import { SelectContextKey } from './context'
const props = defineProps<SelectValueProps>() const props = defineProps<{ placeholder?: string }>()
const context = inject(SelectContextKey)!
</script> </script>
<template> <template>
<SelectValue <span
data-slot="select-value" data-slot="select-value"
v-bind="props" :data-placeholder="context.modelValue.value ? undefined : placeholder"
> >
<slot /> <slot>{{ context.modelValue.value !== undefined ? context.itemLabels.get(context.modelValue.value) : placeholder }}</slot>
</SelectValue> </span>
</template> </template>

View file

@ -0,0 +1,14 @@
import type { InjectionKey, Ref } from 'vue'
export interface SelectContext {
open: Ref<boolean>
modelValue: Ref<string | undefined>
disabled: Ref<boolean>
triggerRef: Ref<HTMLElement | null>
contentId: string
itemLabels: Map<string, string>
registerLabel: (value: string, label: string) => void
unregisterLabel: (value: string) => void
}
export const SelectContextKey: InjectionKey<SelectContext> = Symbol('SelectContext')