Skip to content

Schedule Picker

Choose a date and time in one field. Type naturally or use the calendar and time controls, with the timezone always visible. The value is an exact ISO instant.

Schedule Picker accepts future times by default. Add allowPast when editing historical records or choosing a time on either side of today.

SchedulePicker.vue
Try “tomorrow at 9am”, “Friday at 14:30”, “in 5 minutes”, or “in one hour”.

Installation

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add schedule-picker

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

When to use

Use Schedule Picker whenever both the day and time matter:

  • Schedule publishing, sending, appointments, or jobs with the future-only default.
  • Edit a record's timestamp, log an event, or search historical records with allowPast.
  • Restrict a datetime to a permitted window with min and max.

When not to use

Use Date Picker when only the day matters, Date Range Picker for a date-only period, and Calendar when the calendar itself is the workspace. An invoice due date or birthday should stay a date-only value; do not add a midnight time or timezone to make it fit Schedule Picker.

Usage

Vue

PublishSchedule.vue
<script setup>
import { ref } from 'vue'
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.vue'

const publishAt = ref('')
</script>

<template>
  <label for="publish-at">Publish at</label>
  <SchedulePicker
    id="publish-at"
    v-model="publishAt"
    name="publishAt"
    time-zone="Africa/Lagos"
    required
  />
</template>

React

PublishSchedule.jsx
import { useState } from 'react'
import SchedulePicker from '@/components/ui/schedule-picker/SchedulePicker.jsx'

export default function PublishSchedule() {
  const [publishAt, setPublishAt] = useState('')

  return (
    <>
      <label htmlFor="publish-at">Publish at</label>
      <SchedulePicker
        id="publish-at"
        value={publishAt}
        onValueChange={setPublishAt}
        name="publishAt"
        timeZone="Africa/Lagos"
        required
      />
    </>
  )
}

Svelte

PublishSchedule.svelte
<script>
  import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.svelte'

  let publishAt = $state('')
</script>

<label for="publish-at">Publish at</label>
<SchedulePicker
  id="publish-at"
  bind:value={publishAt}
  name="publishAt"
  timeZone="Africa/Lagos"
  required
/>

Historical dates and times

Add allowPast to accept both past and future instants. The same field, calendar, time controls, and natural input work for editing records—there is no separate datetime component to learn.

RecordedAt.vue
Edit February 29, 2020, or type “yesterday at 2:35pm”. Enter or leaving the picker applies a valid choice.

Vue

RecordedAt.vue
<script setup>
import { ref } from 'vue'
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.vue'

const recordedAt = ref('2020-02-29T13:35:00.000Z')
</script>

<template>
  <label for="recorded-at">Recorded at</label>
  <SchedulePicker
    id="recorded-at"
    v-model="recordedAt"
    name="recordedAt"
    allow-past
    time-zone="Africa/Lagos"
    locale="en-US"
    placeholder="February 29, 2020 at 2:35pm"
    class="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"
  />
</template>

React

RecordedAt.jsx
import { useState } from 'react'
import SchedulePicker from '@/components/ui/schedule-picker/SchedulePicker.jsx'

export default function RecordedAt() {
  const [recordedAt, setRecordedAt] = useState('2020-02-29T13:35:00.000Z')

  return (
    <>
      <label htmlFor="recorded-at">Recorded at</label>
      <SchedulePicker
        id="recorded-at"
        value={recordedAt}
        onValueChange={setRecordedAt}
        name="recordedAt"
        allowPast
        timeZone="Africa/Lagos"
        locale="en-US"
        placeholder="February 29, 2020 at 2:35pm"
        className="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"
      />
    </>
  )
}

Svelte

RecordedAt.svelte
<script>
  import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.svelte'

  let recordedAt = $state('2020-02-29T13:35:00.000Z')
</script>

<label for="recorded-at">Recorded at</label>
<SchedulePicker
  id="recorded-at"
  bind:value={recordedAt}
  name="recordedAt"
  allowPast
  timeZone="Africa/Lagos"
  locale="en-US"
  placeholder="February 29, 2020 at 2:35pm"
  class="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"
/>

Allowed date and time window

min and max are inclusive ISO instants, not date-only strings. They constrain both the calendar and time selection, including typed input. A value exactly at either boundary is allowed, provided it also satisfies the future-only default.

An earlier min does not enable historical dates on its own. Use allowPast for a window that includes the past.

ReviewTime.vue

ReviewTime.vue
<script setup>
import { ref } from 'vue'
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.vue'

const reviewAt = ref('2020-02-29T13:35:00.000Z')
</script>

<template>
  <label for="review-at">Review time</label>
  <SchedulePicker
    id="review-at"
    v-model="reviewAt"
    name="reviewAt"
    allow-past
    min="2020-02-29T08:00:00.000Z"
    max="2020-02-29T16:00:00.000Z"
    time-zone="Africa/Lagos"
    locale="en-US"
  />
</template>

In Vue use allow-past; in React and Svelte use allowPast. The min and max names are identical in all three frameworks. Continue validating the permitted window on the server when saving the value.

Choosing the time

Choose the hour and minute in the compact time control beneath the calendar. A 12-hour locale such as en-US includes AM/PM; a 24-hour locale such as en-GB uses hours 00–23. The timezone stays beside the time, and the locale controls presentation, not the stored instant.

Every minute is selectable. minuteStep defaults to 15 and only rounds the starting time for an empty picker; existing selections and typed values are not rounded.

Natural input and commit

The following all create a proposal:

  • tomorrow at 9am
  • Friday at 14:30
  • in 5 minutes
  • in one hour

The interpreted date, time, and IANA timezone remain visible. Press Enter, leave the complete picker, or choose Done to commit. Moving focus between the text field, calendar, time controls, and footer action does not commit prematurely. Until a valid choice is committed, the field retains the last valid ISO instant. An incomplete phrase or invalid edit cannot silently replace it. Choosing Done without changing an existing value simply closes the picker.

With allowPast, phrases such as yesterday at 2:35pm, 2 hours ago, and February 29, 2020 at 2:35pm are also accepted. The allowed window still applies.

Relative durations retain exact seconds. If the reference instant is 13:07:30 in Lagos, in 5 minutes proposes 13:12:30 and stores the matching UTC instant. Ordinary choices such as tomorrow at 9am remain minute-clean.

Timezone convention

Pass the account or application IANA timezone when it is known. When it is not, the browser timezone is the useful zero-configuration default. Display remains localized through Intl; the committed value remains an ISO instant suitable for storage and server scheduling.

Pass an ISO timestamp with Z or an explicit offset as the value. Do not pass a timezone-less form string such as 2020-02-29T14:35: it does not identify an exact instant. Keep date-only values as YYYY-MM-DD with Date Picker. An existing form that stores local date and time strings needs an explicit conversion at its boundary; do not append Z unless those values really are UTC.

The component handles timezone offset changes for the selected date. Local times skipped by daylight saving are rejected. When a clock time occurs twice, the earlier occurrence is used unless the input includes an explicit UTC offset. Invalid timezone input falls back to the browser timezone.

API

PurposeVueReactSvelte
Current ISO instantv-modelvalue, onValueChangebind:value
Initial instantdefault-valuedefaultValuedefaultValue
Native form namenamenamename
Interpretation timezonetime-zonetimeZonetimeZone
Localelocale, dirlocale, dirlocale, dir
Accept past instantsallow-pastallowPastallowPast
Earliest instantminminmin
Latest instantmaxmaxmax
Initial time roundingminute-stepminuteStepminuteStep
Open statev-model:openopen, onOpenChangebind:open
Native statesrequired, disabled, readonlyrequired, disabled, readOnlyrequired, disabled, readonly

allowPast defaults to false. min and max are optional, inclusive limits; neither disables the future-only default. minuteStep sets the suggested starting time, not the precision of the value.

Styling

Use ordinary classes on the component. Target a part through its data-slot when you want to change only that part. The historical example above gives the input a dashed border without changing the text, calendar, or time controls:

html
class="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"

Use className in React. Classes and ordinary attributes remain application-owned.

Durable behavior

  • Editing text does not replace the committed value until Enter or leaving the picker.
  • Enter commits without moving focus from the field.
  • Internal focus movement does not commit; leaving the complete picker does.
  • Calendar, hour, minute, and AM/PM choices are keyboard navigable.
  • Past proposals are rejected unless allowPast is enabled.
  • Bounds are checked at commit, so an expired scheduling choice cannot slip through.
  • Escape dismisses only ephemeral open state and returns focus predictably.
  • Klean never writes the draft, open state, or selected instant to storage or the URL.

The application may persist the committed value or a form draft using its own Durable UI policy. Klean does not guess that persistence scope.

Schedule Picker combines date, time, and IANA timezone as an exact ISO instant. Choose the date-only components below when wall-clock time must not exist.

  • Date Picker — one date-only YYYY-MM-DD value without time or timezone.
  • Calendar — an always-visible date-only YYYY-MM-DD surface.
  • Date Range Picker — ordered date-only YYYY-MM-DD periods.
  • Popover — the non-modal floating behavior.
  • Toast — announce the server result after a schedule is saved.

Complete framework source

Vue

SchedulePicker.vue
<script setup>
import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.vue'
import Input from '../input/Input.vue'
import Popover from '../popover/Popover.vue'
import {
  formatSchedule,
  initialScheduleWallClock,
  instantToWallClock,
  interpretSchedule,
  resolveTimeZone,
  scheduleCalendarBounds,
  scheduleConstraint,
  timeFields,
  updateTimeField,
  wallClockToIso
} from './schedule.js'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** An exact ISO instant, such as 2026-08-12T08:30:00.000Z. */
  modelValue: { type: String, default: undefined },
  defaultValue: { type: String, default: undefined },
  id: { type: String, default: undefined },
  name: { type: String, default: undefined },
  placeholder: { type: String, default: 'Tomorrow at 9am' },
  /** IANA timezone used to interpret wall-clock input. */
  timeZone: { type: String, default: undefined },
  locale: { type: String, default: undefined },
  dir: { type: String, default: undefined },
  /** Earliest allowed ISO instant. Scheduling remains future-only by default. */
  min: { type: String, default: undefined },
  /** Latest allowed ISO instant, inclusive. */
  max: { type: String, default: undefined },
  /** Allow historical records as well as future dates. */
  allowPast: { type: Boolean, default: false },
  /** Round the initial suggested time to this interval. Edits may use any minute. */
  minuteStep: { type: Number, default: 15 },
  open: { type: Boolean, default: undefined },
  defaultOpen: { type: Boolean, default: false },
  required: { type: Boolean, default: false },
  disabled: { type: Boolean, default: false },
  readonly: { type: Boolean, default: false }
})

const emit = defineEmits(['update:modelValue', 'change', 'update:open'])
const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const inputId = computed(
  () => props.id ?? `klean-schedule-picker-${generatedId}`
)
const popoverId = computed(() => `${inputId.value}-panel`)
const statusId = computed(() => `${inputId.value}-status`)
const timeHeadingId = computed(() => `${inputId.value}-time-heading`)
const zone = computed(() => resolveTimeZone(props.timeZone))
const validDefault = !Number.isNaN(new Date(props.defaultValue).getTime())
  ? props.defaultValue
  : ''
