gbs-add-block 1.2.18 → 1.3.0

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 (95) hide show
  1. package/README.md +2 -2
  2. package/index.cjs +28 -7
  3. package/package.json +1 -1
  4. package/source/beta-components/accordion/README.md +45 -0
  5. package/source/beta-components/accordion/__tests__/core.test.ts +58 -0
  6. package/source/beta-components/accordion/core/index.ts +2 -0
  7. package/source/beta-components/accordion/core/open.ts +56 -0
  8. package/source/beta-components/accordion/core/types.ts +17 -0
  9. package/source/beta-components/accordion/index.ts +8 -0
  10. package/source/beta-components/accordion/react/Accordion.tsx +133 -0
  11. package/source/beta-components/accordion/react/AccordionItem.tsx +120 -0
  12. package/source/beta-components/accordion/react/context.ts +20 -0
  13. package/source/beta-components/accordion/react/props.ts +13 -0
  14. package/source/beta-components/accordion/styles.css +208 -0
  15. package/source/beta-components/alert/README.md +39 -0
  16. package/source/beta-components/alert/__tests__/core.test.ts +17 -0
  17. package/source/beta-components/alert/core/index.ts +2 -0
  18. package/source/beta-components/alert/core/live.ts +25 -0
  19. package/source/beta-components/alert/core/types.ts +7 -0
  20. package/source/beta-components/alert/index.ts +5 -0
  21. package/source/beta-components/alert/react/Alert.tsx +113 -0
  22. package/source/beta-components/alert/react/icons.tsx +21 -0
  23. package/source/beta-components/alert/react/locale.ts +5 -0
  24. package/source/beta-components/alert/react/props.ts +11 -0
  25. package/source/beta-components/alert/styles.css +155 -0
  26. package/source/beta-components/avatar/__tests__/core.test.ts +88 -0
  27. package/source/beta-components/avatar/core/identity.ts +103 -0
  28. package/source/beta-components/avatar/core/index.ts +2 -0
  29. package/source/beta-components/avatar/core/types.ts +12 -0
  30. package/source/beta-components/avatar/index.ts +7 -0
  31. package/source/beta-components/avatar/react/Avatar.tsx +118 -0
  32. package/source/beta-components/avatar/react/AvatarGroup.tsx +74 -0
  33. package/source/beta-components/avatar/react/locale.ts +11 -0
  34. package/source/beta-components/avatar/react/props.ts +6 -0
  35. package/source/beta-components/avatar/styles.css +227 -0
  36. package/source/beta-components/badge/README.md +37 -0
  37. package/source/beta-components/badge/__tests__/core.test.ts +46 -0
  38. package/source/beta-components/badge/core/count.ts +37 -0
  39. package/source/beta-components/badge/core/index.ts +2 -0
  40. package/source/beta-components/badge/core/types.ts +11 -0
  41. package/source/beta-components/badge/index.ts +7 -0
  42. package/source/beta-components/badge/react/Badge.tsx +89 -0
  43. package/source/beta-components/badge/react/Tag.tsx +82 -0
  44. package/source/beta-components/badge/react/locale.ts +5 -0
  45. package/source/beta-components/badge/react/props.ts +6 -0
  46. package/source/beta-components/badge/styles.css +204 -0
  47. package/source/beta-components/number-input/README.md +53 -0
  48. package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
  49. package/source/beta-components/number-input/core/format.ts +37 -0
  50. package/source/beta-components/number-input/core/index.ts +4 -0
  51. package/source/beta-components/number-input/core/parse.ts +118 -0
  52. package/source/beta-components/number-input/core/step.ts +73 -0
  53. package/source/beta-components/number-input/core/types.ts +32 -0
  54. package/source/beta-components/number-input/index.ts +5 -0
  55. package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
  56. package/source/beta-components/number-input/react/icons.tsx +17 -0
  57. package/source/beta-components/number-input/react/locale.ts +7 -0
  58. package/source/beta-components/number-input/react/props.ts +11 -0
  59. package/source/beta-components/number-input/styles.css +231 -0
  60. package/source/beta-components/progress/README.md +38 -0
  61. package/source/beta-components/progress/__tests__/core.test.ts +56 -0
  62. package/source/beta-components/progress/core/index.ts +2 -0
  63. package/source/beta-components/progress/core/progress.ts +40 -0
  64. package/source/beta-components/progress/core/types.ts +23 -0
  65. package/source/beta-components/progress/index.ts +7 -0
  66. package/source/beta-components/progress/react/CircularProgress.tsx +106 -0
  67. package/source/beta-components/progress/react/Progress.tsx +113 -0
  68. package/source/beta-components/progress/react/locale.ts +7 -0
  69. package/source/beta-components/progress/react/props.ts +6 -0
  70. package/source/beta-components/progress/styles.css +202 -0
  71. package/source/beta-components/radio-group/README.md +58 -0
  72. package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
  73. package/source/beta-components/radio-group/core/group.ts +36 -0
  74. package/source/beta-components/radio-group/core/index.ts +2 -0
  75. package/source/beta-components/radio-group/core/types.ts +20 -0
  76. package/source/beta-components/radio-group/index.ts +9 -0
  77. package/source/beta-components/radio-group/react/Radio.tsx +144 -0
  78. package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
  79. package/source/beta-components/radio-group/react/context.ts +18 -0
  80. package/source/beta-components/radio-group/react/locale.ts +5 -0
  81. package/source/beta-components/radio-group/react/props.ts +12 -0
  82. package/source/beta-components/radio-group/styles.css +270 -0
  83. package/source/beta-components/shared/__tests__/position.test.ts +143 -170
  84. package/source/beta-components/shared/core/position.ts +2 -15
  85. package/source/beta-components/shared/react/Popover.tsx +5 -18
  86. package/source/beta-components/switch/README.md +46 -0
  87. package/source/beta-components/switch/__tests__/core.test.ts +64 -0
  88. package/source/beta-components/switch/core/index.ts +2 -0
  89. package/source/beta-components/switch/core/toggle.ts +54 -0
  90. package/source/beta-components/switch/core/types.ts +9 -0
  91. package/source/beta-components/switch/index.ts +5 -0
  92. package/source/beta-components/switch/react/Switch.tsx +219 -0
  93. package/source/beta-components/switch/react/locale.ts +5 -0
  94. package/source/beta-components/switch/react/props.ts +12 -0
  95. package/source/beta-components/switch/styles.css +229 -0
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatCount, showCount } from "../core/count";
3
+
4
+ describe("formatCount", () => {
5
+ it("prints the number while it fits", () => {
6
+ expect(formatCount(0)).toEqual({ text: "0" });
7
+ expect(formatCount(99)).toEqual({ text: "99" });
8
+ });
9
+
10
+ it("caps it, and says aloud what the cap means", () => {
11
+ // "99+" read out as "ninety-nine plus" is a guess at a number.
12
+ expect(formatCount(100)).toEqual({ text: "99+", spoken: "more than 99" });
13
+ expect(formatCount(4821, 999)).toEqual({ text: "999+", spoken: "more than 999" });
14
+ });
15
+
16
+ it("takes a formatter, for grouped or localized numbers", () => {
17
+ const format = new Intl.NumberFormat("de-DE").format;
18
+ expect(formatCount(1234, 9999, format).text).toBe("1.234");
19
+ expect(formatCount(20000, 9999, format)).toEqual({
20
+ text: "9.999+",
21
+ spoken: "more than 9.999",
22
+ });
23
+ });
24
+
25
+ it("drops the fraction of a number that should not have one", () => {
26
+ expect(formatCount(3.7).text).toBe("3");
27
+ });
28
+
29
+ it("has nothing to print for a number that is not one", () => {
30
+ expect(formatCount(Number.NaN).text).toBe("");
31
+ expect(formatCount(Number.POSITIVE_INFINITY).text).toBe("");
32
+ });
33
+ });
34
+
35
+ describe("showCount", () => {
36
+ it("leaves a zero off the page unless it is asked for", () => {
37
+ expect(showCount(0)).toBe(false);
38
+ expect(showCount(0, true)).toBe(true);
39
+ expect(showCount(1)).toBe(true);
40
+ });
41
+
42
+ it("never shows a negative or a non-number", () => {
43
+ expect(showCount(-1)).toBe(false);
44
+ expect(showCount(Number.NaN, true)).toBe(false);
45
+ });
46
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The label a counter shows, and the one a screen reader hears.
3
+ *
4
+ * A badge is small, so a number that keeps growing has to stop somewhere —
5
+ * but "99+" read aloud as "ninety-nine plus" is a guess, not a count, which is
6
+ * why the two strings are computed together and the spoken one says what it
7
+ * means.
8
+ */
9
+ export interface CountLabel {
10
+ /** What is printed in the badge. */
11
+ text: string;
12
+ /** What assistive technology reads instead, when the two differ. */
13
+ spoken?: string;
14
+ }
15
+
16
+ export function formatCount(
17
+ count: number,
18
+ max = 99,
19
+ format?: (value: number) => string,
20
+ ): CountLabel {
21
+ const show = (value: number) => (format ? format(value) : String(value));
22
+ if (!Number.isFinite(count)) return { text: "" };
23
+ const whole = Math.trunc(count);
24
+ if (whole <= max) return { text: show(whole) };
25
+ return { text: `${show(max)}+`, spoken: `more than ${show(max)}` };
26
+ }
27
+
28
+ /**
29
+ * Whether a counter should be on the page at all.
30
+ *
31
+ * Zero is the interesting case: a badge reading "0" is noise on a page and a
32
+ * spurious announcement in a screen reader, so it is left off unless the caller
33
+ * asks for it — but a caller sometimes does, when the number sits in a table of
34
+ * numbers and a gap would read as missing data.
35
+ */
36
+ export const showCount = (count: number, showZero = false): boolean =>
37
+ Number.isFinite(count) && (count > 0 || (showZero && count === 0));
@@ -0,0 +1,2 @@
1
+ export { formatCount, showCount, type CountLabel } from "./count";
2
+ export type * from "./types";
@@ -0,0 +1,11 @@
1
+ export type BadgeVariant = "neutral" | "accent" | "success" | "warning" | "danger" | "info";
2
+
3
+ /** `soft` is a tint, `solid` a filled block, `outline` a border only. */
4
+ export type BadgeAppearance = "soft" | "solid" | "outline";
5
+
6
+ export type BadgeSize = "sm" | "md";
7
+
8
+ export interface BadgeLocaleText {
9
+ /** The remove button on a Tag. `label` is the tag's own text. */
10
+ remove(label: string): string;
11
+ }
@@ -0,0 +1,7 @@
1
+ export { Badge } from "./react/Badge";
2
+ export type { BadgeProps } from "./react/Badge";
3
+ export { Tag } from "./react/Tag";
4
+ export type { TagProps } from "./react/Tag";
5
+ export { defaultBadgeText } from "./react/locale";
6
+ export type { BadgeSlot, TagSlot } from "./react/props";
7
+ export * from "./core";
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, ReactNode, Ref } from "react";
4
+ import { formatCount, showCount } from "../core/count";
5
+ import type { BadgeAppearance, BadgeSize, BadgeVariant } from "../core/types";
6
+ import { cx, type BadgeSlot } from "./props";
7
+
8
+ export interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {
9
+ children?: ReactNode;
10
+ /** Default `neutral`. */
11
+ variant?: BadgeVariant;
12
+ /** Default `soft`. */
13
+ appearance?: BadgeAppearance;
14
+ /** Default `md`. */
15
+ size?: BadgeSize;
16
+ /** A number instead of children. Above `max` it reads "99+". */
17
+ count?: number;
18
+ /** Where the count stops. Default 99. */
19
+ max?: number;
20
+ /** Show a count of zero. Default false: an empty counter is noise. */
21
+ showZero?: boolean;
22
+ /** Format the number, e.g. with `Intl.NumberFormat`. */
23
+ formatValue?(value: number): string;
24
+ /** A small filled circle before the label, for a status. */
25
+ dot?: boolean;
26
+ icon?: ReactNode;
27
+ classNames?: Partial<Record<BadgeSlot, string>>;
28
+ ref?: Ref<HTMLSpanElement>;
29
+ }
30
+
31
+ /**
32
+ * A short, static label: a status, a category, a count.
33
+ *
34
+ * It is text, not a control — nothing here is clickable or removable. Use
35
+ * **Tag** for something the user can take off, and a **Button** for something
36
+ * they can press.
37
+ */
38
+ export function Badge(props: BadgeProps) {
39
+ const {
40
+ ref,
41
+ children,
42
+ variant = "neutral",
43
+ appearance = "soft",
44
+ size = "md",
45
+ count,
46
+ max = 99,
47
+ showZero = false,
48
+ formatValue,
49
+ dot = false,
50
+ icon,
51
+ className,
52
+ classNames,
53
+ ...rest
54
+ } = props;
55
+
56
+ const counted = count !== undefined;
57
+ if (counted && !showCount(count, showZero)) return null;
58
+ const label = counted ? formatCount(count, max, formatValue) : null;
59
+
60
+ return (
61
+ <span
62
+ {...rest}
63
+ ref={ref}
64
+ className={cx("bd-root", classNames?.root, className)}
65
+ data-variant={variant}
66
+ data-appearance={appearance}
67
+ data-size={size}
68
+ data-count={counted || undefined}
69
+ >
70
+ {dot && <span className={cx("bd-dot", classNames?.dot)} aria-hidden="true" />}
71
+ {icon && (
72
+ <span className={cx("bd-icon", classNames?.icon)} aria-hidden="true">
73
+ {icon}
74
+ </span>
75
+ )}
76
+ <span className={cx("bd-label", classNames?.label)}>
77
+ {label ? (
78
+ <>
79
+ <span aria-hidden={label.spoken ? "true" : undefined}>{label.text}</span>
80
+ {/* "99+" read aloud is a guess at a number; this says what it means. */}
81
+ {label.spoken && <span className="bd-sr-only">{label.spoken}</span>}
82
+ </>
83
+ ) : (
84
+ children
85
+ )}
86
+ </span>
87
+ </span>
88
+ );
89
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useMemo, type HTMLAttributes, type ReactNode, type Ref } from "react";
4
+ import { XIcon } from "../../shared/react/icons";
5
+ import type { BadgeAppearance, BadgeLocaleText, BadgeSize, BadgeVariant } from "../core/types";
6
+ import { defaultBadgeText } from "./locale";
7
+ import { cx, type TagSlot } from "./props";
8
+
9
+ export interface TagProps extends Omit<HTMLAttributes<HTMLSpanElement>, "onRemove"> {
10
+ children?: ReactNode;
11
+ variant?: BadgeVariant;
12
+ appearance?: BadgeAppearance;
13
+ size?: BadgeSize;
14
+ icon?: ReactNode;
15
+ /** Show a remove button. Without it a Tag is a Badge with a bit more padding. */
16
+ onRemove?(): void;
17
+ /** Names the remove button when the tag's own text is not a plain string. */
18
+ label?: string;
19
+ disabled?: boolean;
20
+ classNames?: Partial<Record<TagSlot, string>>;
21
+ localeText?: Partial<BadgeLocaleText>;
22
+ ref?: Ref<HTMLSpanElement>;
23
+ }
24
+
25
+ /**
26
+ * A chip the user can take off: a filter, a recipient, a selected value.
27
+ *
28
+ * The remove button carries the tag's own text in its name, so a screen reader
29
+ * says "Remove Berlin" rather than announcing a row of identical "Remove"
30
+ * buttons with no way to tell them apart.
31
+ */
32
+ export function Tag(props: TagProps) {
33
+ const {
34
+ ref,
35
+ children,
36
+ variant = "neutral",
37
+ appearance = "soft",
38
+ size = "md",
39
+ icon,
40
+ onRemove,
41
+ label,
42
+ disabled = false,
43
+ className,
44
+ classNames,
45
+ localeText,
46
+ ...rest
47
+ } = props;
48
+
49
+ const text = useMemo(() => ({ ...defaultBadgeText, ...localeText }), [localeText]);
50
+ const name = label ?? (typeof children === "string" ? children : "");
51
+
52
+ return (
53
+ <span
54
+ {...rest}
55
+ ref={ref}
56
+ className={cx("bd-tag", classNames?.root, className)}
57
+ data-variant={variant}
58
+ data-appearance={appearance}
59
+ data-size={size}
60
+ data-disabled={disabled || undefined}
61
+ >
62
+ {icon && (
63
+ <span className={cx("bd-icon", classNames?.icon)} aria-hidden="true">
64
+ {icon}
65
+ </span>
66
+ )}
67
+ <span className={cx("bd-label", classNames?.label)}>{children}</span>
68
+ {onRemove && (
69
+ <button
70
+ type="button"
71
+ className={cx("bd-remove", classNames?.remove)}
72
+ aria-label={text.remove(name)}
73
+ title={text.remove(name)}
74
+ disabled={disabled}
75
+ onClick={onRemove}
76
+ >
77
+ <XIcon width={12} height={12} />
78
+ </button>
79
+ )}
80
+ </span>
81
+ );
82
+ }
@@ -0,0 +1,5 @@
1
+ import type { BadgeLocaleText } from "../core/types";
2
+
3
+ export const defaultBadgeText: BadgeLocaleText = {
4
+ remove: (label: string) => `Remove ${label}`,
5
+ };
@@ -0,0 +1,6 @@
1
+ export type BadgeSlot = "root" | "dot" | "icon" | "label";
2
+
3
+ export type TagSlot = "root" | "icon" | "label" | "remove";
4
+
5
+ // Shared with every other component, so class handling cannot drift.
6
+ export { cx } from "../../shared/core/cx";
@@ -0,0 +1,204 @@
1
+ /*
2
+ * Badge and Tag styles.
3
+ *
4
+ * One tinted pill in six variants and three appearances; the variant only ever
5
+ * sets --bd-accent, so adding one is a single rule. Tokens read the shared
6
+ * --gbs-* variables when they are set on an ancestor (set them on :root to
7
+ * theme every component at once). Rules live in the `components` layer, so
8
+ * utility classes passed through `className` / `classNames` override them.
9
+ */
10
+ @layer theme, base, components, utilities;
11
+
12
+ @layer components {
13
+ .bd-root,
14
+ .bd-tag {
15
+ --bd-fg: var(--gbs-fg, var(--dg-fg, light-dark(#18181b, #f4f4f5)));
16
+ --bd-muted: var(--gbs-muted, var(--dg-muted, light-dark(#71717a, #a1a1aa)));
17
+ --bd-bg: var(--gbs-bg, var(--dg-bg, light-dark(#ffffff, #0b0b0e)));
18
+ --bd-border: var(--gbs-border, var(--dg-border, light-dark(#e4e4e7, #27272a)));
19
+ --bd-on-accent: var(--gbs-accent-fg, var(--dg-accent-fg, light-dark(#ffffff, #0b1220)));
20
+ --bd-focus: var(--gbs-focus, var(--dg-focus, light-dark(#2563eb, #60a5fa)));
21
+ --bd-accent-color: var(--gbs-accent, var(--dg-accent, light-dark(#2563eb, #60a5fa)));
22
+ --bd-success: var(--gbs-success, light-dark(#15803d, #4ade80));
23
+ --bd-warning: var(--gbs-warning, light-dark(#b45309, #fbbf24));
24
+ --bd-danger: var(--gbs-danger, var(--dg-danger, light-dark(#dc2626, #f87171)));
25
+ --bd-info: var(--gbs-info, var(--bd-accent-color));
26
+ --bd-font-size: calc(var(--gbs-font-size, var(--dg-font-size, 13px)) - 1px);
27
+ --bd-radius: 999px;
28
+ --bd-height: 20px;
29
+ --bd-px: 8px;
30
+ /* Neutral is the text colour rather than a hue, so it reads as "no state". */
31
+ --bd-accent: var(--bd-fg);
32
+
33
+ color-scheme: inherit;
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: 4px;
37
+ max-width: 100%;
38
+ height: var(--bd-height);
39
+ padding: 0 var(--bd-px);
40
+ border: 1px solid transparent;
41
+ border-radius: var(--bd-radius);
42
+ font-size: var(--bd-font-size);
43
+ font-weight: 500;
44
+ line-height: 1;
45
+ white-space: nowrap;
46
+ vertical-align: middle;
47
+ }
48
+
49
+ :where(.dark, [data-theme="dark"]) :is(.bd-root, .bd-tag) {
50
+ color-scheme: dark;
51
+ }
52
+ :where(.light, [data-theme="light"]) :is(.bd-root, .bd-tag) {
53
+ color-scheme: light;
54
+ }
55
+
56
+ :where(.bd-root, .bd-tag) *,
57
+ :where(.bd-root, .bd-tag) *::before,
58
+ :where(.bd-root, .bd-tag) *::after {
59
+ box-sizing: border-box;
60
+ }
61
+
62
+ .bd-root[data-variant="accent"],
63
+ .bd-tag[data-variant="accent"] {
64
+ --bd-accent: var(--bd-accent-color);
65
+ }
66
+ .bd-root[data-variant="success"],
67
+ .bd-tag[data-variant="success"] {
68
+ --bd-accent: var(--bd-success);
69
+ }
70
+ .bd-root[data-variant="warning"],
71
+ .bd-tag[data-variant="warning"] {
72
+ --bd-accent: var(--bd-warning);
73
+ }
74
+ .bd-root[data-variant="danger"],
75
+ .bd-tag[data-variant="danger"] {
76
+ --bd-accent: var(--bd-danger);
77
+ }
78
+ .bd-root[data-variant="info"],
79
+ .bd-tag[data-variant="info"] {
80
+ --bd-accent: var(--bd-info);
81
+ }
82
+
83
+ /* --------------------------------------------------------- appearances */
84
+
85
+ .bd-root[data-appearance="soft"],
86
+ .bd-tag[data-appearance="soft"] {
87
+ background: color-mix(in oklab, var(--bd-accent) 12%, var(--bd-bg));
88
+ color: color-mix(in oklab, var(--bd-accent) 85%, var(--bd-fg));
89
+ }
90
+ .bd-root[data-appearance="solid"],
91
+ .bd-tag[data-appearance="solid"] {
92
+ background: var(--bd-accent);
93
+ color: var(--bd-on-accent);
94
+ }
95
+ /* Neutral solid would be black on near-black text; invert it instead. */
96
+ .bd-root[data-variant="neutral"][data-appearance="solid"],
97
+ .bd-tag[data-variant="neutral"][data-appearance="solid"] {
98
+ background: var(--bd-fg);
99
+ color: var(--bd-bg);
100
+ }
101
+ .bd-root[data-appearance="outline"],
102
+ .bd-tag[data-appearance="outline"] {
103
+ border-color: color-mix(in oklab, var(--bd-accent) 40%, var(--bd-border));
104
+ color: color-mix(in oklab, var(--bd-accent) 85%, var(--bd-fg));
105
+ }
106
+
107
+ .bd-root[data-size="sm"],
108
+ .bd-tag[data-size="sm"] {
109
+ --bd-height: 17px;
110
+ --bd-px: 6px;
111
+ --bd-font-size: 11px;
112
+ }
113
+
114
+ /* A counter is a circle until it needs to be a pill. */
115
+ .bd-root[data-count] {
116
+ justify-content: center;
117
+ min-width: var(--bd-height);
118
+ padding: 0 5px;
119
+ font-variant-numeric: tabular-nums;
120
+ }
121
+
122
+ .bd-dot {
123
+ width: 6px;
124
+ height: 6px;
125
+ border-radius: 50%;
126
+ background: currentColor;
127
+ }
128
+ .bd-icon {
129
+ display: inline-flex;
130
+ flex: none;
131
+ }
132
+ .bd-icon > svg {
133
+ width: 12px;
134
+ height: 12px;
135
+ }
136
+ .bd-label {
137
+ overflow: hidden;
138
+ text-overflow: ellipsis;
139
+ }
140
+
141
+ /* ------------------------------------------------------------------ tag */
142
+
143
+ .bd-tag {
144
+ --bd-px: 8px;
145
+ padding-inline-end: 4px;
146
+ }
147
+ .bd-tag:not(:has(.bd-remove)) {
148
+ padding-inline-end: var(--bd-px);
149
+ }
150
+
151
+ .bd-remove {
152
+ display: inline-flex;
153
+ flex: none;
154
+ align-items: center;
155
+ justify-content: center;
156
+ width: 16px;
157
+ height: 16px;
158
+ padding: 0;
159
+ border: 0;
160
+ border-radius: 50%;
161
+ background: none;
162
+ color: inherit;
163
+ opacity: 0.7;
164
+ cursor: pointer;
165
+ }
166
+ .bd-remove:hover:not(:disabled) {
167
+ background: color-mix(in oklab, currentColor 18%, transparent);
168
+ opacity: 1;
169
+ }
170
+ .bd-remove:focus-visible {
171
+ outline: 2px solid var(--bd-focus);
172
+ outline-offset: 1px;
173
+ }
174
+ .bd-remove:disabled {
175
+ cursor: not-allowed;
176
+ }
177
+ .bd-tag[data-disabled] {
178
+ opacity: 0.6;
179
+ }
180
+
181
+ .bd-sr-only {
182
+ position: absolute;
183
+ width: 1px;
184
+ height: 1px;
185
+ margin: -1px;
186
+ padding: 0;
187
+ border: 0;
188
+ overflow: hidden;
189
+ clip-path: inset(50%);
190
+ white-space: nowrap;
191
+ }
192
+
193
+ @media (forced-colors: active) {
194
+ .bd-root,
195
+ .bd-tag {
196
+ border-color: CanvasText;
197
+ }
198
+ .bd-root[data-appearance="solid"],
199
+ .bd-tag[data-appearance="solid"] {
200
+ background: Highlight;
201
+ color: HighlightText;
202
+ }
203
+ }
204
+ }
@@ -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.