forty-cdk
llms.txt

Primitives

Time Picker

A trigger that opens a floating listbox of generated time slots over a pluggable date adapter, with a configurable step, 12 / 24-hour labels and min / max bounds. Picking a slot preserves the date, so it composes inside a date-time picker.

forty-cdk/time-picker WAI-ARIA APG

Open the listbox from the trigger and pick a slot. The arrow keys walk the timeline, and the trigger keeps data-placeholder until something is chosen.

Headless and styleless: a combobox trigger opens a floating listbox of generated time slots. Value is typed as your adapter's date-time type D.

Requires a time-capable adapter: provideNativeDateAdapter() or the @internationalized/date adapter from forty-cdk/internationalized-date.

Anatomy

<div
  forTimePicker
  [(value)]="value"
  [(open)]="open"
  [step]="30"
  [hourCycle]="24"
  #picker="forTimePicker"
>
  <button forTimePickerTrigger>
    <span forTimePickerValue placeholder="Pick a time"></span>
  </button>

  <!-- @if (picker.open()) { -->
  <div forTimePickerContent>
    <!-- @for slot of picker.slots() -->
    <div forTimePickerOption [value]="slot.value" [disabled]="slot.disabled">{{ slot.label }}</div>
  </div>
  <!-- } -->
</div>

Examples

States

One class and one directive, three states. disabled removes the trigger from the tab order and keeps the listbox closed. readonly keeps the trigger focusable and announced, and still opens the listbox so the slots can be browsed, but picking a slot (click, Enter, Space or Tab) leaves the value unchanged. Each reflects a styling hook of its own: data-disabled and data-readonly.

Default
Disabled
Read-only

Bounded slots

minTime and maxTime fence the selectable time-of-day. Slots outside the window are not removed. They stay in the listbox as disabled options (data-disabled), skipped by keyboard navigation, so the full timeline stays visible. Open the listbox and scroll past 17:00 to see the late slots dimmed out.

API

ForTimePicker

PropertyTypeDescription
value
D | null
Selected time (two-way)
Default: null
open
boolean
Open state (two-way)
Default: false
anatomy
'trigger' | 'field'
Which piece is the control. 'field' makes a projected [forTimeField] the control and the trigger a plain button; see Field anatomy
Default: 'trigger'
step
number
Slot interval in minutes
Default: 30
granularity
'hour' | 'minute' | 'second'
Selection precision
Default: 'minute'
hourCycle
12 | 24 | null
Hour cycle for labels. null → the scope's hourCycle, then the locale
Default: null
locale
string | null
BCP 47 locale for labels. null → the adapter's locale(), then the runtime locale
Default: null
minTime
D | null
Earliest selectable time
Default: null
maxTime
D | null
Latest selectable time
Default: null
closeOnSelect
boolean
Close on slot selection
Default: true
modal
boolean
Modal (focus-trapped) mode
Default: false
dismissible
boolean
Escape / outside close
Default: true
returnFocus
boolean
Return focus to trigger on close
Default: true
placeholder
string
Value display placeholder
Default: ''
formatOptions
Intl.DateTimeFormatOptions
Format of the slot labels and [forTimePickerValue]. With no hour / minute / second, a two-digit hour and minute (plus seconds at granularity="second") are filled in, matching [forTimeField]. The resolved hourCycle applies unless the options set hour12 or hourCycle
Default: {}

Inherits all FormUiControl inputs (disabled, readonly, required, invalid, errors, touched, name, pending) for [formField] auto-wiring.

Data attributes

PieceAttributeValues
[forTimePicker]data-stateopen | closed
[forTimePicker]data-disabledpresent | absent
[forTimePicker]data-readonlypresent | absent
[forTimePickerTrigger]data-stateopen | closed
[forTimePickerTrigger]data-disabledpresent | absent
[forTimePickerTrigger]data-readonlypresent | absent
[forTimePickerValue]data-placeholderpresent | absent
[forTimePickerContent]data-stateopen | closed
[forTimePickerContent]data-orientationvertical | horizontal
[forTimePickerContent]data-modalpresent | absent
[forTimePickerOption]data-statechecked | unchecked
[forTimePickerOption]data-disabledpresent | absent
[forTimePickerOption]data-highlightedpresent | absent

data-highlighted marks the active slot: the one the pointer is over, else the keyboard-focused one (shared vocabulary with the listbox / menu / select primitives; see Pointer highlight). [forTimePickerContent] also carries the positioner markers data-side / data-align / data-placement (and data-detached while hideWhenDetached is active). See Styling floating content.

Pointer highlight

