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.
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.
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
| Property | Type | Description |
|---|---|---|
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 anatomyDefault: '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 localeDefault: null |
locale | string | null | BCP 47 locale for labels. null → the adapter's locale(), then the runtime localeDefault: 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 hourCycleDefault: {} |
Inherits all FormUiControl inputs (disabled, readonly, required, invalid,
errors, touched, name, pending) for [formField] auto-wiring.
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forTimePicker] | data-state | open | closed | |
[forTimePicker] | data-disabled | present | absent | |
[forTimePicker] | data-readonly | present | absent | |
[forTimePickerTrigger] | data-state | open | closed | |
[forTimePickerTrigger] | data-disabled | present | absent | |
[forTimePickerTrigger] | data-readonly | present | absent | |
[forTimePickerValue] | data-placeholder | present | absent | |
[forTimePickerContent] | data-state | open | closed | |
[forTimePickerContent] | data-orientation | vertical | horizontal | |
[forTimePickerContent] | data-modal | present | absent | |
[forTimePickerOption] | data-state | checked | unchecked | |
[forTimePickerOption] | data-disabled | present | absent | |
[forTimePickerOption] | data-highlighted | present | 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
pointermovefor 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).
| Key | Library fallback | Meaning | |
|---|---|---|---|
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. | |
sideOffset | 4 | Main-axis gap (px) for time pickers that don't set sideOffset themselves. | |
collisionPadding | 8 | Collision-middleware padding (px) for time pickers that don't set it themselves. | |
hourCycle | null | 12- 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,hourCycleandlocaleon 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'srole="group",aria-invalidlands there, a label press focuses its first segment, and the picker'sfocus()goes there too. - The trigger is a plain button. It drops
role="combobox"and the form-controlaria-*state, and keepsaria-haspopup="listbox",aria-expandedandaria-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
| Key | Behavior | |
|---|---|---|
Enter / Space on trigger | Toggle the listbox, focusing the selected slot, else the first | |
ArrowDown on trigger | Open, focusing the selected slot, else the first | |
ArrowUp on trigger | Open, focusing the selected slot, else the last | |
Home / End on trigger | Open, focusing the first / last enabled slot | |
Enter / Space | Select the focused slot | |
ArrowDown / ArrowUp | Move focus between slots | |
Home | Focus the first enabled slot | |
End | Focus the last enabled slot | |
PageUp / PageDown | Focus the first / last enabled slot | |
| Printable characters | Focus the next slot whose text starts with the typed prefix | |
Tab | Commit the focused slot and advance focus | |
Escape | Close without committing |
Accessibility
Implements the WAI-ARIA Listbox pattern.
role="combobox"on the trigger ([forTimePickerTrigger]) witharia-haspopup="listbox"andaria-expandedreflectingopen. 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 isrole="option"witharia-selectedandaria-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]viaFOR_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'scontrolIdand itsaria-labelledby/aria-describedby/aria-errormessageland on[forTimePickerTrigger], so[forLabel]'sforpoints 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'srole="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 {}