feat(base): themed DatePicker + TimePicker for activity forms

Scaffolds shadcn-vue Calendar + adds @internationalized/date, then
wraps them in two small shared components living alongside ImageUpload
for reuse across activity / market / future forms.

DatePicker — Popover + Calendar, bridges the wire format (YYYY-MM-DD
string) to reka-ui's CalendarDate. Closes on pick (the Calendar
primitive doesn't auto-close). Optional min-date for forward-only
selection.

TimePicker — two shadcn <Select> dropdowns (HH 00–23, MM at minuteStep
granularity, default 15). Bound to a single "HH:MM" string externally,
clearable. Mobile-first: a tap opens the native sheet / wheel — no
typeahead overlay (reka-ui's Select doesn't expose typeahead on the
closed trigger and the workaround proved brittle against its internal
document-level key handling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-20 19:36:38 +02:00
commit 93c05104df
17 changed files with 583 additions and 20 deletions

75
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "aio-shadcn-vite", "name": "aio-shadcn-vite",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.1",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"@vueuse/components": "^12.5.0", "@vueuse/components": "^12.5.0",
@ -28,7 +29,7 @@
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-vue": "^1.9.13", "radix-vue": "^1.9.13",
"reka-ui": "^2.6.0", "reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
@ -3948,9 +3949,9 @@
} }
}, },
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.7.0", "version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
"integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==", "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@swc/helpers": "^0.5.0" "@swc/helpers": "^0.5.0"
@ -7054,9 +7055,9 @@
} }
}, },
"node_modules/defu": { "node_modules/defu": {
"version": "6.1.4", "version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
@ -12225,9 +12226,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.6.0", "version": "2.9.7",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.7.tgz",
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==", "integrity": "sha512-aX7foYYR20v4+majO58OJJdBNfLMm0eJb448l9N4JVy8JB7GXOr4H/S4a+J1pkcoxZH8Cb7YHpJ855+miAm7sA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@ -12235,26 +12236,62 @@
"@internationalized/date": "^3.5.0", "@internationalized/date": "^3.5.0",
"@internationalized/number": "^3.5.0", "@internationalized/number": "^3.5.0",
"@tanstack/vue-virtual": "^3.12.0", "@tanstack/vue-virtual": "^3.12.0",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^14.1.0",
"@vueuse/shared": "^12.5.0", "@vueuse/shared": "^14.1.0",
"aria-hidden": "^1.2.4", "aria-hidden": "^1.2.4",
"defu": "^6.1.4", "defu": "^6.1.5",
"ohash": "^2.0.11" "ohash": "^2.0.11"
}, },
"funding": {
"type": "github",
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": { "peerDependencies": {
"vue": ">= 3.2.0" "vue": ">= 3.4.0"
} }
}, },
"node_modules/reka-ui/node_modules/@vueuse/shared": { "node_modules/reka-ui/node_modules/@types/web-bluetooth": {
"version": "12.8.2", "version": "0.0.21",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/reka-ui/node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/reka-ui/node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/reka-ui/node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
} }
}, },
"node_modules/require-directory": { "node_modules/require-directory": {

View file

@ -43,6 +43,7 @@
"make": "electron-forge make" "make": "electron-forge make"
}, },
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.1",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"@vueuse/components": "^12.5.0", "@vueuse/components": "^12.5.0",
@ -63,7 +64,7 @@
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-vue": "^1.9.13", "radix-vue": "^1.9.13",
"reka-ui": "^2.6.0", "reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",

View file

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View file

@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View file

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from "reka-ui"
import { CalendarGridBody } from "reka-ui"
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View file

