gbs-add-block 1.2.18 → 1.2.19
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 +5 -0
- package/package.json +1 -1
- 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/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,58 @@
|
|
|
1
|
+
# Radio and RadioGroup
|
|
2
|
+
|
|
3
|
+
One choice out of a few, for React 19, styled to match the rest of the library.
|
|
4
|
+
The buttons are real `<input type="radio">` elements sharing a `name`, so the
|
|
5
|
+
arrow keys roam the group, the group is one tab stop, and the browser validates
|
|
6
|
+
it as one required field — without a line of JavaScript. No runtime
|
|
7
|
+
dependencies besides React.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Radio, RadioGroup } from "@/components/radio-group";
|
|
11
|
+
import "@/components/radio-group/styles.css";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
<RadioGroup
|
|
16
|
+
label="Send the report"
|
|
17
|
+
name="frequency"
|
|
18
|
+
options={["Daily", "Weekly", "Monthly"]}
|
|
19
|
+
value={frequency}
|
|
20
|
+
onValueChange={setFrequency}
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
{/* Tiles, for choices that need explaining. */}
|
|
24
|
+
<RadioGroup
|
|
25
|
+
label="Plan"
|
|
26
|
+
variant="card"
|
|
27
|
+
orientation="horizontal"
|
|
28
|
+
options={[
|
|
29
|
+
{ value: "starter", label: "Starter", description: "Up to 3 projects" },
|
|
30
|
+
{ value: "team", label: "Team", description: "Unlimited projects and members" },
|
|
31
|
+
]}
|
|
32
|
+
defaultValue="team"
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
{/* Or compose them, when each choice needs its own markup. */}
|
|
36
|
+
<RadioGroup label="Delivery" name="delivery">
|
|
37
|
+
<Radio value="standard" label="Standard" description="3–5 days" />
|
|
38
|
+
<Radio value="express" label="Express" description="Tomorrow" disabled />
|
|
39
|
+
</RadioGroup>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**RadioGroup:** `value` / `defaultValue` / `onValueChange` (`string | null`),
|
|
43
|
+
`options` (plain strings or full options) or `<Radio value>` children, `label`
|
|
44
|
+
(legend), `description`, `error`, `required`, `disabled`, `name`, `size`,
|
|
45
|
+
`orientation`, `variant` (`default` `card`), `clearable`, `classNames` (`root`
|
|
46
|
+
`legend` `description` `items` `clear` `error`), `localeText`.
|
|
47
|
+
|
|
48
|
+
**Radio:** `value`, `label`, `description`, `error`, `size`, `disabled`,
|
|
49
|
+
`classNames` (`root` `control` `input` `label` `description` `error`), `ref`
|
|
50
|
+
(the `<input>`), and every `<input>` attribute.
|
|
51
|
+
|
|
52
|
+
A radio cannot be unselected by clicking or by keyboard — that is what the
|
|
53
|
+
control is — so an optional question needs either `clearable`, which offers a
|
|
54
|
+
button that empties the group, or a "None" option of its own.
|
|
55
|
+
|
|
56
|
+
`normalizeOptions`, `resolveValue` and `hasEnabledOption` are exported from the
|
|
57
|
+
framework-free `core`. `resolveValue` is what makes a value the group does not
|
|
58
|
+
offer select nothing, rather than guessing at an option the record never chose.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasEnabledOption, normalizeOptions, resolveValue } from "../core/group";
|
|
3
|
+
|
|
4
|
+
describe("normalizeOptions", () => {
|
|
5
|
+
it("turns plain strings into options labelled with themselves", () => {
|
|
6
|
+
expect(normalizeOptions(["daily", "weekly"])).toEqual([
|
|
7
|
+
{ value: "daily", label: "daily" },
|
|
8
|
+
{ value: "weekly", label: "weekly" },
|
|
9
|
+
]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("leaves full options as they are, mixed in with strings", () => {
|
|
13
|
+
const options = normalizeOptions([
|
|
14
|
+
"daily",
|
|
15
|
+
{ value: "weekly", label: "Every week", description: "On Mondays", disabled: true },
|
|
16
|
+
]);
|
|
17
|
+
expect(options[1]).toEqual({
|
|
18
|
+
value: "weekly",
|
|
19
|
+
label: "Every week",
|
|
20
|
+
description: "On Mondays",
|
|
21
|
+
disabled: true,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("resolveValue", () => {
|
|
27
|
+
const options = normalizeOptions([
|
|
28
|
+
"email",
|
|
29
|
+
{ value: "sms", label: "SMS", disabled: true },
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
it("selects the matching option", () => {
|
|
33
|
+
expect(resolveValue(options, "email")).toBe("email");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("selects a disabled option, because a locked-in choice still has to show", () => {
|
|
37
|
+
expect(resolveValue(options, "sms")).toBe("sms");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("selects nothing for a value the group does not offer", () => {
|
|
41
|
+
// A stale value from a record: picking some other option instead would
|
|
42
|
+
// claim a choice that was never made.
|
|
43
|
+
expect(resolveValue(options, "pigeon")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("selects nothing for no value at all", () => {
|
|
47
|
+
expect(resolveValue(options, null)).toBeNull();
|
|
48
|
+
expect(resolveValue(options, undefined)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("hasEnabledOption", () => {
|
|
53
|
+
const options = normalizeOptions(["email", { value: "sms", label: "SMS", disabled: true }]);
|
|
54
|
+
|
|
55
|
+
it("is true while something can still be chosen", () => {
|
|
56
|
+
expect(hasEnabledOption(options, false)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is false when the group is disabled or has nothing to offer", () => {
|
|
60
|
+
expect(hasEnabledOption(options, true)).toBe(false);
|
|
61
|
+
expect(hasEnabledOption(normalizeOptions([{ value: "sms", label: "SMS", disabled: true }]), false)).toBe(false);
|
|
62
|
+
expect(hasEnabledOption([], false)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RadioOption } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Accepts `["daily", "weekly"]` as well as the full option objects, because a
|
|
5
|
+
* list of plain values is the common case and writing `{ value: x, label: x }`
|
|
6
|
+
* for each one adds nothing.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeOptions(
|
|
9
|
+
options: readonly (string | RadioOption)[],
|
|
10
|
+
): RadioOption[] {
|
|
11
|
+
return options.map((option) =>
|
|
12
|
+
typeof option === "string" ? { value: option, label: option } : option,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The value the group should actually show as selected.
|
|
18
|
+
*
|
|
19
|
+
* A value that matches no option selects nothing, which is what native radios
|
|
20
|
+
* do and the right answer for a stale value from a server: showing an arbitrary
|
|
21
|
+
* option instead would claim a choice the record never made. A *disabled*
|
|
22
|
+
* option still counts — a locked-in choice has to be visible, or the group
|
|
23
|
+
* would look empty while refusing to let anything be chosen.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveValue(
|
|
26
|
+
options: readonly RadioOption[],
|
|
27
|
+
value: string | null | undefined,
|
|
28
|
+
): string | null {
|
|
29
|
+
if (value === null || value === undefined) return null;
|
|
30
|
+
return options.some((option) => option.value === value) ? value : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether anything in the group can still be chosen, for a `clear` button. */
|
|
34
|
+
export function hasEnabledOption(options: readonly RadioOption[], disabled: boolean): boolean {
|
|
35
|
+
return !disabled && options.some((option) => !option.disabled);
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type RadioSize = "sm" | "md" | "lg";
|
|
4
|
+
|
|
5
|
+
/** `card` draws each choice as a bordered tile, for choices that carry detail. */
|
|
6
|
+
export type RadioVariant = "default" | "card";
|
|
7
|
+
|
|
8
|
+
export type RadioOrientation = "vertical" | "horizontal";
|
|
9
|
+
|
|
10
|
+
export interface RadioOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: ReactNode;
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RadioGroupLocaleText {
|
|
18
|
+
/** The button that empties a `clearable` group. */
|
|
19
|
+
clear: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Radio } from "./react/Radio";
|
|
2
|
+
export type { RadioProps } from "./react/Radio";
|
|
3
|
+
export { RadioGroup } from "./react/RadioGroup";
|
|
4
|
+
export type { RadioGroupProps } from "./react/RadioGroup";
|
|
5
|
+
export { RadioGroupContext } from "./react/context";
|
|
6
|
+
export type { RadioGroupContextValue } from "./react/context";
|
|
7
|
+
export { defaultRadioGroupText } from "./react/locale";
|
|
8
|
+
export type { RadioGroupSlot, RadioSlot } from "./react/props";
|
|
9
|
+
export * from "./core";
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
use,
|
|
5
|
+
useId,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useRef,
|
|
8
|
+
type ChangeEvent,
|
|
9
|
+
type ChangeEventHandler,
|
|
10
|
+
type InputHTMLAttributes,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
type Ref,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { useControllableState } from "../../shared/react/useControllableState";
|
|
15
|
+
import { describeField } from "../../shared/core/field";
|
|
16
|
+
import type { RadioSize } from "../core/types";
|
|
17
|
+
import { RadioGroupContext } from "./context";
|
|
18
|
+
import { cx, type RadioSlot } from "./props";
|
|
19
|
+
|
|
20
|
+
export interface RadioProps
|
|
21
|
+
extends Omit<
|
|
22
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
23
|
+
"type" | "size" | "checked" | "defaultChecked" | "onChange"
|
|
24
|
+
> {
|
|
25
|
+
/** The value this choice contributes to its group. */
|
|
26
|
+
value: string;
|
|
27
|
+
/** Selected (controlled). Inside a RadioGroup the group decides instead. */
|
|
28
|
+
checked?: boolean;
|
|
29
|
+
defaultChecked?: boolean;
|
|
30
|
+
onCheckedChange?(checked: boolean): void;
|
|
31
|
+
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
32
|
+
label?: ReactNode;
|
|
33
|
+
description?: ReactNode;
|
|
34
|
+
error?: ReactNode;
|
|
35
|
+
size?: RadioSize;
|
|
36
|
+
classNames?: Partial<Record<RadioSlot, string>>;
|
|
37
|
+
/** The `<input>` element. */
|
|
38
|
+
ref?: Ref<HTMLInputElement>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One choice, as a native radio. Inside a `RadioGroup` the group owns the
|
|
43
|
+
* value; on its own it behaves like any other radio sharing a `name`.
|
|
44
|
+
*
|
|
45
|
+
* Nothing here manages focus: radios that share a `name` already roam with the
|
|
46
|
+
* arrow keys, skip the disabled ones, wrap around and leave one tab stop for
|
|
47
|
+
* the whole group — in every browser, and without JavaScript.
|
|
48
|
+
*/
|
|
49
|
+
export function Radio(props: RadioProps) {
|
|
50
|
+
const {
|
|
51
|
+
ref,
|
|
52
|
+
value,
|
|
53
|
+
checked: checkedProp,
|
|
54
|
+
defaultChecked = false,
|
|
55
|
+
onCheckedChange,
|
|
56
|
+
onChange,
|
|
57
|
+
label,
|
|
58
|
+
description,
|
|
59
|
+
error,
|
|
60
|
+
size,
|
|
61
|
+
className,
|
|
62
|
+
classNames,
|
|
63
|
+
style,
|
|
64
|
+
id: idProp,
|
|
65
|
+
disabled,
|
|
66
|
+
required,
|
|
67
|
+
name,
|
|
68
|
+
"aria-describedby": describedByProp,
|
|
69
|
+
...inputProps
|
|
70
|
+
} = props;
|
|
71
|
+
|
|
72
|
+
const group = use(RadioGroupContext);
|
|
73
|
+
const reactId = useId();
|
|
74
|
+
const id = idProp ?? reactId;
|
|
75
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
76
|
+
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
|
|
77
|
+
|
|
78
|
+
const [own, setOwn] = useControllableState(checkedProp, defaultChecked);
|
|
79
|
+
const checked = group !== null ? group.value === value : own;
|
|
80
|
+
|
|
81
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
82
|
+
if (group !== null) group.select(value);
|
|
83
|
+
else setOwn(event.target.checked);
|
|
84
|
+
onChange?.(event);
|
|
85
|
+
onCheckedChange?.(event.target.checked);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isDisabled = disabled ?? group?.disabled;
|
|
89
|
+
const invalid = Boolean(error) || (group?.invalid ?? false);
|
|
90
|
+
const describedBy = describeField(id, { extra: describedByProp, description, error });
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className={cx("rd-root", classNames?.root, className)}
|
|
95
|
+
style={style}
|
|
96
|
+
data-size={size ?? group?.size ?? "md"}
|
|
97
|
+
data-variant={group?.variant ?? "default"}
|
|
98
|
+
data-checked={checked || undefined}
|
|
99
|
+
data-disabled={isDisabled || undefined}
|
|
100
|
+
data-invalid={invalid ? "" : undefined}
|
|
101
|
+
>
|
|
102
|
+
<span className={cx("rd-control", classNames?.control)}>
|
|
103
|
+
<input
|
|
104
|
+
{...inputProps}
|
|
105
|
+
ref={inputRef}
|
|
106
|
+
id={id}
|
|
107
|
+
type="radio"
|
|
108
|
+
className={cx("rd-input", classNames?.input)}
|
|
109
|
+
checked={checked}
|
|
110
|
+
value={value}
|
|
111
|
+
name={name ?? group?.name}
|
|
112
|
+
disabled={isDisabled}
|
|
113
|
+
// Every radio in a group carries `required`, which is how a browser
|
|
114
|
+
// validates the group as a whole rather than one button in it.
|
|
115
|
+
required={required ?? group?.required}
|
|
116
|
+
aria-invalid={invalid || undefined}
|
|
117
|
+
aria-describedby={describedBy}
|
|
118
|
+
onChange={handleChange}
|
|
119
|
+
/>
|
|
120
|
+
<span className="rd-dot" aria-hidden="true" />
|
|
121
|
+
</span>
|
|
122
|
+
|
|
123
|
+
{(label || description || error) && (
|
|
124
|
+
<span className="rd-text">
|
|
125
|
+
{label && (
|
|
126
|
+
<label htmlFor={id} className={cx("rd-label", classNames?.label)}>
|
|
127
|
+
{label}
|
|
128
|
+
</label>
|
|
129
|
+
)}
|
|
130
|
+
{description && (
|
|
131
|
+
<span id={`${id}-description`} className={cx("rd-description", classNames?.description)}>
|
|
132
|
+
{description}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
{error && (
|
|
136
|
+
<span id={`${id}-error`} className={cx("rd-error", classNames?.error)} role="alert">
|
|
137
|
+
{error}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|