# TimePicker

A time picker with dropdown hour/minute/second columns (plus an AM/PM column in 12-hour mode); `↑`/`↓` adjusts, `Home`/`End` jumps to bounds, `Enter` confirms, `Esc` cancels. Stepping intervals, disabled times, ranges, and direct typing are supported.

## Basic Usage

<DemoBlock title="Basic usage">
  <oas-time-picker value="09:05:30"></oas-time-picker>
</DemoBlock>

## Manual Input

<DemoBlock title="Type a time (committed on blur / Enter)">
  <oas-time-picker placeholder="Try typing 14:30"></oas-time-picker>
</DemoBlock>

Input is parsed as `H:mm[:ss]` (single digits tolerated, e.g. `9:5`); in 12-hour mode an AM/PM word may be appended. Invalid input reverts on blur without touching the value.

## Custom Format

<DemoBlock title="format column selection">
  <oas-time-picker value="09:05:30" format="HH:mm"></oas-time-picker>
  <oas-time-picker value="09:05:30" format="HH:mm:ss"></oas-time-picker>
</DemoBlock>

A column appears only when `format` contains the corresponding token.

## 12-Hour Mode

<DemoBlock title="use12-hours (value stays 24-hour)">
  <oas-time-picker value="15:30:00" use12-hours></oas-time-picker>
</DemoBlock>

Display and columns switch to 12-hour (AM/PM words follow the locale); the fourth column toggles AM/PM. The `value` contract stays `HH:mm:ss` 24-hour.

## Stepping

<DemoBlock title="step minute interval (single number, back-compat)">
  <oas-time-picker value="09:15:00" step="15"></oas-time-picker>
</DemoBlock>

<DemoBlock title="step JSON triple (hour/minute/second)">
  <oas-time-picker value="09:00:00" step='{"h":2,"m":10}'></oas-time-picker>
</DemoBlock>

`step` accepts `{"h":2,"m":5,"s":1}` for per-unit stepping; a single number remains the minute step. Values outside the step set snap to the nearest option visually without changing the value.

## Disabled Times

<DemoBlock title="disabledTime (grayed, not hidden)">
  <oas-time-picker id="time-picker-disabled-time" value="12:00:00"></oas-time-picker>
</DemoBlock>

`disabledTime` (property, `(parts) => { hours?, minutes?, seconds? }`) returns disabled lists per current-time context; disabled options are grayed out (clicks and keyboard skip them).

<DemoBlock title="hide-disabled-options (hide disabled options instead of greying)">
  <oas-time-picker id="time-picker-hide-disabled" value="15:30:00" hide-disabled-options></oas-time-picker>
</DemoBlock>

Used with `disabledTime`: `hide-disabled-options` removes disabled options from the columns entirely (default keeps them visible but grayed out) — in this example the current value sits in the closed hours (14–16), so those three entries are hidden from the hour column; keyboard `↑`/`↓` and nearest-snapping already skip disabled values at the value level, so behavior is unchanged.

## Accessible Naming

<DemoBlock title="label: trigger accessible name">
  <oas-time-picker label="Meeting start time" value="09:00:00"></oas-time-picker>
</DemoBlock>

`label` gives the trigger input an accessible name (`aria-label`, announced by screen readers); it falls back to `placeholder` when unset. An external `<label for>` association takes precedence (native label semantics).

## Now & Presets

<DemoBlock title="Now button (footer)">
  <oas-time-picker placeholder="Open the panel and click Now"></oas-time-picker>
</DemoBlock>

The panel footer has a built-in Now button that fills the current time and confirms.

<DemoBlock title="presets quick times (property)">
  <oas-time-picker id="time-picker-presets" value="10:00:00"></oas-time-picker>
</DemoBlock>

`presets` (property, an array of `{ label, value: 'HH:mm:ss' }`) renders a button row atop the panel; clicking one applies it and closes the panel.

## Timezone (timezone)

<DemoBlock title="timezone anchor: UTC vs local">
  <oas-time-picker id="time-picker-timezone-utc" timezone="UTC" placeholder="UTC: open and click Now" style="margin-inline-end: var(--oas-space-4)"></oas-time-picker>
  <oas-time-picker id="time-picker-timezone-local" placeholder="Local: open and click Now"></oas-time-picker>
  <span id="time-picker-timezone-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)"></span>
</DemoBlock>

`timezone` accepts an IANA zone name (e.g. `Asia/Tokyo`) or `UTC`; an omitted/invalid value falls back to the host local zone. The Now button and default-time derivation read the wall clock of the configured zone; the `value` contract (`HH:mm:ss`) is unchanged. Open the left panel and click Now for UTC, the right one for the local time; the reference line below shows both zones' current time.

## Time Range

<DemoBlock title="is-range (dual column groups, auto order)">
  <oas-time-picker is-range value='["09:00:00","11:00:00"]'></oas-time-picker>
