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.
- package/README.md +2 -2
- package/index.cjs +28 -7
- package/package.json +1 -1
- package/source/beta-components/accordion/README.md +45 -0
- package/source/beta-components/accordion/__tests__/core.test.ts +58 -0
- package/source/beta-components/accordion/core/index.ts +2 -0
- package/source/beta-components/accordion/core/open.ts +56 -0
- package/source/beta-components/accordion/core/types.ts +17 -0
- package/source/beta-components/accordion/index.ts +8 -0
- package/source/beta-components/accordion/react/Accordion.tsx +133 -0
- package/source/beta-components/accordion/react/AccordionItem.tsx +120 -0
- package/source/beta-components/accordion/react/context.ts +20 -0
- package/source/beta-components/accordion/react/props.ts +13 -0
- package/source/beta-components/accordion/styles.css +208 -0
- package/source/beta-components/alert/README.md +39 -0
- package/source/beta-components/alert/__tests__/core.test.ts +17 -0
- package/source/beta-components/alert/core/index.ts +2 -0
- package/source/beta-components/alert/core/live.ts +25 -0
- package/source/beta-components/alert/core/types.ts +7 -0
- package/source/beta-components/alert/index.ts +5 -0
- package/source/beta-components/alert/react/Alert.tsx +113 -0
- package/source/beta-components/alert/react/icons.tsx +21 -0
- package/source/beta-components/alert/react/locale.ts +5 -0
- package/source/beta-components/alert/react/props.ts +11 -0
- package/source/beta-components/alert/styles.css +155 -0
- package/source/beta-components/avatar/__tests__/core.test.ts +88 -0
- package/source/beta-components/avatar/core/identity.ts +103 -0
- package/source/beta-components/avatar/core/index.ts +2 -0
- package/source/beta-components/avatar/core/types.ts +12 -0
- package/source/beta-components/avatar/index.ts +7 -0
- package/source/beta-components/avatar/react/Avatar.tsx +118 -0
- package/source/beta-components/avatar/react/AvatarGroup.tsx +74 -0
- package/source/beta-components/avatar/react/locale.ts +11 -0
- package/source/beta-components/avatar/react/props.ts +6 -0
- package/source/beta-components/avatar/styles.css +227 -0
- package/source/beta-components/badge/README.md +37 -0
- package/source/beta-components/badge/__tests__/core.test.ts +46 -0
- package/source/beta-components/badge/core/count.ts +37 -0
- package/source/beta-components/badge/core/index.ts +2 -0
- package/source/beta-components/badge/core/types.ts +11 -0
- package/source/beta-components/badge/index.ts +7 -0
- package/source/beta-components/badge/react/Badge.tsx +89 -0
- package/source/beta-components/badge/react/Tag.tsx +82 -0
- package/source/beta-components/badge/react/locale.ts +5 -0
- package/source/beta-components/badge/react/props.ts +6 -0
- package/source/beta-components/badge/styles.css +204 -0
- package/source/beta-components/number-input/README.md +53 -0
- package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
- package/source/beta-components/number-input/core/format.ts +37 -0
- package/source/beta-components/number-input/core/index.ts +4 -0
- package/source/beta-components/number-input/core/parse.ts +118 -0
- package/source/beta-components/number-input/core/step.ts +73 -0
- package/source/beta-components/number-input/core/types.ts +32 -0
- package/source/beta-components/number-input/index.ts +5 -0
- package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
- package/source/beta-components/number-input/react/icons.tsx +17 -0
- package/source/beta-components/number-input/react/locale.ts +7 -0
- package/source/beta-components/number-input/react/props.ts +11 -0
- package/source/beta-components/number-input/styles.css +231 -0
- package/source/beta-components/progress/README.md +38 -0
- package/source/beta-components/progress/__tests__/core.test.ts +56 -0
- package/source/beta-components/progress/core/index.ts +2 -0
- package/source/beta-components/progress/core/progress.ts +40 -0
- package/source/beta-components/progress/core/types.ts +23 -0
- package/source/beta-components/progress/index.ts +7 -0
- package/source/beta-components/progress/react/CircularProgress.tsx +106 -0
- package/source/beta-components/progress/react/Progress.tsx +113 -0
- package/source/beta-components/progress/react/locale.ts +7 -0
- package/source/beta-components/progress/react/props.ts +6 -0
- package/source/beta-components/progress/styles.css +202 -0
- package/source/beta-components/radio-group/README.md +58 -0
- package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
- package/source/beta-components/radio-group/core/group.ts +36 -0
- package/source/beta-components/radio-group/core/index.ts +2 -0
- package/source/beta-components/radio-group/core/types.ts +20 -0
- package/source/beta-components/radio-group/index.ts +9 -0
- package/source/beta-components/radio-group/react/Radio.tsx +144 -0
- package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
- package/source/beta-components/radio-group/react/context.ts +18 -0
- package/source/beta-components/radio-group/react/locale.ts +5 -0
- package/source/beta-components/radio-group/react/props.ts +12 -0
- package/source/beta-components/radio-group/styles.css +270 -0
- package/source/beta-components/shared/__tests__/position.test.ts +143 -170
- package/source/beta-components/shared/core/position.ts +2 -15
- package/source/beta-components/shared/react/Popover.tsx +5 -18
- package/source/beta-components/switch/README.md +46 -0
- package/source/beta-components/switch/__tests__/core.test.ts +64 -0
- package/source/beta-components/switch/core/index.ts +2 -0
- package/source/beta-components/switch/core/toggle.ts +54 -0
- package/source/beta-components/switch/core/types.ts +9 -0
- package/source/beta-components/switch/index.ts +5 -0
- package/source/beta-components/switch/react/Switch.tsx +219 -0
- package/source/beta-components/switch/react/locale.ts +5 -0
- package/source/beta-components/switch/react/props.ts +12 -0
- 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,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,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.
|