Moving the pointer over an enabled slot hands it data-highlighted, so exactly one slot is ever decorated no matter which device the user reached for. That is the same feel as [forSelect], [forListbox] and the menu family. Style that one attribute; you do not need a separate :hover rule (and combining both is what puts two rows in a highlighted state at once).

  • Hover never selects and never moves DOM focus. The pointer's own click still activates the slot.
  • The keyboard takes it back on the next move: the highlight falls back to the DOM-focused slot, so the first arrow / Home / End move drops the pointer highlight.
  • Moving the pointer off [forTimePickerContent] releases it, so the highlight goes back to the DOM-focused slot and an open listbox never keeps a row decorated with the cursor somewhere else on the page. Crossing between two adjacent slots is not a leave: the highlight moves straight from one to the other without blinking off.
  • A programmatic scroll cannot hijack it. Focusing a slot scrolls it into view, which can slide a different slot under a stationary cursor and make the browser fire a synthetic pointermove for it; moves arriving in a short window after such a scroll are ignored.

A hover on a disabled slot is ignored, and the highlight falls back to the focused slot if the hovered one is disabled or unmounted while the cursor rests on it.

Scoped defaults

provideForTimePickerDefaults configures positioning defaults for an injector subtree, either at the application root or in any component's providers array. Partial overrides inherit unspecified keys from the parent scope (or the library fallbacks at the root).

KeyLibrary fallbackMeaning
side'bottom'Anchor side for time pickers that don't set side themselves.
align'start'Alignment along side for time pickers that don't set align themselves.
sideOffset4Main-axis gap (px) for time pickers that don't set sideOffset themselves.
collisionPadding8Collision-middleware padding (px) for time pickers that don't set it themselves.
hourCyclenull12- or 24-hour cycle for time pickers that don't set hourCycle themselves. null derives it from the locale.

Per-instance inputs always win over the scope defaults. The four positioning keys are no-ops when modal is set: [forTimePickerContent] mounts the modal shell instead of the anchored positioner, so the surface is never positioned against the trigger and the consumer's own CSS places it.

import { provideForTimePickerDefaults } from 'forty-cdk/defaults';

// Every time picker in the app opens above its trigger, aligned to the end edge
bootstrapApplication(App, {
  providers: [provideForTimePickerDefaults({ side: 'top', align: 'end' })],
});

// component-level override layers on top, per key
@Component({
  providers: [provideForTimePickerDefaults({ sideOffset: 0 })],
  ...
})
class CompactToolbar {}

Anchoring to a field box

By default the listbox is positioned against [forTimePickerTrigger]. When the trigger lives inside a decorated field box (padding, a prefix icon, a clear / chevron button), anchoring to the inner button makes the panel offset from the visible field's edge. Wrap the field box in [forTimePickerAnchor] so floating-ui positions (and sizes, via --for-floating-anchor-width) the listbox against the box instead:

<div forTimePicker #picker="forTimePicker" [(value)]="value" [(open)]="open">
  <div forTimePickerAnchor class="field-box">
    <icon name="clock" />
    <button forTimePickerTrigger>
      <span forTimePickerValue placeholder="Pick a time"></span>
    </button>
    <button class="clear" (click)="value.set(null)">×</button>
  </div>
  @if (open()) {
  <div forTimePickerContent style="width: var(--for-floating-anchor-width)">
    @for (slot of picker.slots(); track slot.id) {
    <div forTimePickerOption [value]="slot.value" [disabled]="slot.disabled">{{ slot.label }}</div>
    }
  </div>
  }
</div>

[forTimePickerAnchor] changes only positioning. The trigger keeps aria-haspopup / aria-expanded / aria-controls, the click toggle, focus return on close, and its exemption from outside-pointer dismissal. Without an anchor the listbox falls back to the trigger, so existing markup is unaffected. It wins over a surrounding field's [forFieldAnchor]. A [forTimePicker] takes one [forTimePickerAnchor], and a second one warns in dev mode.

Date-time composition

Place [forTimePicker] inside [forDatePickerContent] alongside a [forCalendar]. The FOR_TIME_VALUE_SOURCE token is provided by [forTimePicker], and [forDatePicker] resolves it automatically via contentChild to graft time changes onto the committed date.

<div forDatePicker granularity="minute" [(value)]="value" [(open)]="open" #dp="forDatePicker">
  <button forDatePickerTrigger>...</button>
  @if (open()) {
  <div forDatePickerContent>
    <div forCalendar [value]="dp.value()">...</div>
    <div forTimePicker [value]="dp.value()" [step]="60" #tp="forTimePicker">
      <button forTimePickerTrigger>...</button>
      @if (tp.open()) {
      <div forTimePickerContent>
        @for (slot of tp.slots(); track slot.id) {
        <div forTimePickerOption [value]="slot.value" [disabled]="slot.disabled">
          {{ slot.label }}
        </div>
        }
      </div>
      }
    </div>
  </div>
  }
</div>

Signal Forms

<div forTimePicker [formField]="profile.meetingTime" [(open)]="open" #picker="forTimePicker">
  <button forTimePickerTrigger>
    <span forTimePickerValue placeholder="Pick a time"></span>
  </button>
  @if (open()) {
  <div forTimePickerContent>
    @for (slot of picker.slots(); track slot.id) {
    <div forTimePickerOption [value]="slot.value" [disabled]="slot.disabled">{{ slot.label }}</div>
    }
  </div>
  }
