refactor: rewrite SelectTrigger/SelectItem without reka-ui

role="combobox" trigger and role="option" items driven by the shared
Select context. SelectContent (next commit) is needed before the
Select family works end-to-end again.
This commit is contained in:
Matthew McPeak 2026-07-13 18:16:55 -04:00
parent f69a3b0c65
commit 1cb1486fbd
2 changed files with 76 additions and 39 deletions

View file

@ -1,45 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon } from '@lucide/vue'; import { CheckIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { SelectContextKey } from './context'
import SelectItemText from './SelectItemText.vue'
import type { SelectItemProps } from "reka-ui" const props = defineProps<{
import type { HTMLAttributes } from "vue" value: string
import { reactiveOmit } from "@vueuse/core" disabled?: boolean
import { class?: HTMLAttributes['class']
SelectItem, }>()
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>() const context = inject(SelectContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const delegatedProps = reactiveOmit(props, "class") const isSelected = computed(() => context.modelValue.value === props.value)
const forwardedProps = useForwardProps(delegatedProps) function select() {
if (props.disabled) return
context.modelValue.value = props.value
context.open.value = false
}
function onPointerMove() {
if (props.disabled) return
itemRef.value?.focus()
}
onMounted(() => {
context.registerLabel(props.value, itemRef.value?.textContent?.trim() ?? '')
})
onBeforeUnmount(() => {
context.unregisterLabel(props.value)
})
</script> </script>
<template> <template>
<SelectItem <div
ref="itemRef"
data-slot="select-item" data-slot="select-item"
v-bind="forwardedProps" role="option"
:data-value="value"
:aria-selected="isSelected"
:data-disabled="disabled ? '' : undefined"
tabindex="-1"
:class=" :class="
cn( cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', 'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class, props.class,
) )
" "
@click="select"
@pointermove="onPointerMove"
> >
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center"> <span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectItemIndicator> <slot v-if="isSelected" name="indicator-icon">
<slot name="indicator-icon"> <CheckIcon class="pointer-events-none" />
<CheckIcon class="pointer-events-none" /> </slot>
</slot>
</SelectItemIndicator>
</span> </span>
<SelectItemText> <SelectItemText>
<slot /> <slot />
</SelectItemText> </SelectItemText>
</SelectItem> </div>
</template> </template>

View file

@ -1,34 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'; import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import type { SelectTriggerProps } from "reka-ui" import { inject } from 'vue'
import type { HTMLAttributes } from "vue" import { cn } from '@/lib/utils'
import { reactiveOmit } from "@vueuse/core" import { SelectContextKey } from './context'
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults( const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(), defineProps<{ class?: HTMLAttributes['class'], size?: 'sm' | 'default', disabled?: boolean }>(),
{ size: "default" }, { size: 'default' },
) )
const delegatedProps = reactiveOmit(props, "class", "size") const context = inject(SelectContextKey)!
const forwardedProps = useForwardProps(delegatedProps)
function onClick() {
if (props.disabled || context.disabled.value) return
context.open.value = !context.open.value
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
context.open.value = true
}
}
</script> </script>
<template> <template>
<SelectTrigger <button
:ref="(el) => { context.triggerRef.value = el as HTMLElement | null }"
type="button"
data-slot="select-trigger" data-slot="select-trigger"
:data-size="size" :data-size="size"
v-bind="forwardedProps" role="combobox"
:aria-controls="context.contentId"
:aria-expanded="context.open.value"
aria-autocomplete="none"
:disabled="disabled || context.disabled.value"
:class="cn( :class="cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0', 'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class, props.class,
)" )"
@click="onClick"
@keydown="onKeydown"
> >
<slot /> <slot />
<SelectIcon as-child> <ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" /> </button>
</SelectIcon>
</SelectTrigger>
</template> </template>