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,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
|
+
);
|