</DemoBlock>

With `is-range` the value is a JSON array; start/end are picked independently and auto-ordered on confirm.

## Form States

<DemoBlock title="Sizes">
  <oas-time-picker size="small" value="09:05:30"></oas-time-picker>
  <oas-time-picker size="medium" value="09:05:30"></oas-time-picker>
  <oas-time-picker size="large" value="09:05:30"></oas-time-picker>
</DemoBlock>

<DemoBlock title="Status & clearable">
  <oas-time-picker status="error" clearable value="09:05:30" placeholder="error, clearable"></oas-time-picker>
  <oas-time-picker status="warning" value="09:05:30" placeholder="warning"></oas-time-picker>
</DemoBlock>

<DemoBlock title="Readonly">
  <oas-time-picker readonly value="09:05:30" placeholder="Readonly echo"></oas-time-picker>
</DemoBlock>

Under `readonly` the panel still opens for browsing, but picking, Now, presets, clearing, and typing never commit.

## Controlled Open & Events

<DemoBlock title="oas-change / oas-confirm events">
  <oas-time-picker id="time-picker-event" value="10:00:00"></oas-time-picker>
  <span id="time-picker-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)"></span>
</DemoBlock>

<DemoBlock title="Controlled open (open + oas-open-change)">
  <oas-time-picker id="time-picker-open" value="10:00:00"></oas-time-picker>
  <button id="time-picker-open-toggle" type="button">Toggle open</button>
</DemoBlock>

With the `open` attribute present the picker runs in controlled mode: gestures only emit `oas-open-change`, and opening/closing is decided by the host adding or removing the attribute; confirm actions (Enter, outside click, Now) emit `oas-confirm` alongside `oas-change`.

## Disabled

<DemoBlock title="Disabled">
  <oas-time-picker disabled value="09:05:30"></oas-time-picker>
</DemoBlock>

## Floating Positioning (placement)

<DemoBlock title="Explicit placement (top-end)">
  <oas-time-picker placement="top-end" value="09:05:30" placeholder="placement=top-end"></oas-time-picker>
</DemoBlock>

Same positioning contract as date-picker: `fixed` + collision flipping + viewport clamping; the panel width matches the trigger. 12 directions (default `bottom-start`).

## Mobile form (bottom sheet)

On touch screens (coarse pointer) or narrow viewports (<768px), the panel is automatically hosted by an `oas-bottom-sheet` bottom sheet: rises from the viewport bottom + backdrop + drag handle swipe-down to close (backdrop click / Esc also close), with bottom safe-area inset; the hour/minute/second columns (including the dual-column range layout) scroll inside the sheet; the desktop form keeps the original floating dropdown. Inside the sheet, time option rows are lifted to a 44px touch target (`--oas-touch-target-min`).

## required

<DemoBlock title="required (native validation chain)">
  <oas-time-picker required name="at"></oas-time-picker>
</DemoBlock>

`required` drives the native validation chain (form-associated): when unfilled, `checkValidity()` returns false (`valueMissing`) and native form submission is blocked; it recovers to `:valid` once filled.

Programmatic read/write of the current value goes through the public `value` property: `el.value` reads the raw `value` attribute in single mode, or an array of start/end time strings in `is-range` mode (same shape as the `oas-change` detail); `el.value = x` writes the controlled `value` attribute and immediately syncs the trigger (arrays for range mode are JSON-serialized automatically, empty values remove the attribute). No events are dispatched.

## API

### oas-time-picker

#### Attributes

| Attribute | Description | Type | Default |
| --- | --- | --- | --- |
| `clearable` | Clearable: shows a clear button when a value exists; emits `oas-clear` + `oas-change` (empty value) | `boolean` | — |
| `disabled` | Disabled | `boolean` | — |
| `format` | Display format tokens; a column appears only when the corresponding token exists | `string` | `HH:mm:ss` |
| `hide-disabled-options` | Hide disabled options instead of graying them out (keyboard navigation still skips them) | `boolean` | — |
| `is-range` | Time range: value is a JSON array `["HH:mm:ss","HH:mm:ss"]`, auto-ordered on confirm; the typing channel is read-only in this mode | `boolean` | — |
| `label` | Accessible name (aria-label) for the trigger; an external label-for association takes precedence | `string` | — |
| `name` | Form field name (key for native FormData submission; range mode derives name-start / name-end entries; the browser skips submission when name is absent) | `string` | — |
| `open` | Controlled open: present = open, removed = closed; gestures only emit `oas-open-change` for the host to write back; in the mobile form (touch / viewport <768px) the open panel is hosted by an oas-bottom-sheet bottom sheet (swipe-down/backdrop/Esc to close); desktop keeps the floating dropdown | — | — |
| `placeholder` | Placeholder text | — | — |
| `placement` | Popup placement, 12 directions (default `bottom-start`), `fixed` + collision flipping + viewport clamping (same contract as date-picker) | `string` | `bottom-start` |
| `readonly` | Readonly: the panel can be opened and browsed, but no interaction commits | `boolean` | — |
| `required` | Required marker (drives the native valueMissing validation chain; not passed through to the inner control) | `boolean` | — |
| `size` | Size: `small` / `medium` / `large` (reads the nearest config-provider injection) | `string` | `medium` |
| `status` | Validation status: `success` / `warning` / `error` (`error` also sets `aria-invalid`) | `string` | — |
| `step` | Stepping: a JSON triple `{"h":2,"m":5,"s":1}` per unit; a single number remains the minute step | `string` | — |
| `timezone` | Timezone anchor: an IANA zone name or UTC; omitted/invalid falls back to local. The Now button and default-value derivation read this zone's wall clock; the value contract is unchanged | `string` | — |
| `use12-hours` | 12-hour mode: display and columns switch to 12-hour (with an AM/PM column following the locale); the value stays 24-hour | `boolean` | — |
| `value` | Current value (`HH:mm:ss`; a JSON array when `is-range`) | `string \| string[] \| null` | — |

