refactor: rewrite SelectContent and scroll buttons without reka-ui

Positioning via usePopoverPosition, focus/dismiss via useFocusTrap +
useDismissableLayer, arrow/Home/End navigation via useRovingFocus, plus
inline typeahead. This is the last reka-ui import in the library.
This commit is contained in:
Matthew McPeak 2026-07-13 18:17:53 -04:00
parent 1cb1486fbd
commit 81f7e0c2cc
3 changed files with 178 additions and 68 deletions

View file

@ -1,58 +1,121 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { inject, nextTick, ref, watch } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { useScrollLock } from '@vueuse/core'
import { import { cn } from '@/lib/utils'
SelectContent, import { useFocusTrap } from '@/lib/use-focus-trap'
SelectPortal, import { useDismissableLayer } from '@/lib/use-dismissable-layer'
SelectViewport, import { useRovingFocus } from '@/lib/use-roving-focus'
useForwardPropsEmits, import { usePopoverPosition } from '@/lib/use-popover-position'
} from "reka-ui" import { SelectContextKey } from './context'
import { cn } from "@/lib/utils" import SelectScrollUpButton from './SelectScrollUpButton.vue'
import { SelectScrollDownButton, SelectScrollUpButton } from "." import SelectScrollDownButton from './SelectScrollDownButton.vue'
defineOptions({ defineOptions({ inheritAttrs: false })
inheritAttrs: false,
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(SelectContextKey)!
const contentRef = ref<HTMLElement | null>(null)
const viewportRef = ref<HTMLElement | null>(null)
const items = ref<HTMLElement[]>([])
const position = usePopoverPosition(context.triggerRef, contentRef, context.open)
function collectItems() {
const viewport = viewportRef.value
items.value = viewport
? Array.from(viewport.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
: []
}
function initialFocusTarget() {
collectItems()
return items.value.find((item) => item.dataset.value === context.modelValue.value) ?? items.value[0] ?? null
}
useFocusTrap(contentRef, context.open, { initialFocus: initialFocusTarget })
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
}) })
const props = withDefaults( const { handleKeydown: handleRovingKeydown } = useRovingFocus(items, { orientation: 'vertical', loop: false })
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "item-aligned",
align: "center",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class") let typeaheadBuffer = ''
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined
const forwarded = useForwardPropsEmits(delegatedProps, emits) function handleTypeahead(char: string) {
typeaheadBuffer += char.toLowerCase()
clearTimeout(typeaheadTimeout)
typeaheadTimeout = setTimeout(() => { typeaheadBuffer = '' }, 500)
const match = items.value.find((item) => (item.textContent ?? '').trim().toLowerCase().startsWith(typeaheadBuffer))
match?.focus()
}
function onKeydown(event: KeyboardEvent) {
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
handleRovingKeydown(event)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
const value = (document.activeElement as HTMLElement | null)?.dataset.value
if (value !== undefined) {
context.modelValue.value = value
context.open.value = false
}
return
}
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
handleTypeahead(event.key)
}
}
watch(context.open, async (isOpen) => {
if (!isOpen) return
await nextTick()
collectItems()
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script> </script>
<template> <template>
<SelectPortal> <Teleport to="body">
<SelectContent <Transition
enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
>
<div
v-if="context.open.value"
:id="context.contentId"
ref="contentRef"
data-slot="select-content" data-slot="select-content"
:data-align-trigger="position === 'item-aligned'" role="listbox"
v-bind="{ ...$attrs, ...forwarded }" :data-side="position.side"
:style="{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
minWidth: `${position.minWidth}px`,
maxHeight: `${position.maxHeight}px`,
}"
tabindex="-1"
v-bind="$attrs"
:class="cn( :class="cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 border border-border min-w-36 rounded-lg shadow-md duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none', 'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 border border-border min-w-36 rounded-lg shadow-md overflow-x-hidden overflow-y-auto',
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',
props.class, props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:data-position="position"
:class="cn(
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
)" )"
@keydown="onKeydown"
> >
<SelectScrollUpButton :viewport="viewportRef" />
<div ref="viewportRef" class="overflow-y-auto">
<slot /> <slot />
</SelectViewport> </div>
<SelectScrollDownButton /> <SelectScrollDownButton :viewport="viewportRef" />
</SelectContent> </div>
</SelectPortal> </Transition>
</Teleport>
</template> </template>

View file

@ -1,27 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'; import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
import type { SelectScrollDownButtonProps } from "reka-ui" const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>() const visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
const delegatedProps = reactiveOmit(props, "class") function updateVisibility() {
const viewport = props.viewport
visible.value = !!viewport && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight
}
const forwardedProps = useForwardProps(delegatedProps) watchEffect((onCleanup) => {
const viewport = props.viewport
if (!viewport) return
updateVisibility()
viewport.addEventListener('scroll', updateVisibility)
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
})
function startScroll() {
stopScroll()
scrollInterval = setInterval(() => {
if (props.viewport) props.viewport.scrollTop += 8
}, 16)
}
function stopScroll() {
if (scrollInterval) clearInterval(scrollInterval)
scrollInterval = undefined
}
onBeforeUnmount(stopScroll)
</script> </script>
<template> <template>
<SelectScrollDownButton <div
v-if="visible"
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)" :class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
@pointerdown="startScroll"
@pointerup="stopScroll"
@pointerleave="stopScroll"
> >
<slot> <slot>
<ChevronDownIcon /> <ChevronDownIcon />
</slot> </slot>
</SelectScrollDownButton> </div>
</template> </template>

View file

@ -1,27 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronUpIcon } from '@lucide/vue'; import { ChevronUpIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
import type { SelectScrollUpButtonProps } from "reka-ui" const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>() const visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
const delegatedProps = reactiveOmit(props, "class") function updateVisibility() {
visible.value = !!props.viewport && props.viewport.scrollTop > 0
}
const forwardedProps = useForwardProps(delegatedProps) watchEffect((onCleanup) => {
const viewport = props.viewport
if (!viewport) return
updateVisibility()
viewport.addEventListener('scroll', updateVisibility)
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
})
function startScroll() {
stopScroll()
scrollInterval = setInterval(() => {
if (props.viewport) props.viewport.scrollTop -= 8
}, 16)
}
function stopScroll() {
if (scrollInterval) clearInterval(scrollInterval)
scrollInterval = undefined
}
onBeforeUnmount(stopScroll)
</script> </script>
<template> <template>
<SelectScrollUpButton <div
v-if="visible"
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)" :class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
@pointerdown="startScroll"
@pointerup="stopScroll"
@pointerleave="stopScroll"
> >
<slot> <slot>
<ChevronUpIcon /> <ChevronUpIcon />
</slot> </slot>
</SelectScrollUpButton> </div>
</template> </template>