ComponentsText & numbersNumber
On this page
Text & numbers

Number released

A number field with a step and bounds, and the keyboard the platform already gives it.

  • Import@pacit/components/field
  • Selectorinput[pctNumber]

Implements the W3C ARIA APG Spinbutton pattern ↗

number.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 { PctNumber, PctField } from '@pacit/components/field';
ExportedWhat it is
PctFieldcomponentpct-fieldThe chrome of a form field: label, hint, warning, error message, the required marker and the [pctPrefix] / [pctSuffix] slots inside the field.
PctFieldSizetypePctSizeThe 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="Monthly budget">
  <input pctNumber [formField]="limits.budget" />
</pct-field>
Examples

Every state, running

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

Digits the way you write them

The value is a number; what you type is parsed in your own locale and written back the same way. step sets what the arrows add, minFractionDigits how many decimals always show.

Stored as 19.5.

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 1 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.

Number field: a component on a native <input type="text"> with the spinbutton role, a value of type number | null and locale-aware formatting.

Inputs

NameTypeWhat it does
valuetwo-waydefault nullnumber | nullThe value — null means the field is empty.
disableddefault falsebooleanBlocks 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 falsebooleanKeeps the value from being edited while the field stays focusable and readable — the native readonly.
invaliddefault falsebooleanThe form's verdict; shown only once touched, so an empty form does not open red.
toucheddefault falsebooleanWhether the user has left the field once; with invalid it gates the error face.
requireddefault falsebooleanThe native required; a pct-field around the control reads it to mark the label.
errorsdefault []readonly ValidationError.WithOptionalFieldTree[]The form's validation errors; a pct-field around the control shows the first one's message in place of the hint once the field is touched.
warningsunreleaseddefault undefinedreadonly ValidationError.WithOptionalFieldTree[] | undefinedA 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 ''stringThe native name — what a form submission calls the value.
autocompletedefault 'off'AutoFillThe field's purpose for a browser's autofill, off by default — the value the control wrote by itself until it took this input.
mindefault undefinedunknownValue bounds. They belong to the FormUiControl contract, so with [formField] the directive fills them itself from the schema's min() / max() validators — there is no need to repeat them in the template. The value is clamped to them on commit.
maxdefault undefinedunknownThe upper bound — as min: filled by the directive from the schema's max(), and the value is clamped to it on commit.
stepdefault 1numberThe up/down arrow step; PageUp/PageDown jumps ten times as far.
minFractionDigitsdefault 0numberMinimum number of decimal places written (2 for amounts, say: "12.50").
maxFractionDigitsdefault 0numberMaximum number of decimal places; 0 (the default) = an integer.
useGroupingdefault truebooleanLocale-aware thousands grouping ("1 234 567").
localedefault ''stringOverrides the application's LOCALE_ID for this field.

Outputs

NamePayloadWhen it fires
touchvoidEmitted on blur — lets the form mark the field as touched.

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-text pct-number
typetext
rolespinbutton
autocompleteautocomplete()
idpropertycontrolId
inputmodeinputMode()
disabledpropertydisabled()
readOnlypropertyreadonly()
namename() || null
requiredrequired() || null
aria-invalidshowInvalid() ? "true" : null
aria-describedbydescribedBy()
aria-valuenowvalue()
aria-valuetextvalueText()
aria-valueminmin()
aria-valuemaxmax()
Styling

Parts to select, tokens to override

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

Parts

In a test, PctNumberHarness 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
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

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-field-affix-fg{pct.text-muted}#334155 · #e2e8f0Text colour of an affix.
--pct-field-bg{pct.surface}#ffffff · #0f172aBackground of the field.
--pct-field-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of the field while disabled.
--pct-field-border{pct.border-strong}#64748bEdge colour of the field.
--pct-field-border-disabled{pct.border}#e2e8f0 · #334155Edge colour of the field while disabled.
--pct-field-border-focus{pct.primary}#2563eb · #60a5faEdge colour of the field when focused.
--pct-field-border-hover{pct.text-muted}#334155 · #e2e8f0Edge colour of the field on hover.
--pct-field-border-invalid{pct.danger}#b91c1c · #f87171Edge colour of the field when invalid.
--pct-field-border-warning{pct.warning}#b45309 · #fbbf24Edge colour of the field under a warning (0087).
--pct-field-fg{pct.text}#0f172a · #ffffffText colour of the field.
--pct-field-fg-disabled{pct.text-disabled}#64748bText colour of the field while disabled.
--pct-field-fg-invalid{pct.danger}#b91c1c · #f87171Text colour of the field when invalid.
--pct-field-fg-warning{pct.warning}#b45309 · #fbbf24Text colour of the warning line (0087).
--pct-field-font-size{pct.font.size.md}14pxFont size of the field.
--pct-field-font-size-lg{pct.font.size.lg}16pxFont size of the field at size lg.
--pct-field-font-size-sm{pct.font.size.sm}13pxFont size of the field at size sm.
--pct-field-gap{pct.space.3}8pxGap between the field's label, control and hint.
--pct-field-height{pct.control.height.md}36pxHeight of the field.
--pct-field-height-lg{pct.control.height.lg}44pxHeight of the field at size lg.
--pct-field-height-sm{pct.control.height.sm}28pxHeight of the field at size sm.
--pct-field-hint-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the hint.
--pct-field-label-fg{pct.text}#0f172a · #ffffffText colour of the label.
--pct-field-label-gap{pct.space.2}4pxGap inside the label.
--pct-field-padding-x{pct.space.4}12pxHorizontal padding of the field.
--pct-field-padding-x-lg{pct.space.5}16pxHorizontal padding of the field at size lg.
--pct-field-padding-x-sm{pct.space.3}8pxHorizontal padding of the field at size sm.
--pct-field-placeholder-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the placeholder.
--pct-field-radius{pct.radius.md}8pxCorner radius of the field.

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-field-border-focus: #0f766e;
  --pct-field-bg: #f0fdfa;
  --pct-field-fg: #134e4a;
}
Accessibility

