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,231 @@
1
+ /*
2
+ * NumberInput styles.
3
+ *
4
+ * The field is a text input, so it looks and sizes exactly like the Input
5
+ * component; the spinner is two buttons the stylesheet owns rather than the
6
+ * browser's, which cannot be styled. Tokens read the shared --gbs-* variables
7
+ * when they are set on an ancestor (set them on :root to theme every component
8
+ * at once). Rules live in the `components` layer, so utility classes passed
9
+ * through `className` / `classNames` override them.
10
+ */
11
+ @layer theme, base, components, utilities;
12
+
13
+ @layer components {
14
+ .nm-root {
15
+ --nm-fg: var(--gbs-fg, var(--dg-fg, light-dark(#18181b, #f4f4f5)));
16
+ --nm-muted: var(--gbs-muted, var(--dg-muted, light-dark(#71717a, #a1a1aa)));
17
+ --nm-border: var(--gbs-border, var(--dg-border, light-dark(#e4e4e7, #27272a)));
18
+ --nm-input-bg: var(--gbs-input-bg, var(--dg-input-bg, light-dark(#ffffff, #121216)));
19
+ --nm-readonly-bg: var(--gbs-readonly-bg, light-dark(#fafafa, #0e0e12));
20
+ --nm-hover: var(--gbs-hover, var(--dg-hover, light-dark(#f4f4f5, #1f1f23)));
21
+ --nm-accent: var(--gbs-accent, var(--dg-accent, light-dark(#2563eb, #60a5fa)));
22
+ --nm-focus: var(--gbs-focus, var(--dg-focus, light-dark(#2563eb, #60a5fa)));
23
+ --nm-danger: var(--gbs-danger, var(--dg-danger, light-dark(#dc2626, #f87171)));
24
+ --nm-radius: var(--gbs-radius, var(--dg-radius, 8px));
25
+ --nm-font-size: var(--gbs-font-size, var(--dg-font-size, 13px));
26
+ --nm-height: 36px;
27
+ --nm-px: 10px;
28
+
29
+ color-scheme: inherit;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 4px;
33
+ min-width: 0;
34
+ font-size: var(--nm-font-size);
35
+ line-height: 1.4;
36
+ color: var(--nm-fg);
37
+ }
38
+
39
+ :where(.dark, [data-theme="dark"]) .nm-root {
40
+ color-scheme: dark;
41
+ }
42
+ :where(.light, [data-theme="light"]) .nm-root {
43
+ color-scheme: light;
44
+ }
45
+
46
+ :where(.nm-root) *,
47
+ :where(.nm-root) *::before,
48
+ :where(.nm-root) *::after {
49
+ box-sizing: border-box;
50
+ }
51
+
52
+ .nm-root[data-size="sm"] {
53
+ --nm-height: 30px;
54
+ --nm-px: 8px;
55
+ --nm-font-size: 12px;
56
+ }
57
+ .nm-root[data-size="lg"] {
58
+ --nm-height: 44px;
59
+ --nm-px: 12px;
60
+ --nm-font-size: 14px;
61
+ }
62
+
63
+ .nm-label {
64
+ font-weight: 500;
65
+ }
66
+ .nm-label[data-required]::after {
67
+ content: " *";
68
+ color: var(--nm-danger);
69
+ }
70
+
71
+ /* -------------------------------------------------------------- control */
72
+
73
+ .nm-control {
74
+ display: flex;
75
+ align-items: stretch;
76
+ gap: 6px;
77
+ min-width: 0;
78
+ height: var(--nm-height);
79
+ padding-inline-start: var(--nm-px);
80
+ border: 1px solid var(--nm-border);
81
+ border-radius: var(--nm-radius);
82
+ background: var(--nm-input-bg);
83
+ transition:
84
+ border-color 120ms ease,
85
+ box-shadow 120ms ease;
86
+ }
87
+ .nm-control:hover:not([data-disabled]) {
88
+ border-color: color-mix(in oklab, var(--nm-border) 60%, var(--nm-fg));
89
+ }
90
+ .nm-control:has(.nm-input:focus-visible) {
91
+ border-color: var(--nm-focus);
92
+ box-shadow: 0 0 0 1px var(--nm-focus);
93
+ }
94
+ .nm-control[data-invalid] {
95
+ border-color: var(--nm-danger);
96
+ }
97
+ .nm-control[data-invalid]:has(.nm-input:focus-visible) {
98
+ box-shadow: 0 0 0 1px var(--nm-danger);
99
+ }
100
+ .nm-control[data-disabled] {
101
+ opacity: 0.6;
102
+ cursor: not-allowed;
103
+ }
104
+ .nm-control[data-readonly] {
105
+ background: var(--nm-readonly-bg);
106
+ }
107
+
108
+ .nm-input {
109
+ flex: 1 1 auto;
110
+ min-width: 0;
111
+ padding: 0;
112
+ padding-inline-end: var(--nm-px);
113
+ border: 0;
114
+ background: none;
115
+ color: inherit;
116
+ font: inherit;
117
+ /* Figures of the same width, so a column of amounts lines up and the
118
+ number does not jitter as it is typed. */
119
+ font-variant-numeric: tabular-nums;
120
+ outline: none;
121
+ }
122
+ .nm-input::placeholder {
123
+ color: var(--nm-muted);
124
+ }
125
+ .nm-input:disabled {
126
+ cursor: not-allowed;
127
+ }
128
+
129
+ .nm-adornment {
130
+ display: inline-flex;
131
+ flex: none;
132
+ align-items: center;
133
+ color: var(--nm-muted);
134
+ white-space: nowrap;
135
+ }
136
+ .nm-adornment[data-side="trailing"] {
137
+ padding-inline-end: 2px;
138
+ }
139
+
140
+ .nm-icon-button {
141
+ display: inline-flex;
142
+ flex: none;
143
+ align-self: center;
144
+ align-items: center;
145
+ justify-content: center;
146
+ width: 20px;
147
+ height: 20px;
148
+ padding: 0;
149
+ border: 0;
150
+ border-radius: 4px;
151
+ background: none;
152
+ color: var(--nm-muted);
153
+ cursor: pointer;
154
+ }
155
+ .nm-icon-button:hover {
156
+ background: var(--nm-hover);
157
+ color: var(--nm-fg);
158
+ }
159
+
160
+ /* -------------------------------------------------------------- stepper */
161
+
162
+ .nm-stepper {
163
+ display: flex;
164
+ flex: none;
165
+ flex-direction: column;
166
+ width: 22px;
167
+ border-inline-start: 1px solid var(--nm-border);
168
+ }
169
+
170
+ .nm-step {
171
+ display: flex;
172
+ flex: 1 1 50%;
173
+ align-items: center;
174
+ justify-content: center;
175
+ min-height: 0;
176
+ padding: 0;
177
+ border: 0;
178
+ background: none;
179
+ color: var(--nm-muted);
180
+ cursor: pointer;
181
+ /* The buttons repeat while held, so the browser must not read the hold as
182
+ a scroll gesture or a text selection. */
183
+ touch-action: none;
184
+ user-select: none;
185
+ }
186
+ .nm-step:first-child {
187
+ border-start-end-radius: calc(var(--nm-radius) - 1px);
188
+ border-block-end: 1px solid var(--nm-border);
189
+ }
190
+ .nm-step:last-child {
191
+ border-end-end-radius: calc(var(--nm-radius) - 1px);
192
+ }
193
+ .nm-step:hover:not(:disabled) {
194
+ background: var(--nm-hover);
195
+ color: var(--nm-fg);
196
+ }
197
+ .nm-step:active:not(:disabled) {
198
+ background: color-mix(in oklab, var(--nm-hover) 70%, var(--nm-accent));
199
+ }
200
+ .nm-step:disabled {
201
+ opacity: 0.4;
202
+ cursor: not-allowed;
203
+ }
204
+
205
+ /* ----------------------------------------------------------------- text */
206
+
207
+ .nm-description {
208
+ color: var(--nm-muted);
209
+ text-wrap: pretty;
210
+ }
211
+ .nm-error {
212
+ color: var(--nm-danger);
213
+ text-wrap: pretty;
214
+ }
215
+
216
+ @media (prefers-reduced-motion: reduce) {
217
+ .nm-control {
218
+ transition: none;
219
+ }
220
+ }
221
+
222
+ @media (forced-colors: active) {
223
+ .nm-control {
224
+ border: 1px solid CanvasText;
225
+ }
226
+ .nm-stepper,
227
+ .nm-step:first-child {
228
+ border-color: CanvasText;
229
+ }
230
+ }
231
+ }
@@ -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";
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useId, useMemo, type CSSProperties, type ReactNode, type Ref } from "react";
4
+ import { describeProgress } from "../core/progress";
5
+ import type { ProgressLocaleText, ProgressVariant } from "../core/types";
6
+ import { defaultProgressText } from "./locale";
7
+ import { cx, type CircularProgressSlot } from "./props";
8
+
9
+ export interface CircularProgressProps {
10
+ value?: number | null;
11
+ max?: number;
12
+ /** Diameter in pixels. Default 44. */
13
+ size?: number;
14
+ /** Ring thickness in pixels. Default 4. */
15
+ thickness?: number;
16
+ variant?: ProgressVariant;
17
+ /** Names the ring for assistive technology. */
18
+ label?: string;
19
+ /** Print the percentage in the middle, or your own node. */
20
+ showValue?: boolean;
21
+ children?: ReactNode;
22
+ className?: string;
23
+ classNames?: Partial<Record<CircularProgressSlot, string>>;
24
+ style?: CSSProperties;
25
+ localeText?: Partial<ProgressLocaleText>;
26
+ ref?: Ref<HTMLDivElement>;
27
+ }
28
+
29
+ /**
30
+ * The same progress as a ring, for a tile or a table cell where a bar would be
31
+ * too wide.
32
+ *
33
+ * The arc is one circle with a dashed stroke: the dash is the whole
34
+ * circumference, and the gap moves it — no arc paths, no trigonometry, and it
35
+ * scales with `size` without redrawing anything.
36
+ */
37
+ export function CircularProgress(props: CircularProgressProps) {
38
+ const {
39
+ ref,
40
+ value = null,
41
+ max = 100,
42
+ size = 44,
43
+ thickness = 4,
44
+ variant = "accent",
45
+ label,
46
+ showValue = false,
47
+ children,
48
+ className,
49
+ classNames,
50
+ style,
51
+ localeText,
52
+ } = props;
53
+
54
+ const id = useId();
55
+ const text = useMemo(() => ({ ...defaultProgressText, ...localeText }), [localeText]);
56
+ const state = describeProgress(value, max);
57
+
58
+ const radius = (size - thickness) / 2;
59
+ const circumference = 2 * Math.PI * radius;
60
+ // An indeterminate ring shows a quarter arc that spins; a determinate one
61
+ // shows its fraction.
62
+ const filled = state.indeterminate ? 0.25 : state.fraction;
63
+
64
+ return (
65
+ <div
66
+ ref={ref}
67
+ className={cx("pr-circle", classNames?.root, className)}
68
+ style={{ ...style, width: size, height: size }}
69
+ data-variant={variant}
70
+ data-indeterminate={state.indeterminate || undefined}
71
+ role="progressbar"
72
+ aria-valuenow={state.indeterminate ? undefined : state.value ?? undefined}
73
+ aria-valuemin={state.indeterminate ? undefined : 0}
74
+ aria-valuemax={state.indeterminate ? undefined : max}
75
+ aria-valuetext={state.indeterminate ? undefined : text.valueText(state.percent)}
76
+ aria-label={label ?? text.label}
77
+ >
78
+ <svg className={cx("pr-svg", classNames?.svg)} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
79
+ <circle
80
+ className={cx("pr-circle-track", classNames?.track)}
81
+ cx={size / 2}
82
+ cy={size / 2}
83
+ r={radius}
84
+ fill="none"
85
+ strokeWidth={thickness}
86
+ />
87
+ <circle
88
+ className={cx("pr-circle-bar", classNames?.bar)}
89
+ cx={size / 2}
90
+ cy={size / 2}
91
+ r={radius}
92
+ fill="none"
93
+ strokeWidth={thickness}
94
+ strokeLinecap="round"
95
+ strokeDasharray={circumference}
96
+ strokeDashoffset={circumference * (1 - filled)}
97
+ />
98
+ </svg>
99
+ {(showValue || children) && (
100
+ <span className={cx("pr-circle-label", classNames?.label)} aria-hidden="true" id={`${id}-label`}>
101
+ {children ?? (state.indeterminate ? "" : `${state.percent}%`)}
102
+ </span>
103
+ )}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import {
4
+ useId,
5
+ useMemo,
6
+ type CSSProperties,
7
+ type HTMLAttributes,
8
+ type ReactNode,
9
+ type Ref,
10
+ } from "react";
11
+ import { describeProgress } from "../core/progress";
12
+ import type { ProgressLocaleText, ProgressSize, ProgressVariant } from "../core/types";
13
+ import { defaultProgressText } from "./locale";
14
+ import { cx, type ProgressSlot } from "./props";
15
+
16
+ export interface ProgressProps extends Omit<HTMLAttributes<HTMLDivElement>, "role"> {
17
+ /** How far along. `null` means the length is not known yet. */
18
+ value?: number | null;
19
+ /** What `value` is measured against. Default 100. */
20
+ max?: number;
21
+ /** Names the bar. Shown above it, and used as the accessible name. */
22
+ label?: ReactNode;
23
+ /** Show the percentage beside the label. */
24
+ showValue?: boolean;
25
+ /** Replace what is shown and read, e.g. "3.2 MB of 8 MB". */
26
+ valueText?: ReactNode;
27
+ /** Default `accent`. */
28
+ variant?: ProgressVariant;
29
+ /** Default `md`. */
30
+ size?: ProgressSize;
31
+ classNames?: Partial<Record<ProgressSlot, string>>;
32
+ localeText?: Partial<ProgressLocaleText>;
33
+ ref?: Ref<HTMLDivElement>;
34
+ }
35
+
36
+ /**
37
+ * A bar for something with a known end: an upload, an import, a quota.
38
+ *
39
+ * Use a **Spinner** when there is nothing to measure and nothing to say about
40
+ * how long it will take. A progress bar that spends its life indeterminate is
41
+ * a spinner drawn as a promise it cannot keep.
42
+ */
43
+ export function Progress(props: ProgressProps) {
44
+ const {
45
+ ref,
46
+ value = null,
47
+ max = 100,
48
+ label,
49
+ showValue = false,
50
+ valueText,
51
+ variant = "accent",
52
+ size = "md",
53
+ className,
54
+ classNames,
55
+ style,
56
+ localeText,
57
+ id: idProp,
58
+ "aria-label": ariaLabel,
59
+ ...rest
60
+ } = props;
61
+
62
+ const reactId = useId();
63
+ const id = idProp ?? reactId;
64
+ const text = useMemo(() => ({ ...defaultProgressText, ...localeText }), [localeText]);
65
+ const state = describeProgress(value, max);
66
+ const shown = valueText ?? (state.indeterminate ? text.working : text.valueText(state.percent));
67
+
68
+ return (
69
+ <div
70
+ {...rest}
71
+ ref={ref}
72
+ id={id}
73
+ className={cx("pr-root", classNames?.root, className)}
74
+ style={style}
75
+ data-variant={variant}
76
+ data-size={size}
77
+ data-indeterminate={state.indeterminate || undefined}
78
+ >
79
+ {(label || showValue) && (
80
+ <div className={cx("pr-header", classNames?.header)}>
81
+ {label && (
82
+ <span id={`${id}-label`} className={cx("pr-label", classNames?.label)}>
83
+ {label}
84
+ </span>
85
+ )}
86
+ {showValue && <span className={cx("pr-value", classNames?.value)}>{shown}</span>}
87
+ </div>
88
+ )}
89
+
90
+ <div
91
+ className={cx("pr-track", classNames?.track)}
92
+ role="progressbar"
93
+ // No aria-valuenow at all while the length is unknown: that is what
94
+ // tells a screen reader to say "busy" rather than to read out a zero.
95
+ aria-valuenow={state.indeterminate ? undefined : state.value ?? undefined}
96
+ aria-valuemin={state.indeterminate ? undefined : 0}
97
+ aria-valuemax={state.indeterminate ? undefined : max}
98
+ aria-valuetext={state.indeterminate ? undefined : typeof shown === "string" ? shown : undefined}
99
+ // The visible label names the bar when there is one, so the name and
100
+ // what is on screen can never drift apart.
101
+ aria-labelledby={label ? `${id}-label` : undefined}
102
+ aria-label={label ? undefined : (ariaLabel ?? text.label)}
103
+ >
104
+ <div
105
+ className={cx("pr-bar", classNames?.bar)}
106
+ // Drawn from the unrounded fraction, so the bar does not reach the
107
+ // end while the last of the work is still going.
108
+ style={{ "--pr-fraction": state.fraction } as CSSProperties}
109
+ />
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,7 @@
1
+ import type { ProgressLocaleText } from "../core/types";
2
+
3
+ export const defaultProgressText: ProgressLocaleText = {
4
+ label: "Progress",
5
+ valueText: (percent: number) => `${percent}%`,
6
+ working: "Working\u2026",
7
+ };
@@ -0,0 +1,6 @@
1
+ export type ProgressSlot = "root" | "header" | "label" | "value" | "track" | "bar";
2
+
3
+ export type CircularProgressSlot = "root" | "svg" | "track" | "bar" | "label";
4
+
5
+ // Shared with every other component, so class handling cannot drift.
6
+ export { cx } from "../../shared/core/cx";