forty-cdk
llms.txt

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.

forty-cdk/time-field WAI-ARIA APG

Focus a segment and type, or step it with the arrow keys. Hour, minute and meridiem are separate spinbuttons, each announced on its own.

0930AM

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:

ProviderDate-time type DDependency
provideInternationalizedDateTimeAdapter()CalendarDateTime (@internationalized/date)Recommended. From forty-cdk/internationalized-date; needs @internationalized/date (optional peer)
provideNativeDateAdapter()DateNone (zero-dependency fallback)

The day-only provideInternationalizedDateAdapter() (CalendarDate) cannot carry a time, so ForTimeField throws 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.

1200PM

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.

hhmm--

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.

0900AM
0530PM

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.

1000AM
1230PM

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.

hhmm--
hhmm--

API

ForTimeField

PropertyTypeDescription
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, not min / max? FormUiControl.min / max are reserved members typed number | undefined for numeric validators bound by [formField]. A date-time-typed min / max would break the FormValueControl contract, so the time bounds use distinct names. Only the time-of-day component of the bounds is considered.

Data attributes

PieceAttributeValues
[forTimeField]data-disabledpresent | absent
[forTimeField]data-readonlypresent | absent
[forTimeField]data-emptypresent | absent
[forTimeFieldSegment]data-highlightedpresent | absent
[forTimeFieldSegment]data-placeholderpresent | absent
[forTimeFieldSegment]data-disabledpresent | absent
[forTimeFieldSegment]data-readonlypresent | 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

PropertyTypeDescription
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, not min / max? Beyond the reason above, FormUiControl.min / max are additionally typed NonNullable<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".

KeyBehavior
0–9Type the value; auto-advances to the next segment when full.
a / pOn the AM/PM segment, set the period of the entered hour.
ArrowUp / ArrowDownStep the value. Hour / minute / second wrap; the AM/PM segment toggles. Empty seeds from midnight.
ArrowLeft / ArrowRightMove to the previous / next segment (no wrap).
Home / EndJump to the segment minimum / maximum (the AM/PM segment → AM / PM).
BackspaceDelete the last entered digit of a numeric segment; the value becomes null when the last digit is removed.
DeleteClear 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 native aria-labelledby at a visible label).
  • role="spinbutton" per segment, with aria-valuemin / aria-valuemax / aria-valuenow reflected; the AM/PM segment also exposes a localized aria-valuetext ("AM" / "PM"), so screen readers read the period rather than 0 / 1.
  • Roving tabindex: exactly one segment is tabbable, so Tab enters and leaves the whole field in one stop; arrows move between segments.
  • Literals are aria-hidden and 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-readonly belongs on the segments, not the group. WAI-ARIA supports it on role="spinbutton" but not on role="group", so each segment carries aria-readonly="true" while the group reflects the data-readonly styling 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.