const internalValue = ref(validDefault)
const value = computed(() =>
  props.modelValue === undefined ? internalValue.value : props.modelValue
)
const initialWallClock = initialScheduleWallClock(value.value, {
  allowPast: props.allowPast,
  min: props.min,
  max: props.max,
  timeZone: zone.value,
  minuteStep: props.minuteStep
})
const selectedDate = ref(initialWallClock.date)
const selectedTime = ref(initialWallClock.time)
const draft = ref(
  value.value ? formatSchedule(value.value, props.locale, zone.value) : ''
)
const interpretation = ref(
  value.value
    ? {
        state: 'committed',
        iso: value.value,
        date: initialWallClock.date,
        time: initialWallClock.time,
        label: formatSchedule(value.value, props.locale, zone.value)
      }
    : { state: 'empty' }
)
const input = ref()
const popover = ref()
const panel = ref()
const root = ref()
const touched = ref(false)
const validationClock = ref(Date.now())
const constraints = computed(() => ({
  allowPast: props.allowPast,
  min: props.min,
  max: props.max,
  reference: new Date(validationClock.value)
}))
const calendarBounds = computed(() =>
  scheduleCalendarBounds({ ...constraints.value, timeZone: zone.value })
)
const fields = computed(() => timeFields(selectedTime.value, props.locale))
const minutes = Array.from({ length: 60 }, (_, minute) =>
  String(minute).padStart(2, '0')
)
const constraintError = computed(() =>
  interpretation.value.iso
    ? scheduleConstraint(interpretation.value.iso, constraints.value)
    : ''
)
const committable = computed(
  () => interpretation.value.state === 'proposal' && !constraintError.value
)
const invalid = computed(
  () =>
    interpretation.value.state === 'invalid' ||
    Boolean(constraintError.value) ||
    (touched.value && interpretation.value.state === 'incomplete')
)
const statusText = computed(() => {
  if (interpretation.value.state === 'empty') {
    return 'Type a date and time, or choose them from the calendar.'
  }
  if (interpretation.value.state === 'invalid') {
    return 'Enter a date and time, such as tomorrow at 9am.'
  }
  if (interpretation.value.state === 'incomplete') {
    return interpretation.value.message
  }
  if (constraintError.value === 'past') return 'Choose a time in the future.'
  if (constraintError.value === 'min')
    return `Choose ${formatSchedule(props.min, props.locale, zone.value)} or later.`
  if (constraintError.value === 'max')
    return `Choose ${formatSchedule(props.max, props.locale, zone.value)} or earlier.`
  if (interpretation.value.state === 'proposal') {
    return `${props.allowPast ? 'Use' : 'Will schedule for'} ${interpretation.value.label} in ${zone.value}. Press Enter or leave the picker to use it.`
  }
  return `${props.allowPast ? 'Selected' : 'Scheduled for'} ${interpretation.value.label} in ${zone.value}.`
})
const inputAttrs = computed(() => {
  const {
    class: _class,
    'data-slot': _dataSlot,
    'aria-describedby': _describedBy,
    ...rest
  } = attrs
  return rest
})
const describedBy = computed(() =>
  [attrs['aria-describedby'], statusId.value].filter(Boolean).join(' ')
)
const rootClasses = computed(() =>
  twMerge(
    'grid w-full gap-2 **:data-[slot=schedule-picker-field]:relative **:data-[slot=schedule-picker-field]:flex **:data-[slot=schedule-picker-field]:items-stretch **:data-[slot=input]:pe-12',
    attrs.class
  )
)

function setInternalValue(nextValue) {
  if (props.modelValue === undefined) internalValue.value = nextValue
  emit('update:modelValue', nextValue)
  emit('change', nextValue)
}

function clear() {
  setInternalValue('')
  interpretation.value = { state: 'empty' }
}

function readDraft(nextDraft) {
  validationClock.value = Date.now()
  draft.value = nextDraft
  if (!nextDraft.trim()) {
    clear()
    return
  }

  const next = interpretSchedule(nextDraft, {
    reference: new Date(),
    locale: props.locale,
    timeZone: zone.value,
    allowPast: props.allowPast
  })
  interpretation.value = next
  if (next.date) selectedDate.value = next.date
  if (next.time) selectedTime.value = next.time
}

function handleInput(event) {
  touched.value = false
  readDraft(event.target.value)
}

function stage(date = selectedDate.value, time = selectedTime.value) {
  if (props.disabled || props.readonly) return
  validationClock.value = Date.now()
  // Opening or reselecting an unchanged instant must not discard its seconds.
  const previous = interpretation.value
  const iso =
    previous.iso && previous.date === date && previous.time === time
      ? previous.iso
      : wallClockToIso({ date, time, timeZone: zone.value })
  selectedDate.value = date
  selectedTime.value = time
  if (!iso) {
    interpretation.value = { state: 'invalid' }
    return
  }
  const label = formatSchedule(iso, props.locale, zone.value)
  interpretation.value = {
    state: 'proposal',
    iso,
    date,
    time,
    label,
    timeZone: zone.value
  }
  draft.value = label
}

function commitProposal({ restoreFocus = true } = {}) {
  validationClock.value = Date.now()
  if (!committable.value || props.disabled || props.readonly) return
  const next = interpretation.value
  setInternalValue(next.iso)
  draft.value = next.label
  interpretation.value = { ...next, state: 'committed' }
  popover.value?.close({ restoreFocus })
}

function handleFocusOut(event) {
  if (event.relatedTarget && root.value?.contains(event.relatedTarget)) return
  touched.value = true
  commitProposal({ restoreFocus: false })
}

function finish() {
  if (props.disabled || props.readonly) return
  validationClock.value = Date.now()
  if (interpretation.value.state === 'committed' && !constraintError.value) {
    popover.value?.close()
  } else {
    commitProposal()
  }
}

function handleInputKeydown(event) {
  if (event.key === 'ArrowDown' && !props.disabled && !props.readonly) {
    event.preventDefault()
    popover.value?.open()
  } else if (
    event.key === 'Enter' &&
    (interpretation.value.state === 'proposal' ||
      interpretation.value.state === 'incomplete' ||
      invalid.value)
  ) {
    event.preventDefault()
    touched.value = true
    commitProposal({ restoreFocus: false })
  }
}

async function handleOpenUpdate(nextOpen) {
  emit('update:open', nextOpen)
  if (!nextOpen) return
  validationClock.value = Date.now()
  if (interpretation.value.state === 'empty') {
    const initial = initialScheduleWallClock('', {
      ...constraints.value,
      timeZone: zone.value,
      minuteStep: props.minuteStep
    })
    selectedDate.value = initial.date
    selectedTime.value = initial.time
  }
  await nextTick()
  requestAnimationFrame(() => {
    if (!panel.value?.getClientRects().length) return
    panel.value.parentElement.scrollTop = 0
  })
}

function chooseDate(nextDate) {
  stage(nextDate, selectedTime.value)
}

function chooseTime(nextTime) {
  stage(selectedDate.value, nextTime)
}

function chooseTimeField(part, nextValue) {
  chooseTime(updateTimeField(selectedTime.value, part, nextValue, props.locale))
}

watch([value, zone, () => props.locale], ([nextValue]) => {
  const wallClock = instantToWallClock(nextValue, zone.value)
  if (!wallClock) {
    if (!nextValue) {
      draft.value = ''
      interpretation.value = { state: 'empty' }
    }
    return
  }
  selectedDate.value = wallClock.date
  selectedTime.value = wallClock.time
  const label = formatSchedule(nextValue, props.locale, zone.value)
  draft.value = label
  interpretation.value = {
    state: 'committed',
    iso: nextValue,
    ...wallClock,
    label
  }
})

watch(
  () => [
    invalid.value,
    committable.value,
    props.required,
    draft.value,
    value.value
  ],
  async () => {
    await nextTick()
    const element = input.value?.element
    if (!element) return
    if (props.required && !value.value) {
      element.setCustomValidity('Choose a date and time.')
    } else if (invalid.value || interpretation.value.state === 'incomplete') {
      element.setCustomValidity(statusText.value)
    } else {
      element.setCustomValidity('')
    }
  },
  { immediate: true }
)

defineExpose({
  input,
  focus: (options) => input.value?.focus(options),
  open: () => popover.value?.open(),
  close: () => popover.value?.close()
})
</script>

