Within bounds
min and max are days, and the calendar greys what falls outside; typing a day outside them marks the field invalid rather than silently moving it.
Chosen: not yet
A date typed into a field or picked from a calendar the field opens.
@pacit/components/datepct-dateImplements the W3C ARIA APG Date Picker Dialog pattern ↗
import { Component, signal } from '@angular/core';
import { PctDate, PctDay } from '@pacit/components/date';
/** Type it or pick it — the format comes from Intl, the letters from the texts channel. */
@Component({
selector: 'demo-date',
imports: [PctDate],
styles: ':host { display: block; max-inline-size: 24rem; }',
template: `
<pct-date
label="Starts on"
hint="Type it, or pick it from the calendar"
[(value)]="startsOn"
/>
`,
})
export class DateDemo {
readonly startsOn = signal<PctDay | null>(null);
}date.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 { PctDate } from '@pacit/components/date';| Exported | What it is |
|---|---|
PctCalendarCellinterface | One cell of the grid, with everything the template and the reader need already decided. |
PctDayPredicatetype(day: PctDay) => boolean | A day the consumer refuses — weekends, holidays, a taken slot. |
PctCalendarcomponentpct-calendar | Calendar — one month of days as a role="grid", walked by a roving tabindex. |
PctDatePanelAligntype'start' | 'end' | Which edge of the field the panel lines its own up with. |
PctDatecomponentpct-date | Date field — a text control the library formats and parses per locale, with a calendar in a panel beside it. |
PctDayBoundtypeunreleasedPctDay | undefined | LogicFn<TValue, PctDay | undefined, TPathKind> | A bound pctDayBounds takes: a day, or a function of the field's context that returns one — the two shapes Angular's own min() takes. One that is not a day, undefined among them, is no bound, as it is on the field's input. |
PctDayBoundErrortypeunreleased| { readonly kind: 'minDay' | What pctDayBounds reports: minDay for a day before min, maxDay for one after max. Each carries the bound it broke, as the bound was written. |
PctDayBoundsConfiginterfaceunreleased | The bounds of pctDayBounds, and the sentence its errors carry — one for both, or one written from the error, which holds the bound. |
pctDayBoundsfunctionunreleased | Bounds a date field in a signal form: what Angular's minDate() and maxDate() do for a Date, for a PctDay — which they cannot take (their path is a Date's, and their validator reads the value with getTime()), and which the compiler refuses as an attribute beside [formField] (NG8022). The bounds reach the field's min / max, so the calendar's walk stops at them, and the form grades the value: a day outside is minDay or maxDay. |
PctDaytypestring | A calendar day, written the way the platform already writes one: YYYY-MM-DD. |
PctDayPartsinterface | The three fields of a day, as numbers. month is 1–12, the way a person says it. |
pctDaysInMonthfunction | How many days February has — the Gregorian rule, written once. |
isPctDayfunction | Whether a string is a day this library will work with: the right shape and a day the calendar has. 2026-02-30 has the shape and is not a day, and the difference matters — a value read from a server, a query string or a localStorage entry is a string somebody else wrote. |
pctDayfunction | Builds a day from its three fields, normalising an overflow the way a person expects — month 13 is January of the next year, day 0 is the last day of the previous month. That is what makes pctAddMonths and pctAddDays one line each instead of three branches. |
pctDayAsUtcfunction | The day as the one Date anything outside this file is allowed to see: midnight UTC, which is what Intl.DateTimeFormat with timeZone: 'UTC' reads back as the same three fields it was built from. Handing out a local-time Date is the defect this whole module exists to refuse. |
pctDayPartsfunction | The three fields of a day. The caller has already established that it is one. |
pctTodayfunction | Today, read from the LOCAL clock — the one read of local time in this file, and the only one that belongs: "what day is it" is a question about where the user is standing, and every other question here is arithmetic on a day that already has an answer. |
pctAddDaysfunction | n days on (or back). |
pctAddMonthsfunction | n months on (or back), clamped to the length of the month it lands in: 31 January plus one month is 28 February and not 3 March. The clamp is what a calendar's "next month" button means — a walk that changed the day of the month would move the cursor twice. |
pctCompareDaysfunction | -1, 0 or 1 — read from the three fields, though the shape invites a string compare and carried one for months. |
pctWeekdayfunction | The ISO weekday: Monday is 1, Sunday is 7 — the numbering Intl.Locale.prototype.getWeekInfo() answers in, so the two never need converting between them. |
pctClampDayfunction | Held inside the bounds, either of which may be absent — and undefined is what absent is, because that is how the FormUiControl contract's min / max spell an absent bound. |
pctMonthGridfunction | The grid of one month: whole weeks, starting on firstDayOfWeek, with the days of the neighbouring months that share those weeks. Six weeks always, and that is a decision rather than an oversight — a month spans four to six weeks depending on where it starts, and a panel that changed height between March and August would move the page under the pointer mid-walk. |
PctDayFieldtype'day' | 'month' | 'year' | Which of the three fields a position in the format holds. |
PctDayLettersinterface | The letters a format hint is written with — one per field, from PCT_TEXTS. |
pctFirstDayOfWeekfunction | Which day the week starts on, 1 (Monday) … 7 (Sunday). |
PctDayFormatinterface | Everything about writing and reading a day in one language, built once per locale. |
pctDayFormatfunction | The day, weekday and month names of a locale, built once and shared. |
PctWeekdayNameinterface | The two names a column header needs: the one the eye reads and the one a reader says. |
pctWeekdayNamesfunction | The seven column headings, rotated so that the first is firstDayOfWeek. |
pctMonthCaptionfunction | The month and year over the grid ("December 2026"), in the value's own calendar. |
<pct-date label="Starts on" [(value)]="startsOn" />Each example is the component itself, not a screenshot. Open the code under any of them.
min and max are days, and the calendar greys what falls outside; typing a day outside them marks the field invalid rather than silently moving it.
Chosen: not yet
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
unreleased marks 5 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.
Date field — a text control the library formats and parses per locale, with a calendar in a panel beside it.
| Name | Type | What it does |
|---|---|---|
valuetwo-waydefault null | PctDay | null | The chosen day, or null when the field is empty. |
disableddefault false | boolean | Blocks the control and greys it — the native disabled, so it leaves the tab order as well. With [formField] the directive writes it, as it writes every input of the FormUiControl contract. |
readonlydefault false | boolean | Keeps the value: the input takes the native readonly and the calendar button is disabled with it, while the field stays focusable. |
invaliddefault false | boolean | The form's verdict; shown only once touched, so an empty form does not open red. |
toucheddefault false | boolean | Whether the user has left the field once; with invalid it gates the error face. |
requireddefault false | boolean | Marks the label with the required sign; with [formField] it follows the schema's required(). |
errorsdefault [] | readonly ValidationError.WithOptionalFieldTree[] | The form's validation errors; the first one's message takes the hint's place once the field is touched. |
warningsunreleaseddefault undefined | readonly ValidationError.WithOptionalFieldTree[] | undefined | A verdict without a veto (0087): shown after the error and before the hint once the field is touched, in the warning tone, with aria-invalid untouched. Left unbound, the control reads what pctWarn() wrote for its own [formField]; bound — [] included — the list given stands. |
namedefault '' | string | The native name — what a form submission calls the value. |
mindefault undefined | unknown | The earliest and latest day. They belong to the FormUiControl contract, which spells an absent bound undefined, and a signal form gives them through pctDayBounds — the compiler refuses the attribute beside [formField]. |
maxdefault undefined | unknown | The latest day — as min, a bound on the calendar's walk and not a rewrite of what was typed. |
dateDisableddefault null | PctDayPredicate | null | Days inside the bounds that still cannot be picked — weekends, holidays, taken slots. |
labeldefault '' | string | The visible label, rendered by the control itself when it stands outside a pct-field; inside one, the field's label is the name. |
hintdefault '' | string | A line of help under the control, outside a pct-field; the first error message takes its place while the field is invalid and touched. |
ariaLabeldefault '' | string | The accessible name of a field with no visible label — an INPUT rather than an aria-label on the tag, because the textbox sits inside this template and an ARIA name on the roleless host is ignored. |
ariaLabelledbydefault '' | string | As ariaLabel, for a name that already stands somewhere on the page. |
localedefault '' | string | Overrides the application's LOCALE_ID for this field, as on [pctNumber]. |
firstDayOfWeekdefault 0 | number | Which day the week starts on, 1 (Monday) … 7 (Sunday). Absent, the locale decides. |
showFormatdefault true | boolean | Whether the format hint stands in the field while it is empty (dd.mm.yyyy). |
sizedefault config.defaultSize | PctSize | Height 28 / 36 / 44 px — the axis every field shares; from providePctConfig by default (req-api-config). |
panelAligndefault 'start' | PctDatePanelAlign | Which edge of the field the panel lines up with. |
| Name | Payload | When it fires |
|---|---|---|
touch | void | Emitted on blur — lets the form mark the field as touched. |
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-date | |
data-pct-size | size() | |
data-pct-open | open() ? "" : null | |
data-pct-invalid | showInvalid() ? "" : null | |
data-pct-warning | showWarning() ? "" : null | |
data-pct-disabled | disabled() ? "" : null | |
data-pct-malformed | malformed() ? "" : null | What the control knows and the form cannot: there is text in the field and it is not a date. See malformed below. |
data-pct-in-field | inField ? "" : null |
Two ways in, both stable. No class names to depend on.
In a test, PctDateHarness 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 |
|---|---|---|
caption | [data-pct-part="caption"] | the month and the year over the grid |
day | [data-pct-part="day"] | one day cell |
grid | [data-pct-part="grid"] | the grid of days |
nav | [data-pct-part="nav"] | the previous-month and next-month buttons |
week | [data-pct-part="week"] | one row of the grid |
weekday | [data-pct-part="weekday"] | a heading over a column of days |
control | [data-pct-part="control"] | the 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 typed input |
panel | [data-pct-part="panel"] | the calendar, floating or inline |
toggle | [data-pct-part="toggle"] | the button that opens the calendar |
warning | [data-pct-part="warning"] | the message when the value is allowed and suspect — after the error, before the hint (0087) |
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-date-bg{pct.surface}#ffffff · #0f172a | Background of the date field. |
--pct-date-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293b | Background of the date field while disabled. |
--pct-date-border{pct.border-strong}#64748b | Edge colour of the date field. |
--pct-date-border-disabled{pct.border}#e2e8f0 · #334155 | Edge colour of the date field while disabled. |
--pct-date-border-focus{pct.primary}#2563eb · #60a5fa | Edge colour of the date field when focused. |
--pct-date-border-hover{pct.text-muted}#334155 · #e2e8f0 | Edge colour of the date field on hover. |
--pct-date-border-invalid{pct.danger}#b91c1c · #f87171 | Edge colour of the date field when invalid. |
--pct-date-border-warning{pct.warning}#b45309 · #fbbf24 | Edge colour of the date field under a warning (0087). |
--pct-date-caption-font-size{pct.font.size.md}14px | Font size of the month caption. |
--pct-date-caption-font-weight{pct.font.weight.medium}500 | Font weight of the month caption. |
--pct-date-day-bg-hover{pct.surface-100}#f1f5f9 · #1e293b | Background of a day cell on hover. |
--pct-date-day-bg-selected{pct.primary}#2563eb · #60a5fa | Background of a day cell when selected. |
--pct-date-day-border-today{pct.primary}#2563eb · #60a5fa | Edge colour of a day cell for today. |
--pct-date-day-fg-disabled{pct.text-disabled}#64748b | Text colour of a day cell while disabled. |
--pct-date-day-fg-muted{pct.text-muted}#334155 · #e2e8f0 | Text colour of a day outside the month shown. |
--pct-date-day-fg-selected{pct.on-primary}#ffffff · #0f172a | Text colour of a day cell when selected. |
--pct-date-day-font-size{pct.font.size.md}14px | Font size of a day cell. |
--pct-date-day-font-size-lg{pct.font.size.lg}16px | Font size of a day cell at size lg. |
--pct-date-day-font-size-sm{pct.font.size.sm}13px | Font size of a day cell at size sm. |
--pct-date-day-font-weight-selected{pct.font.weight.medium}500 | Font weight of a day cell when selected. |
--pct-date-day-radius{pct.radius.md}8px | Corner radius of a day cell. |
--pct-date-day-size32px | Size of a day cell. |
--pct-date-day-size-lg36px | Size of a day cell at size lg. |
--pct-date-day-size-sm28px | Size of a day cell at size sm. |
--pct-date-fg{pct.text}#0f172a · #ffffff | Text colour of the date field. |
--pct-date-fg-disabled{pct.text-disabled}#64748b | Text colour of the date field while disabled. |
--pct-date-fg-invalid{pct.danger}#b91c1c · #f87171 | Text colour of the date field when invalid. |
--pct-date-fg-warning{pct.warning}#b45309 · #fbbf24 | Text colour of the warning line (0087). |
--pct-date-font-size{pct.font.size.md}14px | Font size of the date field. |
--pct-date-font-size-lg{pct.font.size.lg}16px | Font size of the date field at size lg. |
--pct-date-font-size-sm{pct.font.size.sm}13px | Font size of the date field at size sm. |
--pct-date-gap{pct.space.3}8px | Gap between the label, the input and the hint. |
--pct-date-hint-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the hint. |
--pct-date-icon-size16px | Size of the icon. |
--pct-date-label-fg{pct.text}#0f172a · #ffffff | Text colour of the label. |
--pct-date-nav-bg-hover{pct.surface-100}#f1f5f9 · #1e293b | Background of the month navigation on hover. |
--pct-date-nav-radius{pct.radius.md}8px | Corner radius of the month navigation. |
--pct-date-nav-size28px | Size of the month navigation. |
--pct-date-padding-x{pct.space.4}12px | Horizontal padding of the date field. |
--pct-date-padding-x-lg{pct.space.4}12px | Horizontal padding of the date field at size lg. |
--pct-date-padding-x-sm{pct.space.3}8px | Horizontal padding of the date field at size sm. |
--pct-date-padding-y{pct.space.3}8px | Vertical padding of the date field. |
--pct-date-padding-y-lg{pct.space.4}12px | Vertical padding of the date field at size lg. |
--pct-date-padding-y-sm{pct.space.2}4px | Vertical padding of the date field at size sm. |
--pct-date-panel-bg{pct.surface}#ffffff · #0f172a | Background of the panel. |
--pct-date-panel-border{pct.border-strong}#64748b | Edge colour of the panel. |
--pct-date-panel-padding-x{pct.space.3}8px | Horizontal padding of the panel. |
--pct-date-panel-padding-y{pct.space.3}8px | Vertical padding of the panel. |
--pct-date-panel-radius{pct.radius.md}8px | Corner radius of the panel. |
--pct-date-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-date-placeholder-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the placeholder. |
--pct-date-radius{pct.radius.md}8px | Corner radius of the date field. |
--pct-date-target-min{pct.target.min}24px | Touch-target floor of the date field. |
--pct-date-toggle-bg-hover{pct.surface-100}#f1f5f9 · #1e293b | Background of the calendar toggle on hover. |
--pct-date-toggle-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the calendar toggle. |
--pct-date-toggle-radius{pct.radius.md}8px | Corner radius of the calendar toggle. |
--pct-date-toggle-size20px | Size of the calendar toggle. |
--pct-date-weekday-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of a weekday heading. |
--pct-date-weekday-font-size{pct.font.size.sm}13px | Font size of a weekday heading. |
--pct-date-weekday-font-weight{pct.font.weight.medium}500 | Font weight of a weekday heading. |
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-date-border-focus: #0f766e;
--pct-date-day-bg-selected: #0f766e;
--pct-date-day-fg-selected: #ffffff;
} Pattern: Date Picker Dialog — a plain textbox with a button beside it that opens a role="dialog" holding a role="grid". The textbox is deliberately not a combobox: a combobox promises a popup that helps complete what is being typed, and a calendar narrows nothing — it is a second road to the same value (0035 read backwards)
| key | effect | test |
|---|---|---|
| any text | typed into the field, parsed on commit | apps/sandbox-e2e/src/date.spec.ts |
Tab | field → calendar button → out | native |
Enter on the button | opens the panel, focus lands on the cursor's cell | apps/sandbox-e2e/src/date.spec.ts |
Escape in the panel | closes it, focus back to the field | apps/sandbox-e2e/src/date.spec.ts |
Tab out of the panel | closes it, focus spliced back onto the field | apps/sandbox-e2e/src/date.spec.ts |
The grid's own map is on PctCalendar. The field itself adds no key of its own: what a user types is text, and the platform's own editing keys are the whole of it.
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/date/src/date.ts — the pattern is named and linked in the class commentapps/sandbox-e2e/src/date.spec.ts — the panel's map in three engines; the field's editing keys are the platform'sapps/sandbox-e2e/src/a11y.spec.ts — the /date view and /all, in both directions, and separately the open calendar with the field it belongs to, whole-page: the toggle's aria-expanded/aria-controls pointing into the overlay container and the roving cell that takes focus, a stage the derived list asked for (lesson-196) and the one panel the audit had never openedapps/sandbox-e2e/src/visual.spec.ts — date-field, date-calendar and date-calendar-rtlforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the chosen day takes the mode's own Highlight pair, today keeps a shape — chromium and firefox force box-shadow to none there and webkit leaves it, so the block writes both halves (lesson-119)prefers-reduced-motiontools/check-styles.mjs point 9 — the day cell's transition takes --pct-motion-transition-duration, which the token build switches offlibs/components/date/src/date.scss — the calendar button is max(--pct-date-toggle-size, --pct-date-target-min), a floor on the DRAWING and not a zone laid over itapps/sandbox/src/app/views/size/size-view.html — the /size view puts the field on the shared sm/md/lg axis beside a text field and a buttonreq-token-density has not one token in the sources; the day cell is the number a density axis would move)apps/sandbox-e2e/src/date.spec.ts — the grid mirrors as geometry AND the arrows mirror with it; plus apps/sandbox-e2e/src/rtl.spec.ts over the /date viewapps/sandbox-e2e/src/hydration.spec.ts — the /date view is in SBX_ROUTESlibs/components/date/src/date.spec.ts › the forms that bind it — [formField], [formControl] and [(ngModel)], each from a non-empty value (lesson-26): written in the field's language (27/08/2026 in es-ES, 08/27/2026 in en-US), typed back into the model, a setValue and disable() reaching the input, and no warning; a form's reset — reset() keeping the value, reset(value) showing it, the form pristine: libs/components/date/src/date.spec.ts › the forms that bind itlibs/components/date/src/date.spec.ts › bounds from a signal form (pctDayBounds) — the bounds reach the field and the calendar walks inside them, a day outside is minDay / maxDay and a bound is inside, the message, a bound read off another field, two rules walked as their intersection in either order, days past the year 9999, and Angular's minDate() refused by the compiler; apps/sandbox-e2e/src/date.spec.ts › bounds from a signal form — the bounds reaching the calendar and a day typed outside reported, in three enginestools/check-aria.mjs (point 7, target check-aria) — the error part carries role="alert"libs/components/date/src/day.property.spec.ts — the step invertible and composing, the weekday anchored on the platform's own epoch, a month's length read from Date's normalisation, an order agreeing with the millisecond difference, the grid six whole weeks containing its month, and the domain refused loudly at both ends. It found two defects no worked case had reached (lesson-185)libs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)contrast.policy.jsonlibs/tokens/src/contrast.policy.json — 23 pairs for the field and the calendar togetherPCT_TEXTStools/check-texts.mjs — six keys, and the three letters are texts because they are words: dd.mm.yyyy reads as language, and a y in a Polish field means nothinglibs/components/size.snapshot.md, tools/check-bundle.mjs (target check-bundle)libs/components/date/src/date.spec.ts › junk in the field — the sentence in the message line ahead of the form's, in the default wording and in the application's; apps/sandbox-e2e/src/date.spec.ts › keeps junk in the field and reports it — the same in three engines, in French, with aria-describedby naming it (0070)docs/acr/at/ — three readers, whole sandbox, and the act that opens the grid. The INPUT is answered as before: Start date · entry · 27/08/2026 · required. · Type it, or pick it from the calendar. (Orca), Day, edit, …, selected 27 slash 08 slash 2026 (NVDA), 27/08/2026 … Day … edit text (VoiceOver), and the toggle says it opens a dialog in all three. The grid is now read, and the cell's name is not: opening lands focus on the chosen day, NVDA says Choisir une date, dialog and stops, Orca gives Choisir une date · dialog · août 2026. · table with 7 rows 7 columns · jeudi column header 27 · row 6 column 4 — the column heading and the cell's text, not the whole date the cell carries as its accessible name — and VoiceOver was silent in the window and said 27 on the row after it. The calendar card reads the same rows/components/date on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
type="text"ControlValueAccessorFormField supplies an NgControl itselfDefaultValueAccessor leadsnull into a model typed number, and nothing says so[PctDay, PctDay], which by 0034 is a question about the tag rather than about an input. Two PctDates with min / max bound to each other is what a consumer composes today — in a signal form, pctDayBounds with a bound read off the other field.Intl resolves fa-IR to the persian calendar and th-TH to the buddhist one, and the value is a Gregorian day — so the formatter is pinned and a user in Bangkok reads 01.12.2026 rather than 01.12.2569. Supporting the others means the value stops being one string, which Temporal will answer better than we can.null; a form that then writes null again — ctrl.reset(), setValue(null) — writes what the model already holds, the model does not move, and the field keeps the text and the report. A date written from outside does take it back.01.12.26 lands in [today − 80, today + 19], the convention every other date field a user has met uses. It is the only guess about intent this control makes, and the number is written down in locale.ts where it can be read.