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.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/index.cjs +5 -0
  3. package/package.json +1 -1
  4. package/source/beta-components/number-input/README.md +53 -0
  5. package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
  6. package/source/beta-components/number-input/core/format.ts +37 -0
  7. package/source/beta-components/number-input/core/index.ts +4 -0
  8. package/source/beta-components/number-input/core/parse.ts +118 -0
  9. package/source/beta-components/number-input/core/step.ts +73 -0
  10. package/source/beta-components/number-input/core/types.ts +32 -0
  11. package/source/beta-components/number-input/index.ts +5 -0
  12. package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
  13. package/source/beta-components/number-input/react/icons.tsx +17 -0
  14. package/source/beta-components/number-input/react/locale.ts +7 -0
  15. package/source/beta-components/number-input/react/props.ts +11 -0
  16. package/source/beta-components/number-input/styles.css +231 -0
  17. package/source/beta-components/radio-group/README.md +58 -0
  18. package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
  19. package/source/beta-components/radio-group/core/group.ts +36 -0
  20. package/source/beta-components/radio-group/core/index.ts +2 -0
  21. package/source/beta-components/radio-group/core/types.ts +20 -0
  22. package/source/beta-components/radio-group/index.ts +9 -0
  23. package/source/beta-components/radio-group/react/Radio.tsx +144 -0
  24. package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
  25. package/source/beta-components/radio-group/react/context.ts +18 -0
  26. package/source/beta-components/radio-group/react/locale.ts +5 -0
  27. package/source/beta-components/radio-group/react/props.ts +12 -0
  28. package/source/beta-components/radio-group/styles.css +270 -0
  29. package/source/beta-components/shared/__tests__/position.test.ts +143 -170
  30. package/source/beta-components/shared/core/position.ts +2 -15
  31. package/source/beta-components/shared/react/Popover.tsx +5 -18
  32. package/source/beta-components/switch/README.md +46 -0
  33. package/source/beta-components/switch/__tests__/core.test.ts +64 -0
  34. package/source/beta-components/switch/core/index.ts +2 -0
  35. package/source/beta-components/switch/core/toggle.ts +54 -0
  36. package/source/beta-components/switch/core/types.ts +9 -0
  37. package/source/beta-components/switch/index.ts +5 -0
  38. package/source/beta-components/switch/react/Switch.tsx +219 -0
  39. package/source/beta-components/switch/react/locale.ts +5 -0
  40. package/source/beta-components/switch/react/props.ts +12 -0
  41. package/source/beta-components/switch/styles.css +229 -0
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import { useCallback, useId, useMemo, type CSSProperties, type ReactNode } from "react";
4
+ import { useControllableState } from "../../shared/react/useControllableState";
5
+ import { describeField } from "../../shared/core/field";
6
+ import { hasEnabledOption, normalizeOptions, resolveValue } from "../core/group";
7
+ import type {
8
+ RadioGroupLocaleText,
9
+ RadioOption,
10
+ RadioOrientation,
11
+ RadioSize,
12
+ RadioVariant,
13
+ } from "../core/types";
14
+ import { Radio } from "./Radio";
15
+ import { RadioGroupContext, type RadioGroupContextValue } from "./context";
16
+ import { defaultRadioGroupText } from "./locale";
17
+ import { cx, type RadioGroupSlot } from "./props";
18
+
19
+ export interface RadioGroupProps {
20
+ /** The chosen value (controlled). `null` for nothing chosen. */
21
+ value?: string | null;
22
+ defaultValue?: string | null;
23
+ onValueChange?(value: string | null): void;
24
+ /** Render these choices, or pass `<Radio value="…">` children. Plain strings work too. */
25
+ options?: readonly (string | RadioOption)[];
26
+ children?: ReactNode;
27
+ /** The question the choices answer, shown as the group's legend. */
28
+ label?: ReactNode;
29
+ description?: ReactNode;
30
+ error?: ReactNode;
31
+ required?: boolean;
32
+ disabled?: boolean;
33
+ /** Form field name shared by every radio. One is generated when it is left out. */
34
+ name?: string;
35
+ size?: RadioSize;
36
+ /** Default `vertical`. */
37
+ orientation?: RadioOrientation;
38
+ /** Default `default`. `card` draws each choice as a tile. */
39
+ variant?: RadioVariant;
40
+ /**
41
+ * Offer a button that empties the group. Radios cannot be unselected by
42
+ * clicking or by keyboard — that is the control's nature, not an oversight —
43
+ * so an optional choice needs either this or a "None" option of its own.
44
+ */
45
+ clearable?: boolean;
46
+ id?: string;
47
+ className?: string;
48
+ classNames?: Partial<Record<RadioGroupSlot, string>>;
49
+ style?: CSSProperties;
50
+ localeText?: Partial<RadioGroupLocaleText>;
51
+ }
52
+
53
+ /** A labelled set of radios sharing one value. */
54
+ export function RadioGroup(props: RadioGroupProps) {
55
+ const {
56
+ value,
57
+ defaultValue,
58
+ onValueChange,
59
+ options,
60
+ children,
61
+ label,
62
+ description,
63
+ error,
64
+ required = false,
65
+ disabled = false,
66
+ name: nameProp,
67
+ size,
68
+ orientation = "vertical",
69
+ variant = "default",
70
+ clearable = false,
71
+ id: idProp,
72
+ className,
73
+ classNames,
74
+ style,
75
+ localeText,
76
+ } = props;
77
+
78
+ const reactId = useId();
79
+ const id = idProp ?? reactId;
80
+ // Radios are grouped by their name, so one is needed whether or not the
81
+ // group posts with a form: without it, two groups on a page share arrows.
82
+ const name = nameProp ?? `${id}-radio`;
83
+ const text = useMemo(() => ({ ...defaultRadioGroupText, ...localeText }), [localeText]);
84
+ const [current, setCurrent] = useControllableState<string | null>(value, defaultValue ?? null);
85
+
86
+ const items = useMemo(() => (options ? normalizeOptions(options) : null), [options]);
87
+ // With children rather than options there is nothing to check the value
88
+ // against, so it is taken as given.
89
+ const selected = items ? resolveValue(items, current) : (current ?? null);
90
+
91
+ const commit = useCallback(
92
+ (next: string | null) => {
93
+ setCurrent(next);
94
+ onValueChange?.(next);
95
+ },
96
+ [setCurrent, onValueChange],
97
+ );
98
+
99
+ const context = useMemo<RadioGroupContextValue>(
100
+ () => ({
101
+ value: selected,
102
+ select: commit,
103
+ name,
104
+ disabled,
105
+ required,
106
+ invalid: Boolean(error),
107
+ size,
108
+ variant,
109
+ }),
110
+ [selected, commit, name, disabled, required, error, size, variant],
111
+ );
112
+
113
+ const showClear = clearable && selected !== null && hasEnabledOption(items ?? [], disabled);
114
+ const describedBy = describeField(id, { description, error });
115
+
116
+ return (
117
+ <fieldset
118
+ id={id}
119
+ className={cx("rd-group", classNames?.root, className)}
120
+ style={style}
121
+ // A disabled fieldset disables every control inside it natively.
122
+ disabled={disabled}
123
+ // A fieldset is a plain group by default, which is right for checkboxes
124
+ // and wrong here: a set of radios is one control with one value, and
125
+ // screen readers say so and count the choices ("2 of 3") only for
126
+ // radiogroup. The legend is repeated as the name, because overriding the
127
+ // role is not guaranteed to carry a fieldset's own naming with it.
128
+ role="radiogroup"
129
+ aria-labelledby={label ? `${id}-legend` : undefined}
130
+ aria-describedby={describedBy}
131
+ data-orientation={orientation}
132
+ data-variant={variant}
133
+ data-invalid={error ? "" : undefined}
134
+ >
135
+ {label && (
136
+ <legend id={`${id}-legend`} className={cx("rd-legend", classNames?.legend)} data-required={required || undefined}>
137
+ {label}
138
+ </legend>
139
+ )}
140
+ {description && (
141
+ <span id={`${id}-description`} className={cx("rd-description", classNames?.description)}>
142
+ {description}
143
+ </span>
144
+ )}
145
+
146
+ <RadioGroupContext value={context}>
147
+ <div className={cx("rd-items", classNames?.items)}>
148
+ {items
149
+ ? items.map((option) => (
150
+ <Radio
151
+ key={option.value}
152
+ value={option.value}
153
+ label={option.label}
154
+ description={option.description}
155
+ disabled={option.disabled}
156
+ />
157
+ ))
158
+ : children}
159
+ </div>
160
+ </RadioGroupContext>
161
+
162
+ {showClear && (
163
+ <button
164
+ type="button"
165
+ className={cx("rd-clear", classNames?.clear)}
166
+ onClick={() => commit(null)}
167
+ >
168
+ {text.clear}
169
+ </button>
170
+ )}
171
+
172
+ {error && (
173
+ <span id={`${id}-error`} className={cx("rd-error", classNames?.error)} role="alert">
174
+ {error}
175
+ </span>
176
+ )}
177
+ </fieldset>
178
+ );
179
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { createContext } from "react";
4
+ import type { RadioSize, RadioVariant } from "../core/types";
5
+
6
+ export interface RadioGroupContextValue {
7
+ value: string | null;
8
+ select(value: string): void;
9
+ name: string;
10
+ disabled: boolean;
11
+ required: boolean;
12
+ invalid: boolean;
13
+ size?: RadioSize;
14
+ variant: RadioVariant;
15
+ }
16
+
17
+ /** Lets each Radio inside a RadioGroup read the group's value and change it. */
18
+ export const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
@@ -0,0 +1,5 @@
1
+ import type { RadioGroupLocaleText } from "../core/types";
2
+
3
+ export const defaultRadioGroupText: RadioGroupLocaleText = {
4
+ clear: "Clear",
5
+ };
@@ -0,0 +1,12 @@
1
+ export type RadioSlot =
2
+ | "root"
3
+ | "control"
4
+ | "input"
5
+ | "label"
6
+ | "description"
7
+ | "error";
8
+
9
+ export type RadioGroupSlot = "root" | "legend" | "description" | "items" | "clear" | "error";
10
+
11
+ // Shared with every other component, so class handling cannot drift.
12
+ export { cx } from "../../shared/core/cx";
@@ -0,0 +1,270 @@
1
+ /*
2
+ * Radio and RadioGroup styles.
3
+ *
4
+ * The button is the native <input type="radio"> with `appearance: none`, so it
5
+ * keeps native behavior: arrow keys roam the group, one tab stop, and the whole
6
+ * group validates as one required field. Tokens read the shared --gbs-*
7
+ * variables when they are set on an ancestor (set them on :root to theme every
8
+ * component at once). Rules live in the `components` layer, so utility classes
9
+ * passed through `className` / `classNames` override them.
10
+ */
11
+ @layer theme, base, components, utilities;
12
+
13
+ @layer components {
14
+ .rd-root,
15
+ .rd-group {
16
+ --rd-fg: var(--gbs-fg, var(--dg-fg, light-dark(#18181b, #f4f4f5)));
17
+ --rd-muted: var(--gbs-muted, var(--dg-muted, light-dark(#71717a, #a1a1aa)));
18
+ --rd-border: var(--gbs-border-control, light-dark(#a1a1aa, #52525b));
19
+ --rd-card-border: var(--gbs-border, var(--dg-border, light-dark(#e4e4e7, #27272a)));
20
+ --rd-input-bg: var(--gbs-input-bg, var(--dg-input-bg, light-dark(#ffffff, #121216)));
21
+ --rd-hover: var(--gbs-hover, var(--dg-hover, light-dark(#f4f4f5, #1f1f23)));
22
+ --rd-accent: var(--gbs-accent, var(--dg-accent, light-dark(#2563eb, #60a5fa)));
23
+ --rd-accent-soft: var(--gbs-accent-soft, var(--dg-accent-soft, light-dark(#eff6ff, #172554)));
24
+ --rd-focus: var(--gbs-focus, var(--dg-focus, light-dark(#2563eb, #60a5fa)));
25
+ --rd-danger: var(--gbs-danger, var(--dg-danger, light-dark(#dc2626, #f87171)));
26
+ --rd-radius: var(--gbs-radius, var(--dg-radius, 8px));
27
+ --rd-font-size: var(--gbs-font-size, var(--dg-font-size, 13px));
28
+ --rd-size: 16px;
29
+
30
+ color-scheme: inherit;
31
+ font-size: var(--rd-font-size);
32
+ line-height: 1.4;
33
+ color: var(--rd-fg);
34
+ }
35
+
36
+ :where(.dark, [data-theme="dark"]) :is(.rd-root, .rd-group) {
37
+ color-scheme: dark;
38
+ }
39
+ :where(.light, [data-theme="light"]) :is(.rd-root, .rd-group) {
40
+ color-scheme: light;
41
+ }
42
+
43
+ :where(.rd-root, .rd-group) *,
44
+ :where(.rd-root, .rd-group) *::before,
45
+ :where(.rd-root, .rd-group) *::after {
46
+ box-sizing: border-box;
47
+ }
48
+
49
+ /* ---------------------------------------------------------------- radio */
50
+
51
+ .rd-root {
52
+ display: flex;
53
+ align-items: flex-start;
54
+ gap: 8px;
55
+ min-width: 0;
56
+ }
57
+ .rd-root[data-size="sm"] {
58
+ --rd-size: 14px;
59
+ --rd-font-size: 12px;
60
+ }
61
+ .rd-root[data-size="lg"] {
62
+ --rd-size: 20px;
63
+ --rd-font-size: 14px;
64
+ }
65
+
66
+ .rd-control {
67
+ position: relative;
68
+ display: inline-flex;
69
+ flex: none;
70
+ align-items: center;
71
+ justify-content: center;
72
+ /* Line the button up with the first line of the label. */
73
+ height: calc(var(--rd-font-size) * 1.4);
74
+ }
75
+
76
+ .rd-input {
77
+ width: var(--rd-size);
78
+ height: var(--rd-size);
79
+ margin: 0;
80
+ border: 1px solid var(--rd-border);
81
+ border-radius: 50%;
82
+ appearance: none;
83
+ background: var(--rd-input-bg);
84
+ cursor: pointer;
85
+ transition:
86
+ border-color 120ms ease,
87
+ background-color 120ms ease;
88
+ }
89
+ .rd-input:hover:not(:disabled) {
90
+ border-color: var(--rd-accent);
91
+ }
92
+ .rd-input:checked {
93
+ border-color: var(--rd-accent);
94
+ border-width: 2px;
95
+ }
96
+ .rd-input:focus-visible {
97
+ outline: 2px solid var(--rd-focus);
98
+ outline-offset: 2px;
99
+ }
100
+
101
+ /* The dot is a separate element rather than a border trick, so its size can
102
+ follow the control's and it can animate in. */
103
+ .rd-dot {
104
+ position: absolute;
105
+ width: calc(var(--rd-size) / 2);
106
+ height: calc(var(--rd-size) / 2);
107
+ border-radius: 50%;
108
+ background: var(--rd-accent);
109
+ scale: 0;
110
+ transition: scale 120ms ease;
111
+ pointer-events: none;
112
+ }
113
+ .rd-input:checked ~ .rd-dot {
114
+ scale: 1;
115
+ }
116
+
117
+ .rd-root[data-invalid] .rd-input {
118
+ border-color: var(--rd-danger);
119
+ }
120
+ /* Only the button dims. Fading the whole choice takes its label below the
121
+ contrast floor, and someone still has to be able to read the option they
122
+ cannot pick. */
123
+ .rd-input:disabled {
124
+ opacity: 0.5;
125
+ cursor: not-allowed;
126
+ }
127
+ .rd-root[data-disabled] .rd-dot {
128
+ opacity: 0.5;
129
+ }
130
+
131
+ .rd-text {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 2px;
135
+ min-width: 0;
136
+ }
137
+ .rd-label {
138
+ font-weight: 500;
139
+ cursor: pointer;
140
+ }
141
+ .rd-root[data-disabled] .rd-label {
142
+ color: var(--rd-muted);
143
+ cursor: not-allowed;
144
+ }
145
+ .rd-description {
146
+ color: var(--rd-muted);
147
+ text-wrap: pretty;
148
+ }
149
+ .rd-error {
150
+ color: var(--rd-danger);
151
+ text-wrap: pretty;
152
+ }
153
+
154
+ /* ----------------------------------------------------------------- card */
155
+
156
+ .rd-root[data-variant="card"] {
157
+ position: relative;
158
+ align-items: flex-start;
159
+ padding: 12px;
160
+ border: 1px solid var(--rd-card-border);
161
+ border-radius: var(--rd-radius);
162
+ background: var(--rd-input-bg);
163
+ cursor: pointer;
164
+ transition:
165
+ border-color 120ms ease,
166
+ background-color 120ms ease;
167
+ }
168
+ .rd-root[data-variant="card"]:hover:not([data-disabled]) {
169
+ background: var(--rd-hover);
170
+ }
171
+ .rd-root[data-variant="card"][data-checked] {
172
+ border-color: var(--rd-accent);
173
+ background: var(--rd-accent-soft);
174
+ }
175
+ /* Muted grey clears 4.5:1 on white but only reaches 4.44:1 on the tinted
176
+ background of a selected card, so it moves a step towards the text colour
177
+ there rather than the description quietly failing contrast. */
178
+ .rd-root[data-variant="card"][data-checked] .rd-description {
179
+ color: color-mix(in oklab, var(--rd-muted) 72%, var(--rd-fg));
180
+ }
181
+ /* The whole tile is the target: the input covers it, so a press anywhere
182
+ lands on the radio itself rather than on a label that forwards it. */
183
+ .rd-root[data-variant="card"] .rd-input::after {
184
+ content: "";
185
+ position: absolute;
186
+ inset: 0;
187
+ border-radius: var(--rd-radius);
188
+ }
189
+ .rd-root[data-variant="card"]:has(.rd-input:focus-visible) {
190
+ outline: 2px solid var(--rd-focus);
191
+ outline-offset: 2px;
192
+ }
193
+ .rd-root[data-variant="card"] .rd-input:focus-visible {
194
+ outline: none;
195
+ }
196
+
197
+ /* ---------------------------------------------------------------- group */
198
+
199
+ .rd-group {
200
+ display: flex;
201
+ flex-direction: column;
202
+ gap: 8px;
203
+ min-width: 0;
204
+ margin: 0;
205
+ padding: 0;
206
+ border: 0;
207
+ }
208
+ .rd-legend {
209
+ padding: 0;
210
+ font-weight: 500;
211
+ }
212
+ .rd-legend[data-required]::after {
213
+ content: " *";
214
+ color: var(--rd-danger);
215
+ }
216
+
217
+ .rd-items {
218
+ display: flex;
219
+ flex-direction: column;
220
+ gap: 8px;
221
+ min-width: 0;
222
+ }
223
+ .rd-group[data-orientation="horizontal"] .rd-items {
224
+ flex-flow: row wrap;
225
+ gap: 16px;
226
+ }
227
+ .rd-group[data-variant="card"][data-orientation="horizontal"] .rd-items {
228
+ gap: 8px;
229
+ }
230
+ .rd-group[data-variant="card"][data-orientation="horizontal"] .rd-root {
231
+ flex: 1 1 180px;
232
+ }
233
+
234
+ .rd-clear {
235
+ align-self: flex-start;
236
+ padding: 0;
237
+ border: 0;
238
+ background: none;
239
+ color: var(--rd-accent);
240
+ font: inherit;
241
+ text-decoration: underline;
242
+ text-underline-offset: 2px;
243
+ cursor: pointer;
244
+ }
245
+ .rd-clear:focus-visible {
246
+ outline: 2px solid var(--rd-focus);
247
+ outline-offset: 2px;
248
+ border-radius: 3px;
249
+ }
250
+
251
+ @media (prefers-reduced-motion: reduce) {
252
+ .rd-input,
253
+ .rd-dot,
254
+ .rd-root[data-variant="card"] {
255
+ transition: none;
256
+ }
257
+ }
258
+
259
+ @media (forced-colors: active) {
260
+ .rd-input {
261
+ border: 1px solid CanvasText;
262
+ }
263
+ .rd-dot {
264
+ background: Highlight;
265
+ }
266
+ .rd-root[data-variant="card"][data-checked] {
267
+ border-color: Highlight;
268
+ }
269
+ }
270
+ }