iOS Style Time Picker

Coding | December 2023Read in Outline

<template>
    <HStack
        p="2"
        justifyContent="center"
        alignItems="center"
        :css="{ '--height': '25px' }"
        :_before="{
            content: ' ',
            position: 'absolute',
            top: 0,
            width: '100%',
            height: 'calc(50% - 3.5ex)',
            pointerEvents: 'none',
            background:
                'linear-gradient(hsl(200 20% 10%),65%,hsl(200 20% 10% 0%))'
        }"
        :_after="{
            content: ' ',
            position: 'absolute',
            top: 0,
            width: '100%',
            height: 'calc(50% - 3.5ex)',
            pointerEvents: 'none',
            background:
                'linear-gradient(hsl(200 20% 10%),65%,hsl(200 20% 10% 0%))'
        }"
    >
        <Stack height="calc(5 * var(--height))" justifyContent="center">
            <Stack
                ref="hourScroll"
                gap="0"
                overflowY="auto"
                scrollSnapType="y mandatory"
                py="calc(2 * var(--height))"
                alignItems="center"
                scrollbar="none"
                overscrollBehavior="contain"
            >
                <template v-for="i in 24" :key="i">
                    <Text scrollSnapAlign="center" py="3" px="8">
                        {{ i - 1 }}
                    </Text>
                    <Divider />
                </template>
            </Stack>
        </Stack>
        <Text> : </Text>
        <Stack height="calc(5 * var(--height))" justifyContent="center">
            <Stack
                ref="minuteScroll"
                gap="0"
                overflowY="auto"
                scrollSnapAlign="center"
                scrollSnapType="y mandatory"
                py="calc(2 * var(--height))"
                scrollbar="none"
                alignItems="center"
                overscrollBehavior="contain"
            >
                <template v-for="i in 60" :key="i">
                    <Text scrollSnapAlign="center" py="3" px="8">
                        {{ (i - 1).toString().padStart(2, '0') }}
                    </Text>
                    <Divider />
                </template>
            </Stack>
        </Stack>
    </HStack>
</template>

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

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

    const lastScrollMinute = ref<number>(0)
    const lastScrollHour = ref<number>(0)

    const props = defineProps<{ modelValue?: string }>()
    const emit = defineEmits(['update:modelValue'])

    const getMinutesHours = (time?: string) => {
        const [hour, minutes] = time?.includes(':')
            ? time.split(':')
            : [new Date().getHours(), new Date().getMinutes()].map((r) =>
                  r.toString()
              )
        return { hour, minutes }
    }

    function determineSnapped(
        container: HTMLElement,
        e: Event,
        lastScrollPos: number
    ) {
        if (!container) {
            return
        }
        const target = e?.target as HTMLDivElement

        if (!e || !target || target.scrollTop === lastScrollPos) {
            return undefined
        }

        const viewportHeight = window.innerHeight
        container.style.gap = '100vh'
        container.getBoundingClientRect()
        const value = Array.from(container.children).find((child) => {
            const { top } = child.getBoundingClientRect()
            return top > 0 && top < viewportHeight
        })?.textContent
        container.style.gap = ''
        return {
            value,
            scrollPos: target.scrollTop
        }
    }

    const handleHourScroll = debounce((e: Event) => {
        const res = determineSnapped(
            hourScroll.value?.$el,
            e,
            lastScrollHour.value
        )
        if (!res) {
            return
        }
        const { value, scrollPos } = res
        lastScrollHour.value = scrollPos
        const { minutes } = getMinutesHours(props.modelValue)
        emit('update:modelValue', `${value}:${minutes}`)
    }, 100)

    const handleMinuteScroll = debounce((e: Event) => {
        const res = determineSnapped(
            minuteScroll.value?.$el,
            e,
            lastScrollMinute.value
        )
        if (!res) {
            return
        }
        const { value, scrollPos } = res
        lastScrollMinute.value = scrollPos
        const { hour } = getMinutesHours(props.modelValue)
        emit('update:modelValue', `${hour}:${value?.padStart(2, '0')}`)
    }, 100)

    onMounted(() => {
        const { hour, minutes } = getMinutesHours(props.modelValue)
        const hourContainer = hourScroll.value?.$el as HTMLDivElement
        const minuteContainer = minuteScroll.value?.$el as HTMLDivElement
        if (!hourContainer || !minuteContainer) {
            return
        }
        console.log('mounted', hour, minutes)
        Array.from(hourContainer.children).forEach((child) => {
            if (child.textContent === hour) {
                child.scrollIntoView({
                    block: 'center'
                })
            }
        })

        Array.from(minuteContainer.children).forEach((child) => {
            if (child.textContent === minutes) {
                child.scrollIntoView({
                    block: 'center'
                })
            }
        })

        hourContainer?.addEventListener('scroll', handleHourScroll)
        minuteContainer?.addEventListener('scroll', handleMinuteScroll)
    })

    onUnmounted(() => {
        hourScroll.value?.$el?.removeEventListener('scroll', handleHourScroll)
        minuteScroll.value?.$el?.removeEventListener(
            'scroll',
            handleMinuteScroll
        )
    })
</script>

<style></style>

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