Measured, not assumed

Pattern: Spinbutton — role="spinbutton", aria-valuenow, aria-valuetext

Keyboard

keyeffecttest
↑ / ↓step by stepapps/sandbox-e2e/src/number.spec.ts
PageUp / PageDowna larger stepapps/sandbox-e2e/src/number.spec.ts
typingthe text is not rewritten, so the caret does not jump to the endlibs/components/field/src/number.spec.ts
Enter / blurcommit: rounding and clamping to the bounds; text that is not a number is kept and named Not a number (0070)libs/components/field/src/number.spec.ts

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.

MeasuredJunk kept and namedlibs/components/field/src/number.spec.ts — rejected content stays in the field with aria-invalid, the wrapper's line says Not a number, and a keystroke, reset() — a signal form's own among them — or a number from outside takes it back; apps/sandbox-e2e/src/number.spec.ts — junk kept and named in three engines, in French, and a number typed over it (0070)
MeasuredLocale-aware parsinglibs/components/field/src/number.property.spec.ts — parse(format(n)) === n asked of the real control over 22 locales from a fixed seed, beside the five laws that hold the rest of a commit (grouping visible in the text and absent from the value, the rounding, junk kept and named, blank cleared and not named, the announced value). The parser's three widenings past pl/en are measured rather than argued: disabling one at a time turns 2, 3 and 3 of the six sweeps red (req-api-number)
GapARIA APG pattern in the JSDocnone — gap
MeasuredKeyboard map testedapps/sandbox-e2e/src/number.spec.ts, libs/components/field/src/number.spec.ts
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts (the /number view)
MeasuredInput purpose (WCAG 1.3.5)libs/components/field/src/number.spec.ts — autocomplete="off" with no binding, and the purpose a consumer declares written through. The default refuses autofill because a browser's formatted string is a value this control's parser rejects; the input exists because three purposes on the criterion's list (bday-day, bday-month, bday-year) are numbers a person types here
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts
Measuredprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts
MeasuredTouch targetapps/sandbox-e2e/src/field-hitarea.spec.ts
MeasuredSize axisapps/sandbox-e2e/src/size.spec.ts
GapDensity axisnone — gap
In partRTLpartly — tools/check-styles.mjs (point 5, req-token-logical) over the stylesheet, and the number-amount-rtl baseline in apps/sandbox-e2e/src/visual.spec.ts. Not yet: numbers carry a direction of their own inside RTL text, and nothing measures what the field does with a mixed string
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts
MeasuredFormslibs/components/field/src/field-controls.spec.ts, apps/sandbox-e2e/src/forms.spec.ts; a form's reset — reset() keeping the value, reset(value) showing it, the form pristine: libs/components/field/src/number.spec.ts › signal forms
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts) — the directive exposes no parts of its own; the field entrypoint's inventory covers it in the same row
MeasuredTokens + contrast.policy.jsonshared with field
MeasuredStrings through PCT_TEXTStools/check-texts.mjs — the developer warnings are permanently English and go dark outside isDevMode(), which the gate measures with a point of its own (0007)
MeasuredSize budgetlibs/components/size.snapshot.md — the ./field row, the entrypoint the number shares
MeasuredScreen-reader logRead 2026-09-16 from docs/acr/at/ — three readers, whole sandbox, and the formatted value is what is announced, not the raw number: Orca reads Price · spin button · 1 499,90. · The arrows change the value by 0.5. The role differs by reader — spin button for Orca and NVDA, stepper for VoiceOver — and NVDA gives the whole set on a required one: Number of seats, spin button, required, editable, The range is 1–500; a fraction is rounded on commit, selected 1. Orca alone read the Price field, because the walk`s first stop is placed programmatically and both Guidepup readers are silent on it
Measureddocs page/components/number 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.

84.7%mutation score238 of 281 mutants killed
10 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
0colour pairs measuredlight and dark, at every token build
2visual baselinesChromium, the sandbox's own pictures
13elements in the preview5 listeners · 1 render to settle · 6212 µs in jsdom, held exactly but for the clock

Decisions behind it

  • 0009The number field on type="text"
  • 0003Wrapper and control
  • 0005Signal forms without ControlValueAccessor
  • lesson-32A number field step computed from the DOM dropped keypresses

Known limitations

  • A typed dot cannot mean both things where the locale groups with one. In de-DE or tr-TR a typed 0.123 is read as the grouped 123 and not as nought point one two three: three digits and then the end is exactly the shape Intl writes a thousand in, and grouping has to win the tie — losing it would mean the control could not read back the text it had just written. The tie is now a stated property rather than an argument, and the law that does hold (a keypad dot at every decimal length) is asked of the 18 locales that group with something else.
  • What the parser's sweep does not reach yet: clamping to min/max, the stepping keys, a locale changing under a value the field already holds, and the negative zero a commit can produce — -0.0001 into a field of no decimals shows a minus sign the model does not carry. Each of those needs a new fixture surface rather than one more assertion, which is why they are named here instead of being asserted badly.
  • The starting point of a step is the signal, not the DOM — and that is a requirement, not an optimisation. A read from the DOM can always be one pass behind (lesson-32).
  • The mobile numeric keypad does not follow from the field's type and has to be requested separately.
  • An empty value written from outside does not take junk back. Junk leaves the value null; a binding that then writes null again writes what the model already holds, the model does not move, and the field keeps the text and the report. A number written from outside does take it back, and so does reset() — which a signal form's reset calls.