gbs-add-block 1.2.19 → 2.0.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 (57) hide show
  1. package/README.md +4 -4
  2. package/index.cjs +25 -9
  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/progress/README.md +38 -0
  48. package/source/beta-components/progress/__tests__/core.test.ts +56 -0
  49. package/source/beta-components/progress/core/index.ts +2 -0
  50. package/source/beta-components/progress/core/progress.ts +40 -0
  51. package/source/beta-components/progress/core/types.ts +23 -0
  52. package/source/beta-components/progress/index.ts +7 -0
  53. package/source/beta-components/progress/react/CircularProgress.tsx +106 -0
  54. package/source/beta-components/progress/react/Progress.tsx +113 -0
  55. package/source/beta-components/progress/react/locale.ts +7 -0
  56. package/source/beta-components/progress/react/props.ts +6 -0
  57. package/source/beta-components/progress/styles.css +202 -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,38 @@
1
+ # Progress and CircularProgress
2
+
3
+ A bar, or a ring, for something with a known end: an upload, an import, a
4
+ quota. For React 19, styled to match the rest of the library. No runtime
5
+ dependencies besides React.
6
+
7
+ ```ts
8
+ import { CircularProgress, Progress } from "@/components/progress";
9
+ import "@/components/progress/styles.css";
10
+ ```
11
+
12
+ ```tsx
13
+ <Progress label="Uploading report.pdf" value={62} showValue />
14
+ <Progress value={null} label="Preparing export" /> {/* length unknown */}
15
+ <Progress value={92} max={100} variant="warning" label="Storage" size="sm" />
16
+
17
+ <CircularProgress value={62} size={56} showValue />
18
+ ```
19
+
20
+ **Progress:** `value` (`number | null`), `max`, `label`, `showValue`,
21
+ `valueText`, `variant` (`accent` `success` `warning` `danger`), `size` (`sm`
22
+ `md` `lg`), `classNames` (`root` `header` `label` `value` `track` `bar`),
23
+ `localeText`, `ref`, and every `<div>` attribute.
24
+
25
+ **CircularProgress:** the same value props, plus `size` (pixels), `thickness`,
26
+ `showValue` or `children` for the middle.
27
+
28
+ `value={null}` means the length is not known yet, which is a different thing
29
+ from zero: the bar sweeps, and `aria-valuenow` is left off entirely, which is
30
+ what makes a screen reader say "busy" instead of reading out a zero.
31
+
32
+ Use a **Spinner** when there is nothing to measure at all. A progress bar that
33
+ spends its life indeterminate is a spinner drawn as a promise it cannot keep.
34
+
35
+ `describeProgress` and `stepFraction` are exported from the framework-free
36
+ `core`. `describeProgress` is where a `max` of zero stops being a division by
37
+ zero, and where the drawn fraction stays unrounded so a 99.6% bar does not
38
+ reach the end early.
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { describeProgress, stepFraction } from "../core/progress";
3
+
4
+ describe("describeProgress", () => {
5
+ it("measures a value against its max", () => {
6
+ expect(describeProgress(25)).toMatchObject({ fraction: 0.25, percent: 25, value: 25 });
7
+ expect(describeProgress(3, 8)).toMatchObject({ fraction: 0.375, percent: 38 });
8
+ });
9
+
10
+ it("treats no value as unknown, not as zero", () => {
11
+ // The difference decides whether a screen reader says "busy" or "0 percent".
12
+ for (const value of [null, undefined, Number.NaN]) {
13
+ expect(describeProgress(value)).toEqual({
14
+ indeterminate: true,
15
+ value: null,
16
+ fraction: 0,
17
+ percent: 0,
18
+ });
19
+ }
20
+ });
21
+
22
+ it("clamps into range rather than drawing past the end", () => {
23
+ expect(describeProgress(140).percent).toBe(100);
24
+ expect(describeProgress(-20).percent).toBe(0);
25
+ });
26
+
27
+ it("survives a task with nothing in it", () => {
28
+ // 0 of 0 files: without the guard this is NaN% and an empty bar drawn at
29
+ // Infinity.
30
+ const state = describeProgress(0, 0);
31
+ expect(state.fraction).toBe(0);
32
+ expect(Number.isNaN(state.percent)).toBe(false);
33
+ });
34
+
35
+ it("keeps the drawn fraction unrounded", () => {
36
+ // 99.6% must not snap the bar to full while bytes are still moving.
37
+ const state = describeProgress(99.6);
38
+ expect(state.percent).toBe(100);
39
+ expect(state.fraction).toBeCloseTo(0.996);
40
+ });
41
+ });
42
+
43
+ describe("stepFraction", () => {
44
+ it("counts a step as done, not as started", () => {
45
+ // "Step 2 of 5" is two fifths finished.
46
+ expect(stepFraction(2, 5)).toBe(0.4);
47
+ expect(stepFraction(0, 5)).toBe(0);
48
+ expect(stepFraction(5, 5)).toBe(1);
49
+ });
50
+
51
+ it("stays inside the wizard", () => {
52
+ expect(stepFraction(9, 5)).toBe(1);
53
+ expect(stepFraction(-1, 5)).toBe(0);
54
+ expect(stepFraction(1, 0)).toBe(0);
55
+ });
56
+ });
@@ -0,0 +1,2 @@
1
+ export { describeProgress, stepFraction } from "./progress";
2
+ export type * from "./types";
@@ -0,0 +1,40 @@
1
+ import type { ProgressState } from "./types";
2
+
3
+ /**
4
+ * What a progress bar knows about itself.
5
+ *
6
+ * Kept apart from the component because the two interesting cases are easy to
7
+ * get wrong and invisible when you do: a value of `null` is *unknown*, not
8
+ * zero, and a `max` of zero is a task with nothing in it, which must not become
9
+ * a division by zero shown as `NaN%`.
10
+ */
11
+ export function describeProgress(
12
+ value: number | null | undefined,
13
+ max = 100,
14
+ ): ProgressState {
15
+ if (value === null || value === undefined || !Number.isFinite(value)) {
16
+ return { indeterminate: true, value: null, fraction: 0, percent: 0 };
17
+ }
18
+ const top = Number.isFinite(max) && max > 0 ? max : 0;
19
+ const clamped = Math.min(Math.max(value, 0), top || value);
20
+ const fraction = top === 0 ? 0 : clamped / top;
21
+ return {
22
+ indeterminate: false,
23
+ value: clamped,
24
+ fraction,
25
+ // Rounded for display only; the bar itself is drawn from `fraction`, so a
26
+ // 99.6% bar does not snap to full while the last bytes are still moving.
27
+ percent: Math.round(fraction * 100),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * A step counter's fraction: "step 2 of 5" is 40%, not 20%.
33
+ *
34
+ * Off-by-one here is the classic wizard bug — the first step showing an empty
35
+ * bar, or the last showing one still short of full.
36
+ */
37
+ export function stepFraction(step: number, total: number): number {
38
+ if (!Number.isFinite(step) || !Number.isFinite(total) || total <= 0) return 0;
39
+ return Math.min(Math.max(step, 0), total) / total;
40
+ }
@@ -0,0 +1,23 @@
1
+ export type ProgressVariant = "accent" | "success" | "warning" | "danger";
2
+
3
+ export type ProgressSize = "sm" | "md" | "lg";
4
+
5
+ export interface ProgressState {
6
+ /** The task is running but its length is unknown. */
7
+ indeterminate: boolean;
8
+ /** Clamped into range, or null when indeterminate. */
9
+ value: number | null;
10
+ /** 0 to 1, for drawing. */
11
+ fraction: number;
12
+ /** 0 to 100, rounded, for reading. */
13
+ percent: number;
14
+ }
15
+
16
+ export interface ProgressLocaleText {
17
+ /** The accessible name when none is given. */
18
+ label: string;
19
+ /** What a screen reader reads instead of the raw number. */
20
+ valueText(percent: number): string;
21
+ /** Announced while the length is unknown. */
22
+ working: string;
23
+ }
@@ -0,0 +1,7 @@
1
+ export { Progress } from "./react/Progress";
2
+ export type { ProgressProps } from "./react/Progress";
3
+ export { CircularProgress } from "./react/CircularProgress";
4
+ export type { CircularProgressProps } from "./react/CircularProgress";
5
+ export { defaultProgressText } from "./react/locale";
6
+ export type { CircularProgressSlot, ProgressSlot } from "./react/props";
7
+ export * from "./core";