On this page
Choices

Columns released

A time of day as columns of hours and minutes, standing on the page rather than folded into a panel.

  • Import@pacit/components/time
  • Selectorpct-time-columns

Implements the W3C ARIA APG Listbox pattern ↗

12
1
2
3
4
5
6
7
8
9
10
11
00
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
AM
PM

Value: 13:05

columns.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctTimeColumns } from '@pacit/components/time';
ExportedWhat 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.
PctTimeLettersinterfaceunreleasedThe 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).
PctTimeFormatinterfaceunreleasedEverything about writing and reading a time in one language, built once per locale.
pctTimeFormatfunctionunreleasedHow a time is written and read in one language, built once and shared.
PctTimeOfDaytypeunreleasedstringA 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.
PctTimeOfDayPartsinterfaceunreleasedThe 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.
isPctTimeOfDayfunctionunreleasedWhether 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:
pctTimeOfDayfunctionunreleasedBuilds 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.
pctTimeOfDayPartsfunctionunreleasedThe three fields of a time. The caller has already established that it is one.
pctNowfunctionunreleasedThe 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.
pctAddSecondsfunctionunreleasedn 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.
pctAddMinutesfunctionunreleasedn 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.
pctClampTimefunctionunreleasedHeld 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.
isPctTimeStepfunctionunreleasedWhether 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).
pctTimeOnStepfunctionunreleasedWhether a time stands on the step, counted from base — the field's min when it has one, as the native element counts (0086, A11).
pctSnapToStepfunctionunreleasedThe 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.
PctTimeRowinterfaceunreleasedOne row of a column, with everything the template and the walk need already decided.
PctTimeColumninterfaceunreleasedOne column — one field of the time, one role="listbox", one tab stop.
PctTimePredicatetypeunreleased(time: PctTimeOfDay) => booleanA 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-columnsTime 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-timeTime 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.
PctTimeBoundsConfiginterfaceunreleasedThe bounds of pctTimeBounds, and the sentence its errors carry — one for both, or one written from the error, which holds the bound.
pctTimeBoundsfunctionunreleasedBounds 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" />
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

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.

오전
오후
12
1
2
3
4
5
6
7
8
9
10
11
00
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
00
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
00
15
30
45
API

What it reads, and what it writes back

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).

Inputs

NameTypeWhat it does
valuetwo-wayunreleaseddefault nullPctTimeOfDay | nullThe chosen time, or null. Every movement in a column writes it.
minunreleaseddefault undefinedunknownThe 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 undefinedunknownThe latest time a row may stand for; absent means no bound on that side — the other half of min.
stepunreleaseddefault 60numberThe 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 nullPctTimePredicate | nullTimes 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 falsebooleanFreezes the columns: no row can be taken, the walk stops, and aria-disabled says so.
localeunreleaseddefault ''stringOverrides the application's LOCALE_ID, as on <pct-time> — the clock, the digits and the day-period words follow it.
sizeunreleaseddefault config.defaultSizePctSizeScales the rows with the field sizes, so a panel matches the field it opens from; from providePctConfig by default (req-api-config).
ariaLabelunreleaseddefault ''stringThe accessible name of the group the columns stand in; a panel that owns them names itself instead.
ariaLabelledbyunreleaseddefault ''stringAs ariaLabel, for a name that already stands somewhere on the page.

Outputs

NamePayloadWhen it fires
timePickedunreleasedPctTimeOfDayA 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.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-time-columns
rolegroup
aria-labelariaLabel() || null
aria-labelledbyariaLabelledby() || null
data-pct-sizesize()
data-pct-disableddisabled() ? "" : null
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

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.

PartSelectorWhat 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

Tokens

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.

