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,465 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type FocusEvent,
12
+ type InputHTMLAttributes,
13
+ type KeyboardEvent,
14
+ type PointerEvent,
15
+ type ReactNode,
16
+ type Ref,
17
+ } from "react";
18
+ import { describeField } from "../../shared/core/field";
19
+ import { formatNumber } from "../core/format";
20
+ import { parseNumber, toEditText } from "../core/parse";
21
+ import { clamp, round, snapToStep, stepBy } from "../core/step";
22
+ import type {
23
+ ClampBehavior,
24
+ NumberInputLocaleText,
25
+ NumberSize,
26
+ NumberStyle,
27
+ } from "../core/types";
28
+ import { ChevronDownIcon, ChevronUpIcon, XIcon } from "./icons";
29
+ import { defaultNumberInputText } from "./locale";
30
+ import { cx, type NumberInputSlot } from "./props";
31
+
32
+ /** Press and hold: the wait before repeating, then the gap between repeats. */
33
+ const REPEAT_DELAY = 400;
34
+ const REPEAT_INTERVAL = 60;
35
+
36
+ export interface NumberInputProps
37
+ extends Omit<
38
+ InputHTMLAttributes<HTMLInputElement>,
39
+ "size" | "type" | "value" | "defaultValue" | "onChange" | "step" | "min" | "max" | "role"
40
+ > {
41
+ /** The number, or `null` for an empty field (controlled). */
42
+ value?: number | null;
43
+ defaultValue?: number | null;
44
+ /** Fires with the parsed number, or `null` when the field is emptied. */
45
+ onValueChange?(value: number | null): void;
46
+ min?: number;
47
+ max?: number;
48
+ /** How much one arrow press or stepper button moves. Default 1. */
49
+ step?: number;
50
+ /** Step with **Page Up** / **Page Down**. Default ten steps. */
51
+ largeStep?: number;
52
+ /** Round every committed value onto the step grid, counting from `min`. */
53
+ snapToStep?: boolean;
54
+ /** Fraction digits to show, and to round to. Left out, the number is shown as it is. */
55
+ decimals?: number;
56
+ /** BCP 47 tag deciding the separators and digits, for both reading and showing. */
57
+ locale?: string;
58
+ /** Default `decimal`. `percent` shows 0.45 as 45%, as `Intl` does. */
59
+ format?: NumberStyle;
60
+ /** ISO 4217 code, for `format="currency"`. */
61
+ currency?: string;
62
+ /** Thousands separators while the field is not being edited. Default true. */
63
+ useGrouping?: boolean;
64
+ /** When a value outside the limits is pulled back. Default `blur`. */
65
+ clampBehavior?: ClampBehavior;
66
+ /** The up and down buttons. Default true. */
67
+ stepper?: boolean;
68
+ /**
69
+ * Let the wheel change the value while the field has focus. Default **false**:
70
+ * a wheel that edits a focused field turns an ordinary page scroll into a
71
+ * silent change to someone's data.
72
+ */
73
+ wheel?: boolean;
74
+ /** Select the whole number when the field is focused, so typing replaces it. */
75
+ selectOnFocus?: boolean;
76
+ /** A button that empties the field. */
77
+ clearable?: boolean;
78
+ label?: ReactNode;
79
+ description?: ReactNode;
80
+ error?: ReactNode;
81
+ size?: NumberSize;
82
+ /** Before the number: a currency symbol, a unit. */
83
+ leading?: ReactNode;
84
+ /** After the number: a unit, a suffix. */
85
+ trailing?: ReactNode;
86
+ classNames?: Partial<Record<NumberInputSlot, string>>;
87
+ localeText?: Partial<NumberInputLocaleText>;
88
+ /** The `<input>` element. */
89
+ ref?: Ref<HTMLInputElement>;
90
+ }
91
+
92
+ /**
93
+ * A numeric field that reads and writes numbers the way the locale does.
94
+ *
95
+ * It is a text input with `role="spinbutton"`, not `<input type="number">`,
96
+ * which buys three things that control cannot give: the wheel never edits it by
97
+ * accident, what was typed is still readable when it is not yet a number, and
98
+ * `1.234,56` means the same to it as `1,234.56` does to someone else.
99
+ */
100
+ export function NumberInput(props: NumberInputProps) {
101
+ const {
102
+ ref,
103
+ value: valueProp,
104
+ defaultValue = null,
105
+ onValueChange,
106
+ min,
107
+ max,
108
+ step = 1,
109
+ largeStep = step * 10,
110
+ snapToStep: snap = false,
111
+ decimals,
112
+ locale,
113
+ format = "decimal",
114
+ currency,
115
+ useGrouping = true,
116
+ clampBehavior = "blur",
117
+ stepper = true,
118
+ wheel = false,
119
+ selectOnFocus = false,
120
+ clearable = false,
121
+ label,
122
+ description,
123
+ error,
124
+ size = "md",
125
+ leading,
126
+ trailing,
127
+ className,
128
+ classNames,
129
+ style,
130
+ localeText,
131
+ id: idProp,
132
+ disabled,
133
+ readOnly,
134
+ required,
135
+ placeholder,
136
+ onFocus,
137
+ onBlur,
138
+ onKeyDown,
139
+ "aria-describedby": describedByProp,
140
+ ...inputProps
141
+ } = props;
142
+
143
+ const reactId = useId();
144
+ const id = idProp ?? reactId;
145
+ const text = useMemo(() => ({ ...defaultNumberInputText, ...localeText }), [localeText]);
146
+ const inputRef = useRef<HTMLInputElement>(null);
147
+ useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
148
+
149
+ const controlled = valueProp !== undefined;
150
+ const [own, setOwn] = useState<number | null>(defaultValue);
151
+ const value = controlled ? valueProp : own;
152
+
153
+ const [draft, setDraft] = useState<string | null>(null);
154
+ const [focused, setFocused] = useState(false);
155
+
156
+ const formatOptions = useMemo(
157
+ () => ({ locale, style: format, currency, decimals, useGrouping }),
158
+ [locale, format, currency, decimals, useGrouping],
159
+ );
160
+
161
+ // Formatted at rest, plain while being edited: separators inserted under the
162
+ // caret are the reason typing in a "smart" number field feels wrong.
163
+ const shown =
164
+ draft ?? (focused ? toEditText(value, locale) : formatNumber(value, formatOptions));
165
+
166
+ const commit = useCallback(
167
+ (next: number | null) => {
168
+ if (!controlled) setOwn(next);
169
+ if (next !== value) onValueChange?.(next);
170
+ },
171
+ [controlled, value, onValueChange],
172
+ );
173
+
174
+ /** Rounds, snaps and clamps a raw number into what the field will hold. */
175
+ const settle = useCallback(
176
+ (raw: number): number => {
177
+ let next = decimals === undefined ? raw : round(raw, decimals);
178
+ if (snap) next = snapToStep(next, { step, min, max });
179
+ if (clampBehavior !== "none") next = clamp(next, min, max);
180
+ return next;
181
+ },
182
+ [decimals, snap, step, min, max, clampBehavior],
183
+ );
184
+
185
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
186
+ const typed = event.target.value;
187
+ setDraft(typed);
188
+ const parsed = parseNumber(typed, locale);
189
+ if (parsed === null) {
190
+ // An empty field is a value; anything else unreadable is a half-typed
191
+ // number, and the last good one stands until it is finished or left.
192
+ if (typed.trim() === "") commit(null);
193
+ return;
194
+ }
195
+ commit(clampBehavior === "strict" ? clamp(parsed, min, max) : parsed);
196
+ };
197
+
198
+ const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
199
+ setFocused(true);
200
+ setDraft(null);
201
+ onFocus?.(event);
202
+ if (selectOnFocus) event.target.select();
203
+ };
204
+
205
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
206
+ setFocused(false);
207
+ const typed = draft;
208
+ setDraft(null);
209
+ onBlur?.(event);
210
+ if (typed === null) return;
211
+ if (typed.trim() === "") {
212
+ commit(null);
213
+ return;
214
+ }
215
+ const parsed = parseNumber(typed, locale);
216
+ // Something unreadable falls back to the last number the field held, rather
217
+ // than being thrown away: leaving a field should never destroy a value.
218
+ if (parsed !== null) commit(settle(parsed));
219
+ };
220
+
221
+ const applyStep = useCallback(
222
+ (direction: 1 | -1, amount = step) => {
223
+ if (disabled || readOnly) return;
224
+ const from = draft === null ? value : (parseNumber(draft, locale) ?? value);
225
+ const next = stepBy(from, direction, { step: amount, min, max });
226
+ setDraft(null);
227
+ commit(snap ? snapToStep(next, { step, min, max }) : next);
228
+ },
229
+ [disabled, readOnly, draft, value, locale, step, min, max, snap, commit],
230
+ );
231
+
232
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
233
+ onKeyDown?.(event);
234
+ if (event.defaultPrevented || disabled || readOnly) return;
235
+ switch (event.key) {
236
+ case "ArrowUp":
237
+ event.preventDefault();
238
+ applyStep(1);
239
+ break;
240
+ case "ArrowDown":
241
+ event.preventDefault();
242
+ applyStep(-1);
243
+ break;
244
+ case "PageUp":
245
+ event.preventDefault();
246
+ applyStep(1, largeStep);
247
+ break;
248
+ case "PageDown":
249
+ event.preventDefault();
250
+ applyStep(-1, largeStep);
251
+ break;
252
+ case "Home":
253
+ if (min === undefined) return;
254
+ event.preventDefault();
255
+ setDraft(null);
256
+ commit(min);
257
+ break;
258
+ case "End":
259
+ if (max === undefined) return;
260
+ event.preventDefault();
261
+ setDraft(null);
262
+ commit(max);
263
+ break;
264
+ case "Escape":
265
+ // Back to the committed number, leaving whatever was half-typed.
266
+ if (draft === null) return;
267
+ event.preventDefault();
268
+ setDraft(null);
269
+ break;
270
+ default:
271
+ }
272
+ };
273
+
274
+ // The wheel only ever reaches the value when the caller asked for it and the
275
+ // field has focus, and the page is then held still so the two cannot both
276
+ // happen at once.
277
+ useEffect(() => {
278
+ const element = inputRef.current;
279
+ if (!element || !wheel) return;
280
+ const onWheel = (event: globalThis.WheelEvent) => {
281
+ if (document.activeElement !== element || event.deltaY === 0) return;
282
+ event.preventDefault();
283
+ applyStep(event.deltaY < 0 ? 1 : -1);
284
+ };
285
+ element.addEventListener("wheel", onWheel, { passive: false });
286
+ return () => element.removeEventListener("wheel", onWheel);
287
+ }, [wheel, applyStep]);
288
+
289
+ // Press and hold a stepper button to repeat, as the native spinner does.
290
+ // The repeat reads the step function through a ref: the timer is set up once
291
+ // at the press, and calling the `applyStep` captured then would step from the
292
+ // value as it was at the press, over and over, so the field would move once
293
+ // and then sit still.
294
+ const latestStep = useRef(applyStep);
295
+ useEffect(() => {
296
+ latestStep.current = applyStep;
297
+ }, [applyStep]);
298
+
299
+ const repeat = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
300
+ const stopRepeat = useCallback(() => {
301
+ clearTimeout(repeat.current);
302
+ repeat.current = undefined;
303
+ }, []);
304
+ useEffect(() => stopRepeat, [stopRepeat]);
305
+
306
+ const startRepeat = (event: PointerEvent<HTMLButtonElement>, direction: 1 | -1) => {
307
+ // Left button only, and never on a touch long-press, which is a text
308
+ // selection gesture rather than a request for a hundred more.
309
+ if (event.button !== 0) return;
310
+ event.preventDefault();
311
+ inputRef.current?.focus();
312
+ applyStep(direction);
313
+ event.currentTarget.setPointerCapture(event.pointerId);
314
+ const tick = () => {
315
+ latestStep.current(direction);
316
+ repeat.current = setTimeout(tick, REPEAT_INTERVAL);
317
+ };
318
+ repeat.current = setTimeout(tick, REPEAT_DELAY);
319
+ };
320
+
321
+ const invalid = Boolean(error);
322
+ const atMin = value !== null && min !== undefined && value <= min;
323
+ const atMax = value !== null && max !== undefined && value >= max;
324
+ const describedBy = describeField(id, { extra: describedByProp, description, error });
325
+
326
+ return (
327
+ <div
328
+ className={cx("nm-root", classNames?.root, className)}
329
+ style={style}
330
+ data-size={size}
331
+ data-disabled={disabled || undefined}
332
+ data-invalid={invalid ? "" : undefined}
333
+ >
334
+ {label && (
335
+ <label htmlFor={id} className={cx("nm-label", classNames?.label)} data-required={required || undefined}>
336
+ {label}
337
+ </label>
338
+ )}
339
+
340
+ <div
341
+ className={cx("nm-control", classNames?.control)}
342
+ data-disabled={disabled || undefined}
343
+ data-readonly={readOnly || undefined}
344
+ data-invalid={invalid ? "" : undefined}
345
+ // A press on the padding or an adornment focuses the number, like a
346
+ // native field. Buttons keep their own press.
347
+ onMouseDown={(event) => {
348
+ const target = event.target as Element;
349
+ if (target === inputRef.current || target.closest("button") || disabled) return;
350
+ event.preventDefault();
351
+ inputRef.current?.focus();
352
+ }}
353
+ >
354
+ {leading && (
355
+ <span className="nm-adornment" data-side="leading">
356
+ {leading}
357
+ </span>
358
+ )}
359
+
360
+ <input
361
+ {...inputProps}
362
+ ref={inputRef}
363
+ id={id}
364
+ // Text, not number: see the note on the component.
365
+ type="text"
366
+ inputMode={min !== undefined && min >= 0 && decimals === 0 ? "numeric" : "decimal"}
367
+ autoComplete="off"
368
+ autoCorrect="off"
369
+ spellCheck={false}
370
+ role="spinbutton"
371
+ className={cx("nm-input", classNames?.input)}
372
+ value={shown}
373
+ placeholder={placeholder}
374
+ disabled={disabled}
375
+ readOnly={readOnly}
376
+ required={required}
377
+ aria-valuenow={value ?? undefined}
378
+ aria-valuemin={min}
379
+ aria-valuemax={max}
380
+ // Read out as "1,234.56 US dollars" rather than as bare digits.
381
+ aria-valuetext={value === null ? undefined : formatNumber(value, formatOptions)}
382
+ aria-invalid={invalid || undefined}
383
+ aria-describedby={describedBy}
384
+ onChange={handleChange}
385
+ onFocus={handleFocus}
386
+ onBlur={handleBlur}
387
+ onKeyDown={handleKeyDown}
388
+ />
389
+
390
+ {clearable && shown !== "" && !disabled && !readOnly && (
391
+ <button
392
+ type="button"
393
+ tabIndex={-1}
394
+ className="nm-icon-button"
395
+ aria-label={text.clear}
396
+ title={text.clear}
397
+ onClick={() => {
398
+ setDraft(null);
399
+ commit(null);
400
+ inputRef.current?.focus();
401
+ }}
402
+ >
403
+ <XIcon />
404
+ </button>
405
+ )}
406
+ {trailing && (
407
+ <span className="nm-adornment" data-side="trailing">
408
+ {trailing}
409
+ </span>
410
+ )}
411
+
412
+ {stepper && !readOnly && (
413
+ <span className={cx("nm-stepper", classNames?.stepper)}>
414
+ {/*
415
+ Outside the tab order, like the native spinner's buttons: the
416
+ keyboard has the arrow keys, and two extra tab stops per field
417
+ would make a form of them painful to move through.
418
+ */}
419
+ <button
420
+ type="button"
421
+ tabIndex={-1}
422
+ className="nm-step"
423
+ data-direction="up"
424
+ aria-label={text.increment}
425
+ aria-controls={id}
426
+ disabled={disabled || atMax}
427
+ onPointerDown={(event) => startRepeat(event, 1)}
428
+ onPointerUp={stopRepeat}
429
+ onPointerCancel={stopRepeat}
430
+ onLostPointerCapture={stopRepeat}
431
+ >
432
+ <ChevronUpIcon />
433
+ </button>
434
+ <button
435
+ type="button"
436
+ tabIndex={-1}
437
+ className="nm-step"
438
+ data-direction="down"
439
+ aria-label={text.decrement}
440
+ aria-controls={id}
441
+ disabled={disabled || atMin}
442
+ onPointerDown={(event) => startRepeat(event, -1)}
443
+ onPointerUp={stopRepeat}
444
+ onPointerCancel={stopRepeat}
445
+ onLostPointerCapture={stopRepeat}
446
+ >
447
+ <ChevronDownIcon />
448
+ </button>
449
+ </span>
450
+ )}
451
+ </div>
452
+
453
+ {description && (
454
+ <span id={`${id}-description`} className={cx("nm-description", classNames?.description)}>
455
+ {description}
456
+ </span>
457
+ )}
458
+ {error && (
459
+ <span id={`${id}-error`} className={cx("nm-error", classNames?.error)} role="alert">
460
+ {error}
461
+ </span>
462
+ )}
463
+ </div>
464
+ );
465
+ }
@@ -0,0 +1,17 @@
1
+ import { Svg, XIcon as SharedX, type IconProps } from "../../shared/react/icons";
2
+
3
+ /** 14px, to sit inside the field without crowding the number. */
4
+ export const XIcon = (p: IconProps) => <SharedX width={14} height={14} {...p} />;
5
+
6
+ /** Chevrons rather than plus and minus: the pair reads as one spinner. */
7
+ export const ChevronUpIcon = (p: IconProps) => (
8
+ <Svg width={12} height={12} {...p}>
9
+ <path d="m6 15 6-6 6 6" />
10
+ </Svg>
11
+ );
12
+
13
+ export const ChevronDownIcon = (p: IconProps) => (
14
+ <Svg width={12} height={12} {...p}>
15
+ <path d="m6 9 6 6 6-6" />
16
+ </Svg>
17
+ );
@@ -0,0 +1,7 @@
1
+ import type { NumberInputLocaleText } from "../core/types";
2
+
3
+ export const defaultNumberInputText: NumberInputLocaleText = {
4
+ increment: "Increase",
5
+ decrement: "Decrease",
6
+ clear: "Clear",
7
+ };
@@ -0,0 +1,11 @@
1
+ export type NumberInputSlot =
2
+ | "root"
3
+ | "label"
4
+ | "control"
5
+ | "input"
6
+ | "stepper"
7
+ | "description"
8
+ | "error";
9
+
10
+ // Shared with every other component, so class handling cannot drift.
11
+ export { cx } from "../../shared/core/cx";