gbs-add-block 1.2.18 → 1.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/index.cjs +5 -0
  3. package/package.json +1 -1
  4. package/source/beta-components/number-input/README.md +53 -0
  5. package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
  6. package/source/beta-components/number-input/core/format.ts +37 -0
  7. package/source/beta-components/number-input/core/index.ts +4 -0
  8. package/source/beta-components/number-input/core/parse.ts +118 -0
  9. package/source/beta-components/number-input/core/step.ts +73 -0
  10. package/source/beta-components/number-input/core/types.ts +32 -0
  11. package/source/beta-components/number-input/index.ts +5 -0
  12. package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
  13. package/source/beta-components/number-input/react/icons.tsx +17 -0
  14. package/source/beta-components/number-input/react/locale.ts +7 -0
  15. package/source/beta-components/number-input/react/props.ts +11 -0
  16. package/source/beta-components/number-input/styles.css +231 -0
  17. package/source/beta-components/radio-group/README.md +58 -0
  18. package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
  19. package/source/beta-components/radio-group/core/group.ts +36 -0
  20. package/source/beta-components/radio-group/core/index.ts +2 -0
  21. package/source/beta-components/radio-group/core/types.ts +20 -0
  22. package/source/beta-components/radio-group/index.ts +9 -0
  23. package/source/beta-components/radio-group/react/Radio.tsx +144 -0
  24. package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
  25. package/source/beta-components/radio-group/react/context.ts +18 -0
  26. package/source/beta-components/radio-group/react/locale.ts +5 -0
  27. package/source/beta-components/radio-group/react/props.ts +12 -0
  28. package/source/beta-components/radio-group/styles.css +270 -0
  29. package/source/beta-components/shared/__tests__/position.test.ts +143 -170
  30. package/source/beta-components/shared/core/position.ts +2 -15
  31. package/source/beta-components/shared/react/Popover.tsx +5 -18
  32. package/source/beta-components/switch/README.md +46 -0
  33. package/source/beta-components/switch/__tests__/core.test.ts +64 -0
  34. package/source/beta-components/switch/core/index.ts +2 -0
  35. package/source/beta-components/switch/core/toggle.ts +54 -0
  36. package/source/beta-components/switch/core/types.ts +9 -0
  37. package/source/beta-components/switch/index.ts +5 -0
  38. package/source/beta-components/switch/react/Switch.tsx +219 -0
  39. package/source/beta-components/switch/react/locale.ts +5 -0
  40. package/source/beta-components/switch/react/props.ts +12 -0
  41. package/source/beta-components/switch/styles.css +229 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.18)
1
+ # GBS Building Blocks 2.0 (v1.2.19)
2
2
 
3
3
  Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
4
4
 
@@ -10,7 +10,7 @@ For detailed documentation on usage and props, Please visit: [Building Block Doc
10
10
 
11
11
  New Beta Components are avilable for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app/2.0.0-beta)
12
12
 
13
- ## What's New 🎉 (Ver 1.2.18)
13
+ ## What's New 🎉 (Ver 1.2.19)
14
14
 
15
15
  - Update Candidate for next major change 2.0.0
16
16
 
package/index.cjs CHANGED
@@ -56,12 +56,17 @@ const CONFIG = {
56
56
  "Popover",
57
57
  "Card",
58
58
  "Skeleton",
59
+ "NumberInput",
60
+ "RadioGroup",
61
+ "Switch"
59
62
  ],
60
63
  // Beta folder names that are not simply the lowercased component name.
61
64
  betaFolders: {
62
65
  DataGrid: "data-grid",
63
66
  DatePicker: "date-picker",
64
67
  FileUploader: "file-uploader",
68
+ NumberInput: "number-input",
69
+ RadioGroup: "radio-group"
65
70
  },
66
71
  // Define component dependencies
67
72
  dependencies: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.18",
