Availability Picker
Weekly scheduler with day switches, animated time ranges and a drag-to-paint week grid.
Weekly availability
31h / week across 5 days
- Mon
- Tue
- Wed
- Thu
- Fri
- Sat
Unavailable
- Sun
Unavailable
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/availability-picker.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/availability-picker/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AvailabilityRange, AvailabilityWeek } from './time'
import { AnimatePresence, motion } from 'motion-v'
import { computed, toRaw, useId } from 'vue'
import { cn } from '@/lib/utils'
import AvailabilityGrid from './AvailabilityGrid.vue'
import { dayMinutes, formatTime, toMinutes, toTime } from './time'
const props = withDefaults(defineProps<{
/** Day labels; the model has one entry per label. */
days?: string[]
/** First selectable hour (0–23). */
startHour?: number
/** Last selectable hour (1–24). */
endHour?: number
/** Minutes between selectable times (15, 30 or 60). */
step?: number
/** Show times as 9:00am instead of 09:00. */
hour12?: boolean
/** Range added when a day is switched on. */
defaultRange?: AvailabilityRange
class?: HTMLAttributes['class']
}>(), {
days: () => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
startHour: 7,
endHour: 21,
step: 30,
hour12: false,
defaultRange: () => ({ start: '09:00', end: '17:00' }),
})
const week = defineModel<AvailabilityWeek>({ default: () => [] })
const view = defineModel<'list' | 'grid'>('view', { default: 'list' })
const SPRING = { type: 'spring', stiffness: 420, damping: 36 } as const
const uid = useId()
// Make sure there is one entry per day.
const days = computed<AvailabilityWeek>(() => props.days.map((_, i) => week.value[i] ?? { enabled: false, ranges: [] }))
function setDay(i: number, day: AvailabilityWeek[number]) {
week.value = days.value.map((d, j) => (j === i ? day : d))
}
const options = computed(() => {
const list: { value: string, label: string }[] = []
for (let m = props.startHour * 60; m <= props.endHour * 60; m += props.step)
list.push({ value: toTime(m), label: formatTime(m, props.hour12) })
return list
})
function toggle(i: number) {
const day = days.value[i]!
setDay(i, { enabled: !day.enabled, ranges: day.ranges.length || day.enabled ? day.ranges : [{ ...props.defaultRange }] })
}
function nextRange(i: number): AvailabilityRange | null {
const day = days.value[i]!
const limit = props.endHour * 60
const last = day.ranges.reduce((max, r) => Math.max(max, toMinutes(r.end)), 0)
if (!day.ranges.length)
return { ...props.defaultRange }
const start = last + 60
if (start + props.step > limit)
return null
return { start: toTime(start), end: toTime(Math.min(limit, start + 60)) }
}
function addRange(i: number) {
const range = nextRange(i)
if (range)
setDay(i, { enabled: true, ranges: [...days.value[i]!.ranges, range] })
}
function removeRange(i: number, r: number) {
const ranges = days.value[i]!.ranges.filter((_, j) => j !== r)
setDay(i, { enabled: ranges.length > 0, ranges })
}
function updateRange(i: number, r: number, patch: Partial<AvailabilityRange>) {
setDay(i, {
...days.value[i]!,
ranges: days.value[i]!.ranges.map((range, j) => {
if (j !== r)
return range
const updated = { ...range, ...patch }
keys.set(updated, keyOf(range)) // keep the row's identity while editing
return updated
}),
})
}
function rangeError(i: number, r: number) {
const ranges = days.value[i]!.ranges
const a = ranges[r]!
if (toMinutes(a.end) <= toMinutes(a.start))
return 'End must be after start'
const overlaps = ranges.some((b, j) => j !== r && toMinutes(a.start) < toMinutes(b.end) && toMinutes(b.start) < toMinutes(a.end))
return overlaps ? 'Overlaps another range' : ''
}
// Stable keys for ranges so add/remove animates the right row.
const keys = new WeakMap<AvailabilityRange, number>()
let seq = 0
function keyOf(range: AvailabilityRange) {
const raw = toRaw(range)
if (!keys.has(raw))
keys.set(raw, ++seq)
return keys.get(raw)!
}
const totalMinutes = computed(() => days.value.reduce((sum, d) => sum + dayMinutes(d), 0))
const totalHours = computed(() => Math.round((totalMinutes.value / 60) * 10) / 10)
const activeDays = computed(() => days.value.filter(d => dayMinutes(d) > 0).length)
const perDay = computed(() => days.value.map(d => dayMinutes(d)))
const maxDay = computed(() => Math.max(60, ...perDay.value))
function onViewKey(e: KeyboardEvent) {
if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault()
view.value = view.value === 'list' ? 'grid' : 'list'
;(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-view="${view.value}"]`)?.focus()
}
}
</script>
<template>
<div :class="cn('@container w-full rounded-2xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5', props.class)">
<!-- Summary + view switch -->
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="m-0 text-xs font-medium text-muted-foreground">
Weekly availability
</p>
<p class="m-0 mt-0.5 flex items-baseline gap-1.5" aria-live="polite">
<span class="relative inline-flex overflow-hidden text-2xl font-semibold tabular-nums tracking-tight">
<AnimatePresence mode="popLayout" :initial="false">
<motion.span
:key="totalHours"
:initial="{ y: 16, opacity: 0 }"
:animate="{ y: 0, opacity: 1 }"
:exit="{ y: -16, opacity: 0 }"
:transition="SPRING"
>
{{ totalHours }}
</motion.span>
</AnimatePresence>
</span>
<span class="text-sm text-muted-foreground">h / week across {{ activeDays }} {{ activeDays === 1 ? 'day' : 'days' }}</span>
</p>
</div>
<div class="flex items-end gap-3">
<div class="hidden h-8 items-end gap-1 sm:flex" aria-hidden="true">
<motion.span
v-for="(m, i) in perDay"
:key="i"
class="w-1.5 rounded-full bg-brand"
:animate="{ height: `${Math.max(3, (m / maxDay) * 32)}px`, opacity: m ? 1 : 0.25 }"
:transition="SPRING"
/>
</div>
<div role="tablist" aria-label="View" class="flex gap-1 rounded-lg bg-muted p-1" @keydown="onViewKey">
<button
v-for="v in (['list', 'grid'] as const)"
:key="v"
type="button"
role="tab"
:data-view="v"
:aria-selected="view === v"
:tabindex="view === v ? 0 : -1"
:class="cn('relative rounded-md px-2.5 py-1 text-xs font-medium capitalize outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50', view === v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')"
@click="view = v"
>
<motion.span
v-if="view === v"
:layout-id="`vx-avail-view-${uid}`"
class="absolute inset-0 rounded-md bg-background shadow-sm"
:transition="SPRING"
/>
<span class="relative">{{ v }}</span>
</button>
</div>
</div>
</div>
<div class="mt-4">
<AnimatePresence mode="wait" :initial="false">
<!-- List view -->
<motion.ul
v-if="view === 'list'"
key="list"
class="m-0 list-none divide-y p-0"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -8 }"
:transition="{ duration: 0.18 }"
>
<li v-for="(day, i) in days" :key="props.days[i]" class="flex flex-wrap items-start gap-x-3 py-2.5 @md:flex-nowrap">
<button
type="button"
role="switch"
:aria-checked="day.enabled"
:aria-label="`Available on ${props.days[i]}`"
:class="cn(
'relative mt-1.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full p-0.5 outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
day.enabled ? 'bg-brand' : 'bg-input',
)"
@click="toggle(i)"
>
<motion.span
class="block size-4 rounded-full bg-background shadow-sm"
:animate="{ x: day.enabled ? 16 : 0 }"
:transition="{ type: 'spring', stiffness: 700, damping: 30 }"
/>
</button>
<span :class="cn('mt-1.5 w-10 shrink-0 text-sm font-medium', !day.enabled && 'text-muted-foreground')">{{ props.days[i] }}</span>
<p v-if="!day.enabled" class="m-0 mt-1.5 text-sm text-muted-foreground @md:hidden">
Unavailable
</p>
<!-- Narrow containers: ranges wrap onto their own line under the day -->
<div class="order-last mt-1 w-full min-w-0 @md:order-none @md:mt-0 @md:w-auto @md:flex-1">
<p v-if="!day.enabled" class="m-0 mt-1.5 hidden text-sm text-muted-foreground @md:block">
Unavailable
</p>
<AnimatePresence :initial="false">
<motion.div
v-for="(range, r) in (day.enabled ? day.ranges : [])"
:key="keyOf(range)"
class="overflow-hidden"
:initial="{ opacity: 0, height: 0 }"
:animate="{ opacity: 1, height: 'auto' }"
:exit="{ opacity: 0, height: 0 }"
:transition="SPRING"
>
<div class="flex items-center gap-2 py-0.5">
<label class="relative">
<span class="sr-only">{{ props.days[i] }} range {{ r + 1 }} start</span>
<select
:value="range.start"
:aria-invalid="!!rangeError(i, r) || undefined"
class="h-8 appearance-none rounded-lg border bg-background py-0 pl-2.5 pr-7 text-sm tabular-nums text-foreground outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive"
@change="updateRange(i, r, { start: ($event.target as HTMLSelectElement).value })"
>
<option v-for="o in options.slice(0, -1)" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</label>
<span class="text-muted-foreground" aria-hidden="true">–</span>
<label class="relative">
<span class="sr-only">{{ props.days[i] }} range {{ r + 1 }} end</span>
<select
:value="range.end"
:aria-invalid="!!rangeError(i, r) || undefined"
class="h-8 appearance-none rounded-lg border bg-background py-0 pl-2.5 pr-7 text-sm tabular-nums text-foreground outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive"
@change="updateRange(i, r, { end: ($event.target as HTMLSelectElement).value })"
>
<option v-for="o in options.slice(1)" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<svg class="pointer-events-none absolute right-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</label>
<button
type="button"
:aria-label="`Remove ${props.days[i]} ${formatTime(toMinutes(range.start), props.hour12)} to ${formatTime(toMinutes(range.end), props.hour12)}`"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="removeRange(i, r)"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<p v-if="rangeError(i, r)" class="m-0 pb-1 text-xs text-destructive">
{{ rangeError(i, r) }}
</p>
</motion.div>
</AnimatePresence>
</div>
<button
type="button"
:aria-label="`Add a time range on ${props.days[i]}`"
:disabled="!!day.ranges.length && day.enabled && !nextRange(i)"
class="ml-auto mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40"
@click="day.enabled ? addRange(i) : toggle(i)"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
</button>
</li>
</motion.ul>
<!-- Grid view -->
<motion.div
v-else
key="grid"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -8 }"
:transition="{ duration: 0.18 }"
>
<AvailabilityGrid
:model-value="days"
:days="props.days"
:start-hour="props.startHour"
:end-hour="props.endHour"
:step="props.step"
:hour12="props.hour12"
@update:model-value="week = $event"
/>
<p class="m-0 mt-3 text-xs text-muted-foreground">
Drag to paint availability, drag over painted time to erase. Arrow keys move, Space toggles, Shift + arrows paints.
</p>
</motion.div>
</AnimatePresence>
</div>
</div>
</template><script setup lang="ts">
import type { AvailabilityWeek } from './time'
import { AnimatePresence, motion } from 'motion-v'
import { computed, nextTick, ref } from 'vue'
import { cn } from '@/lib/utils'
import { formatTime, rangesToSlots, slotsToRanges, toMinutes } from './time'
const props = defineProps<{
days: string[]
startHour: number
endHour: number
step: number
hour12: boolean
}>()
const week = defineModel<AvailabilityWeek>({ required: true })
const HOUR_HEIGHT = 26
const LABEL_W = 48
const BLOCK = { type: 'spring', stiffness: 500, damping: 40 } as const
const origin = computed(() => props.startHour * 60)
const count = computed(() => Math.round(((props.endHour - props.startHour) * 60) / props.step))
const rowH = computed(() => (HOUR_HEIGHT * props.step) / 60)
const slots = computed(() => week.value.map(d => rangesToSlots(d, origin.value, props.step, count.value)))
function commit(next: boolean[][]) {
week.value = week.value.map((day, i) => {
const ranges = slotsToRanges(next[i]!, origin.value, props.step)
return { enabled: ranges.length > 0, ranges }
})
}
// ---------------------------------------------------------------------------
// Pointer painting: drag paints (or erases) a rectangle of slots
// ---------------------------------------------------------------------------
const area = ref<HTMLElement>()
let drag: { day: number, slot: number, value: boolean, snapshot: boolean[][] } | null = null
function cellAt(e: PointerEvent) {
const rect = area.value!.getBoundingClientRect()
const colW = (rect.width - LABEL_W) / props.days.length
const day = Math.floor((e.clientX - rect.left - LABEL_W) / colW)
const slot = Math.floor((e.clientY - rect.top) / rowH.value)
return {
day: Math.min(props.days.length - 1, Math.max(0, day)),
slot: Math.min(count.value - 1, Math.max(0, slot)),
inside: day >= 0 && day < props.days.length,
}
}
function paint(to: { day: number, slot: number }) {
if (!drag)
return
const next = drag.snapshot.map(col => [...col])
const [d0, d1] = [Math.min(drag.day, to.day), Math.max(drag.day, to.day)]
const [s0, s1] = [Math.min(drag.slot, to.slot), Math.max(drag.slot, to.slot)]
for (let d = d0; d <= d1; d++) {
for (let s = s0; s <= s1; s++) next[d]![s] = drag.value
}
commit(next)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0)
return
const cell = cellAt(e)
if (!cell.inside)
return
e.preventDefault()
area.value!.setPointerCapture(e.pointerId)
const snapshot = slots.value.map(col => [...col])
drag = { day: cell.day, slot: cell.slot, value: !snapshot[cell.day]![cell.slot], snapshot }
active.value = { day: cell.day, slot: cell.slot }
paint(cell)
}
function onPointerMove(e: PointerEvent) {
if (drag)
paint(cellAt(e))
}
function onPointerUp() {
drag = null
}
// ---------------------------------------------------------------------------
// Keyboard: roving focus over cells, Space / Enter toggles
// ---------------------------------------------------------------------------
const active = ref({ day: 0, slot: Math.max(0, Math.round(((9 - props.startHour) * 60) / props.step)) })
function focusCell() {
nextTick(() => area.value?.querySelector<HTMLElement>(`[data-cell="${active.value.day}-${active.value.slot}"]`)?.focus())
}
function onKeydown(e: KeyboardEvent) {
const { day, slot } = active.value
const moves: Record<string, [number, number]> = {
ArrowUp: [day, slot - 1],
ArrowDown: [day, slot + 1],
ArrowLeft: [day - 1, slot],
ArrowRight: [day + 1, slot],
Home: [day, 0],
End: [day, count.value - 1],
}
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
const next = slots.value.map(col => [...col])
next[day]![slot] = !next[day]![slot]
commit(next)
return
}
const move = moves[e.key]
if (!move)
return
e.preventDefault()
// Shift + arrows paints the slot you move onto with the current slot's value.
if (e.shiftKey) {
const value = slots.value[day]![slot]!
drag = { day, slot, value, snapshot: slots.value.map(col => [...col]) }
}
active.value = {
day: Math.min(props.days.length - 1, Math.max(0, move[0])),
slot: Math.min(count.value - 1, Math.max(0, move[1])),
}
if (drag) {
paint(active.value)
drag = null
}
focusCell()
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
const rows = computed(() => Array.from({ length: count.value }, (_, i) => origin.value + i * props.step))
const blocks = computed(() => week.value.map(day => (day.enabled
? day.ranges
.map(r => ({ start: toMinutes(r.start), end: toMinutes(r.end) }))
.filter(r => r.end > r.start)
.map(r => ({
key: r.start,
top: ((Math.max(r.start, origin.value) - origin.value) / props.step) * rowH.value,
height: ((Math.min(r.end, props.endHour * 60) - Math.max(r.start, origin.value)) / props.step) * rowH.value,
from: formatTime(r.start, props.hour12),
to: formatTime(r.end, props.hour12),
}))
.filter(b => b.height > 0)
: [])))
const columns = computed(() => `${LABEL_W}px repeat(${props.days.length}, minmax(0, 1fr))`)
</script>
<template>
<div role="grid" aria-label="Weekly availability" aria-multiselectable="true" class="select-none">
<div role="row" class="grid h-8 items-center" :style="{ gridTemplateColumns: columns }">
<span role="columnheader" aria-label="Time" />
<span
v-for="(d, i) in props.days"
:key="d"
role="columnheader"
:class="cn('text-center text-xs font-medium', week[i]?.enabled ? 'text-foreground' : 'text-muted-foreground')"
>
{{ d }}
</span>
</div>
<div
ref="area"
class="relative touch-none"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@keydown="onKeydown"
>
<!-- Hour lines + column separators -->
<div
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 grid rounded-lg border bg-muted/30"
:style="{
left: `${LABEL_W}px`,
gridTemplateColumns: `repeat(${props.days.length}, minmax(0, 1fr))`,
backgroundImage: `linear-gradient(to bottom, var(--border) 1px, transparent 1px)`,
backgroundSize: `100% ${HOUR_HEIGHT}px`,
}"
>
<div v-for="(d, i) in props.days" :key="d" :class="cn('relative', i > 0 && 'border-l')">
<AnimatePresence :initial="false">
<motion.div
v-for="b in blocks[i]"
:key="b.key"
class="absolute inset-x-1 overflow-hidden rounded-md bg-brand/85 px-1 text-[10px] font-medium leading-tight text-background shadow-xs"
:initial="{ opacity: 0, scaleX: 0.6, top: b.top, height: b.height }"
:animate="{ opacity: 1, scaleX: 1, top: b.top, height: b.height }"
:exit="{ opacity: 0, scaleX: 0.6, transition: { duration: 0.12 } }"
:transition="BLOCK"
>
<template v-if="b.height >= HOUR_HEIGHT * 1.5">
<span class="mt-0.5 block truncate tabular-nums">{{ b.from }}</span>
<span class="block truncate tabular-nums opacity-75">{{ b.to }}</span>
</template>
</motion.div>
</AnimatePresence>
</div>
</div>
<!-- Cells (transparent, on top for hit-testing and focus rings) -->
<div
v-for="(minutes, s) in rows"
:key="minutes"
role="row"
class="relative z-10 grid"
:style="{ gridTemplateColumns: columns, height: `${rowH}px` }"
>
<span role="rowheader" class="relative text-[10px] text-muted-foreground">
<span v-if="minutes % 60 === 0" class="absolute -top-1.5 right-2 tabular-nums">{{ formatTime(minutes, props.hour12) }}</span>
<span v-else class="sr-only">{{ formatTime(minutes, props.hour12) }}</span>
</span>
<span
v-for="(d, i) in props.days"
:key="d"
role="gridcell"
:data-cell="`${i}-${s}`"
:aria-selected="!!slots[i]?.[s]"
:aria-label="`${d} ${formatTime(minutes, props.hour12)}`"
:tabindex="active.day === i && active.slot === s ? 0 : -1"
class="cursor-cell rounded-[3px] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
@focus="active = { day: i, slot: s }"
/>
</div>
</div>
</div>
</template>export interface AvailabilityRange {
/** "HH:MM", 24-hour */
start: string
/** "HH:MM", 24-hour */
end: string
}
export interface AvailabilityDay {
enabled: boolean
ranges: AvailabilityRange[]
}
/** One entry per day, in the same order as the `days` labels. */
export type AvailabilityWeek = AvailabilityDay[]
export function toMinutes(time: string) {
const [h = 0, m = 0] = time.split(':').map(Number)
return h * 60 + m
}
export function toTime(minutes: number) {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
export function formatTime(minutes: number, hour12: boolean) {
const h = Math.floor(minutes / 60)
const m = String(minutes % 60).padStart(2, '0')
if (!hour12)
return `${String(h).padStart(2, '0')}:${m}`
const suffix = h >= 12 && h < 24 ? 'pm' : 'am'
return `${h % 12 || 12}:${m}${suffix}`
}
/** Valid minutes in a day's ranges (overlaps counted once). */
export function dayMinutes(day: AvailabilityDay) {
if (!day.enabled)
return 0
const spans = day.ranges
.map(r => [toMinutes(r.start), toMinutes(r.end)] as const)
.filter(([s, e]) => e > s)
.sort((a, b) => a[0] - b[0])
let total = 0
let cursor = -1
for (const [s, e] of spans) {
const from = Math.max(s, cursor)
if (e > from)
total += e - from
cursor = Math.max(cursor, e)
}
return total
}
/** Slot booleans for a day, `count` slots of `step` minutes starting at `origin`. */
export function rangesToSlots(day: AvailabilityDay, origin: number, step: number, count: number) {
const slots = Array.from<boolean>({ length: count }).fill(false)
if (!day.enabled)
return slots
for (const r of day.ranges) {
const a = Math.max(0, Math.floor((toMinutes(r.start) - origin) / step))
const b = Math.min(count, Math.ceil((toMinutes(r.end) - origin) / step))
for (let i = a; i < b; i++) slots[i] = true
}
return slots
}
export function slotsToRanges(slots: boolean[], origin: number, step: number): AvailabilityRange[] {
const ranges: AvailabilityRange[] = []
let runStart = -1
slots.forEach((on, i) => {
if (on && runStart < 0)
runStart = i
if ((!on || i === slots.length - 1) && runStart >= 0) {
const end = on ? i + 1 : i
ranges.push({ start: toTime(origin + runStart * step), end: toTime(origin + end * step) })
runStart = -1
}
})
return ranges
}export { default as AvailabilityPicker } from './AvailabilityPicker.vue'
export { dayMinutes } from './time'
export type { AvailabilityDay, AvailabilityRange, AvailabilityWeek } from './time'Usage
import { AvailabilityPicker, type AvailabilityWeek } from '@/components/ui/availability-picker'
// One entry per day label (Mon → Sun by default); times are "HH:MM", 24-hour.
const week = ref<AvailabilityWeek>([
{ enabled: true, ranges: [{ start: '09:00', end: '17:00' }] },
// …
])<AvailabilityPicker v-model="week" v-model:view="view" :start-hour="8" :end-hour="20" :step="30" hour12 />List view: switch days on and off, and add, edit or remove time ranges per day (invalid or overlapping ranges are flagged inline). Grid view: drag across the week to paint a block of time, or start on painted time to erase. The grid is an ARIA grid with one tab stop — arrow keys move, Space / Enter toggles a slot, Shift + arrows paints as you move. The total updates live; the dayMinutes() helper used for it is exported too.
API
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | AvailabilityWeek | [] | One { enabled, ranges } per day. |
v-model:view | 'list' | 'grid' | 'list' | Which editor is shown. |
days | string[] | ['Mon', …, 'Sun'] | Day labels. |
startHour | number | 7 | First selectable hour. |
endHour | number | 21 | Last selectable hour. |
step | number | 30 | Minutes between selectable times. |
hour12 | boolean | false | Show 9:00am instead of 09:00. |
defaultRange | { start, end } | 09:00–17:00 | Range added when a day is switched on. |
AvailabilityWeek: Array<{ enabled: boolean, ranges: Array<{ start: string, end: string }> }>.