Skip to content

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.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/availability-picker/ (plus lib/utils.ts if you don't have it):

vue
<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>
vue
<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>
ts
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
}
ts
export { default as AvailabilityPicker } from './AvailabilityPicker.vue'
export { dayMinutes } from './time'
export type { AvailabilityDay, AvailabilityRange, AvailabilityWeek } from './time'

Usage ​

ts
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' }] },
  // …
])
vue
<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 ​

PropTypeDefaultDescription
v-modelAvailabilityWeek[]One { enabled, ranges } per day.
v-model:view'list' | 'grid''list'Which editor is shown.
daysstring[]['Mon', …, 'Sun']Day labels.
startHournumber7First selectable hour.
endHournumber21Last selectable hour.
stepnumber30Minutes between selectable times.
hour12booleanfalseShow 9:00am instead of 09:00.
defaultRange{ start, end }09:00–17:00Range added when a day is switched on.

AvailabilityWeek: Array<{ enabled: boolean, ranges: Array<{ start: string, end: string }> }>.

Designed by Kuldeep Pisda · Released under the MIT License