3
+ "version": "1.2.19",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,53 @@
1
+ # NumberInput
2
+
3
+ A numeric field for React 19 that reads and writes numbers the way the locale
4
+ does, styled to match the Input component. No runtime dependencies besides
5
+ React.
6
+
7
+ ```ts
8
+ import { NumberInput } from "@/components/number-input";
9
+ import "@/components/number-input/styles.css";
10
+ ```
11
+
12
+ ```tsx
13
+ <NumberInput label="Quantity" min={1} max={99} value={qty} onValueChange={setQty} />
14
+
15
+ <NumberInput label="Amount" format="currency" currency="USD" decimals={2} locale="en-US" />
16
+
17
+ {/* 45% in the field is 0.45 in the value, as Intl and CSS both have it. */}
18
+ <NumberInput label="Discount" format="percent" step={0.01} min={0} max={1} />
19
+ ```
20
+
21
+ **Why not `<input type="number">`:** the wheel silently edits it while the page
22
+ scrolls; an entry it considers invalid reports itself as the empty string, so
23
+ "nothing" and "not a number yet" cannot be told apart and what was typed is
24
+ gone; it understands one notation, so `1.234,56` is not a number to it; and its
25
+ spin buttons cannot be styled. This field is a text input with
26
+ `role="spinbutton"`, which has none of those problems and the same
27
+ announcements.
28
+
29
+ **Props:** `value` / `defaultValue` / `onValueChange` (`number | null`), `min`,
30
+ `max`, `step`, `largeStep`, `snapToStep`, `decimals`, `locale`, `format`
31
+ (`decimal` `currency` `percent`), `currency`, `useGrouping`, `clampBehavior`
32
+ (`blur` `strict` `none`), `stepper`, `wheel`, `selectOnFocus`, `clearable`,
33
+ `label`, `description`, `error`, `size`, `leading`, `trailing`, `disabled`,
34
+ `readOnly`, `required`, `classNames` (`root` `label` `control` `input`
35
+ `stepper` `description` `error`), `localeText`, `ref` (the `<input>`), and
36
+ every `<input>` attribute.
37
+
38
+ **Keyboard:** **↑** / **↓** step, **Page Up** / **Page Down** step by
39
+ `largeStep`, **Home** / **End** jump to `min` / `max` where there is one, and
40
+ **Escape** puts back the last committed number.
41
+
42
+ The field shows a grouped, formatted number at rest and the plain number while
43
+ it has focus, so separators never appear under the caret. An entry that cannot
44
+ be read falls back to the last good value on blur rather than being discarded.
45
+
46
+ `parseNumber`, `formatNumber`, `toEditText`, `localeParts`, `stepBy`,
47
+ `snapToStep`, `clamp`, `round` and `decimalsOf` are exported from the
48
+ framework-free `core` — the same parsing a server can reuse to accept what the
49
+ field sends.
50
+
51
+ **`wheel` is off by default.** Turning it on lets the wheel change a focused
52
+ field, which is exactly the behaviour that makes `<input type="number">`
53
+ dangerous in a long form.
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatNumber } from "../core/format";
3
+ import { localeParts, parseNumber, toEditText } from "../core/parse";
4
+ import { clamp, decimalsOf, round, snapToStep, stepBy } from "../core/step";
5
+
6
+ describe("localeParts", () => {
7
+ it("reads each locale's notation out of Intl", () => {
8
+ expect(localeParts("en-US")).toMatchObject({ group: ",", decimal: "." });
9
+ expect(localeParts("de-DE")).toMatchObject({ group: ".", decimal: "," });
10
+ expect(localeParts("fr-FR")).toMatchObject({ decimal: "," });
11
+ expect(localeParts("ar-EG").digits[0]).toBe("٠");
12
+ });
13
+ });
14
+
15
+ describe("parseNumber", () => {
16
+ it("reads the locale's own notation", () => {
17
+ expect(parseNumber("1,234.56", "en-US")).toBe(1234.56);
18
+ expect(parseNumber("1.234,56", "de-DE")).toBe(1234.56);
19
+ // The separator here is a narrow no-break space, not one anybody types.
20
+ expect(parseNumber("1 234,56", "fr-FR")).toBe(1234.56);
21
+ });
22
+
23
+ it("reads the locale's digits", () => {
24
+ expect(parseNumber("١٢٣", "ar-EG")).toBe(123);
25
+ });
26
+
27
+ it("ignores currency symbols, percent signs and spare spaces", () => {
28
+ expect(parseNumber("$1,234.56", "en-US")).toBe(1234.56);
29
+ expect(parseNumber("45 %", "en-US")).toBe(45);
30
+ expect(parseNumber(" 12 ", "en-US")).toBe(12);
31
+ });
32
+
33
+ it("reads negatives, including the typographic minus", () => {
34
+ expect(parseNumber("-42", "en-US")).toBe(-42);
35
+ expect(parseNumber("−42", "en-US")).toBe(-42);
36
+ expect(parseNumber("-1.234,5", "de-DE")).toBe(-1234.5);
37
+ });
38
+
39
+ it("refuses what is not a number, and keeps that apart from empty", () => {
40
+ expect(parseNumber("", "en-US")).toBeNull();
41
+ expect(parseNumber("abc", "en-US")).toBeNull();
42
+ expect(parseNumber("1.2.3", "en-US")).toBeNull();
43
+ expect(parseNumber(".", "en-US")).toBeNull();
44
+ expect(parseNumber("-", "en-US")).toBeNull();
45
+ });
46
+
47
+ it("does not mistake one locale's group separator for another's decimal", () => {
48
+ // The whole point: the same five characters, two different numbers.
49
+ expect(parseNumber("1.234", "en-US")).toBe(1.234);
50
+ expect(parseNumber("1.234", "de-DE")).toBe(1234);
51
+ });
52
+ });
53
+
54
+ describe("toEditText", () => {
55
+ it("gives the plain form, in the locale's decimal separator", () => {
56
+ expect(toEditText(1234.5, "en-US")).toBe("1234.5");
57
+ expect(toEditText(1234.5, "de-DE")).toBe("1234,5");
58
+ expect(toEditText(null, "en-US")).toBe("");
59
+ });
60
+
61
+ it("round-trips through parseNumber", () => {
62
+ for (const locale of ["en-US", "de-DE", "fr-FR"]) {
63
+ expect(parseNumber(toEditText(-1234.56, locale), locale)).toBe(-1234.56);
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("formatNumber", () => {
69
+ it("groups, and respects a fixed number of decimals", () => {
70
+ expect(formatNumber(1234.5, { locale: "en-US", decimals: 2 })).toBe("1,234.50");
71
+ expect(formatNumber(1234.5, { locale: "en-US", useGrouping: false })).toBe("1234.5");
72
+ });
73
+
74
+ it("formats money and percentages", () => {
75
+ expect(formatNumber(1234.5, { locale: "en-US", style: "currency", currency: "USD" })).toBe("$1,234.50");
76
+ expect(formatNumber(0.45, { locale: "en-US", style: "percent" })).toBe("45%");
77
+ });
78
+
79
+ it("falls back to a plain number when a currency is asked for without a code", () => {
80
+ // Intl throws on style:"currency" with no currency; a field mid-configuration
81
+ // should not take the page down with it.
82
+ expect(formatNumber(12, { locale: "en-US", style: "currency" })).toBe("12");
83
+ });
84
+
85
+ it("has nothing to show for no value", () => {
86
+ expect(formatNumber(null, { locale: "en-US" })).toBe("");
87
+ });
88
+ });
89
+
90
+ describe("decimalsOf and round", () => {
91
+ it("counts fraction digits, including exponent notation", () => {
92
+ expect(decimalsOf(1)).toBe(0);
93
+ expect(decimalsOf(0.25)).toBe(2);
94
+ expect(decimalsOf(1e-7)).toBe(7);
95
+ });
96
+
97
+ it("rounds the way the number reads, not the way the float sits", () => {
98
+ expect(round(1.005, 2)).toBe(1.01);
99
+ expect(round(2.675, 2)).toBe(2.68);
100
+ expect(round(-1.005, 2)).toBe(-1.01);
101
+ });
102
+ });
103
+
104
+ describe("stepBy", () => {
105
+ it("adds a step without floating-point drift", () => {
106
+ expect(stepBy(0.1, 1, { step: 0.2 })).toBe(0.3);
107
+ expect(stepBy(0.3, -1, { step: 0.1 })).toBe(0.2);
108
+ expect(stepBy(1.1, 1, { step: 0.1 })).toBe(1.2);
109
+ });
110
+
111
+ it("starts an empty field at min, or one step from zero", () => {
112
+ expect(stepBy(null, 1, { step: 1 })).toBe(1);
113
+ expect(stepBy(null, -1, { step: 1 })).toBe(-1);
114
+ expect(stepBy(null, 1, { step: 1, min: 5 })).toBe(5);
115
+ expect(stepBy(null, -1, { step: 1, min: 5 })).toBe(5);
116
+ });
117
+
118
+ it("stops at the limits", () => {
119
+ expect(stepBy(9, 1, { step: 5, max: 10 })).toBe(10);
120
+ expect(stepBy(1, -1, { step: 5, min: 0 })).toBe(0);
121
+ });
122
+
123
+ it("treats a step of zero as one, rather than freezing", () => {
124
+ expect(stepBy(3, 1, { step: 0 })).toBe(4);
125
+ });
126
+ });
127
+
128
+ describe("snapToStep", () => {
129
+ it("counts the grid from min", () => {
130
+ expect(snapToStep(7, { step: 5, min: 0 })).toBe(5);
131
+ expect(snapToStep(8, { step: 5, min: 0 })).toBe(10);
132
+ expect(snapToStep(7, { step: 5, min: 1 })).toBe(6);
133
+ });
134
+
135
+ it("works in fractions", () => {
136
+ expect(snapToStep(0.27, { step: 0.05, min: 0 })).toBe(0.25);
137
+ });
138
+
139
+ it("stays inside the limits", () => {
140
+ expect(snapToStep(99, { step: 10, min: 0, max: 95 })).toBe(95);
141
+ });
142
+ });
143
+
144
+ describe("clamp", () => {
145
+ it("pulls a value inside whichever limits exist", () => {
146
+ expect(clamp(5, 0, 10)).toBe(5);
147
+ expect(clamp(-1, 0, 10)).toBe(0);
148
+ expect(clamp(11, 0, 10)).toBe(10);
149
+ expect(clamp(11, undefined, undefined)).toBe(11);
150
+ });
151
+ });
@@ -0,0 +1,37 @@
1
+ import type { NumberFormatOptions } from "./types";
2
+
3
+ const formats = new Map<string, Intl.NumberFormat>();
4
+
5
+ /**
6
+ * Cached `Intl.NumberFormat`. Building one costs about as much as formatting a
7
+ * hundred numbers, and a grid of amounts formats thousands.
8
+ */
9
+ export function numberFormat(options: NumberFormatOptions = {}): Intl.NumberFormat {
10
+ const { locale, style = "decimal", currency, decimals, useGrouping = true } = options;
11
+ const key = [locale ?? "", style, currency ?? "", decimals ?? "", useGrouping].join("|");
12
+ const cached = formats.get(key);
13
+ if (cached) return cached;
14
+
15
+ const format = new Intl.NumberFormat(locale, {
16
+ style: style === "currency" && !currency ? "decimal" : style,
17
+ currency: style === "currency" ? currency : undefined,
18
+ useGrouping,
19
+ minimumFractionDigits: decimals,
20
+ maximumFractionDigits: decimals,
21
+ });
22
+ formats.set(key, format);
23
+ return format;
24
+ }
25
+
26
+ /**
27
+ * The number as it is read rather than edited: grouped, with its currency
28
+ * symbol or percent sign.
29
+ *
30
+ * `style="percent"` follows `Intl` and multiplies by 100, so a field showing
31
+ * 45% holds 0.45. That is the convention every chart and CSS property already
32
+ * uses, and having one component disagree is worse than the surprise.
33
+ */
34
+ export function formatNumber(value: number | null, options: NumberFormatOptions = {}): string {
35
+ if (value === null || !Number.isFinite(value)) return "";
36
+ return numberFormat(options).format(value);
37
+ }
@@ -0,0 +1,4 @@
1
+ export { formatNumber, numberFormat } from "./format";
2
+ export { localeParts, parseNumber, toEditText, type NumberLocaleParts } from "./parse";
3
+ export { clamp, decimalsOf, round, snapToStep, stepBy } from "./step";
4
+ export type * from "./types";
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Reading numbers the way the person in front of the screen writes them.
3
+ *
4
+ * This is the reason the component exists. `<input type="number">` understands
5
+ * one notation, roughly the C locale's, so a German typing `1.234,56` or an
6
+ * Egyptian typing `١٢٣٤` hands it something it calls invalid — and an invalid
7
+ * number input reports its value as the empty string, which loses what was
8
+ * typed as well as the meaning.
9
+ *
10
+ * Every part of a locale's notation is discovered from `Intl` rather than
11
+ * listed here: format a known number, look at the pieces that come back, and
12
+ * you have that locale's group separator, decimal separator, minus sign and
13
+ * digits, for any locale the runtime supports.
14
+ */
15
+
16
+ export interface NumberLocaleParts {
17
+ group: string;
18
+ decimal: string;
19
+ minus: string;
20
+ /** The locale's digits, ASCII 0-9 in order. Latin digits for most locales. */
21
+ digits: readonly string[];
22
+ }
23
+
24
+ const partsCache = new Map<string, NumberLocaleParts>();
25
+
26
+ function partValue(parts: Intl.NumberFormatPart[], type: Intl.NumberFormatPartTypes, fallback: string) {
27
+ return parts.find((part) => part.type === type)?.value ?? fallback;
28
+ }
29
+
30
+ /** The notation one locale uses, read out of `Intl` once and kept. */
31
+ export function localeParts(locale?: string): NumberLocaleParts {
32
+ const key = locale ?? "";
33
+ const cached = partsCache.get(key);
34
+ if (cached) return cached;
35
+
36
+ const format = new Intl.NumberFormat(locale);
37
+ const sample = format.formatToParts(-12345.6);
38
+ const digits = Array.from({ length: 10 }, (_, index) =>
39
+ format.format(index).replace(/\s/g, ""),
40
+ );
41
+
42
+ const parts: NumberLocaleParts = {
43
+ group: partValue(sample, "group", ","),
44
+ decimal: partValue(sample, "decimal", "."),
45
+ minus: partValue(sample, "minusSign", "-"),
46
+ digits,
47
+ };
48
+ partsCache.set(key, parts);
49
+ return parts;
50
+ }
51
+
52
+ /** Turns a locale's digits into 0-9, leaving everything else alone. */
53
+ function toAsciiDigits(text: string, digits: readonly string[]): string {
54
+ if (digits[0] === "0") return text;
55
+ let result = "";
56
+ for (const character of text) {
57
+ const index = digits.indexOf(character);
58
+ result += index === -1 ? character : String(index);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ /**
64
+ * Reads what was typed, in the notation of `locale`.
65
+ *
66
+ * Returns `null` for anything that is not a number, including an empty field —
67
+ * the component keeps those apart by looking at the text, so an unreadable
68
+ * entry never silently becomes "nothing".
69
+ *
70
+ * Currency symbols, percent signs and stray spaces are ignored, so a pasted
71
+ * `"$1,234.56"` or `"45 %"` is read rather than rejected. Only one sign and one
72
+ * decimal separator are accepted: `"1.2.3"` is not a number in any locale.
73
+ */
74
+ export function parseNumber(text: string, locale?: string): number | null {
75
+ const parts = localeParts(locale);
76
+ let value = toAsciiDigits(text.trim(), parts.digits);
77
+ if (value === "") return null;
78
+
79
+ // Every kind of space, including the narrow no-break space French and German
80
+ // use as a group separator, and the ordinary one people type by hand.
81
+ value = value.replace(/\s/gu, "");
82
+
83
+ const negative =
84
+ value.startsWith(parts.minus) || value.startsWith("-") || value.startsWith("−");
85
+ if (negative) value = value.replace(/^(-|−|-)/u, "").replace(parts.minus, "");
86
+
87
+ // Group separators go; the locale's decimal separator becomes a point. Doing
88
+ // it in this order matters where one locale's group is another's decimal.
89
+ if (parts.group !== "") value = value.split(parts.group).join("");
90
+ if (parts.decimal !== ".") value = value.split(parts.decimal).join(".");
91
+
92
+ // Whatever is left that is not a digit or the one point — currency symbols,
93
+ // percent signs, stray letters — is dropped, but a second point is not a
94
+ // typo to guess at.
95
+ const cleaned = value.replace(/[^0-9.]/gu, "");
96
+ if (cleaned === "" || cleaned === ".") return null;
97
+ if (cleaned.indexOf(".") !== cleaned.lastIndexOf(".")) return null;
98
+
99
+ const parsed = Number(cleaned);
100
+ if (!Number.isFinite(parsed)) return null;
101
+ return negative ? -parsed : parsed;
102
+ }
103
+
104
+ /**
105
+ * The text a number is edited as: the locale's decimal separator, no grouping
106
+ * and no currency symbol.
107
+ *
108
+ * Editing text and reading text are different jobs. Separators inserted while
109
+ * someone is typing move the caret out from under their fingers, so the field
110
+ * shows the plain form while it has focus and the formatted one the rest of
111
+ * the time.
112
+ */
113
+ export function toEditText(value: number | null, locale?: string): string {
114
+ if (value === null || Number.isNaN(value)) return "";
115
+ const parts = localeParts(locale);
116
+ const text = String(value);
117
+ return parts.decimal === "." ? text : text.replace(".", parts.decimal);
118
+ }
@@ -0,0 +1,73 @@
1
+ import type { StepOptions } from "./types";
2
+
3
+ /** How many fraction digits a number is written with. */
4
+ export function decimalsOf(value: number): number {
5
+ if (!Number.isFinite(value)) return 0;
6
+ const text = String(value);
7
+ const exponent = text.indexOf("e-");
8
+ if (exponent !== -1) {
9
+ const digits = text.slice(0, exponent).split(".")[1]?.length ?? 0;
10
+ return digits + Number(text.slice(exponent + 2));
11
+ }
12
+ return text.split(".")[1]?.length ?? 0;
13
+ }
14
+
15
+ /**
16
+ * Rounds to `decimals` places without the drift of `toFixed` on binary
17
+ * fractions: 1.005 rounds up, as anyone reading the number expects.
18
+ */
19
+ export function round(value: number, decimals: number): number {
20
+ if (!Number.isFinite(value)) return value;
21
+ const factor = 10 ** decimals;
22
+ // The epsilon nudge is what keeps 1.005 * 100 = 100.49999999999999 from
23
+ // rounding down to 1.00.
24
+ return Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor;
25
+ }
26
+
27
+ export function clamp(value: number, min?: number, max?: number): number {
28
+ let result = value;
29
+ if (min !== undefined && result < min) result = min;
30
+ if (max !== undefined && result > max) result = max;
31
+ return result;
32
+ }
33
+
34
+ /**
35
+ * The value one press of an arrow or a stepper button produces.
36
+ *
37
+ * Arithmetic runs on integers scaled by the number of decimals involved, so
38
+ * `0.1 + 0.2` gives `0.3` rather than `0.30000000000000004` — a field that
39
+ * shows that after two presses looks broken, and rounding afterwards would
40
+ * only hide it one place further down.
41
+ *
42
+ * An empty field follows `<input type="number">`: the first press lands on
43
+ * `min` if there is one, and otherwise one step away from zero.
44
+ */
45
+ export function stepBy(
46
+ value: number | null,
47
+ direction: 1 | -1,
48
+ { step, min, max }: StepOptions,
49
+ ): number {
50
+ const size = Math.abs(step) || 1;
51
+ if (value === null) return clamp(min ?? size * direction, min, max);
52
+
53
+ const scale = 10 ** Math.max(decimalsOf(value), decimalsOf(size));
54
+ const next = (Math.round(value * scale) + Math.round(size * scale) * direction) / scale;
55
+ return clamp(round(next, decimalsOf(1 / scale)), min, max);
56
+ }
57
+
58
+ /**
59
+ * Moves a value onto the step grid, counting from `min` where there is one.
60
+ *
61
+ * Only used when a field is asked to enforce its step (`snapToStep`). Typing is
62
+ * left alone until then: correcting 7 to 5 while someone is still typing 75
63
+ * would be unusable.
64
+ */
65
+ export function snapToStep(value: number, { step, min, max }: StepOptions): number {
66
+ const size = Math.abs(step);
67
+ if (!Number.isFinite(size) || size === 0) return clamp(value, min, max);
68
+ const base = min ?? 0;
69
+ const scale = 10 ** Math.max(decimalsOf(size), decimalsOf(base), decimalsOf(value));
70
+ const steps = Math.round((Math.round(value * scale) - Math.round(base * scale)) / Math.round(size * scale));
71
+ const snapped = (Math.round(base * scale) + steps * Math.round(size * scale)) / scale;
72
+ return clamp(round(snapped, decimalsOf(size)), min, max);
73
+ }
@@ -0,0 +1,32 @@
1
+ export type NumberSize = "sm" | "md" | "lg";
2
+
3
+ /** Plain number, an amount of money, or a percentage. */
4
+ export type NumberStyle = "decimal" | "currency" | "percent";
5
+
6
+ /** When a value outside `min`/`max` is pulled back into range. */
7
+ export type ClampBehavior = "blur" | "strict" | "none";
8
+
9
+ export interface NumberFormatOptions {
10
+ /** BCP 47 tag. Left out, the runtime's own locale is used. */
11
+ locale?: string;
12
+ /** Default `decimal`. */
13
+ style?: NumberStyle;
14
+ /** ISO 4217 code, required by `style="currency"`. */
15
+ currency?: string;
16
+ /** Exact number of fraction digits to show, and to round to when committing. */
17
+ decimals?: number;
18
+ /** Thousands separators. Default true. */
19
+ useGrouping?: boolean;
20
+ }
21
+
22
+ export interface StepOptions {
23
+ step: number;
24
+ min?: number;
25
+ max?: number;
26
+ }
27
+
28
+ export interface NumberInputLocaleText {
29
+ increment: string;
30
+ decrement: string;
31
+ clear: string;
32
+ }
@@ -0,0 +1,5 @@
1
+ export { NumberInput } from "./react/NumberInput";
2
+ export type { NumberInputProps } from "./react/NumberInput";
3
+ export { defaultNumberInputText } from "./react/locale";
4
+ export type { NumberInputSlot } from "./react/props";
5
+ export * from "./core";