</div>

Field anatomy

By default the trigger is the control and shows the value. Set anatomy="field" and project a [forTimeField] inside the picker to make the typed field the control instead, with the trigger as a plain button that opens the slot listbox beside it.

<div forField>
  <label forLabel>Start time</label>
  <div
    forTimePicker
    anatomy="field"
    [formField]="form.start"
    [step]="15"
    [minTime]="opening"
    #picker="forTimePicker"
  >
    <div forTimeField #field="forTimeField">
      @for (seg of field.segments(); track seg.id) { @if (seg.isLiteral) {
      <span forTimeFieldLiteral>{{ seg.text }}</span>
      } @else {
      <span forTimeFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
      } }
    </div>
    <button forTimePickerTrigger aria-label="Choose a time">…icon…</button>

    @if (picker.open()) {
    <div forTimePickerContent>
      @for (slot of picker.slots(); track slot.id) {
      <div forTimePickerOption [value]="slot.value" [disabled]="slot.disabled">
        {{ slot.label }}
      </div>
      }
    </div>
    }
  </div>
</div>

With the field adopted:

  • One form control. [formField] binds once, on the picker. A typed time and a picked slot both write the picker's value and mark it dirty; focus leaving the field or the trigger marks it touched.
  • State is set once. minTime, maxTime, disabled, readonly, granularity, hourCycle and locale on the picker apply to the field. A read-only picker blocks typing and picking alike.
  • The field is the control. A surrounding [forField] names and describes the field's role="group", aria-invalid lands there, a label press focuses its first segment, and the picker's focus() goes there too.
  • The trigger is a plain button. It drops role="combobox" and the form-control aria-* state, and keeps aria-haspopup="listbox", aria-expanded and aria-controls. Give it a name of its own.

With anatomy="field" and no projected [forTimeField], opening the listbox or calling focus() throws FORCDK-TIME-PICKER-004 in dev mode.

Keyboard

KeyBehavior
Enter / Space on triggerToggle the listbox, focusing the selected slot, else the first
ArrowDown on triggerOpen, focusing the selected slot, else the first
ArrowUp on triggerOpen, focusing the selected slot, else the last
Home / End on triggerOpen, focusing the first / last enabled slot
Enter / SpaceSelect the focused slot
ArrowDown / ArrowUpMove focus between slots
HomeFocus the first enabled slot
EndFocus the last enabled slot
PageUp / PageDownFocus the first / last enabled slot
Printable charactersFocus the next slot whose text starts with the typed prefix
TabCommit the focused slot and advance focus
EscapeClose without committing

Accessibility

Implements the WAI-ARIA Listbox pattern.

  • role="combobox" on the trigger ([forTimePickerTrigger]) with aria-haspopup="listbox" and aria-expanded reflecting open. In the field anatomy the trigger is a plain button with the same popup attributes, and the time field carries the form-control state.
  • role="listbox" on the portaled content ([forTimePickerContent]); each slot is role="option" with aria-selected and aria-disabled.
  • A mouse press on the listbox surface that lands on no focusable element (padding, a heading) is cancelled, so focus stays on the slot that had it and the arrow keys keep working. A press on a slot or on your own <button> inside the surface still focuses it.
  • When used inside [forDatePickerContent] alongside a [forCalendar], the time picker delegates its value to [forDatePicker] via FOR_TIME_VALUE_SOURCE, so the combined date-time value is surfaced on the date picker's form-control ARIA.
  • data-highlighted="" is reflected on the active slot (the hovered one, else the focused one), so it is the one hook to style rather than pairing it with :hover (see Pointer highlight).
  • Inside a [forField] the labelled element is the trigger, not the [forTimePicker] wrapper: the field's controlId and its aria-labelledby / aria-describedby / aria-errormessage land on [forTimePickerTrigger], so [forLabel]'s for points at the element that takes focus, clicking a non-<label> [forLabel] opens the listbox, and Signal Forms' focus-on-error reaches the trigger. In the field anatomy the association lands on the time field's role="group" instead, and a label press focuses its first segment.

Wrapping in a design system

Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_TIME_PICKER_HOST_DIRECTIVE_INPUTS / FOR_TIME_PICKER_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.

import { Component } from '@angular/core';
import {
  FOR_TIME_PICKER_HOST_DIRECTIVE_INPUTS,
  FOR_TIME_PICKER_HOST_DIRECTIVE_OUTPUTS,
  ForTimePicker,
} from 'forty-cdk/time-picker';

@Component({
  selector: '[myTimePicker]',
  hostDirectives: [
    {
      directive: ForTimePicker,
      inputs: [...FOR_TIME_PICKER_HOST_DIRECTIVE_INPUTS],
      outputs: [...FOR_TIME_PICKER_HOST_DIRECTIVE_OUTPUTS],
    },
  ],
})
export class MyTimePicker {}