Around the control
pctPrefix and pctSuffix sit inside the row, beside the control; pctLabelAux and pctMessageAux sit beside the label and the message — a counter, a link, a unit.
The wrapper around a form control — its label, its hint, its error, and the wiring between the three.
@pacit/components/fieldpct-fieldNo APG pattern applies, and none is invented — the reasoning is under Accessibility ↓
import { Component, signal } from '@angular/core';
import { FormField, email, form, required } from '@angular/forms/signals';
import { PctField, PctText } from '@pacit/components/field';
/** One line under the field: the hint — or, once you leave it broken, the error. */
@Component({
selector: 'demo-field',
imports: [FormField, PctField, PctText],
styles: ':host { display: block; max-inline-size: 24rem; }',
template: `
<pct-field label="E-mail" hint="A work address">
<input pctText type="email" [formField]="userForm.email" />
</pct-field>
`,
})
export class FieldDemo {
readonly model = signal({ email: '' });
readonly userForm = form(this.model, (path) => {
required(path.email, { message: 'The e-mail address is required' });
email(path.email, { message: 'That does not look like an address' });
});
}field.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 { PctField, PctText } from '@pacit/components/field';| Exported | What it is |
|---|---|
PctFieldcomponentpct-field | The chrome of a form field: label, hint, warning, error message, the required marker and the [pctPrefix] / [pctSuffix] slots inside the field. |
PctFieldSizetypePctSize | The size of a field: the library's own scale. |
PctTextcomponentinput[pctText], textarea[pctText] | Text field: a directive on a native <input>. The input is not wrapped in a component of ours, so type, browser autofill, mobile keyboard modes and the whole semantics are kept without an abstraction in between (req-api-platform). |
PctAutosizedirectivetextarea[pctText][pctAutosize] | A <textarea> as tall as what is written in it — the ARIA APG has no pattern for this, because it is not a behaviour but a height. |
PctNumbercomponentinput[pctNumber] | Number field: a component on a native <input type="text"> with the spinbutton role, a value of type number | null and locale-aware formatting. |
PctAffixFittype'inset' | 'fill' | How a decoration sits in its slot: |
PctPrefixdirective[pctPrefix] | A decoration before the control, inside the field border (a currency unit, an icon). Decorative content should be hidden from the screen reader (aria-hidden), or have an accessible name of its own if it is interactive. |
PctSuffixdirective[pctSuffix] | A decoration after the control, inside the field border (a clear button, say). |
PctLabelAuxdirective[pctLabelAux] | A label add-on — content in the label row, aligned to the end (an "i" icon with a hint about the field, a help link). It lies outside the field border, so it does not interfere with the control's touch area. If it is interactive (a button, a link) it needs an accessible name of its own. |
PctMessageAuxdirective[pctMessageAux] | A message-line add-on — content in the row below the field, aligned to the end (a character counter, say). It shares the row with the hint or the error: the chrome shows only one of those messages below the field, and this slot stands beside it whichever one is lit. |
<pct-field label="E-mail" hint="A work address">
<input pctText type="email" />
</pct-field>Each example is the component itself, not a screenshot. Open the code under any of them.
pctPrefix and pctSuffix sit inside the row, beside the control; pctLabelAux and pctMessageAux sit beside the label and the message — a counter, a link, a unit.
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
The chrome of a form field: label, hint, warning, error message, the required marker and the [pctPrefix] / [pctSuffix] slots inside the field.
| Name | Type | What it does |
|---|---|---|
labeldefault '' | string | The visible <label> of the row, wired to the control by for or by aria-labelledby — whichever the control's labelStrategy asks for. |
hintdefault '' | string | A line of help under the row; an error or a warning takes its place while one shows (one message at a time, req-api-message). |
sizedefault config.defaultSize | PctFieldSize | The field size; taken from the global configuration by default (req-api-config). It concerns the field row — the height here equals that of a button of the same size, because both take it from the --pct-control-height-* token (req-api-size). |
requireddefault false | boolean | Requiredness can be given outright when the control does not report it. |
(click), (focus), the form's submit — are the platform's, and two-way models above carry the rest. 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-field | |
data-pct-size | size() | |
data-pct-appearance | appearance() | |
data-pct-cursor | cursor() | |
data-pct-invalid | showInvalid() ? "" : null | |
data-pct-warning | showWarning() ? "" : null | |
data-pct-disabled | disabled() ? "" : null |
Two ways in, both stable. No class names to depend on.
In a test, PctFieldHarness, PctPrefixHarness, PctSuffixHarness, PctLabelAuxHarness, PctMessageAuxHarness from @pacit/components/testing read 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 |
|---|---|---|
field-control | [data-pct-part="field-control"] | the wrapped control itself |
field-error | [data-pct-part="field-error"] | the message when the control is invalid |
field-footer | [data-pct-part="field-footer"] | the row under the control: the hint, the warning or the error |
field-header | [data-pct-part="field-header"] | the row above the control: the label and what stands beside it |
field-hint | [data-pct-part="field-hint"] | the hint, read as the description of the control |
field-label | [data-pct-part="field-label"] | the label, tied to the control by id |
field-label-aux | [data-pct-part="field-label-aux"] | the slot beside the label — a counter, a link |
field-message-aux | [data-pct-part="field-message-aux"] | the slot beside the hint, the warning or the error |
field-prefix | [data-pct-part="field-prefix"] | what sits before the control inside the row |
field-row | [data-pct-part="field-row"] | the control's row, with its prefix and suffix |
field-suffix | [data-pct-part="field-suffix"] | what sits after the control inside the row |
field-warning | [data-pct-part="field-warning"] | the message when the value is allowed and suspect — after the error, before the hint, in the warning tone with a glyph and a hidden word (0087) |
field-label-aux-item | [data-pct-part="field-label-aux-item"] | one projected item inside the label slot |
field-message-aux-item | [data-pct-part="field-message-aux-item"] | one projected item inside the message slot |
field-prefix-item | [data-pct-part="field-prefix-item"] | one projected item inside the prefix |
field-suffix-item | [data-pct-part="field-suffix-item"] | one projected item inside the suffix |
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-field-affix-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of an affix. |
--pct-field-bg{pct.surface}#ffffff · #0f172a | Background of the field. |
--pct-field-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293b | Background of the field while disabled. |
--pct-field-border{pct.border-strong}#64748b | Edge colour of the field. |
--pct-field-border-disabled{pct.border}#e2e8f0 · #334155 | Edge colour of the field while disabled. |
--pct-field-border-focus{pct.primary}#2563eb · #60a5fa | Edge colour of the field when focused. |
--pct-field-border-hover{pct.text-muted}#334155 · #e2e8f0 | Edge colour of the field on hover. |
--pct-field-border-invalid{pct.danger}#b91c1c · #f87171 | Edge colour of the field when invalid. |
--pct-field-border-warning{pct.warning}#b45309 · #fbbf24 | Edge colour of the field under a warning (0087). |
--pct-field-fg{pct.text}#0f172a · #ffffff | Text colour of the field. |
--pct-field-fg-disabled{pct.text-disabled}#64748b | Text colour of the field while disabled. |
--pct-field-fg-invalid{pct.danger}#b91c1c · #f87171 | Text colour of the field when invalid. |
--pct-field-fg-warning{pct.warning}#b45309 · #fbbf24 | Text colour of the warning line (0087). |
--pct-field-font-size{pct.font.size.md}14px | Font size of the field. |
--pct-field-font-size-lg{pct.font.size.lg}16px | Font size of the field at size lg. |
--pct-field-font-size-sm{pct.font.size.sm}13px | Font size of the field at size sm. |
--pct-field-gap{pct.space.3}8px | Gap between the field's label, control and hint. |
--pct-field-height{pct.control.height.md}36px | Height of the field. |
--pct-field-height-lg{pct.control.height.lg}44px | Height of the field at size lg. |
--pct-field-height-sm{pct.control.height.sm}28px | Height of the field at size sm. |
--pct-field-hint-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the hint. |
--pct-field-label-fg{pct.text}#0f172a · #ffffff | Text colour of the label. |
--pct-field-label-gap{pct.space.2}4px | Gap inside the label. |
--pct-field-padding-x{pct.space.4}12px | Horizontal padding of the field. |
--pct-field-padding-x-lg{pct.space.5}16px | Horizontal padding of the field at size lg. |
--pct-field-padding-x-sm{pct.space.3}8px | Horizontal padding of the field at size sm. |
--pct-field-placeholder-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the placeholder. |
--pct-field-radius{pct.radius.md}8px | Corner radius of the field. |
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-field-border-focus: #0f766e;
--pct-field-bg: #f0fdfa;
--pct-field-fg: #134e4a;
}Pattern: none — the wrapper has no role of its own: it supplies the label and the descriptions, and the control inside carries the role
None of its own — the wrapper is not focusable. A click on the row's background is forwarded to the control: focus() on mousedown, activate() on click.
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.
apps/sandbox-e2e/src/a11y.spec.ts (the /field view, plus the validation error state)apps/sandbox-e2e/src/visual.spec.tsforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts; the mapping for the whole library is written down in field.scss, and tools/check-styles.mjs (point 7) makes the bare variant say in the mode itself that it has neither surface nor ring — until now the base rules decided that, and only because they are more specific (lesson-70)prefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.tsapps/sandbox-e2e/src/field-hitarea.spec.ts — a cursor map over a grid of points, not a measurement of one elementapps/sandbox-e2e/src/size.spec.ts — the field row equals a button of the same sizeapps/sandbox-e2e/src/rtl.spec.ts — the affix measured on the other side of the control as GEOMETRY in both directions, which that file names its own reference control; plus the field-affixes-rtl and field-aux-slots-rtl baselines in apps/sandbox-e2e/src/visual.spec.ts, and tools/check-styles.mjs (point 5, req-token-logical) over the stylesheetapps/sandbox-e2e/src/hydration.spec.tslibs/components/field/src/field-controls.spec.ts — the wrapper is tested with every controllibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts). The card has under-counted twice: 11 parts against a requirement listing 7, then 15 against a card listing 11 — four slot parts sit in the host blocks of directives. Both times noticed only on countingcontrast.policy.jsonlibs/tokens/src/contrast.policy.jsonPCT_TEXTStools/check-texts.mjs — every string comes from an inputlibs/components/size.snapshot.md — the ./field rowdocs/acr/at/ — three readers, whole sandbox. The whole routed set arrives in one announcement: Orca gives E-mail · entry · john@example.com · required. · invalid entry. · A work address. — label, role, value, required, invalid, hint — and NVDA gives A short description, edit, has auto complete, Briefly — write at least 10 characters, A few words about yourself, blank, VoiceOver A short description Briefly — write at least 10 characters edit text. The label auxiliary is its own stop and announces as a button in all three, which is what it is. The pass dispatched on 2026-10-08 after the warning landed (run 37733848017) read the amount and the phone with label, role and hint in NVDA and VoiceOver, the amount with its value, and neither reader heard the status line at the stop after it appeared, nor the phone's at the next; that silence is an open finding in the plan, read again by the weekly pass/components/field on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
lesson-68). Registration is a pair (attach / detach), so a control merely replaced inside an @if is not mistaken for a second one, and a control that leaves takes its state out of the chrome with it.inset button has to be one step smaller than the field. At size sm there is no step below, so the button fills the height there and pushes the row out by the thickness of the frame. That is a corollary of 0004, not a fitting defect.