@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { CalendarGridHeadProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { CalendarGridHead } from "reka-ui"
const props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell :class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeadCell>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeader>
</template>

View file

@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { CalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { CalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeft } from "lucide-vue-next"
import { CalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View file

@ -0,0 +1,12 @@
export { default as Calendar } from "./Calendar.vue"
export { default as CalendarCell } from "./CalendarCell.vue"
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
export { default as CalendarGrid } from "./CalendarGrid.vue"
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
export { default as CalendarHeader } from "./CalendarHeader.vue"
export { default as CalendarHeading } from "./CalendarHeading.vue"
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CalendarDate, parseDate, type DateValue } from '@internationalized/date'
import { format } from 'date-fns'
import { Calendar as CalendarIcon, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue: string
placeholder?: string
/** Minimum selectable date as YYYY-MM-DD. */
min?: string
disabled?: boolean
clearable?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
// Bridge between the wire format (YYYY-MM-DD string) and reka-ui's
// CalendarDate. Empty string undefined keeps the field optional.
// Closes the popover on user pick to match the shadcn-vue docs example
// the Calendar component doesn't auto-close by itself.
const dateValue = computed<DateValue | undefined>({
get() {
if (!props.modelValue) return undefined
try {
return parseDate(props.modelValue)
} catch {
return undefined
}
},
set(v) {
if (!v) {
emit('update:modelValue', '')
return
}
const d = new CalendarDate(v.year, v.month, v.day)
emit('update:modelValue', d.toString())
open.value = false
},
})
const minDate = computed<DateValue | undefined>(() => {
if (!props.min) return undefined
try {
return parseDate(props.min)
} catch {
return undefined
}
})
const display = computed(() => {
if (!props.modelValue) return ''
const d = new Date(props.modelValue + 'T00:00:00')
if (isNaN(d.getTime())) return ''
return format(d, 'PPP')
})
function clear(e: Event) {
e.stopPropagation()
emit('update:modelValue', '')
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:disabled="disabled"
:class="cn('w-full justify-start text-left font-normal', !modelValue && 'text-muted-foreground')"
>
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
<span class="truncate">{{ display || placeholder || 'Pick a date' }}</span>
<button
v-if="clearable && modelValue && !disabled"
type="button"
class="ml-auto -mr-1 inline-flex h-5 w-5 items-center justify-center rounded-sm opacity-60 hover:opacity-100"
@click="clear"
aria-label="Clear date"
>
<X class="h-3.5 w-3.5" />
</button>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<Calendar v-model="dateValue" :min-value="minDate" initial-focus />
</PopoverContent>
</Popover>
</template>

View file

@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, X } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
/**
* HH:MM time picker two shadcn <Select> dropdowns. Mobile-first:
* tap to pick from the native sheet / wheel. Clear button when set.
*/
const props = defineProps<{
modelValue: string
/** Minute granularity for the dropdown list. Default 15. */
minuteStep?: number
disabled?: boolean
clearable?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const step = computed(() => props.minuteStep ?? 15)
const hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'))
const minutes = computed(() =>
Array.from({ length: Math.floor(60 / step.value) }, (_, i) =>
(i * step.value).toString().padStart(2, '0')
)
)
const hour = computed<string>({
get() {
return props.modelValue.split(':')[0] ?? ''
},
set(h) {
const m = props.modelValue.split(':')[1] || '00'
emit('update:modelValue', `${h}:${m}`)
},
})
const minute = computed<string>({
get() {
return props.modelValue.split(':')[1] ?? ''
},
set(m) {
const h = props.modelValue.split(':')[0] || '00'
emit('update:modelValue', `${h}:${m}`)
},
})
const hasValue = computed(() => Boolean(props.modelValue))
function clear() {
emit('update:modelValue', '')
}
</script>
<template>
<div :class="cn('flex items-center gap-1.5', disabled && 'opacity-50')">
<Clock class="h-4 w-4 text-muted-foreground shrink-0" />
<Select v-model="hour" :disabled="disabled">
<SelectTrigger class="flex-1 min-w-0 px-2 tabular-nums" aria-label="Hours">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent class="max-h-56">
<SelectItem v-for="h in hours" :key="h" :value="h" class="tabular-nums">
{{ h }}
</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground select-none">:</span>
<Select v-model="minute" :disabled="disabled">
<SelectTrigger class="flex-1 min-w-0 px-2 tabular-nums" aria-label="Minutes">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent class="max-h-56">
<SelectItem v-for="m in minutes" :key="m" :value="m" class="tabular-nums">
{{ m }}
</SelectItem>
</SelectContent>
</Select>
<Button
v-if="clearable && hasValue && !disabled"
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
@click="clear"
aria-label="Clear time"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
</template>