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.
A number field with a step and bounds, and the keyboard the platform already gives it.
@pacit/components/fieldinput[pctNumber]Implements the W3C ARIA APG Spinbutton pattern ↗
import { Component, signal } from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
import { PctField, PctNumber } from '@pacit/components/field';
/** A number the way the locale writes it — parsed back to one canonical value. */
@Component({
selector: 'demo-number',
imports: [FormField, PctField, PctNumber],
styles: ':host { display: block; max-inline-size: 24rem; }',
template: `
<pct-field label="Monthly budget" hint="In your own digits and separators">
<input pctNumber [formField]="limits.budget" />
</pct-field>
`,
})
export class NumberDemo {
readonly model = signal({ budget: 1200 });
readonly limits = form(this.model);
}number.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 { PctNumber, PctField } 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="Monthly budget">
<input pctNumber [formField]="limits.budget" />
</pct-field>Each example is the component itself, not a screenshot. Open the code under any of 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.
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.
| Name | Type | What it does |
|---|---|---|
valuetwo-waydefault null | number | null | The value — null means 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 from being edited while the field stays focusable and readable — the native readonly. |
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 | The 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 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. |
autocompletedefault 'off' | AutoFill | The field's purpose for a browser's autofill, off by default — the value the control wrote by itself until it took this input. |
mindefault undefined | unknown | Value 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 undefined | unknown | The upper bound — as min: filled by the directive from the schema's max(), and the value is clamped to it on commit. |
stepdefault 1 | number | The up/down arrow step; PageUp/PageDown jumps ten times as far. |
minFractionDigitsdefault 0 | number | Minimum number of decimal places written (2 for amounts, say: "12.50"). |
maxFractionDigitsdefault 0 | number | Maximum number of decimal places; 0 (the default) = an integer. |
useGroupingdefault true | boolean | Locale-aware thousands grouping ("1 234 567"). |
localedefault '' | string | Overrides the application's LOCALE_ID for this field. |
| 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-text pct-number | |
type | text | |
role | spinbutton | |
autocomplete | autocomplete() | |
idproperty | controlId | |
inputmode | inputMode() | |
disabledproperty | disabled() | |
readOnlyproperty | readonly() | |
name | name() || null | |
required | required() || null | |
aria-invalid | showInvalid() ? "true" : null | |
aria-describedby | describedBy() | |
aria-valuenow | value() | |
aria-valuetext | valueText() | |
aria-valuemin | min() | |
aria-valuemax | max() |
Two ways in, both stable. No class names to depend on.
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.
| 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: Spinbutton — role="spinbutton", aria-valuenow, aria-valuetext
| key | effect | test |
|---|---|---|
↑ / ↓ | step by step | apps/sandbox-e2e/src/number.spec.ts |
PageUp / PageDown | a larger step | apps/sandbox-e2e/src/number.spec.ts |
| typing | the text is not rewritten, so the caret does not jump to the end | libs/components/field/src/number.spec.ts |
Enter / blur | commit: 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 |
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/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)libs/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)apps/sandbox-e2e/src/number.spec.ts, libs/components/field/src/number.spec.tsapps/sandbox-e2e/src/a11y.spec.ts (the /number view)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 hereapps/sandbox-e2e/src/visual.spec.tsforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.tsprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.tsapps/sandbox-e2e/src/field-hitarea.spec.tsapps/sandbox-e2e/src/size.spec.tstools/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 stringapps/sandbox-e2e/src/hydration.spec.tslibs/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 formslibs/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 rowcontrast.policy.jsonshared with fieldPCT_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)libs/components/size.snapshot.md — the ./field row, the entrypoint the number sharesdocs/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/components/number on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
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.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.lesson-32).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.