The clock and the step
The columns are the field's language: a Korean clock puts the half of the day first, in its own words. And the step decides which rows exist — a quarter hour lists four minutes.
A time of day as columns of hours and minutes, standing on the page rather than folded into a panel.
@pacit/components/timepct-time-columnsImplements the W3C ARIA APG Listbox pattern ↗
Value: 13:05
import { Component, signal } from '@angular/core';
import { PctTimeColumns, PctTimeOfDay } from '@pacit/components/time';
/** One listbox per field of the time — each a tab stop pointing at its row. */
@Component({
selector: 'demo-time-columns',
imports: [PctTimeColumns],
template: `
<pct-time-columns [(value)]="time" ariaLabel="Meeting time" />
<p>Value: {{ time() ?? 'none' }}</p>
`,
})
export class TimeColumnsDemo {
readonly time = signal<PctTimeOfDay | null>('13:05');
}columns.demo.ts — the same file runs here and is highlighted under Code
One import per component, and the shortest possible use — the package itself is installed once, on the start page.
import { PctTimeColumns } from '@pacit/components/time';| Exported | What it is |
|---|---|
PctHourCycletypeunreleased'h11' | 'h12' | 'h23' | 'h24' | The four clocks Intl names. h11 and h12 are twelve-hour clocks that write the first hour 0 and 12; h23 and h24 are twenty-four-hour clocks that write it 0 and 24. The value is 00:05 under all four — a cycle is how a time is written, never which time it is. |
PctTimeFieldtypeunreleased'hour' | 'minute' | 'second' | 'dayPeriod' | Which part of the written time a position holds. |
PctTimeLettersinterfaceunreleased | The letters a format hint is written with — one per field, from PCT_TEXTS, because they are words of the application's language and not facts about the locale (0086 §3). |
PctTimeFormatinterfaceunreleased | Everything about writing and reading a time in one language, built once per locale. |
pctTimeFormatfunctionunreleased | How a time is written and read in one language, built once and shared. |
PctTimeOfDaytypeunreleasedstring | A time of day, written the way the platform already writes one: HH:mm, or HH:mm:ss where the field's step has seconds in it. |
PctTimeOfDayPartsinterfaceunreleased | The three fields of a time, as numbers on a 24-hour clock. second is 0 for a time written without seconds — the two shapes are one time. |
isPctTimeOfDayfunctionunreleased | Whether a string is a time this library will work with: 00:00 to 23:59:59, two digits a field. A value read from a server, a query string or localStorage is a string somebody else wrote, and three of the strings they write are refused on purpose: |
pctTimeOfDayfunctionunreleased | Builds a time from its fields: HH:mm, or HH:mm:ss when second is given — 0 included, because the shape is the caller's to choose. |
pctTimeOfDayPartsfunctionunreleased | The three fields of a time. The caller has already established that it is one. |
pctNowfunctionunreleased | The time on the LOCAL clock — the one read of local time in this file, and the only one that belongs: "what time is it" is a question about where the user is standing, and every other question here is arithmetic on a time that already has an answer. |
pctAddSecondsfunctionunreleased | n seconds on (or back), and the clock comes round: 23:59:30 plus 45 seconds is 00:00:15. The result keeps the shape it was given unless it has seconds that shape cannot carry — 13:05 plus an hour is 14:05, plus 30 seconds is 13:05:30. |
pctAddMinutesfunctionunreleased | n minutes on (or back), and the clock comes round: 23:30 plus 45 minutes is 00:15. The shape is kept — a whole minute never moves the seconds. |
pctCompareTimesfunctionunreleased | -1, 0 or 1, read as seconds since midnight — so 13:05 and 13:05:00, one time in two shapes, compare equal. |
pctClampTimefunctionunreleased | Held inside the bounds, either of which may be absent — and undefined is what absent is, because that is what the FormUiControl contract's min / max are. |
isPctTimeStepfunctionunreleased | Whether step — in seconds, like the native attribute — is one the columns can list: a whole number of seconds that divides a minute, a whole number of minutes that divides an hour, or a whole number of hours that divides a day. Any other — 420 seconds, seven-minute slots — makes the valid minutes depend on the hour, which columns that are fields cannot show (0086 §5). |
pctTimeOnStepfunctionunreleased | Whether a time stands on the step, counted from base — the field's min when it has one, as the native element counts (0086, A11). |
pctSnapToStepfunctionunreleased | The time on the step nearest to this one, counted from base, with the clock coming round: at an hour step 23:40 is 00:00. Exactly halfway goes up, as Math.round does. The shape is kept unless the step puts seconds where it had none. |
PctTimeRowinterfaceunreleased | One row of a column, with everything the template and the walk need already decided. |
PctTimeColumninterfaceunreleased | One column — one field of the time, one role="listbox", one tab stop. |
PctTimePredicatetypeunreleased(time: PctTimeOfDay) => boolean | A predicate on a time of day: true refuses it. The columns ask it of every time on the step, once per change of the step, the bounds or the predicate itself. |
PctTimeColumnscomponentunreleasedpct-time-columns | Time columns — one role="listbox" per field of a time, side by side: hours and minutes, seconds where the step has them, and the two halves of the day where the clock has twelve hours (0086 §4). |
PctTimePanelAligntypeunreleased'start' | 'end' | Which edge of the field the panel lines its own up with. |
PctTimecomponentunreleasedpct-time | Time field — a text control the library formats and parses per locale, with columns of hours and minutes in a panel beside it. |
PctTimeBoundtypeunreleased| PctTimeOfDay | undefined | LogicFn<TValue, PctTimeOfDay | undefined, TPathKind> | A bound pctTimeBounds takes: a time, or a function of the field's context that returns one — the two shapes Angular's own min() takes. One that is not a time, undefined among them, is no bound, as it is on the field's input. |
PctTimeBoundErrortypeunreleased| { readonly kind: 'minTime' | What pctTimeBounds reports: minTime for a time before min, maxTime for one after max — and both at once for a time in the gap of a window across midnight, which is the native element's reading, "under and over" (0086, A12). Each carries the bound it broke, as the bound was written. |
PctTimeBoundsConfiginterfaceunreleased | The bounds of pctTimeBounds, and the sentence its errors carry — one for both, or one written from the error, which holds the bound. |
pctTimeBoundsfunctionunreleased | Bounds a time field in a signal form: what Angular's min() and max() do for a number, for a PctTimeOfDay — which they cannot take, and which the compiler refuses as an attribute beside [formField] (NG8022). The bounds reach the field's min / max, so the panel's walk stops at them, and the form grades the value: a time outside is minTime or maxTime, and a time in the gap of a window across midnight — min later than max, 22:00 to 06:00 — is both, as the native element reads it (0086, A12). |
<pct-time-columns [(value)]="time" ariaLabel="Meeting time" />Each example is the component itself, not a screenshot. Open the code under any of them.
The columns are the field's language: a Korean clock puts the half of the day first, in its own words. And the step decides which rows exist — a quarter hour lists four minutes.
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
unreleased marks 39 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.
Time columns — one role="listbox" per field of a time, side by side: hours and minutes, seconds where the step has them, and the two halves of the day where the clock has twelve hours (0086 §4).
| Name | Type | What it does |
|---|---|---|
valuetwo-wayunreleaseddefault null | PctTimeOfDay | null | The chosen time, or null. Every movement in a column writes it. |
minunreleaseddefault undefined | unknown | The earliest time a row may stand for; absent means no bound on that side. Later than max, the two are a window across midnight. |
maxunreleaseddefault undefined | unknown | The latest time a row may stand for; absent means no bound on that side — the other half of min. |
stepunreleaseddefault 60 | number | The step in seconds, counted from min — 60 by default, as the native attribute's. A step the columns cannot list (one that does not divide a minute, an hour or a day) is refused with a dev-mode warning and read as 60. |
timeDisabledunreleaseddefault null | PctTimePredicate | null | Times inside the bounds that still cannot be taken — the slots already booked on the day a datetime field's panel stands on, which is the control that asks (0090 §5). It is asked of every time on the step, in the value's shape, and a refused time is a row drawn disabled and skipped by the walk; an hour or a half of the day is refused when every time in it is. A value it refuses is kept: the walk starts from the nearest time it lets through. |
disabledunreleaseddefault false | boolean | Freezes the columns: no row can be taken, the walk stops, and aria-disabled says so. |
localeunreleaseddefault '' | string | Overrides the application's LOCALE_ID, as on <pct-time> — the clock, the digits and the day-period words follow it. |
sizeunreleaseddefault config.defaultSize | PctSize | Scales the rows with the field sizes, so a panel matches the field it opens from; from providePctConfig by default (req-api-config). |
ariaLabelunreleaseddefault '' | string | The accessible name of the group the columns stand in; a panel that owns them names itself instead. |
ariaLabelledbyunreleaseddefault '' | string | As ariaLabel, for a name that already stands somewhere on the page. |
| Name | Payload | When it fires |
|---|---|---|
timePickedunreleased | PctTimeOfDay | A time was chosen by the user — Enter or Space in a column — which is not the same event as value changing: every movement changes the value, and only this closes a panel. |
The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-time-columns | |
role | group | |
aria-label | ariaLabel() || null | |
aria-labelledby | ariaLabelledby() || null | |
data-pct-size | size() | |
data-pct-disabled | disabled() ? "" : null |
Two ways in, both stable. No class names to depend on.
In a test, PctTimeColumnsHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.
| Part | Selector | What it is |
|---|---|---|
control | [data-pct-part="control"] | the field's typed input |
error | [data-pct-part="error"] | the message when the value is invalid |
hint | [data-pct-part="hint"] | the hint under the input |
label | [data-pct-part="label"] | the label of the field's typed input |
panel | [data-pct-part="panel"] | the dialog the columns stand in when the field opens them |
toggle | [data-pct-part="toggle"] | the button that opens the columns |
warning | [data-pct-part="warning"] | the message when the value is allowed and suspect — after the error, before the hint (0087) |
column | [data-pct-part="column"] | one listbox — one field of the time, one tab stop, and the element that scrolls |
option | [data-pct-part="option"] | one row of a column; data-pct-chosen, data-pct-active and data-pct-disabled on it |
Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.
| Token | Styles |
|---|---|
--pct-time-bg{pct.surface}#ffffff · #0f172a | Background of the time field. |
--pct-time-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293b | Background of the time field while disabled. |
--pct-time-border{pct.border-strong}#64748b | Edge colour of the time field. |
--pct-time-border-disabled{pct.border}#e2e8f0 · #334155 | Edge colour of the time field while disabled. |
--pct-time-border-focus{pct.primary}#2563eb · #60a5fa | Edge colour of the time field when focused. |
--pct-time-border-hover{pct.text-muted}#334155 · #e2e8f0 | Edge colour of the time field on hover. |
--pct-time-border-invalid{pct.danger}#b91c1c · #f87171 | Edge colour of the time field when invalid. |
--pct-time-border-warning{pct.warning}#b45309 · #fbbf24 | Edge colour of the time field under a warning (0087). |
--pct-time-column-height224px | Height of a column. |
--pct-time-column-min-width56px | Minimum width of a column — a day-period word wider than two digits widens its own column. |
--pct-time-fg{pct.text}#0f172a · #ffffff | Text colour of the time field. |
--pct-time-fg-disabled{pct.text-disabled}#64748b | Text colour of the time field while disabled. |
--pct-time-fg-invalid{pct.danger}#b91c1c · #f87171 | Text colour of the time field when invalid. |
--pct-time-fg-warning{pct.warning}#b45309 · #fbbf24 | Text colour of the warning line (0087). |
--pct-time-font-size{pct.font.size.md}14px | Font size of the time field. |
--pct-time-font-size-lg{pct.font.size.lg}16px | Font size of the time field at size lg. |
--pct-time-font-size-sm{pct.font.size.sm}13px | Font size of the time field at size sm. |
--pct-time-hint-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the hint. |
--pct-time-icon-size16px | Size of the icon. |
--pct-time-label-fg{pct.text}#0f172a · #ffffff | Text colour of the label. |
--pct-time-option-bg-hover{pct.surface-100}#f1f5f9 · #1e293b | Background of a row on hover. |
--pct-time-option-bg-selected{pct.primary}#2563eb · #60a5fa | Background of a row when selected. |
--pct-time-option-fg-disabled{pct.text-disabled}#64748b | Text colour of a row while disabled. |
--pct-time-option-fg-selected{pct.on-primary}#ffffff · #0f172a | Text colour of a row when selected. |
--pct-time-option-font-size{pct.font.size.md}14px | Font size of a row. |
--pct-time-option-font-size-lg{pct.font.size.lg}16px | Font size of a row at size lg. |
--pct-time-option-font-size-sm{pct.font.size.sm}13px | Font size of a row at size sm. |
--pct-time-option-font-weight-selected{pct.font.weight.medium}500 | Font weight of a row when selected. |
--pct-time-option-height32px | Height of a row. |
--pct-time-option-height-lg36px | Height of a row at size lg. |
--pct-time-option-height-sm28px | Height of a row at size sm. |
--pct-time-option-padding-x{pct.space.3}8px | Horizontal padding of a row. |
--pct-time-option-radius{pct.radius.md}8px | Corner radius of a row. |
--pct-time-padding-x{pct.space.4}12px | Horizontal padding of the time field. |
--pct-time-padding-x-lg{pct.space.4}12px | Horizontal padding of the time field at size lg. |
--pct-time-padding-x-sm{pct.space.3}8px | Horizontal padding of the time field at size sm. |
--pct-time-padding-y{pct.space.3}8px | Vertical padding of the time field. |
--pct-time-padding-y-lg{pct.space.4}12px | Vertical padding of the time field at size lg. |
--pct-time-padding-y-sm{pct.space.2}4px | Vertical padding of the time field at size sm. |
--pct-time-panel-bg{pct.surface}#ffffff · #0f172a | Background of the panel. |
--pct-time-panel-border{pct.border-strong}#64748b | Edge colour of the panel. |
--pct-time-panel-gap{pct.space.2}4px | Gap between the columns of the panel. |
--pct-time-panel-padding-x{pct.space.3}8px | Horizontal padding of the panel. |
--pct-time-panel-padding-y{pct.space.3}8px | Vertical padding of the panel. |
--pct-time-panel-radius{pct.radius.md}8px | Corner radius of the panel. |
--pct-time-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55) | Shadow of the panel. |
--pct-time-placeholder-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the placeholder. |
--pct-time-radius{pct.radius.md}8px | Corner radius of the time field. |
--pct-time-target-min{pct.target.min}24px | Touch-target floor of the time field. |
--pct-time-toggle-bg-hover{pct.surface-100}#f1f5f9 · #1e293b | Background of the clock toggle on hover. |
--pct-time-toggle-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the clock toggle. |
--pct-time-toggle-radius{pct.radius.md}8px | Corner radius of the clock toggle. |
--pct-time-toggle-size20px | Size of the clock toggle. |
A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.
[data-theme='brand'] {
--pct-time-option-bg-selected: #0f766e;
--pct-time-option-fg-selected: #ffffff;
--pct-time-column-height: 192px;
}Value: 13:05
Pattern: Listbox — one per column, each a single tab stop pointing at its row with aria-activedescendant
| key | effect | test |
|---|---|---|
ArrowDown / ArrowUp | the next / previous row the bounds allow, coming round at the ends | libs/components/time/src/time-columns.spec.ts |
Home / End | the first / last row the bounds allow | libs/components/time/src/time-columns.spec.ts |
| digits | the row that writes the number typed — 1 then 3 is thirteen on a twenty-four-hour clock, 9 is 09; where no row writes it, the first that begins with it (4 in quarter hours is 45); a number no row holds leaves the column where it is, and a key after a run that named nothing starts afresh | libs/components/time/src/time-columns.spec.ts |
| a letter | the half of the day the letters typed so far begin — a is AM, p then m is PM | libs/components/time/src/time-columns.spec.ts |
Enter / Space | takes the time the columns stand on, and says it was picked | libs/components/time/src/time-columns.spec.ts |
Tab | the next column — one stop each | apps/sandbox-e2e/src/time.spec.ts |
Every key above that moves the walk moves the VALUE, Enter and Space included: a column is a field. A key that moves nothing writes nothing — an arrow at the only row the bounds leave, a digit or a letter no row answers — and a modified key or one the column has no use for — ArrowLeft, Tab, Escape — is left to whoever owns it, which in a panel is the field.
Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.
libs/components/time/src/time-columns.tslibs/components/time/src/time-columns.spec.ts and apps/sandbox-e2e/src/time.spec.tsapps/sandbox-e2e/src/a11y.spec.ts — the /time view holds inline columns, so the listboxes are audited attached and not only inside a panelapps/sandbox-e2e/src/visual.spec.ts — time-columns and time-columns-rtlforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the chosen row takes SelectedItem, the keyboard's row a ring in Highlight and, on the chosen row, a ring in SelectedItemText, and a refused row GrayText: three things after the palette swap, the ring compared with the surface under itprefers-reduced-motiontools/check-styles.mjs point 9 — the row's transition takes --pct-motion-transition-durationlibs/components/time/src/time-columns.scss — a row is --pct-time-option-height (32 px at md, 28 at sm) across a column at least --pct-time-column-min-width wide, above the threshold at every size--pct-time-option-height-sm / -lg and the font sizes beside them — the rows scale with the field the panel opens fromapps/sandbox-e2e/src/rtl.spec.ts — the hour column is drawn on the right; the time-columns-rtl baselineapps/sandbox-e2e/src/hydration.spec.ts — the /time view is in SBX_ROUTES, and the inline columns render on the server<pct-time> that implements FormValueControllibs/components/time/src/lattice.spec.ts — which rows a step lists, which the bounds refuse, where a movement lands; libs/components/time/src/time.property.spec.ts — the value module's lawslibs/components/parts.snapshot.md, tools/check-parts.mjscontrast.policy.jsonlibs/tokens/src/contrast.policy.json — the row, its hover, the chosen pair and the refused rowPCT_TEXTStools/check-texts.mjs — four keys, the columns' names; everything else is Intllibs/components/size.snapshot.md — measured together with the field, one entrypointdocs/acr/at/ — three readers, whole sandbox, and the act on /time that opens the field's panel, which is how these columns enter the logs: the inline ones stand past the walk's twelve stops. The column says its name, its size and its row where the reader speaks: Orca Heures · List with 24 items · 13, VoiceOver 13 selected (14 of 24) — the row's state and place; NVDA says the dialog, Choisir une heure, dialog, and none of the column. See PctTime for what Escape does under each/components/columns on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
It ships in the time entrypoint and is a component of its own rather than a private half of PctTime, for the reason PctCalendar is one: the datetime field's panel is one dialog holding a calendar and these columns, so they are the part it composes, and columns that lived only inside the time field would be built twice (0086 §4).
aria-valuenow (0086 §4).PctSelect of times.