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:
parent
1cb1486fbd
commit
81f7e0c2cc
3 changed files with 178 additions and 68 deletions
|
|
@ -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
|
||||||
data-slot="select-content"
|
enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
|
||||||
:data-align-trigger="position === 'item-aligned'"
|
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
|
||||||
: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',
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<div
|
||||||
<SelectViewport
|
v-if="context.open.value"
|
||||||
:data-position="position"
|
:id="context.contentId"
|
||||||
|
ref="contentRef"
|
||||||
|
data-slot="select-content"
|
||||||
|
role="listbox"
|
||||||
|
: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(
|
||||||
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
|
'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',
|
||||||
|
props.class,
|
||||||
)"
|
)"
|
||||||
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
<slot />
|
<SelectScrollUpButton :viewport="viewportRef" />
|
||||||
</SelectViewport>
|
<div ref="viewportRef" class="overflow-y-auto">
|
||||||
<SelectScrollDownButton />
|
<slot />
|
||||||
</SelectContent>
|
</div>
|
||||||
</SelectPortal>
|
<SelectScrollDownButton :viewport="viewportRef" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue