Carousel V3

Coding | February 2024Read in Outline

<template>
    <Stack
        ref="wrapper"
        :style="{
            '--height': '48px',
            '--items': props.maxItems ?? 3,
            height: 'calc((var(--items, 5)) * var(--height))'
        }"
        position="relative"
        :css="{
            _before: {
                content: `' '`,
                position: 'absolute',
                top: 0,
                width: '100%',
                height: 'calc(50% - 3.5ex)',
                pointerEvents: 'none',
                background:
                    'linear-gradient(rgba(255, 255, 255, 1),65%,rgba(255,255,255,0))'
            },
            _after: {
                content: `' '`,
                position: 'absolute',
                bottom: 0,
                width: '100%',
                height: 'calc(50% - 3.5ex)',
                pointerEvents: 'none',
                background:
                    'linear-gradient(rgba(255,255,255,0),35%,rgba(255,255,255,1))'
            }
        }"
    >
        <Stack
            ref="scroller"
            gap="0"
            overflowY="auto"
            scrollSnapType="y mandatory"
            alignItems="center"
            :style="{
                paddingTop: 'calc(var(--items) / 2 * var(--height))',
                paddingBottom: 'calc(var(--items) / 2 * var(--height))'
            }"
            overscrollBehavior="contain"
            scrollbar="hidden"
            @scroll="handleScroll"
        >
            <Divider />
            <template v-for="option in props.options" :key="option.value">
                <Text
                    scrollSnapAlign="center"
                    py="3"
                    px="8"
                    :data-value="option.value"
                    @click="setValue(option.value)"
                >
                    {{ option.label }}
                </Text>
                <Divider />
            </template>
        </Stack>
    </Stack>
</template>

<script lang="ts" setup>
    import debounce from 'lodash/debounce'
    import type Stack from '../layout/Stack'

    const scroller = ref<ComponentPublicInstance<typeof Stack> | null>(null)

    const model = defineModel<string>()

    const props = defineProps<{
        options: { label: string; value: string }[]
        maxItems?: number
    }>()

    const emit = defineEmits(['change'])

    let lastScrollPos = -1

    let isScrolling = false

    const setNotScrolling = debounce(() => {
        isScrolling = false
    }, 200)

    const updateScroller = (value: string) => {
        const container = scroller.value?.$el as HTMLDivElement

        if (!container || isScrolling) {
            return
        }
        const element = Array.from(container.children).find(
            (child) => child.getAttribute('data-value') === value
        )
        element?.scrollIntoView({
            block: 'center'
        })
    }

    const handleScroll = debounce((e: UIEvent) => {
        const target = e?.target as HTMLDivElement
        const container = scroller.value?.$el as HTMLDivElement
        const parent = container?.parentNode as HTMLDivElement

        if (!e || !target || !container || target.scrollTop === lastScrollPos) {
            return undefined
        }
        isScrolling = true
        setNotScrolling()

        lastScrollPos = target.scrollTop

        const children = Array.from(container.children).filter(
            (item) =>
                item.attributes.getNamedItem('data-value')?.value !== undefined
        )
        const itemHeight = children[0].getBoundingClientRect().height
        const parentOffset = parent.getBoundingClientRect().top

        const value = children
            .find((child) => {
                const { top } = child.getBoundingClientRect()
                const offset = top - parentOffset
                const midPoint = offset + itemHeight / 2

                return midPoint > itemHeight && midPoint < 2 * itemHeight
            })
            ?.attributes.getNamedItem('data-value')?.value

        if (!value) {
            return
        }
        setValue(value)
    }, 200)

    const setValue = (value: string) => {
        model.value = value
    }

    watchEffect(() => {
        if (!model.value || isScrolling) {
            return
        }
        updateScroller(model.value)
    })

    onUpdated(() => {
        if (lastScrollPos >= 0) {
            scroller.value?.$el.scrollTo({ top: lastScrollPos })
        }
    })
</script>

<style></style>

© 2023-2024 HamP, Assets used in the site belongs to respective owner | View Source