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,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,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,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";
|