TokenStyles
--pct-time-bg{pct.surface}#ffffff · #0f172aBackground of the time field.
--pct-time-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of the time field while disabled.
--pct-time-border{pct.border-strong}#64748bEdge colour of the time field.
--pct-time-border-disabled{pct.border}#e2e8f0 · #334155Edge colour of the time field while disabled.
--pct-time-border-focus{pct.primary}#2563eb · #60a5faEdge colour of the time field when focused.
--pct-time-border-hover{pct.text-muted}#334155 · #e2e8f0Edge colour of the time field on hover.
--pct-time-border-invalid{pct.danger}#b91c1c · #f87171Edge colour of the time field when invalid.
--pct-time-border-warning{pct.warning}#b45309 · #fbbf24Edge colour of the time field under a warning (0087).
--pct-time-column-height224pxHeight of a column.
--pct-time-column-min-width56pxMinimum width of a column — a day-period word wider than two digits widens its own column.
--pct-time-fg{pct.text}#0f172a · #ffffffText colour of the time field.
--pct-time-fg-disabled{pct.text-disabled}#64748bText colour of the time field while disabled.
--pct-time-fg-invalid{pct.danger}#b91c1c · #f87171Text colour of the time field when invalid.
--pct-time-fg-warning{pct.warning}#b45309 · #fbbf24Text colour of the warning line (0087).
--pct-time-font-size{pct.font.size.md}14pxFont size of the time field.
--pct-time-font-size-lg{pct.font.size.lg}16pxFont size of the time field at size lg.
--pct-time-font-size-sm{pct.font.size.sm}13pxFont size of the time field at size sm.
--pct-time-hint-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the hint.
--pct-time-icon-size16pxSize of the icon.
--pct-time-label-fg{pct.text}#0f172a · #ffffffText colour of the label.
--pct-time-option-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of a row on hover.
--pct-time-option-bg-selected{pct.primary}#2563eb · #60a5faBackground of a row when selected.
--pct-time-option-fg-disabled{pct.text-disabled}#64748bText colour of a row while disabled.
--pct-time-option-fg-selected{pct.on-primary}#ffffff · #0f172aText colour of a row when selected.
--pct-time-option-font-size{pct.font.size.md}14pxFont size of a row.
--pct-time-option-font-size-lg{pct.font.size.lg}16pxFont size of a row at size lg.
--pct-time-option-font-size-sm{pct.font.size.sm}13pxFont size of a row at size sm.
--pct-time-option-font-weight-selected{pct.font.weight.medium}500Font weight of a row when selected.
--pct-time-option-height32pxHeight of a row.
--pct-time-option-height-lg36pxHeight of a row at size lg.
--pct-time-option-height-sm28pxHeight of a row at size sm.
--pct-time-option-padding-x{pct.space.3}8pxHorizontal padding of a row.
--pct-time-option-radius{pct.radius.md}8pxCorner radius of a row.
--pct-time-padding-x{pct.space.4}12pxHorizontal padding of the time field.
--pct-time-padding-x-lg{pct.space.4}12pxHorizontal padding of the time field at size lg.
--pct-time-padding-x-sm{pct.space.3}8pxHorizontal padding of the time field at size sm.
--pct-time-padding-y{pct.space.3}8pxVertical padding of the time field.
--pct-time-padding-y-lg{pct.space.4}12pxVertical padding of the time field at size lg.
--pct-time-padding-y-sm{pct.space.2}4pxVertical padding of the time field at size sm.
--pct-time-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-time-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-time-panel-gap{pct.space.2}4pxGap between the columns of the panel.
--pct-time-panel-padding-x{pct.space.3}8pxHorizontal padding of the panel.
--pct-time-panel-padding-y{pct.space.3}8pxVertical padding of the panel.
--pct-time-panel-radius{pct.radius.md}8pxCorner 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 · #e2e8f0Text colour of the placeholder.
--pct-time-radius{pct.radius.md}8pxCorner radius of the time field.
--pct-time-target-min{pct.target.min}24pxTouch-target floor of the time field.
--pct-time-toggle-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of the clock toggle on hover.
--pct-time-toggle-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the clock toggle.
--pct-time-toggle-radius{pct.radius.md}8pxCorner radius of the clock toggle.
--pct-time-toggle-size20pxSize of the clock toggle.

Theming

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;
}
12
1
2
3
4
5
6
7
8
9
10
11
00
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
AM
PM

Value: 13:05

Accessibility

Measured, not assumed

Pattern: Listbox — one per column, each a single tab stop pointing at its row with aria-activedescendant

Keyboard

keyeffecttest
ArrowDown / ArrowUpthe next / previous row the bounds allow, coming round at the endslibs/components/time/src/time-columns.spec.ts
Home / Endthe first / last row the bounds allowlibs/components/time/src/time-columns.spec.ts
digitsthe 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 afreshlibs/components/time/src/time-columns.spec.ts
a letterthe half of the day the letters typed so far begin — a is AM, p then m is PMlibs/components/time/src/time-columns.spec.ts
Enter / Spacetakes the time the columns stand on, and says it was pickedlibs/components/time/src/time-columns.spec.ts
Tabthe next column — one stop eachapps/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.

What is measured, and what is not yet

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.

MeasuredARIA APG pattern in the JSDoclibs/components/time/src/time-columns.ts
MeasuredKeyboard maplibs/components/time/src/time-columns.spec.ts and apps/sandbox-e2e/src/time.spec.ts
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /time view holds inline columns, so the listboxes are audited attached and not only inside a panel
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — time-columns and time-columns-rtl
Measuredforced-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 it
Measuredprefers-reduced-motiontools/check-styles.mjs point 9 — the row's transition takes --pct-motion-transition-duration
MeasuredTouch target ≥ 24×24 pxlibs/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
MeasuredSize axis--pct-time-option-height-sm / -lg and the font sizes beside them — the rows scale with the field the panel opens from
GapDensity axisnone — gap (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/rtl.spec.ts — the hour column is drawn on the right; the time-columns-rtl baseline
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /time view is in SBX_ROUTES, and the inline columns render on the server
n/aFormsnot applicable — the columns are not a form control. It is <pct-time> that implements FormValueControl
n/aMessage announcednot applicable — the columns draw no message
MeasuredTime arithmetic as lawslibs/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 laws
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — the row, its hover, the chosen pair and the refused row
MeasuredStrings through PCT_TEXTStools/check-texts.mjs — four keys, the columns' names; everything else is Intl
MeasuredSize budgetlibs/components/size.snapshot.md — measured together with the field, one entrypoint
MeasuredScreen-reader logRead 2026-10-08 from docs/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
Measureddocs page/components/columns on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

77.97%mutation score308 of 395 mutants killed
23 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
0colour pairs measuredlight and dark, at every token build
0visual baselinesChromium, the sandbox's own pictures
79elements in the preview78 listeners · 1 render to settle · 17893 µs in jsdom, held exactly but for the clock

Why it is built this way

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).

Decisions behind it

  • 0086A time of day is a wall clock, and the field that takes one is text the application formats
  • 0032A menu moves focus, a listbox points at it

Known limitations

  • No spinbuttons. A segment per field is a tab stop per field unless the library invents a walk between them — the native element's defect rebuilt by hand — and a single spinbutton has no one number to put in aria-valuenow (0086 §4).
  • A step the columns cannot list is read as 60. A step that does not divide a minute, an hour or a day makes the valid minutes depend on the hour, which columns that are fields cannot show. Seven-minute slots are a PctSelect of times.
  • The window is the column's height, not a count of rows. The rows scale with the size; the column does not, so a smaller field shows a row more and a larger one a row less.