<template>
  <div
    ref="root"
    data-slot="schedule-picker"
    :data-state="interpretation.state"
    :class="rootClasses"
    @focusout="handleFocusOut"
  >
    <div data-slot="schedule-picker-field">
      <Input
        ref="input"
        v-bind="inputAttrs"
        :id="inputId"
        type="text"
        autocomplete="off"
        :value="draft"
        :placeholder="placeholder"
        :required="required"
        :disabled="disabled"
        :readonly="readonly"
        :aria-invalid="invalid || undefined"
        :aria-describedby="describedBy"
        @input="handleInput"
        @click="!disabled && !readonly && popover?.open()"
        @keydown="handleInputKeydown"
      />
      <button
        type="button"
        :popovertarget="popoverId"
        data-slot="schedule-picker-button"
        class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
        :disabled="disabled || readonly"
        aria-label="Choose a date and time"
      >
        <svg
          aria-hidden="true"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="1.8"
          class="size-5"
        >
          <circle cx="12" cy="12" r="9" />
          <path d="M12 7v5l3 2" />
        </svg>
      </button>
    </div>

    <input
      v-if="name"
      type="hidden"
      :name="name"
      :value="value"
      :disabled="disabled"
    />
    <p
      :id="statusId"
      data-slot="schedule-picker-status"
      class="text-sm text-gray-600 aria-invalid:text-red-700 dark:text-gray-400 dark:aria-invalid:text-red-400"
      :aria-invalid="invalid"
      aria-live="polite"
    >
      {{ statusText }}
    </p>

    <Popover
      ref="popover"
      :id="popoverId"
      :anchor="inputId"
      :open="open"
      :default-open="defaultOpen"
      placement="bottom-start"
      data-slot="schedule-picker-popover"
      class="max-h-[min(var(--klean-popover-available-height,100dvh),calc(100dvh-1rem))] w-[min(24rem,calc(100vw-1rem))] overflow-y-auto overscroll-contain rounded-xl p-0"
      @update:open="handleOpenUpdate"
    >
      <div ref="panel" data-slot="schedule-picker-panel">
        <div class="grid">
          <Calendar
            :model-value="selectedDate"
            :min="calendarBounds.min"
            :max="calendarBounds.max"
            :locale="locale"
            :dir="dir"
            :disabled="disabled"
            :readonly="readonly"
            class="max-w-none p-4"
            @update:model-value="chooseDate"
          />

          <section
            data-slot="schedule-picker-times"
            class="sticky bottom-0 grid min-w-0 gap-2 border-t border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950"
            :aria-labelledby="timeHeadingId"
          >
            <div class="flex min-w-0 items-baseline justify-between gap-3">
              <h2 :id="timeHeadingId" class="text-sm font-medium">Time</h2>
              <p
                :id="`${inputId}-time-zone`"
                data-slot="schedule-picker-time-zone"
                class="min-w-0 text-end text-xs wrap-anywhere text-gray-500 dark:text-gray-400"
              >
                {{ zone }}
              </p>
            </div>
            <div class="flex flex-wrap items-center justify-between gap-2">
              <div
                data-slot="schedule-picker-time-fields"
                role="group"
                :aria-labelledby="timeHeadingId"
                :aria-describedby="`${inputId}-time-zone`"
                dir="ltr"
                class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-sm font-medium tabular-nums shadow-xs dark:border-gray-700 dark:bg-gray-900"
              >
                <select
                  data-slot="schedule-picker-hour"
                  aria-label="Hour"
                  class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                  :value="fields.hour"
                  :disabled="disabled || readonly"
                  @change="chooseTimeField('hour', $event.target.value)"
                >
                  <option
                    v-for="hour in fields.hours"
                    :key="hour.value"
                    :value="hour.value"
                  >
                    {{ hour.label }}
                  </option>
                </select>
                <span aria-hidden="true" class="text-gray-400">:</span>
                <select
                  data-slot="schedule-picker-minute"
                  aria-label="Minute"
                  class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                  :value="fields.minute"
                  :disabled="disabled || readonly"
                  @change="chooseTimeField('minute', $event.target.value)"
                >
                  <option
                    v-for="minute in minutes"
                    :key="minute"
                    :value="minute"
                  >
                    {{ minute }}
                  </option>
                </select>
                <div
                  v-if="fields.hour12"
                  class="relative ms-1 border-s border-gray-200 ps-1 dark:border-gray-700"
                >
                  <select
                    data-slot="schedule-picker-period"
                    aria-label="Period"
                    class="h-11 min-w-16 cursor-pointer appearance-none rounded-md bg-transparent ps-2 pe-6 text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                    :value="fields.period"
                    :disabled="disabled || readonly"
                    @change="chooseTimeField('period', $event.target.value)"
                  >
                    <option
                      v-for="period in fields.periods"
                      :key="period.value"
                      :value="period.value"
                    >
                      {{ period.label }}
                    </option>
                  </select>
                  <svg
                    aria-hidden="true"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="1.5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    class="pointer-events-none absolute inset-e-2 top-1/2 size-3 -translate-y-1/2 text-gray-500 dark:text-gray-400"
                  >
                    <path d="m7 10 5 5 5-5" />
                  </svg>
                </div>
              </div>
              <div data-slot="schedule-picker-footer">
                <button
                  type="button"
                  data-slot="schedule-picker-confirm"
                  class="min-h-11 cursor-pointer rounded-lg bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200 dark:focus-visible:outline-white"
                  :disabled="
                    (!committable && interpretation.state !== 'committed') ||
                    invalid ||
                    disabled ||
                    readonly
                  "
                  @click="finish()"
                >
                  Done
                </button>
              </div>
            </div>
            <p v-if="invalid" class="text-sm text-red-700 dark:text-red-400">
              {{ statusText }}
            </p>
          </section>
        </div>
      </div>
    </Popover>
  </div>
</template>

schedule.js
import {
  CalendarDateTime,
  fromAbsolute,
  toZoned
} from '@internationalized/date'
import { en as chrono } from 'chrono-node'
import { dateLabel, parseIsoDate, resolveLocale } from '../calendar/date.js'

export function resolveTimeZone(timeZone) {
  const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
  const candidate = timeZone || fallback
  try {
    new Intl.DateTimeFormat('en', { timeZone: candidate }).format()
    return candidate
  } catch {
    return fallback
  }
}

export function parseTime(value) {
  const match = /^(\d{2}):(\d{2})$/.exec(value ?? '')
  if (!match) return undefined
  const hour = Number(match[1])
  const minute = Number(match[2])
  if (hour > 23 || minute > 59) return undefined
  return { hour, minute }
}

export function formatTime({ hour, minute }) {
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}

export function wallClockToIso({
  date,
  time,
  timeZone,
  timezoneOffset,
  second = 0,
  millisecond = 0
}) {
  const parsedDate = parseIsoDate(date)
  const parsedTime = parseTime(time)
  if (!parsedDate || !parsedTime) return undefined

  if (Number.isFinite(timezoneOffset)) {
    return new Date(
      Date.UTC(
        parsedDate.year,
        parsedDate.month - 1,
        parsedDate.day,
        parsedTime.hour,
        parsedTime.minute,
        second,
        millisecond
      ) +
        -timezoneOffset * 60_000
    ).toISOString()
  }

  try {
    const zoned = toZoned(
      new CalendarDateTime(
        parsedDate.year,
        parsedDate.month,
        parsedDate.day,
        parsedTime.hour,
        parsedTime.minute,
        second,
        millisecond
      ),
      resolveTimeZone(timeZone),
      'compatible'
    )
    // A gap must not silently move an entered time forward. During an overlap,
    // compatible disambiguation keeps the earlier occurrence.
    if (
      zoned.year !== parsedDate.year ||
      zoned.month !== parsedDate.month ||
      zoned.day !== parsedDate.day ||
      zoned.hour !== parsedTime.hour ||
      zoned.minute !== parsedTime.minute ||
      zoned.second !== second ||
      zoned.millisecond !== millisecond
    ) {
      return undefined
    }
    return zoned.toDate().toISOString()
  } catch {
    return undefined
  }
}

export function instantToWallClock(value, timeZone) {
  const instant = new Date(value)
  if (Number.isNaN(instant.getTime())) return undefined
  const zoned = fromAbsolute(instant.getTime(), resolveTimeZone(timeZone))
  return {
    date: `${String(zoned.year).padStart(4, '0')}-${String(zoned.month).padStart(2, '0')}-${String(zoned.day).padStart(2, '0')}`,
    time: formatTime({ hour: zoned.hour, minute: zoned.minute })
  }
}

export function formatSchedule(value, locale, timeZone) {
  const instant = new Date(value)
  if (Number.isNaN(instant.getTime())) return ''
  return new Intl.DateTimeFormat(resolveLocale(locale), {
    timeZone: resolveTimeZone(timeZone),
    dateStyle: 'medium',
    timeStyle:
      instant.getUTCSeconds() || instant.getUTCMilliseconds()
        ? 'medium'
        : 'short'
  }).format(instant)
}

export function formatTimeLabel(value, locale) {
  const time = parseTime(value)
  if (!time) return ''
  return new Intl.DateTimeFormat(resolveLocale(locale), {
    timeZone: 'UTC',
    hour: 'numeric',
    minute: '2-digit'
  }).format(new Date(Date.UTC(2020, 0, 1, time.hour, time.minute)))
}

function timestamp(value) {
  if (value === undefined || value === null || value === '') return NaN
  return new Date(value).getTime()
}

function referenceTimestamp(reference) {
  const value = timestamp(reference)
  return Number.isFinite(value) ? value : Date.now()
}

export function scheduleConstraint(
  value,
  { allowPast = false, min, max, reference = new Date() } = {}
) {
  const instant = timestamp(value)
  if (!Number.isFinite(instant)) return 'invalid'
  if (!allowPast && instant <= referenceTimestamp(reference)) return 'past'
  if (instant < timestamp(min)) return 'min'
  if (instant > timestamp(max)) return 'max'
  return ''
}

export function scheduleCalendarBounds({
  allowPast = false,
  min,
  max,
  timeZone,
  reference = new Date()
} = {}) {
  const configuredMin = timestamp(min)
  const lower = Math.max(
    allowPast ? -Infinity : referenceTimestamp(reference) + 1,
    Number.isFinite(configuredMin) ? configuredMin : -Infinity
  )
  const upper = timestamp(max)
  return {
    min: Number.isFinite(lower)
      ? instantToWallClock(lower, timeZone)?.date
      : undefined,
    max: Number.isFinite(upper)
      ? instantToWallClock(upper, timeZone)?.date
      : undefined
  }
}

export function timeFields(value, locale) {
  const time = parseTime(value) ?? { hour: 0, minute: 0 }
  const resolvedLocale = resolveLocale(locale)
  const hour12 = new Intl.DateTimeFormat(resolvedLocale, {
    hour: 'numeric'
  }).resolvedOptions().hour12
  const hoursFormatter = new Intl.DateTimeFormat(resolvedLocale, {
    timeZone: 'UTC',
    hour: '2-digit',
    hourCycle: hour12 ? 'h12' : 'h23'
  })
  const periodsFormatter = new Intl.DateTimeFormat(resolvedLocale, {
    timeZone: 'UTC',
    hour: 'numeric',
    hourCycle: 'h12'
  })
  const part = (formatter, hour, type) =>
    formatter
      .formatToParts(new Date(Date.UTC(2020, 0, 1, hour)))
      .find((item) => item.type === type)?.value

  return {
    hour: String(hour12 ? time.hour % 12 || 12 : time.hour),
    minute: String(time.minute).padStart(2, '0'),
    period: time.hour < 12 ? 'am' : 'pm',
    hour12,
    periods: [
      { value: 'am', label: part(periodsFormatter, 6, 'dayPeriod') ?? 'AM' },
      { value: 'pm', label: part(periodsFormatter, 18, 'dayPeriod') ?? 'PM' }
    ],
    hours: Array.from({ length: hour12 ? 12 : 24 }, (_, index) => {
      const hour = hour12 ? index + 1 : index
      return {
        value: String(hour),
        label: part(hoursFormatter, hour, 'hour') ?? String(hour)
      }
    })
  }
}

export function updateTimeField(time, part, value, locale) {
  const current = parseTime(time)
  if (!current) return time
  const fields = timeFields(time, locale)
  if (part === 'period') {
    if (!fields.hour12 || !['am', 'pm'].includes(value)) return time
    current.hour = (current.hour % 12) + (value === 'pm' ? 12 : 0)
  } else {
    if (!/^\d{1,2}$/.test(String(value))) return time
    const number = Number(value)
    if (part === 'minute') {
      if (number > 59) return time
      current.minute = number
    } else if (part === 'hour') {
      if (fields.hour12) {
        if (number < 1 || number > 12) return time
        current.hour = (number % 12) + (fields.period === 'pm' ? 12 : 0)
      } else {
        if (number > 23) return time
        current.hour = number
      }
    } else {
      return time
    }
  }
  return formatTime(current)
}

function normalizeMinuteStep(step) {
  const value = Number(step)
  return Number.isFinite(value)
    ? Math.min(60, Math.max(1, Math.round(value)))
    : 15
}

export function timeOptions(step = 15) {
  const safeStep = normalizeMinuteStep(step)
  const values = []
  for (let minute = 0; minute < 24 * 60; minute += safeStep) {
    values.push(
      formatTime({ hour: Math.floor(minute / 60), minute: minute % 60 })
    )
  }
  return values
}

function roundedFutureTimestamp(reference, step) {
  const amount = normalizeMinuteStep(step)
  const rounded = new Date(referenceTimestamp(reference))
  rounded.setSeconds(0, 0)
  const remainder = rounded.getMinutes() % amount
  rounded.setMinutes(
    rounded.getMinutes() + (remainder ? amount - remainder : amount)
  )
  return rounded.getTime()
}

export function roundedFutureWallClock(reference, timeZone, step = 15) {
  return instantToWallClock(roundedFutureTimestamp(reference, step), timeZone)
}

export function initialScheduleWallClock(
  value,
  {
    allowPast = false,
    min,
    max,
    timeZone,
    minuteStep = 15,
    reference = new Date()
  } = {}
) {
  const current = timestamp(value)
  if (Number.isFinite(current)) return instantToWallClock(current, timeZone)

  const now = referenceTimestamp(reference)
  const initial = roundedFutureTimestamp(now, minuteStep)
  const configuredMin = timestamp(min)
  const configuredMax = timestamp(max)
  const lower = Math.max(
    allowPast ? -Infinity : now + 1,
    Number.isFinite(configuredMin) ? configuredMin : -Infinity
  )
  const upper = Number.isFinite(configuredMax) ? configuredMax : Infinity
  // Bounds still disable every choice when the allowed interval is empty.
  // Initializing a view must not imply that a forbidden instant is valid.
  const candidate =
    lower <= upper ? Math.min(upper, Math.max(lower, initial)) : initial
  return instantToWallClock(candidate, timeZone)
}

export function interpretSchedule(
  text,
  { reference = new Date(), locale, timeZone, allowPast = false } = {}
) {
  const source = text?.trim()
  if (!source) return { state: 'empty' }

  const zone = resolveTimeZone(timeZone)
  const referenceDate =
    reference instanceof Date ? reference : new Date(reference)
  const referenceInstant = Number.isNaN(referenceDate.getTime())
    ? new Date()
    : referenceDate
  const zonedReference = fromAbsolute(referenceInstant.getTime(), zone)
  const result = chrono.parse(
    source,
    {
      instant: referenceInstant,
      timezone: zonedReference.offset / 60_000
    },
    { forwardDate: !allowPast }
  )[0]

  if (!result) return { state: 'invalid' }

  const date = `${String(result.start.get('year')).padStart(4, '0')}-${String(result.start.get('month')).padStart(2, '0')}-${String(result.start.get('day')).padStart(2, '0')}`
  const hasTime = result.start.isCertain('hour')
  if (!hasTime) {
    return {
      state: 'incomplete',
      date,
      message: `${dateLabel(date, locale)} needs a time.`
    }
  }

  const time = formatTime({
    hour: result.start.get('hour'),
    minute: result.start.get('minute') ?? 0
  })
  const timezoneOffset = result.start.isCertain('timezoneOffset')
    ? result.start.get('timezoneOffset')
    : undefined
  const iso = wallClockToIso({
    date,
    time,
    timeZone: zone,
    timezoneOffset,
    second: result.start.get('second') ?? 0,
    millisecond: result.start.get('millisecond') ?? 0
  })
  if (!iso) return { state: 'invalid' }

  return {
    state: 'proposal',
    ...instantToWallClock(iso, zone),
    iso,
    label: formatSchedule(iso, locale, zone),
    timeZone: zone
  }
}

React

SchedulePicker.jsx
import {
  forwardRef,
  useEffect,
  useId,
  useImperativeHandle,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.jsx'
import Input from '../input/Input.jsx'
import Popover from '../popover/Popover.jsx'
import {
  formatSchedule,
  instantToWallClock,
  initialScheduleWallClock,
  interpretSchedule,
  resolveTimeZone,
  scheduleCalendarBounds,
  scheduleConstraint,
  timeFields,
  updateTimeField,
  wallClockToIso
} from './schedule.js'

const SchedulePicker = forwardRef(function SchedulePicker(
  {
    value,
    defaultValue,
    onValueChange,
    onChange,
    id,
    name,
    placeholder = 'Tomorrow at 9am',
    timeZone,
    locale,
    dir,
    allowPast = false,
    min,
    max,
    minuteStep = 15,
    open,
    defaultOpen = false,
    onOpenChange,
    required = false,
    disabled = false,
    readOnly = false,
    className,
    'aria-describedby': externalDescribedBy,
    ...inputProps
  },
  forwardedRef
) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const inputId = id ?? `klean-schedule-picker-${generatedId}`
  const popoverId = `${inputId}-panel`
  const statusId = `${inputId}-status`
  const timeHeadingId = `${inputId}-time-heading`
  const zone = resolveTimeZone(timeZone)
  const validDefault = Number.isNaN(new Date(defaultValue).getTime())
    ? ''
    : defaultValue
  const [internalValue, setInternalValue] = useState(validDefault)
  const committedValue = value === undefined ? internalValue : value
  const initialWall = initialScheduleWallClock(committedValue, {
    allowPast,
    min,
    max,
    timeZone: zone,
    minuteStep
  })
  const [selectedDate, setSelectedDate] = useState(initialWall.date)
  const [selectedTime, setSelectedTime] = useState(initialWall.time)
  const [draft, setDraft] = useState(
    committedValue ? formatSchedule(committedValue, locale, zone) : ''
  )
  const [interpretation, setInterpretation] = useState(
    committedValue
      ? {
          state: 'committed',
          iso: committedValue,
          ...initialWall,
          label: formatSchedule(committedValue, locale, zone)
        }
      : { state: 'empty' }
  )
  const [touched, setTouched] = useState(false)
  const [validationClock, setValidationClock] = useState(Date.now)
  const inputRef = useRef(null)
  const popoverRef = useRef(null)
  const panelRef = useRef(null)
  const rootRef = useRef(null)
  const constraints = {
    allowPast,
    min,
    max,
    reference: new Date(validationClock)
  }
  const calendarBounds = scheduleCalendarBounds({
    ...constraints,
    timeZone: zone
  })
  const fields = timeFields(selectedTime, locale)
  const minutes = Array.from({ length: 60 }, (_, minute) =>
    String(minute).padStart(2, '0')
  )
  const constraintError = interpretation.iso
    ? scheduleConstraint(interpretation.iso, constraints)
    : ''
  const committable = interpretation.state === 'proposal' && !constraintError
  const invalid =
    interpretation.state === 'invalid' ||
    Boolean(constraintError) ||
    (touched && interpretation.state === 'incomplete')
  let statusText
  if (interpretation.state === 'empty')
    statusText = 'Type a date and time, or choose them from the calendar.'
  else if (interpretation.state === 'invalid')
    statusText = 'Enter a date and time, such as tomorrow at 9am.'
  else if (interpretation.state === 'incomplete')
    statusText = interpretation.message
  else if (constraintError === 'past')
    statusText = 'Choose a time in the future.'
  else if (constraintError === 'min')
    statusText = `Choose ${formatSchedule(min, locale, zone)} or later.`
  else if (constraintError === 'max')
    statusText = `Choose ${formatSchedule(max, locale, zone)} or earlier.`
  else if (interpretation.state === 'proposal')
    statusText = `${allowPast ? 'Use' : 'Will schedule for'} ${interpretation.label} in ${zone}. Press Enter or leave the picker to use it.`
  else
    statusText = `${allowPast ? 'Selected' : 'Scheduled for'} ${interpretation.label} in ${zone}.`
  const describedBy = [externalDescribedBy, statusId].filter(Boolean).join(' ')

  function updateValue(nextValue) {
    if (value === undefined) setInternalValue(nextValue)
    onValueChange?.(nextValue)
  }

  function readDraft(nextDraft) {
    setValidationClock(Date.now())
    setDraft(nextDraft)
    if (!nextDraft.trim()) {
      updateValue('')
      setInterpretation({ state: 'empty' })
      return
    }
    const next = interpretSchedule(nextDraft, {
      reference: new Date(),
      locale,
      timeZone: zone,
      allowPast
    })
    setInterpretation(next)
    if (next.date) setSelectedDate(next.date)
    if (next.time) setSelectedTime(next.time)
  }

  function stage(date = selectedDate, time = selectedTime) {
    if (disabled || readOnly) return
    setValidationClock(Date.now())
    // Reselecting an unchanged instant must not discard its seconds.
    const iso =
      interpretation.iso &&
      interpretation.date === date &&
      interpretation.time === time
        ? interpretation.iso
        : wallClockToIso({ date, time, timeZone: zone })
    setSelectedDate(date)
    setSelectedTime(time)
    if (!iso) {
      setInterpretation({ state: 'invalid' })
      return
    }
    const label = formatSchedule(iso, locale, zone)
    setDraft(label)
    setInterpretation({ state: 'proposal', iso, date, time, label })
  }

  function commitProposal({ restoreFocus = true } = {}) {
    const reference = new Date()
    setValidationClock(reference.getTime())
    if (
      interpretation.state !== 'proposal' ||
      disabled ||
      readOnly ||
      scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
    )
      return
    updateValue(interpretation.iso)
    setDraft(interpretation.label)
    setInterpretation({ ...interpretation, state: 'committed' })
    popoverRef.current?.close({ restoreFocus })
  }

  function handleBlur(event) {
    if (
      event.relatedTarget &&
      event.currentTarget.contains(event.relatedTarget)
    )
      return
    setTouched(true)
    commitProposal({ restoreFocus: false })
  }

  function finish() {
    const reference = new Date()
    setValidationClock(reference.getTime())
    if (
      disabled ||
      readOnly ||
      scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
    )
      return
    if (interpretation.state === 'committed') {
      popoverRef.current?.close()
      return
    }
    commitProposal()
  }

  function handleOpenChange(nextOpen) {
    setValidationClock(Date.now())
    onOpenChange?.(nextOpen)
    if (!nextOpen) return
    if (interpretation.state === 'empty') {
      const wall = initialScheduleWallClock('', {
        allowPast,
        min,
        max,
        timeZone: zone,
        minuteStep
      })
      setSelectedDate(wall.date)
      setSelectedTime(wall.time)
    }
    requestAnimationFrame(() => {
      if (!panelRef.current?.getClientRects().length) return
      panelRef.current.parentElement.scrollTop = 0
    })
  }

  function chooseTimeField(part, nextValue) {
    stage(selectedDate, updateTimeField(selectedTime, part, nextValue, locale))
  }

  useEffect(() => {
    const wall = instantToWallClock(committedValue, zone)
    if (!wall) {
      if (!committedValue) {
        setDraft('')
        setInterpretation({ state: 'empty' })
      }
      return
    }
    const label = formatSchedule(committedValue, locale, zone)
    setSelectedDate(wall.date)
    setSelectedTime(wall.time)
    setDraft(label)
    setInterpretation({
      state: 'committed',
      iso: committedValue,
      ...wall,
      label
    })
  }, [committedValue, locale, zone])

  useEffect(() => {
    if (!inputRef.current) return
    if (required && !committedValue)
      inputRef.current.setCustomValidity('Choose a date and time.')
    else if (invalid || interpretation.state === 'incomplete')
      inputRef.current.setCustomValidity(statusText)
    else inputRef.current.setCustomValidity('')
  }, [
    committable,
    committedValue,
    interpretation.state,
    invalid,
    required,
    statusText
  ])

  useImperativeHandle(forwardedRef, () => ({
    input: inputRef.current,
    focus: (options) => inputRef.current?.focus(options),
    open: () => popoverRef.current?.open(),
    close: () => popoverRef.current?.close()
  }))

  return (
    <div
      ref={rootRef}
      data-slot="schedule-picker"
      data-state={interpretation.state}
      onBlur={handleBlur}
      className={twMerge(
        'grid w-full gap-2 **:data-[slot=schedule-picker-field]:relative **:data-[slot=schedule-picker-field]:flex **:data-[slot=schedule-picker-field]:items-stretch **:data-[slot=input]:pe-12',
        className
      )}
    >
      <div data-slot="schedule-picker-field">
        <Input
          {...inputProps}
          ref={inputRef}
          id={inputId}
          type="text"
          autoComplete="off"
          value={draft}
          placeholder={placeholder}
          required={required}
          disabled={disabled}
          readOnly={readOnly}
          aria-invalid={invalid || undefined}
          aria-describedby={describedBy}
          onChange={(event) => {
            onChange?.(event)
            if (!event.defaultPrevented) {
              setTouched(false)
              readDraft(event.target.value)
            }
          }}
          onClick={() => !disabled && !readOnly && popoverRef.current?.open()}
          onKeyDown={(event) => {
            inputProps.onKeyDown?.(event)
            if (event.defaultPrevented) return
            if (event.key === 'ArrowDown' && !disabled && !readOnly) {
              event.preventDefault()
              popoverRef.current?.open()
            } else if (
              event.key === 'Enter' &&
              (interpretation.state === 'proposal' ||
                interpretation.state === 'incomplete' ||
                interpretation.state === 'invalid' ||
                constraintError)
            ) {
              event.preventDefault()
              setTouched(true)
              commitProposal({ restoreFocus: false })
            }
          }}
        />
        <button
          type="button"
          popoverTarget={popoverId}
          data-slot="schedule-picker-button"
          className="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
          disabled={disabled || readOnly}
          aria-label="Choose a date and time"
        >
          <svg
            aria-hidden="true"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.8"
            className="size-5"
          >
            <circle cx="12" cy="12" r="9" />
            <path d="M12 7v5l3 2" />
          </svg>
        </button>
      </div>
      {name ? (
        <input
          type="hidden"
          name={name}
          value={committedValue}
          disabled={disabled}
        />
      ) : null}
      <p
        id={statusId}
        data-slot="schedule-picker-status"
        className="text-sm text-gray-600 aria-invalid:text-red-700 dark:text-gray-400 dark:aria-invalid:text-red-400"
        aria-invalid={invalid}
        aria-live="polite"
      >
        {statusText}
      </p>

      <Popover
        ref={popoverRef}
        id={popoverId}
        anchor={inputId}
        open={open}
        defaultOpen={defaultOpen}
        onOpenChange={handleOpenChange}
        placement="bottom-start"
        data-slot="schedule-picker-popover"
        className="max-h-[min(var(--klean-popover-available-height,100dvh),calc(100dvh-1rem))] w-[min(24rem,calc(100vw-1rem))] overflow-y-auto overscroll-contain rounded-xl p-0"
      >
        <div ref={panelRef} data-slot="schedule-picker-panel">
          <div className="grid">
            <Calendar
              value={selectedDate}
              min={calendarBounds.min}
              max={calendarBounds.max}
              locale={locale}
              dir={dir}
              disabled={disabled}
              readOnly={readOnly}
              className="max-w-none p-4"
              onValueChange={(date) => stage(date, selectedTime)}
            />
            <section
              data-slot="schedule-picker-times"
              className="sticky bottom-0 grid min-w-0 gap-2 border-t border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950"
              aria-labelledby={timeHeadingId}
            >
              <div className="flex min-w-0 items-baseline justify-between gap-3">
                <h2 id={timeHeadingId} className="text-sm font-medium">
                  Time
                </h2>
                <p
                  id={`${inputId}-time-zone`}
                  data-slot="schedule-picker-time-zone"
                  className="min-w-0 text-end text-xs wrap-anywhere text-gray-500 dark:text-gray-400"
                >
                  {zone}
                </p>
              </div>
              <div className="flex flex-wrap items-center justify-between gap-2">
                <div
                  data-slot="schedule-picker-time-fields"
                  role="group"
                  aria-labelledby={timeHeadingId}
                  aria-describedby={`${inputId}-time-zone`}
                  dir="ltr"
                  className="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-sm font-medium tabular-nums shadow-xs dark:border-gray-700 dark:bg-gray-900"
                >
                  <select
                    data-slot="schedule-picker-hour"
                    aria-label="Hour"
                    className="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                    value={fields.hour}
                    disabled={disabled || readOnly}
                    onChange={(event) =>
                      chooseTimeField('hour', event.target.value)
                    }
                  >
                    {fields.hours.map((hour) => (
                      <option key={hour.value} value={hour.value}>
                        {hour.label}
                      </option>
                    ))}
                  </select>
                  <span aria-hidden="true" className="text-gray-400">
                    :
                  </span>
                  <select
                    data-slot="schedule-picker-minute"
                    aria-label="Minute"
                    className="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                    value={fields.minute}
                    disabled={disabled || readOnly}
                    onChange={(event) =>
                      chooseTimeField('minute', event.target.value)
                    }
                  >
                    {minutes.map((minute) => (
                      <option key={minute} value={minute}>
                        {minute}
                      </option>
                    ))}
                  </select>
                  {fields.hour12 ? (
                    <div className="relative ms-1 border-s border-gray-200 ps-1 dark:border-gray-700">
                      <select
                        data-slot="schedule-picker-period"
                        aria-label="Period"
                        className="h-11 min-w-16 cursor-pointer appearance-none rounded-md bg-transparent ps-2 pe-6 text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                        value={fields.period}
                        disabled={disabled || readOnly}
                        onChange={(event) =>
                          chooseTimeField('period', event.target.value)
                        }
                      >
                        {fields.periods.map((period) => (
                          <option key={period.value} value={period.value}>
                            {period.label}
                          </option>
                        ))}
                      </select>
                      <svg
                        aria-hidden="true"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className="pointer-events-none absolute inset-e-2 top-1/2 size-3 -translate-y-1/2 text-gray-500 dark:text-gray-400"
                      >
                        <path d="m7 10 5 5 5-5" />
                      </svg>
                    </div>
                  ) : null}
                </div>
                <div data-slot="schedule-picker-footer">
                  <button
                    type="button"
                    data-slot="schedule-picker-confirm"
                    className="min-h-11 cursor-pointer rounded-lg bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200 dark:focus-visible:outline-white"
                    disabled={
                      (!committable && interpretation.state !== 'committed') ||
                      invalid ||
                      disabled ||
                      readOnly
                    }
                    onClick={finish}
                  >
                    Done
                  </button>
                </div>
              </div>
              {invalid ? (
                <p className="text-sm text-red-700 dark:text-red-400">
                  {statusText}
                </p>
              ) : null}
            </section>
          </div>
        </div>
      </Popover>
    </div>
  )
})

export default SchedulePicker

schedule.js
import {
  CalendarDateTime,
  fromAbsolute,
  toZoned
} from '@internationalized/date'
import { en as chrono } from 'chrono-node'
import { dateLabel, parseIsoDate, resolveLocale } from '../calendar/date.js'

export function resolveTimeZone(timeZone) {
  const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
  const candidate = timeZone || fallback
  try {
    new Intl.DateTimeFormat('en', { timeZone: candidate }).format()
    return candidate
  } catch {
    return fallback
  }
}

export function parseTime(value) {
  const match = /^(\d{2}):(\d{2})$/.exec(value ?? '')
  if (!match) return undefined
  const hour = Number(match[1])
  const minute = Number(match[2])
  if (hour > 23 || minute > 59) return undefined
  return { hour, minute }
}

export function formatTime({ hour, minute }) {
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}

export function wallClockToIso({
  date,
  time,
  timeZone,
  timezoneOffset,
  second = 0,
  millisecond = 0
}) {
  const parsedDate = parseIsoDate(date)
  const parsedTime = parseTime(time)
  if (!parsedDate || !parsedTime) return undefined

  if (Number.isFinite(timezoneOffset)) {
    return new Date(
      Date.UTC(
        parsedDate.year,
        parsedDate.month - 1,
        parsedDate.day,
        parsedTime.hour,
        parsedTime.minute,
        second,
        millisecond
      ) +
        -timezoneOffset * 60_000
    ).toISOString()
  }

  try {
    const zoned = toZoned(
      new CalendarDateTime(
        parsedDate.year,
        parsedDate.month,
        parsedDate.day,
        parsedTime.hour,
        parsedTime.minute,
        second,
        millisecond
      ),
      resolveTimeZone(timeZone),
      'compatible'
    )
    // A gap must not silently move an entered time forward. During an overlap,
    // compatible disambiguation keeps the earlier occurrence.
    if (
      zoned.year !== parsedDate.year ||
      zoned.month !== parsedDate.month ||
      zoned.day !== parsedDate.day ||
      zoned.hour !== parsedTime.hour ||
      zoned.minute !== parsedTime.minute ||
      zoned.second !== second ||
      zoned.millisecond !== millisecond
    ) {
      return undefined
    }
    return zoned.toDate().toISOString()
  } catch {
    return undefined
  }
}

export function instantToWallClock(value, timeZone) {
  const instant = new Date(value)
  if (Number.isNaN(instant.getTime())) return undefined
  const zoned = fromAbsolute(instant.getTime(), resolveTimeZone(timeZone))
  return {
    date: `${String(zoned.year).padStart(4, '0')}-${String(zoned.month).padStart(2, '0')}-${String(zoned.day).padStart(2, '0')}`,
    time: formatTime({ hour: zoned.hour, minute: zoned.minute })
  }
}

export function formatSchedule(value, locale, timeZone) {
  const instant = new Date(value)
  if (Number.isNaN(instant.getTime())) return ''
  return new Intl.DateTimeFormat(resolveLocale(locale), {
    timeZone: resolveTimeZone(timeZone),
    dateStyle: 'medium',
    timeStyle:
      instant.getUTCSeconds() || instant.getUTCMilliseconds()
        ? 'medium'
        : 'short'
  }).format(instant)
}

export function formatTimeLabel(value, locale) {
  const time = parseTime(value)
  if (!time) return ''
  return new Intl.DateTimeFormat(resolveLocale(locale), {
    timeZone: 'UTC',
    hour: 'numeric',
    minute: '2-digit'
  }).format(new Date(Date.UTC(2020, 0, 1, time.hour, time.minute)))
}

function timestamp(value) {
  if (value === undefined || value === null || value === '') return NaN
  return new Date(value).getTime()
}

function referenceTimestamp(reference) {
  const value = timestamp(reference)
  return Number.isFinite(value) ? value : Date.now()
}

export function scheduleConstraint(
  value,
  { allowPast = false, min, max, reference = new Date() } = {}
) {
  const instant = timestamp(value)
  if (!Number.isFinite(instant)) return 'invalid'
  if (!allowPast && instant <= referenceTimestamp(reference)) return 'past'
  if (instant < timestamp(min)) return 'min'
  if (instant > timestamp(max)) return 'max'
  return ''
}

export function scheduleCalendarBounds({
  allowPast = false,
  min,
  max,
  timeZone,
  reference = new Date()
} = {}) {
  const configuredMin = timestamp(min)
  const lower = Math.max(
    allowPast ? -Infinity : referenceTimestamp(reference) + 1,
    Number.isFinite(configuredMin) ? configuredMin : -Infinity
  )
  const upper = timestamp(max)
  return {
    min: Number.isFinite(lower)
      ? instantToWallClock(lower, timeZone)?.date
      : undefined,
    max: Number.isFinite(upper)
      ? instantToWallClock(upper, timeZone)?.date
      : undefined
  }
}

export function timeFields(value, locale) {
  const time = parseTime(value) ?? { hour: 0, minute: 0 }
  const resolvedLocale = resolveLocale(locale)
  const hour12 = new Intl.DateTimeFormat(resolvedLocale, {
    hour: 'numeric'
  }).resolvedOptions().hour12
  const hoursFormatter = new Intl.DateTimeFormat(resolvedLocale, {
    timeZone: 'UTC',
    hour: '2-digit',
    hourCycle: hour12 ? 'h12' : 'h23'
  })
  const periodsFormatter = new Intl.DateTimeFormat(resolvedLocale, {
    timeZone: 'UTC',
    hour: 'numeric',
    hourCycle: 'h12'
  })
  const part = (formatter, hour, type) =>
    formatter
      .formatToParts(new Date(Date.UTC(2020, 0, 1, hour)))
      .find((item) => item.type === type)?.value

  return {
    hour: String(hour12 ? time.hour % 12 || 12 : time.hour),
    minute: String(time.minute).padStart(2, '0'),
    period: time.hour < 12 ? 'am' : 'pm',
    hour12,
    periods: [
      { value: 'am', label: part(periodsFormatter, 6, 'dayPeriod') ?? 'AM' },
      { value: 'pm', label: part(periodsFormatter, 18, 'dayPeriod') ?? 'PM' }
    ],
    hours: Array.from({ length: hour12 ? 12 : 24 }, (_, index) => {
      const hour = hour12 ? index + 1 : index
      return {
        value: String(hour),
        label: part(hoursFormatter, hour, 'hour') ?? String(hour)
      }
    })
  }
}

export function updateTimeField(time, part, value, locale) {
  const current = parseTime(time)
  if (!current) return time
  const fields = timeFields(time, locale)
  if (part === 'period') {
    if (!fields.hour12 || !['am', 'pm'].includes(value)) return time
    current.hour = (current.hour % 12) + (value === 'pm' ? 12 : 0)
  } else {
    if (!/^\d{1,2}$/.test(String(value))) return time
    const number = Number(value)
    if (part === 'minute') {
      if (number > 59) return time
      current.minute = number
    } else if (part === 'hour') {
      if (fields.hour12) {
        if (number < 1 || number > 12) return time
        current.hour = (number % 12) + (fields.period === 'pm' ? 12 : 0)
      } else {
        if (number > 23) return time
        current.hour = number
      }
    } else {
      return time
    }
  }
  return formatTime(current)
}

function normalizeMinuteStep(step) {
  const value = Number(step)
  return Number.isFinite(value)
    ? Math.min(60, Math.max(1, Math.round(value)))
    : 15
}

export function timeOptions(step = 15) {
  const safeStep = normalizeMinuteStep(step)
  const values = []
  for (let minute = 0; minute < 24 * 60; minute += safeStep) {
    values.push(
      formatTime({ hour: Math.floor(minute / 60), minute: minute % 60 })
    )
  }
  return values
}

function roundedFutureTimestamp(reference, step) {
  const amount = normalizeMinuteStep(step)
  const rounded = new Date(referenceTimestamp(reference))
  rounded.setSeconds(0, 0)
  const remainder = rounded.getMinutes() % amount
  rounded.setMinutes(
    rounded.getMinutes() + (remainder ? amount - remainder : amount)
  )
  return rounded.getTime()
}

export function roundedFutureWallClock(reference, timeZone, step = 15) {
  return instantToWallClock(roundedFutureTimestamp(reference, step), timeZone)
}

export function initialScheduleWallClock(
  value,
  {
    allowPast = false,
    min,
    max,
    timeZone,
    minuteStep = 15,
    reference = new Date()
  } = {}
) {
  const current = timestamp(value)
  if (Number.isFinite(current)) return instantToWallClock(current, timeZone)

  const now = referenceTimestamp(reference)
  const initial = roundedFutureTimestamp(now, minuteStep)
  const configuredMin = timestamp(min)
  const configuredMax = timestamp(max)
  const lower = Math.max(
    allowPast ? -Infinity : now + 1,
    Number.isFinite(configuredMin) ? configuredMin : -Infinity
  )
  const upper = Number.isFinite(configuredMax) ? configuredMax : Infinity
  // Bounds still disable every choice when the allowed interval is empty.
  // Initializing a view must not imply that a forbidden instant is valid.
  const candidate =
    lower <= upper ? Math.min(upper, Math.max(lower, initial)) : initial
  return instantToWallClock(candidate, timeZone)
}

export function interpretSchedule(
  text,
  { reference = new Date(), locale, timeZone, allowPast = false } = {}
) {
  const source = text?.trim()
  if (!source) return { state: 'empty' }

  const zone = resolveTimeZone(timeZone)
  const referenceDate =
    reference instanceof Date ? reference : new Date(reference)
  const referenceInstant = Number.isNaN(referenceDate.getTime())
    ? new Date()
    : referenceDate
  const zonedReference = fromAbsolute(referenceInstant.getTime(), zone)
  const result = chrono.parse(
    source,
    {
      instant: referenceInstant,
      timezone: zonedReference.offset / 60_000
    },
    { forwardDate: !allowPast }
  )[0]

  if (!result) return { state: 'invalid' }

  const date = `${String(result.start.get('year')).padStart(4, '0')}-${String(result.start.get('month')).padStart(2, '0')}-${String(result.start.get('day')).padStart(2, '0')}`
  const hasTime = result.start.isCertain('hour')
  if (!hasTime) {
    return {
      state: 'incomplete',
      date,
      message: `${dateLabel(date, locale)} needs a time.`
    }
  }

  const time = formatTime({
    hour: result.start.get('hour'),
    minute: result.start.get('minute') ?? 0
  })
  const timezoneOffset = result.start.isCertain('timezoneOffset')
    ? result.start.get('timezoneOffset')
    : undefined
  const iso = wallClockToIso({
    date,
    time,
    timeZone: zone,
    timezoneOffset,
    second: result.start.get('second') ?? 0,
    millisecond: result.start.get('millisecond') ?? 0
  })
  if (!iso) return { state: 'invalid' }

  return {
    state: 'proposal',
    ...instantToWallClock(iso, zone),
    iso,
    label: formatSchedule(iso, locale, zone),
    timeZone: zone
  }
}

Svelte

SchedulePicker.svelte
<script>
  import { untrack } from "svelte";
  import { twMerge } from "tailwind-merge";
  import Calendar from "../calendar/Calendar.svelte";
  import Input from "../input/Input.svelte";
  import Popover from "../popover/Popover.svelte";
  import {
    formatSchedule,
    instantToWallClock,
    initialScheduleWallClock,
    interpretSchedule,
    resolveTimeZone,
    scheduleCalendarBounds,
    scheduleConstraint,
    timeFields,
    updateTimeField,
    wallClockToIso,
  } from "./schedule.js";

  let {
    value = $bindable(),
    defaultValue,
    onchange,
    id,
    name,
    placeholder = "Tomorrow at 9am",
    timeZone,
    locale,
    dir,
    allowPast = false,
    min,
    max,
    minuteStep = 15,
    open = $bindable(),
    defaultOpen = false,
    onopenchange,
    required = false,
    disabled = false,
    readonly = false,
    class: className,
    "aria-describedby": externalDescribedBy,
    ...inputProps
  } = $props();

  const componentId = $props.id();
  const generatedId = componentId.replace(/[^a-zA-Z0-9_-]/g, "");
  let inputId = $derived(id ?? `klean-schedule-picker-${generatedId}`);
  let popoverId = $derived(`${inputId}-panel`);
  let statusId = $derived(`${inputId}-status`);
  let timeHeadingId = $derived(`${inputId}-time-heading`);
  let zone = $derived(resolveTimeZone(timeZone));
  const initialValue = untrack(() => {
    const candidate = value === undefined ? defaultValue : value;
    return Number.isNaN(new Date(candidate).getTime()) ? "" : candidate;
  });
  if (untrack(() => value) === undefined) value = initialValue;
  const initialWall = untrack(() =>
    initialScheduleWallClock(initialValue, {
      allowPast,
      min,
      max,
      timeZone: zone,
      minuteStep,
    }),
  );
  let selectedDate = $state(initialWall.date);
  let selectedTime = $state(initialWall.time);
  let draft = $state(
    initialValue
      ? formatSchedule(
          initialValue,
          untrack(() => locale),
          untrack(() => zone),
        )
      : "",
  );
  let interpretation = $state(
    initialValue
      ? {
          state: "committed",
          iso: initialValue,
          ...initialWall,
          label: formatSchedule(
            initialValue,
            untrack(() => locale),
            untrack(() => zone),
          ),
        }
      : { state: "empty" },
  );
  let touched = $state(false);
  let validationClock = $state(Date.now());
  let input;
  let popover;
  let panel;
  let root;
  let constraints = $derived({
    allowPast,
    min,
    max,
    reference: new Date(validationClock),
  });
  let calendarBounds = $derived(
    scheduleCalendarBounds({ ...constraints, timeZone: zone }),
  );
  let fields = $derived(timeFields(selectedTime, locale));
  const minutes = Array.from({ length: 60 }, (_, minute) =>
    String(minute).padStart(2, "0"),
  );
  let constraintError = $derived(
    interpretation.iso
      ? scheduleConstraint(interpretation.iso, constraints)
      : "",
  );
  let committable = $derived(
    interpretation.state === "proposal" && !constraintError,
  );
  let invalid = $derived(
    interpretation.state === "invalid" ||
      Boolean(constraintError) ||
      (touched && interpretation.state === "incomplete"),
  );
  let statusText = $derived.by(() => {
    if (interpretation.state === "empty")
      return "Type a date and time, or choose them from the calendar.";
    if (interpretation.state === "invalid")
      return "Enter a date and time, such as tomorrow at 9am.";
    if (interpretation.state === "incomplete") return interpretation.message;
    if (constraintError === "past") return "Choose a time in the future.";
    if (constraintError === "min")
      return `Choose ${formatSchedule(min, locale, zone)} or later.`;
    if (constraintError === "max")
      return `Choose ${formatSchedule(max, locale, zone)} or earlier.`;
    if (interpretation.state === "proposal")
      return `${allowPast ? "Use" : "Will schedule for"} ${interpretation.label} in ${zone}. Press Enter or leave the picker to use it.`;
    return `${allowPast ? "Selected" : "Scheduled for"} ${interpretation.label} in ${zone}.`;
  });
  let describedBy = $derived(
    [externalDescribedBy, statusId].filter(Boolean).join(" "),
  );

  function updateValue(nextValue) {
    value = nextValue;
    onchange?.(nextValue);
  }

  function readDraft(nextDraft) {
    validationClock = Date.now();
    draft = nextDraft;
    if (!nextDraft.trim()) {
      updateValue("");
      interpretation = { state: "empty" };
      return;
    }
    const next = interpretSchedule(nextDraft, {
      reference: new Date(),
      locale,
      timeZone: zone,
      allowPast,
    });
    interpretation = next;
    if (next.date) selectedDate = next.date;
    if (next.time) selectedTime = next.time;
  }

  function stage(date = selectedDate, time = selectedTime) {
    if (disabled || readonly) return;
    validationClock = Date.now();
    // Reselecting an unchanged instant must not discard its seconds.
    const iso =
      interpretation.iso &&
      interpretation.date === date &&
      interpretation.time === time
        ? interpretation.iso
        : wallClockToIso({ date, time, timeZone: zone });
    selectedDate = date;
    selectedTime = time;
    if (!iso) {
      interpretation = { state: "invalid" };
      return;
    }
    const label = formatSchedule(iso, locale, zone);
    draft = label;
    interpretation = { state: "proposal", iso, date, time, label };
  }

  function commitProposal({ restoreFocus = true } = {}) {
    const reference = new Date();
    validationClock = reference.getTime();
    if (
      interpretation.state !== "proposal" ||
      disabled ||
      readonly ||
      scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
    )
      return;
    updateValue(interpretation.iso);
    draft = interpretation.label;
    interpretation = { ...interpretation, state: "committed" };
    popover?.close({ restoreFocus });
  }

  function handleFocusOut(event) {
    if (event.relatedTarget && root?.contains(event.relatedTarget)) return;
    touched = true;
    commitProposal({ restoreFocus: false });
  }

  function finish() {
    const reference = new Date();
    validationClock = reference.getTime();
    if (
      disabled ||
      readonly ||
      scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
    )
      return;
    if (interpretation.state === "committed") {
      popover?.close();
      return;
    }
    commitProposal();
  }

  function handleOpenChange(nextOpen) {
    validationClock = Date.now();
    onopenchange?.(nextOpen);
    if (!nextOpen) return;
    if (interpretation.state === "empty") {
      const wall = initialScheduleWallClock("", {
        allowPast,
        min,
        max,
        timeZone: zone,
        minuteStep,
      });
      selectedDate = wall.date;
      selectedTime = wall.time;
    }
    requestAnimationFrame(() => {
      if (!panel?.getClientRects().length) return;
      panel.parentElement.scrollTop = 0;
    });
  }

  function chooseTimeField(part, nextValue) {
    stage(selectedDate, updateTimeField(selectedTime, part, nextValue, locale));
  }

  $effect(() => {
    const committedValue = value;
    const currentZone = zone;
    const currentLocale = locale;
    untrack(() => {
      const wall = instantToWallClock(committedValue, currentZone);
      if (!wall) {
        if (!committedValue) {
          draft = "";
          interpretation = { state: "empty" };
        }
        return;
      }
      const label = formatSchedule(committedValue, currentLocale, currentZone);
      selectedDate = wall.date;
      selectedTime = wall.time;
      draft = label;
      interpretation = {
        state: "committed",
        iso: committedValue,
        ...wall,
        label,
      };
    });
  });

  $effect(() => {
    const element = input?.getElement();
    if (!element) return;
    if (required && !value)
      element.setCustomValidity("Choose a date and time.");
    else if (invalid || interpretation.state === "incomplete")
      element.setCustomValidity(statusText);
    else element.setCustomValidity("");
  });

  export function focus(options) {
    input?.focus(options);
  }

  export function show() {
    popover?.show();
  }

  export function close() {
    popover?.close();
  }
</script>

<div
  bind:this={root}
  data-slot="schedule-picker"
  data-state={interpretation.state}
  onfocusout={handleFocusOut}
  class={twMerge(
    "grid w-full gap-2 **:data-[slot=schedule-picker-field]:relative **:data-[slot=schedule-picker-field]:flex **:data-[slot=schedule-picker-field]:items-stretch **:data-[slot=input]:pe-12",
    className,
  )}
>
  <div data-slot="schedule-picker-field">
    <Input
      {...inputProps}
      bind:this={input}
      id={inputId}
      type="text"
      autocomplete="off"
      value={draft}
      {placeholder}
      {required}
      {disabled}
      {readonly}
      aria-invalid={invalid || undefined}
      aria-describedby={describedBy}
      oninput={(event) => {
        touched = false;
        readDraft(event.target.value);
      }}
      onclick={() => !disabled && !readonly && popover?.show()}
      onkeydown={(event) => {
        inputProps.onkeydown?.(event);
        if (event.defaultPrevented) return;
        if (event.key === "ArrowDown" && !disabled && !readonly) {
          event.preventDefault();
          popover?.show();
        } else if (
          event.key === "Enter" &&
          (interpretation.state === "proposal" ||
            interpretation.state === "incomplete" ||
            interpretation.state === "invalid" ||
            constraintError)
        ) {
          event.preventDefault();
          touched = true;
          commitProposal({ restoreFocus: false });
        }
      }}
    />
    <button
      type="button"
      popovertarget={popoverId}
      data-slot="schedule-picker-button"
      class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
      disabled={disabled || readonly}
      aria-label="Choose a date and time"
    >
      <svg
        aria-hidden="true"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="1.8"
        class="size-5"
      >
        <circle cx="12" cy="12" r="9" />
        <path d="M12 7v5l3 2" />
      </svg>
    </button>
  </div>
  {#if name}
    <input type="hidden" {name} {value} {disabled} />
  {/if}
  <p
    id={statusId}
    data-slot="schedule-picker-status"
    class="text-sm text-gray-600 aria-invalid:text-red-700 dark:text-gray-400 dark:aria-invalid:text-red-400"
    aria-invalid={invalid}
    aria-live="polite"
  >
    {statusText}
  </p>
  <Popover
    bind:this={popover}
    bind:open
    id={popoverId}
    anchor={inputId}
    {defaultOpen}
    onOpenChange={handleOpenChange}
    placement="bottom-start"
    data-slot="schedule-picker-popover"
    class="max-h-[min(var(--klean-popover-available-height,100dvh),calc(100dvh-1rem))] w-[min(24rem,calc(100vw-1rem))] overflow-y-auto overscroll-contain rounded-xl p-0"
  >
    <div bind:this={panel} data-slot="schedule-picker-panel">
      <div class="grid">
        <Calendar
          value={selectedDate}
          min={calendarBounds.min}
          max={calendarBounds.max}
          {locale}
          {dir}
          {disabled}
          {readonly}
          class="max-w-none p-4"
          onchange={(date) => stage(date, selectedTime)}
        />
        <section
          data-slot="schedule-picker-times"
          class="sticky bottom-0 grid min-w-0 gap-2 border-t border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950"
          aria-labelledby={timeHeadingId}
        >
          <div class="flex min-w-0 items-baseline justify-between gap-3">
            <h2 id={timeHeadingId} class="text-sm font-medium">Time</h2>
            <p
              id={`${inputId}-time-zone`}
              data-slot="schedule-picker-time-zone"
              class="min-w-0 text-end text-xs wrap-anywhere text-gray-500 dark:text-gray-400"
            >
              {zone}
            </p>
          </div>
          <div class="flex flex-wrap items-center justify-between gap-2">
            <div
              data-slot="schedule-picker-time-fields"
              role="group"
              aria-labelledby={timeHeadingId}
              aria-describedby={`${inputId}-time-zone`}
              dir="ltr"
              class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-sm font-medium tabular-nums shadow-xs dark:border-gray-700 dark:bg-gray-900"
            >
              <select
                data-slot="schedule-picker-hour"
                aria-label="Hour"
                class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                value={fields.hour}
                disabled={disabled || readonly}
                onchange={(event) =>
                  chooseTimeField("hour", event.target.value)}
              >
                {#each fields.hours as hour (hour.value)}
                  <option value={hour.value}>{hour.label}</option>
                {/each}
              </select>
              <span aria-hidden="true" class="text-gray-400">:</span>
              <select
                data-slot="schedule-picker-minute"
                aria-label="Minute"
                class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                value={fields.minute}
                disabled={disabled || readonly}
                onchange={(event) =>
                  chooseTimeField("minute", event.target.value)}
              >
                {#each minutes as minute (minute)}
                  <option value={minute}>{minute}</option>
                {/each}
              </select>
              {#if fields.hour12}
                <div
                  class="relative ms-1 border-s border-gray-200 ps-1 dark:border-gray-700"
                >
                  <select
                    data-slot="schedule-picker-period"
                    aria-label="Period"
                    class="h-11 min-w-16 cursor-pointer appearance-none rounded-md bg-transparent ps-2 pe-6 text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
                    value={fields.period}
                    disabled={disabled || readonly}
                    onchange={(event) =>
                      chooseTimeField("period", event.target.value)}
                  >
                    {#each fields.periods as period (period.value)}
                      <option value={period.value}>{period.label}</option>
                    {/each}
                  </select>
                  <svg
                    aria-hidden="true"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="1.5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    class="pointer-events-none absolute inset-e-2 top-1/2 size-3 -translate-y-1/2 text-gray-500 dark:text-gray-400"
                  >
                    <path d="m7 10 5 5 5-5" />
                  </svg>
                </div>
              {/if}
            </div>
            <div data-slot="schedule-picker-footer">
              <button
                type="button"
                data-slot="schedule-picker-confirm"
                class="min-h-11 cursor-pointer rounded-lg bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200 dark:focus-visible:outline-white"
                disabled={(!committable &&
                  interpretation.state !== "committed") ||
                  invalid ||
                  disabled ||
                  readonly}
                onclick={finish}
              >
                Done
              </button>
            </div>
          </div>
          {#if invalid}
            <p class="text-sm text-red-700 dark:text-red-400">{statusText}</p>
          {/if}
        </section>
      </div>
    </div>
  </Popover>
</div>

schedule.js
import {
  CalendarDateTime,
  fromAbsolute,
  toZoned
} from '@internationalized/date'
import { en as chrono } from 'chrono-node'
import { dateLabel, parseIsoDate, resolveLocale } from '../calendar/date.js'

export function resolveTimeZone(timeZone) {
  const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
  const candidate = timeZone || fallback
  try {
    new Intl.DateTimeFormat('en', { timeZone: candidate }).format()
    return candidate
  } catch {
    return fallback
  }
}

export function parseTime(value) {
  const match = /^(\d{2}):(\d{2})$/.exec(value ?? '')
  if (!match) return undefined
  const hour = Number(match[1])
  const minute = Number(match[2])
  if (hour > 23 || minute > 59) return undefined
  return { hour, minute }
}

export function formatTime({ hour, minute }) {
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}

export function wallClockToIso({
  date,
  time,
  timeZone,
  timezoneOffset,
  second = 0,
  millisecond = 0
}) {
  const parsedDate = parseIsoDate(date)
  const parsedTime = parseTime(time)
  if (!parsedDate || !parsedTime) return undefined

  if (Number.isFinite(timezoneOffset)) {
    return new Date(
      Date.UTC(
        parsedDate.year,
        parsedDate.month - 1,
        parsedDate.day,
        parsedTime.hour,
        parsedTime.minute,
        second,
        millisecond
      ) +
        -timezoneOffset * 60_000
    ).toISOString()
  }

  try {
    const zoned = toZoned(
      new CalendarDateTime(
        parsedDate.year,
        parsedDate.month,
        parsedDate.day,
        parsedTime.hour,
        parsedTime.minute,
        second,
        millisecond
      ),
      resolveTimeZone(timeZone),
      'compatible'
    )
    // A gap must not silently move an entered time forward. During an overlap,
    // compatible disambiguation keeps the earlier occurrence.
    if (
      zoned.year !== parsedDate.year ||
      zoned.month !== parsedDate.month ||
      zoned.day !== parsedDate.day ||
      zoned.hour !== parsedTime.hour ||
      zoned.minute !== parsedTime.minute ||
      zoned.second !== second ||
      zoned.millisecond !== millisecond
    ) {
      return undefined
    }
    return zoned.toDate().toISOString()
  } catch {
    return undefined
  }
}

export function instantToWallClock(value, timeZone) {
  const instant = new Date(value)
  if (Number.isNaN(instant.getTime())) return undefined
  const zoned = fromAbsolute(instant.getTime(), resolveTimeZone(timeZone))
  return {
    date: `${String(zoned.year).padStart(4, '0')}-${String(zoned.month).padStart(2, '0')}-${String(zoned.day).padStart(2, '0')}`,
    time: formatTime({ hour: zoned.hour, minute: zoned.minute })
  }
}

export function formatSchedule(value, locale, timeZone) {
  const instant = new Date(value)
  if (Number.isNaN(instant.getTime())) return ''
  return new Intl.DateTimeFormat(resolveLocale(locale), {
    timeZone: resolveTimeZone(timeZone),
    dateStyle: 'medium',
    timeStyle:
      instant.getUTCSeconds() || instant.getUTCMilliseconds()
        ? 'medium'
        : 'short'
  }).format(instant)
}

export function formatTimeLabel(value, locale) {
  const time = parseTime(value)
  if (!time) return ''
  return new Intl.DateTimeFormat(resolveLocale(locale), {
    timeZone: 'UTC',
    hour: 'numeric',
    minute: '2-digit'
  }).format(new Date(Date.UTC(2020, 0, 1, time.hour, time.minute)))
}

function timestamp(value) {
  if (value === undefined || value === null || value === '') return NaN
  return new Date(value).getTime()
}

function referenceTimestamp(reference) {
  const value = timestamp(reference)
  return Number.isFinite(value) ? value : Date.now()
}

export function scheduleConstraint(
  value,
  { allowPast = false, min, max, reference = new Date() } = {}
) {
  const instant = timestamp(value)
  if (!Number.isFinite(instant)) return 'invalid'
  if (!allowPast && instant <= referenceTimestamp(reference)) return 'past'
  if (instant < timestamp(min)) return 'min'
  if (instant > timestamp(max)) return 'max'
  return ''
}

export function scheduleCalendarBounds({
  allowPast = false,
  min,
  max,
  timeZone,
  reference = new Date()
} = {}) {
  const configuredMin = timestamp(min)
  const lower = Math.max(
    allowPast ? -Infinity : referenceTimestamp(reference) + 1,
    Number.isFinite(configuredMin) ? configuredMin : -Infinity
  )
  const upper = timestamp(max)
  return {
    min: Number.isFinite(lower)
      ? instantToWallClock(lower, timeZone)?.date
      : undefined,
    max: Number.isFinite(upper)
      ? instantToWallClock(upper, timeZone)?.date
      : undefined
  }
}

export function timeFields(value, locale) {
  const time = parseTime(value) ?? { hour: 0, minute: 0 }
  const resolvedLocale = resolveLocale(locale)
  const hour12 = new Intl.DateTimeFormat(resolvedLocale, {
    hour: 'numeric'
  }).resolvedOptions().hour12
  const hoursFormatter = new Intl.DateTimeFormat(resolvedLocale, {
    timeZone: 'UTC',
    hour: '2-digit',
    hourCycle: hour12 ? 'h12' : 'h23'
  })
  const periodsFormatter = new Intl.DateTimeFormat(resolvedLocale, {
    timeZone: 'UTC',
    hour: 'numeric',
    hourCycle: 'h12'
  })
  const part = (formatter, hour, type) =>
    formatter
      .formatToParts(new Date(Date.UTC(2020, 0, 1, hour)))
      .find((item) => item.type === type)?.value

  return {
    hour: String(hour12 ? time.hour % 12 || 12 : time.hour),
    minute: String(time.minute).padStart(2, '0'),
    period: time.hour < 12 ? 'am' : 'pm',
    hour12,
    periods: [
      { value: 'am', label: part(periodsFormatter, 6, 'dayPeriod') ?? 'AM' },
      { value: 'pm', label: part(periodsFormatter, 18, 'dayPeriod') ?? 'PM' }
    ],
    hours: Array.from({ length: hour12 ? 12 : 24 }, (_, index) => {
      const hour = hour12 ? index + 1 : index
      return {
        value: String(hour),
        label: part(hoursFormatter, hour, 'hour') ?? String(hour)
      }
    })
  }
}

export function updateTimeField(time, part, value, locale) {
  const current = parseTime(time)
  if (!current) return time
  const fields = timeFields(time, locale)
  if (part === 'period') {
    if (!fields.hour12 || !['am', 'pm'].includes(value)) return time
    current.hour = (current.hour % 12) + (value === 'pm' ? 12 : 0)
  } else {
    if (!/^\d{1,2}$/.test(String(value))) return time
    const number = Number(value)
    if (part === 'minute') {
      if (number > 59) return time
      current.minute = number
    } else if (part === 'hour') {
      if (fields.hour12) {
        if (number < 1 || number > 12) return time
        current.hour = (number % 12) + (fields.period === 'pm' ? 12 : 0)
      } else {
        if (number > 23) return time
        current.hour = number
      }
    } else {
      return time
    }
  }
  return formatTime(current)
}

function normalizeMinuteStep(step) {
  const value = Number(step)
  return Number.isFinite(value)
    ? Math.min(60, Math.max(1, Math.round(value)))
    : 15
}

export function timeOptions(step = 15) {
  const safeStep = normalizeMinuteStep(step)
  const values = []
  for (let minute = 0; minute < 24 * 60; minute += safeStep) {
    values.push(
      formatTime({ hour: Math.floor(minute / 60), minute: minute % 60 })
    )
  }
  return values
}

function roundedFutureTimestamp(reference, step) {
  const amount = normalizeMinuteStep(step)
  const rounded = new Date(referenceTimestamp(reference))
  rounded.setSeconds(0, 0)
  const remainder = rounded.getMinutes() % amount
  rounded.setMinutes(
    rounded.getMinutes() + (remainder ? amount - remainder : amount)
  )
  return rounded.getTime()
}

export function roundedFutureWallClock(reference, timeZone, step = 15) {
  return instantToWallClock(roundedFutureTimestamp(reference, step), timeZone)
}

export function initialScheduleWallClock(
  value,
  {
    allowPast = false,
    min,
    max,
    timeZone,
    minuteStep = 15,
    reference = new Date()
  } = {}
) {
  const current = timestamp(value)
  if (Number.isFinite(current)) return instantToWallClock(current, timeZone)

  const now = referenceTimestamp(reference)
  const initial = roundedFutureTimestamp(now, minuteStep)
  const configuredMin = timestamp(min)
  const configuredMax = timestamp(max)
  const lower = Math.max(
    allowPast ? -Infinity : now + 1,
    Number.isFinite(configuredMin) ? configuredMin : -Infinity
  )
  const upper = Number.isFinite(configuredMax) ? configuredMax : Infinity
  // Bounds still disable every choice when the allowed interval is empty.
  // Initializing a view must not imply that a forbidden instant is valid.
  const candidate =
    lower <= upper ? Math.min(upper, Math.max(lower, initial)) : initial
  return instantToWallClock(candidate, timeZone)
}

export function interpretSchedule(
  text,
  { reference = new Date(), locale, timeZone, allowPast = false } = {}
) {
  const source = text?.trim()
  if (!source) return { state: 'empty' }

  const zone = resolveTimeZone(timeZone)
  const referenceDate =
    reference instanceof Date ? reference : new Date(reference)
  const referenceInstant = Number.isNaN(referenceDate.getTime())
    ? new Date()
    : referenceDate
  const zonedReference = fromAbsolute(referenceInstant.getTime(), zone)
  const result = chrono.parse(
    source,
    {
      instant: referenceInstant,
      timezone: zonedReference.offset / 60_000
    },
    { forwardDate: !allowPast }
  )[0]

  if (!result) return { state: 'invalid' }

  const date = `${String(result.start.get('year')).padStart(4, '0')}-${String(result.start.get('month')).padStart(2, '0')}-${String(result.start.get('day')).padStart(2, '0')}`
  const hasTime = result.start.isCertain('hour')
  if (!hasTime) {
    return {
      state: 'incomplete',
      date,
      message: `${dateLabel(date, locale)} needs a time.`
    }
  }

  const time = formatTime({
    hour: result.start.get('hour'),
    minute: result.start.get('minute') ?? 0
  })
  const timezoneOffset = result.start.isCertain('timezoneOffset')
    ? result.start.get('timezoneOffset')
    : undefined
  const iso = wallClockToIso({
    date,
    time,
    timeZone: zone,
    timezoneOffset,
    second: result.start.get('second') ?? 0,
    millisecond: result.start.get('millisecond') ?? 0
  })
  if (!iso) return { state: 'invalid' }

  return {
    state: 'proposal',
    ...instantToWallClock(iso, zone),
    iso,
    label: formatSchedule(iso, locale, zone),
    timeZone: zone
  }
}

All open source projects are released under the MIT License.