Primitives
Time Field
A segmented time-of-day input over a pluggable date adapter, with 12 / 24-hour cycles, optional seconds, and min / max time clamping.
Focus a segment and type, or step it with the arrow keys. Hour, minute and meridiem are separate spinbuttons, each announced on its own.
Headless, segmented and spin-editable, it is the time counterpart to DateField. There is no single WAI-ARIA APG pattern for a time field; it is a composition of spinbuttons inside a labelled role="group". Each hour / minute / second / AM·PM part is an independent role="spinbutton" segment, so entry is unambiguous and locale-correct. Segment order, the separators between them, and whether an AM/PM segment is shown follow the runtime locale and the resolved hour cycle.
ForTimeField implements FormValueControl<D | null> from @angular/forms/signals, so it auto-wires with [formField] and auto-associates inside a [forField] (label / description / error) with no extra markup. The value stays null until every visible segment is filled.
Date adapter
Pick a time-capable one (required). All time math goes through the same pluggable DateAdapter<D> as ForCalendar, so the library hard-depends on no date library. The time field needs the adapter's optional time accessors, so provide a time-capable adapter:
| Provider | Date-time type D | Dependency | |
|---|---|---|---|
provideInternationalizedDateTimeAdapter() | CalendarDateTime (@internationalized/date) | Recommended. From forty-cdk/internationalized-date; needs @internationalized/date (optional peer) | |
provideNativeDateAdapter() | Date | None (zero-dependency fallback) |
The day-only
provideInternationalizedDateAdapter()(CalendarDate) cannot carry a time, soForTimeFieldthrows a descriptive error if it is the active adapter.
import { bootstrapApplication } from '@angular/platform-browser';
import { provideInternationalizedDateTimeAdapter } from 'forty-cdk/internationalized-date';
bootstrapApplication(App, {
providers: [provideInternationalizedDateTimeAdapter()],
});
When no value is bound yet, a composed value is anchored on a fixed, DST-stable sentinel date (2000-01-01) rather than today, so a wall-clock time always round-trips to the same instant. Bind an existing date-time as value to edit its time in place (the calendar day is preserved).
Anatomy
The root iterates its computed segments() and renders each part as either an editable spinbutton segment or a decorative literal.
<div forTimeField [(value)]="time" ariaLabel="Appointment time" #field="forTimeField">
<!-- for each segment in field.segments():
a literal separator (`:`, a space) -->
<span forTimeFieldLiteral>{{ seg.text }}</span>
<!-- or an editable part (hour / minute / second / AM·PM) -->
<span forTimeFieldSegment [segment]="seg.type">{{ seg.text }}</span>
</div>
Examples
Bounded time
minTime and maxTime fence the time-of-day to office hours. Only the time component is compared, so stepping the hour past 17:00 or before 09:00 with ↑ / ↓ clamps back into the 09:00 – 17:00 window.
Signal Forms
ForTimeField implements FormValueControl<CalendarDateTime | null>, so a single [formField] binding wires the committed value into the form and pulls validity and touched back out, with no ControlValueAccessor involved.
Time range
ForTimeRangeField is the range variant, shipped from this same entry point. Two labelled endpoint groups share the hour cycle and bounds; each is its own tab stop, so Tab steps start → end while the arrows move between segments inside one endpoint.
Bounded range
minTime and maxTime fence both endpoints to a window. Only the time component is compared, so stepping a segment past 18:00 or before 08:00 clamps back in. That keeps a booking slot inside business hours, with the start <= end invariant still enforced on top.
Range in Signal Forms
ForTimeRangeField implements FormValueControl<DateRange | null>, so [formField] binds the committed range into the form and pulls validation back out. A half-entered or out-of-order range keeps value() null, so a required field stays invalid until both endpoints are filled and ordered.
API
ForTimeField
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable entered time, or null while any visible segment is empty. The FormValueControl backing.Default: null |
minTime | input | Earliest time-of-day (inclusive). A composed value earlier in the day is clamped up. Named minTime (see note).Default: null |
maxTime | input | Latest time-of-day (inclusive). A composed value later in the day is clamped down. Default: null |
hourCycle | input | 12- or 24-hour cycle. null → the scope's hourCycle, then the locale. 12-hour adds the AM/PM segment.Default: null |
granularity | input | Smallest editable unit. Default: 'minute' |
locale | input | BCP 47 locale driving segment order, separators, and AM/PM names. null → the adapter's locale(), then the runtime locale.Default: null |
placeholder | input | Per-segment placeholder while empty. Unspecified parts fall back to the scope's placeholder, then to hh / mm / ss / --.Default: {} |
ariaLabel | input | Accessible name for the group. Emits no aria-label while null.Default: null |
dir | input | Writing direction. null resolves the ambient direction; mirrors ArrowLeft / ArrowRight segment navigation.Default: null |
Plus the shared FormUiControl members from @angular/forms/signals: disabled, readonly, required, invalid, name, errors, touched (bound automatically by [formField]).
Why
minTime/maxTime, notmin/max?FormUiControl.min/maxare reserved members typednumber | undefinedfor numeric validators bound by[formField]. A date-time-typedmin/maxwould break theFormValueControlcontract, so the time bounds use distinct names. Only the time-of-day component of the bounds is considered.
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forTimeField] | data-disabled | present | absent | |
[forTimeField] | data-readonly | present | absent | |
[forTimeField] | data-empty | present | absent | |
[forTimeFieldSegment] | data-highlighted | present | absent | |
[forTimeFieldSegment] | data-placeholder | present | absent | |
[forTimeFieldSegment] | data-disabled | present | absent | |
[forTimeFieldSegment] | data-readonly | present | absent |
[forTimeFieldLiteral] is aria-hidden and purely decorative, so it carries no data-* hooks. Style it directly via your own class.
Scoped defaults
import { provideForTimeFieldDefaults } from 'forty-cdk/defaults';
// app config or a component's providers — localize segment labels, the
// empty-segment announcement and the placeholders for every nested [forTimeField].
providers: [
provideForTimeFieldDefaults({
emptySegmentText: 'Vacío',
segmentLabels: { hour: 'hora', minute: 'minuto', second: 'segundo', dayPeriod: 'AM/PM' },
placeholder: { dayPeriod: 'a. m.' },
}),
];
segmentLabels supplies each segment's default aria-label, keyed by part type. Unset keys keep the library default (the part name, and 'AM/PM' for the dayPeriod segment), so overriding a single key never wipes the rest. A segment's own [ariaLabel] still wins over the scope default.
placeholder is the text an empty segment shows, keyed the same way. A field's own [placeholder] wins for the parts it names and only those, and a part neither names keeps the letter-repeat default. provideForTimeRangeFieldDefaults takes the same keys for [forTimeRangeField].
hourCycle sets the 12- or 24-hour cycle of every field that doesn't bind [hourCycle], so a product on a 24-hour clock sets it once instead of on each field. Its fallback null derives the cycle from the locale.
For a language the app sets or switches after bootstrap, pass the text keys as functions and the overrides as a factory, as Localizing default text shows.
Range selection — ForTimeRangeField
For a time-of-day range use the dedicated ForTimeRangeField root (selector [forTimeRangeField]), shipped from this same entry point. It is the time analog of DateRangeField: two labelled role="group" endpoints (start / end), each holding a row of spinbutton segments (the same machinery as ForTimeField), nested inside one outer role="group". It implements FormValueControl<DateRange<D> | null>, so the committed range auto-wires with [formField]. The value stays null until both endpoints are fully entered and ordered (start <= end).
The pieces are the range-specific [forTimeRangeFieldStart] / [forTimeRangeFieldEnd] endpoint groups plus [forTimeRangeFieldSegment] / [forTimeRangeFieldLiteral]; each endpoint exposes its own segments() list, so the same @for template renders both sides.
<div forTimeRangeField [(value)]="hours" ariaLabel="Opening hours">
<div forTimeRangeFieldStart #start="forTimeRangeFieldStart">
@for (seg of start.segments(); track seg.id) { @if (seg.isLiteral) {
<span forTimeRangeFieldLiteral>{{ seg.text }}</span>
} @else {
<span forTimeRangeFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
} }
</div>
<span aria-hidden="true">–</span>
<div forTimeRangeFieldEnd #end="forTimeRangeFieldEnd">
@for (seg of end.segments(); track seg.id) { @if (seg.isLiteral) {
<span forTimeRangeFieldLiteral>{{ seg.text }}</span>
} @else {
<span forTimeRangeFieldSegment [segment]="seg.type!">{{ seg.text }}</span>
} }
</div>
</div>
import {
ForTimeRangeField,
ForTimeRangeFieldEnd,
ForTimeRangeFieldLiteral,
ForTimeRangeFieldSegment,
ForTimeRangeFieldStart,
} from 'forty-cdk/time-field';
import type { DateRange } from 'forty-cdk/shared';
readonly model = signal({ hours: null as DateRange<CalendarDateTime> | null });
readonly schedule = form(this.model);
ForTimeRangeField API
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable committed range, or null while incomplete or out of order. The FormValueControl backing.Default: null |
minTime | input | Earliest time-of-day (inclusive) for both endpoints. A composed endpoint earlier is clamped up. See note below. Default: — |
maxTime | input | Latest time-of-day (inclusive) for both endpoints. A composed endpoint later is clamped down. Default: null |
allowOvernight | input | When true, a start > end entry is read as a range crossing midnight (the end advances to the next day) instead of an error. In this mode the endpoints operate purely on time-of-day (a bound value's calendar days are re-anchored on the sentinel).Default: false |
granularity | input | Smallest editable unit shared by both endpoints. Default: 'minute' |
hourCycle | input | 12/24-hour cycle. null → the scope's hourCycle, then the locale. 12-hour adds the AM/PM segment to each endpoint.Default: null |
locale | input | BCP 47 locale driving segment order, separators, and AM/PM names. null → the adapter's locale(), then the runtime locale.Default: null |
placeholder | input | Per-segment placeholder while empty, applied to both endpoints. Unspecified parts fall back to the scope's placeholder.Default: {} |
ariaLabel | input | Accessible name for the whole range field group. Emits no aria-label while null.Default: null |
dir | input | Writing direction. null resolves the ambient direction; mirrors ArrowLeft / ArrowRight segment navigation.Default: null |
The endpoint groups each accept an ariaLabel input for their own group label, falling back to the scope defaults ('Start time' / 'End time'). Plus the shared FormUiControl members bound automatically by [formField].
Why
minTime/maxTime, notmin/max? Beyond the reason above,FormUiControl.min/maxare additionally typedNonNullable<TValue>(the range object itself), which is meaningless as a bound. Only the time-of-day component of the bounds is considered.
[forTimeRangeField] reflects the same data-disabled / data-readonly / data-empty hooks as [forTimeField], plus data-range-error; [forTimeRangeFieldSegment] reflects the same four segment hooks. data-empty marks the field only while both endpoints are entirely empty; a partially-filled or complete-but-disordered range is not empty.
Ordering
The two endpoints are typed independently, so order is not guaranteed by construction. The field preserves the DateRange end >= start invariant by never emitting an out-of-order range: when both endpoints are complete but start > end, the typed segments are kept (not silently rewritten), value stays null, and the root reflects aria-invalid="true" + data-range-error so the disorder is perceivable and stylable. Editing either endpoint back into order emits the range. Two complete endpoints with an equal time compose a valid zero-length range (start === end), not null.
Overnight ranges
Set allowOvernight to read a start > end entry as a range that crosses midnight (a night shift, 22:00–06:00) rather than a disorder. The field then commits { start, end } with the end advanced to the next day, so the end >= start invariant still holds and the emitted range spans the correct duration; aria-invalid / data-range-error are no longer set. In this mode both endpoints operate purely on their time-of-day: the calendar day of a bound value is re-anchored on the DST-stable sentinel rather than preserved, so every edit re-derives the crossing afresh (an end nudged back to a same-day time drops the extra day). Only the time-of-day of each endpoint is meaningful, so this trade-off is immaterial to a time-of-day range. The +1-day advance is carried by the in-memory value only. The native hidden inputs serialize each endpoint's time-of-day (HH:mm), so an overnight range submits as <name>-start / <name>-end with the crossing erased, and a server must re-apply the overnight rule to reconstruct it.
Range keyboard and accessibility
Each endpoint is its own tab stop, so Tab moves start group → end group → next control; arrows move between segments within an endpoint. Every other key behaves as in the Keyboard table below. Roving tabindex is per endpoint, and aria-invalid="true" is reflected on the root when the form marks it invalid or when two complete endpoints are out of order; everything else matches the Accessibility notes below.
Range scoped defaults
provideForTimeRangeFieldDefaults mirrors provideForTimeFieldDefaults and adds startLabel / endLabel for the two endpoint group aria-labels ('Start time' / 'End time' by default). Both wrapper patterns work via FOR_TIME_RANGE_FIELD_HOST_DIRECTIVE_INPUTS / FOR_TIME_RANGE_FIELD_HOST_DIRECTIVE_OUTPUTS. See Wrapping form primitives.
Keyboard
Key behavior applies per segment. Horizontal arrows mirror under dir="rtl".
| Key | Behavior | |
|---|---|---|
| 0–9 | Type the value; auto-advances to the next segment when full. | |
| a / p | On the AM/PM segment, set the period of the entered hour. | |
| ArrowUp / ArrowDown | Step the value. Hour / minute / second wrap; the AM/PM segment toggles. Empty seeds from midnight. | |
| ArrowLeft / ArrowRight | Move to the previous / next segment (no wrap). | |
| Home / End | Jump to the segment minimum / maximum (the AM/PM segment → AM / PM). | |
| Backspace | Delete the last entered digit of a numeric segment; the value becomes null when the last digit is removed. | |
| Delete | Clear the whole numeric segment (the value becomes null until refilled). |
The hour, minute, and second clamp to their valid ranges (hour to the cycle, minute / second to 0–59), and a composed value is clamped into [minTime, maxTime] by time-of-day. The AM/PM period is derived from the entered hour; clearing it is a no-op (clear or step the hour instead).
Accessibility
Composes the WAI-ARIA Spinbutton pattern: each segment is an independent spinbutton inside a labelled group.
role="group"on the root carries the field's accessible name (ariaLabel, or point nativearia-labelledbyat a visible label).role="spinbutton"per segment, witharia-valuemin/aria-valuemax/aria-valuenowreflected; the AM/PM segment also exposes a localizedaria-valuetext("AM" / "PM"), so screen readers read the period rather than0/1.- Roving tabindex: exactly one segment is tabbable, so
Tabenters and leaves the whole field in one stop; arrows move between segments. - Literals are
aria-hiddenand never focusable, so assistive tech reads only the spinbutton segments. - Boolean
data-*hooks on each segment are present when true and absent when false:data-highlighted(focused/roving),data-placeholder(empty),data-disabled,data-readonly. aria-readonlybelongs on the segments, not the group. WAI-ARIA supports it onrole="spinbutton"but not onrole="group", so each segment carriesaria-readonly="true"while the group reflects thedata-readonlystyling hook only.
Styling
The library is styleless: style the boolean data-* hooks yourself. The segments carry [data-highlighted] (the focused/roving segment), [data-placeholder] (empty), [data-disabled] and [data-readonly], and the root group carries [data-empty] / [data-disabled] / [data-readonly].
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).
.time-field-segment[data-placeholder] {
color: gray;
}
.time-field-segment[data-highlighted] {
background: highlight;
}
Wrapping in a design system
Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_TIME_FIELD_HOST_DIRECTIVE_INPUTS / FOR_TIME_FIELD_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.