#### Property (JS property; see each row for attribute reflection)

| Property | Description | Type | Default |
| --- | --- | --- | --- |
| `disabledTime` | Disabled-time predicate (property function channel, `(parts) => { hours?, minutes?, seconds? }`) | `\| ((parts: TimeParts) => { hours?: number[]; minutes?: number[]; seconds?: number[] } \| null) \| null` | — |
| `presets` | Quick time presets (property channel, `[{ label, value }]`) | `PresetItem[] \| null` | — |
| `value` | Current value: a time string for single; a string array when `is-range` | `string \| string[] \| null` | — |

#### Events

| Event | Description |
| --- | --- |
| `oas-blur` | The component as a whole loses focus (internal moves do not fire) |
| `oas-change` | Confirmed value change, `detail: { value }` (string array for `is-range`) |
| `oas-clear` | Clear, `detail: { value }` is the previous value |
| `oas-confirm` | Emitted on confirm actions (Enter / outside click / Now, alongside `oas-change`), `detail: { value: detail }` |
| `oas-focus` | The component as a whole gains focus |
| `oas-open-change` | Open state change, `detail: { open }` (both controlled and uncontrolled) |

#### CSS Variables

| CSS Variable | Default |
| --- | --- |
| `--oas-glass-blur` | `none` |
| `--oas-glass-ring` | `transparent` |

Keyboard: `Enter` / `↓` to open, `↑`/`↓` to adjust the current column, `Home`/`End` to jump to bounds, `←`/`→` to switch columns, `Enter` to confirm, `Esc` to cancel.

<script setup>
import { onMounted } from 'vue'
onMounted(() => {
  const el = document.getElementById('time-picker-event')
  const out = document.getElementById('time-picker-output')
  el?.addEventListener('oas-change', (e) => {
    out.textContent = `oas-change: ${e.detail.value}`
  })
  el?.addEventListener('oas-confirm', (e) => {
    out.textContent = `oas-confirm: ${e.detail.value}`
  })

  const openEl = document.getElementById('time-picker-open')
  document.getElementById('time-picker-open-toggle')?.addEventListener('click', () => {
    if (openEl?.hasAttribute('open')) openEl.removeAttribute('open')
    else openEl?.setAttribute('open', '')
  })

  // Disabled closure hours (14:00-17:00 unavailable) — property assignment must wait for the component to upgrade (whenDefined guard,
  // otherwise the expando shadows the prototype setter and the component never receives it; always reproduces in preview builds)
  customElements.whenDefined('oas-time-picker').then(() => {
    const dt = document.getElementById('time-picker-disabled-time')
    dt.disabledTime = (p) => (p.h >= 14 && p.h < 17 ? { hours: [14, 15, 16] } : null)

    // hide-disabled-options: same closure hours, but hidden instead of grayed
    const hd = document.getElementById('time-picker-hide-disabled')
    hd.disabledTime = (p) => (p.h >= 14 && p.h < 17 ? { hours: [14, 15, 16] } : null)

    const pr = document.getElementById('time-picker-presets')
    pr.presets = [
      { label: '9 AM', value: '09:00:00' },
      { label: '2 PM', value: '14:00:00' },
      { label: 'Noon', value: '12:00:00' },
    ]
  })

  // Timezone reference line: current time per zone, computed the same way the Now button is
  const tzOut = document.getElementById('time-picker-timezone-output')
  if (tzOut) {
    const timeOf = (tz) => {
      const opts = { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }
      if (tz) opts.timeZone = tz
      return new Intl.DateTimeFormat('en-GB', opts).format(new Date())
    }
    tzOut.textContent = `Now: UTC ${timeOf('UTC')} / Local ${timeOf(null)}`
  }
})
</script>
