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">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject, nextTick, ref, watch } from 'vue'
|
||||
import { useScrollLock } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFocusTrap } from '@/lib/use-focus-trap'
|
||||
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
|
||||
import { useRovingFocus } from '@/lib/use-roving-focus'
|
||||
import { usePopoverPosition } from '@/lib/use-popover-position'
|
||||
import { SelectContextKey } from './context'
|
||||
import SelectScrollUpButton from './SelectScrollUpButton.vue'
|
||||
import SelectScrollDownButton from './SelectScrollDownButton.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
defineOptions({ 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(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "item-aligned",
|
||||
align: "center",
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
const { handleKeydown: handleRovingKeydown } = useRovingFocus(items, { orientation: 'vertical', loop: false })
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
<Teleport to="body">
|
||||
<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-align-trigger="position === 'item-aligned'"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
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(
|
||||
'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',
|
||||
'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,
|
||||
)
|
||||
"
|
||||
>
|
||||
<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 />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</div>
|
||||
<SelectScrollDownButton :viewport="viewportRef" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,51 @@
|
|||
<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"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
<div
|
||||
v-if="visible"
|
||||
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)"
|
||||
@pointerdown="startScroll"
|
||||
@pointerup="stopScroll"
|
||||
@pointerleave="stopScroll"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDownIcon />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,50 @@
|
|||
<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"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
<div
|
||||
v-if="visible"
|
||||
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)"
|
||||
@pointerdown="startScroll"
|
||||
@pointerup="stopScroll"
|
||||
@pointerleave="stopScroll"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUpIcon />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue