Skip to content

Commit b7b4091

Browse files
Lisa18289claude
andcommitted
feat(NumberField): support custom units
Add a `unit` prop for units Intl.NumberFormat does not support (e.g. MiB, GiB). React-aria's number parser rejects unknown text in the input value, so the unit is drawn as an overlay right behind the number and the value stays a plain number. The input's name references the unit ("Storage MiB"), matching the current convention of writing such units into the label. Closes #2579 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 839228d commit b7b4091

14 files changed

Lines changed: 192 additions & 7 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {
2+
Label,
3+
NumberField,
4+
} from "@mittwald/flow-react-components";
5+
6+
<NumberField unit="MiB" defaultValue={512}>
7+
<Label>Arbeitsspeicher</Label>
8+
</NumberField>;

‎apps/docs/src/content/components/form-controls/number-field/index.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ definieren (s.
2727

2828
<LiveCodeEditor example="unit" editorCollapsed stretch />
2929

30+
## Eigene Einheit
31+
32+
Kennt `Intl.NumberFormat` die Einheit nicht (etwa `MiB` oder `GiB`), übergib sie
33+
als Text über das Property `unit`. Das NumberField zeigt sie hinter der Zahl an;
34+
der Wert bleibt eine reine Zahl.
35+
36+
- Nutze `unit` nur, wenn `formatOptions` die Einheit nicht abdeckt –
37+
`formatOptions` lokalisiert Einheit und Schreibweise automatisch.
38+
- Übersetze die Einheit selbst, wenn sie sprachabhängig ist.
39+
40+
<LiveCodeEditor example="customUnit" editorCollapsed stretch />
41+
3042
---
3143

3244
# Validierung

‎apps/remote-dom-demo/src/app/remote/simple-form/page.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Checkbox,
1515
Button,
1616
Autocomplete,
17+
NumberField,
1718
} from "@mittwald/flow-remote-react-components";
1819
import { useState } from "react";
1920

@@ -70,6 +71,9 @@ export default function Page() {
7071
Hoth
7172
</Option>
7273
</Select>
74+
<NumberField name="memory" unit="MiB" defaultValue={512}>
75+
<Label>Droid memory</Label>
76+
</NumberField>
7377
<FileField multiple name="certificates">
7478
<Label>Holocrons</Label>
7579
<Button variant="outline" color="secondary">

‎packages/components/src/components/NumberField/NumberField.browser.test.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { page, userEvent } from "vitest/browser";
33
import { expect, test, vi } from "vitest";
44
import { NumberField } from "@/components/NumberField";
55
import { Label } from "@/components/Label";
6+
import { FieldDescription } from "@/components/FieldDescription";
67

78
// react-aria labels the stepper buttons after the field.
89
const increase = () => page.getByRole("button", { name: "Increase Age" });
@@ -96,3 +97,60 @@ test("a disabled number field cannot be stepped", async () => {
9697
await expect.element(input()).toHaveValue("5");
9798
expect(onChange).not.toHaveBeenCalled();
9899
});
100+
101+
/*
102+
* React-aria cannot parse a unit Intl.NumberFormat does not know, so a custom
103+
* unit stays out of the input value. The field's name carries it instead, like
104+
* a unit written into the label.
105+
*/
106+
test("a custom unit is shown behind the number and named with the field", async () => {
107+
const onChange = vi.fn();
108+
await render(
109+
<NumberField unit="MiB" defaultValue={512} onChange={onChange}>
110+
<Label>Age</Label>
111+
<FieldDescription>Per project</FieldDescription>
112+
</NumberField>,
113+
);
114+
115+
const field = page.getByRole("textbox", {
116+
name: "Age (optional) MiB",
117+
exact: true,
118+
});
119+
120+
await expect.element(page.getByText("MiB")).toBeVisible();
121+
await expect.element(field).toHaveValue("512");
122+
await expect.element(field).toHaveAccessibleDescription("Per project");
123+
124+
await userEvent.fill(field, "1024");
125+
await userEvent.tab();
126+
127+
expect(onChange).toHaveBeenLastCalledWith(1024);
128+
});
129+
130+
test("a custom unit is hidden but named while the field is empty", async () => {
131+
await render(
132+
<NumberField unit="MiB">
133+
<Label>Age</Label>
134+
</NumberField>,
135+
);
136+
137+
const field = page.getByRole("textbox", {
138+
name: "Age (optional) MiB",
139+
exact: true,
140+
});
141+
142+
await expect.element(page.getByText("MiB")).not.toBeVisible();
143+
await expect.element(field).toBeInTheDocument();
144+
145+
await userEvent.fill(field, "3");
146+
147+
await expect.element(page.getByText("MiB")).toBeVisible();
148+
});
149+
150+
test("a custom unit extends an aria-label", async () => {
151+
await render(<NumberField unit="MiB" aria-label="Age" />);
152+
153+
await expect
154+
.element(page.getByRole("textbox", { name: "Age MiB", exact: true }))
155+
.toBeInTheDocument();
156+
});

‎packages/components/src/components/NumberField/NumberField.module.d.scss.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
declare const classNames: {
22
readonly group: "group";
3+
readonly inputContainer: "inputContainer";
34
readonly input: "input";
5+
readonly unitOverlay: "unitOverlay";
46
readonly readonly: "readonly";
7+
readonly unitSpacer: "unitSpacer";
58
readonly decrementButton: "decrementButton";
69
readonly incrementButton: "incrementButton";
710
readonly coarsePointerIcon: "coarsePointerIcon";

‎packages/components/src/components/NumberField/NumberField.module.scss‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,48 @@
88
"input increment"
99
"input decrement";
1010

11-
.input {
11+
.inputContainer {
1212
grid-area: input;
13+
display: grid;
14+
min-width: 0;
15+
}
16+
17+
.input,
18+
.unitOverlay {
19+
grid-area: 1 / 1;
20+
}
1321

22+
.input {
1423
@include formControl.formControl();
1524
}
1625

26+
// Mirrors the input's box so the text lines up with the input text
27+
.unitOverlay {
28+
@include formControl.formBorder();
29+
@include formControl.formOutline();
30+
31+
// Grid items paint in `order`; formControl moves the input to 2
32+
order: 2;
33+
border-color: transparent;
34+
color: var(--form-control--content-color--default);
35+
white-space: pre;
36+
overflow: hidden;
37+
pointer-events: none;
38+
contain: inline-size;
39+
40+
&[data-empty] {
41+
visibility: hidden;
42+
}
43+
}
44+
45+
.unitSpacer {
46+
visibility: hidden;
47+
}
48+
49+
&[aria-disabled="true"] .unitOverlay {
50+
color: var(--form-control--content-color--disabled);
51+
}
52+
1753
.decrementButton {
1854
grid-area: decrement;
1955
}

‎packages/components/src/components/NumberField/NumberField.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type PropsWithChildren } from "react";
1+
import { type PropsWithChildren, useId } from "react";
22
import * as Aria from "react-aria-components";
33
import formFieldStyles from "@/components/FormField/FormField.module.scss";
44
import styles from "./NumberField.module.scss";
@@ -15,11 +15,18 @@ import type { FlowComponentProps } from "@/lib/componentFactory/flowComponent";
1515
import { flowComponent } from "@/lib/componentFactory/flowComponent";
1616
import { useFieldComponent } from "@/lib/hooks/useFieldComponent";
1717
import { useControlledHostValueProps } from "@/lib/remote/useControlledHostValueProps";
18+
import { NumberFieldUnit } from "./components/NumberFieldUnit";
1819

1920
export interface NumberFieldProps
2021
extends
2122
PropsWithChildren<Omit<Aria.NumberFieldProps, "children">>,
22-
FlowComponentProps<HTMLInputElement> {}
23+
FlowComponentProps<HTMLInputElement> {
24+
/**
25+
* A unit displayed after the number, for units `Intl.NumberFormat` does not
26+
* support (e.g. "MiB"). For supported units, use `formatOptions` instead.
27+
*/
28+
unit?: string;
29+
}
2330

2431
/** @flr-generate all */
2532
export const NumberField = flowComponent("NumberField", (props) => {
@@ -29,9 +36,13 @@ export const NumberField = flowComponent("NumberField", (props) => {
2936
className,
3037
isWheelDisabled = true,
3138
ref,
39+
unit,
40+
"aria-labelledby": ariaLabelledBy,
3241
...rest
3342
} = useControlledHostValueProps(props, NaN);
3443

44+
const unitId = useId();
45+
3546
const {
3647
FieldErrorView,
3748
FieldErrorCaptureContext,
@@ -46,6 +57,11 @@ export const NumberField = flowComponent("NumberField", (props) => {
4657
<Aria.NumberField
4758
{...rest}
4859
isWheelDisabled={isWheelDisabled}
60+
aria-labelledby={
61+
unit
62+
? [ariaLabelledBy, unitId].filter(Boolean).join(" ")
63+
: ariaLabelledBy
64+
}
4965
aria-describedby={controlProps["aria-describedby"]}
5066
className={clsx(rootClassName, wrapperProps.className)}
5167
>
@@ -64,7 +80,10 @@ export const NumberField = flowComponent("NumberField", (props) => {
6480
<IconChevronDown />
6581
<IconMinus className={styles.coarsePointerIcon} />
6682
</Button>
67-
<Aria.Input className={styles.input} ref={ref} />
83+
<div className={styles.inputContainer}>
84+
<Aria.Input className={styles.input} ref={ref} />
85+
{unit && <NumberFieldUnit id={unitId} unit={unit} />}
86+
</div>
6887
<Button
6988
ariaSlot="increment"
7089
className={styles.incrementButton}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { type FC, useContext } from "react";
2+
import * as Aria from "react-aria-components";
3+
import styles from "../NumberField.module.scss";
4+
5+
interface Props {
6+
id: string;
7+
unit: string;
8+
}
9+
10+
/**
11+
* React-aria cannot parse a unit `Intl.NumberFormat` does not know, so the unit
12+
* is drawn over the input instead of being part of its value. An invisible copy
13+
* of the input text pushes it right behind the number. The input's name
14+
* references the unit, like a unit written into the label.
15+
*/
16+
export const NumberFieldUnit: FC<Props> = (props) => {
17+
const { id, unit } = props;
18+
const inputValue = useContext(Aria.NumberFieldStateContext)?.inputValue;
19+
20+
return (
21+
<span
22+
className={styles.unitOverlay}
23+
data-empty={!inputValue || undefined}
24+
aria-hidden
25+
>
26+
<span className={styles.unitSpacer}>{inputValue} </span>
27+
<span id={id}>{unit}</span>
28+
</span>
29+
);
30+
};

‎packages/components/src/components/NumberField/stories/Default.stories.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export const WithUnit: Story = {
7373
),
7474
};
7575

76+
export const WithCustomUnit: Story = {
77+
render: (props) => (
78+
<NumberField {...props} unit="MiB" defaultValue={512}>
79+
<Label>Storage</Label>
80+
</NumberField>
81+
),
82+
};
83+
7684
export const WithContextualHelp: Story = {
7785
render: (props) => (
7886
<NumberField {...props} minValue={5} maxValue={10}>

‎packages/remote-elements/src/auto-generated/RemoteNumberFieldElement.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export class RemoteNumberFieldElement extends FlowRemoteElement<RemoteNumberFiel
3939
slot: {},
4040
step: {},
4141
translate: {},
42+
unit: {},
4243
validate: {},
4344
validationBehavior: {},
4445
value: {},

0 commit comments

Comments
 (0)