@usefui/components 1.6.0 → 1.7.1
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/CHANGELOG.md +12 -0
- package/dist/index.d.mts +380 -52
- package/dist/index.d.ts +380 -52
- package/dist/index.js +2532 -511
- package/dist/index.mjs +2518 -508
- package/package.json +3 -3
- package/src/__tests__/Avatar.test.tsx +55 -55
- package/src/accordion/Accordion.stories.tsx +6 -4
- package/src/accordion/index.tsx +1 -2
- package/src/avatar/Avatar.stories.tsx +37 -7
- package/src/avatar/index.tsx +90 -19
- package/src/avatar/styles/index.ts +58 -12
- package/src/badge/Badge.stories.tsx +27 -5
- package/src/badge/index.tsx +21 -13
- package/src/badge/styles/index.ts +69 -40
- package/src/button/Button.stories.tsx +40 -27
- package/src/button/index.tsx +13 -9
- package/src/button/styles/index.ts +308 -47
- package/src/card/index.tsx +2 -4
- package/src/checkbox/Checkbox.stories.tsx +72 -33
- package/src/checkbox/index.tsx +8 -6
- package/src/checkbox/styles/index.ts +239 -19
- package/src/collapsible/Collapsible.stories.tsx +6 -4
- package/src/dialog/Dialog.stories.tsx +173 -31
- package/src/dialog/styles/index.ts +13 -8
- package/src/dropdown/Dropdown.stories.tsx +61 -23
- package/src/dropdown/index.tsx +42 -31
- package/src/dropdown/styles/index.ts +30 -19
- package/src/field/Field.stories.tsx +183 -24
- package/src/field/index.tsx +930 -13
- package/src/field/styles/index.ts +246 -14
- package/src/field/types/index.ts +31 -0
- package/src/field/utils/index.ts +201 -0
- package/src/index.ts +2 -1
- package/src/message-bubble/MessageBubble.stories.tsx +59 -12
- package/src/message-bubble/index.tsx +22 -4
- package/src/message-bubble/styles/index.ts +4 -7
- package/src/otp-field/OTPField.stories.tsx +22 -24
- package/src/otp-field/index.tsx +9 -0
- package/src/otp-field/styles/index.ts +114 -16
- package/src/otp-field/types/index.ts +9 -1
- package/src/overlay/styles/index.ts +1 -0
- package/src/ruler/Ruler.stories.tsx +43 -0
- package/src/ruler/constants/index.ts +3 -0
- package/src/ruler/hooks/index.tsx +53 -0
- package/src/ruler/index.tsx +239 -0
- package/src/ruler/styles/index.tsx +154 -0
- package/src/ruler/types/index.ts +17 -0
- package/src/select/Select.stories.tsx +91 -0
- package/src/select/hooks/index.tsx +71 -0
- package/src/select/index.tsx +331 -0
- package/src/select/styles/index.tsx +156 -0
- package/src/shimmer/Shimmer.stories.tsx +6 -4
- package/src/skeleton/index.tsx +7 -6
- package/src/spinner/Spinner.stories.tsx +29 -4
- package/src/spinner/index.tsx +16 -6
- package/src/spinner/styles/index.ts +41 -22
- package/src/switch/Switch.stories.tsx +46 -17
- package/src/switch/index.tsx +5 -8
- package/src/switch/styles/index.ts +45 -45
- package/src/tabs/Tabs.stories.tsx +43 -15
- package/src/text-area/Textarea.stories.tsx +45 -8
- package/src/text-area/index.tsx +9 -6
- package/src/text-area/styles/index.ts +1 -1
- package/src/toggle/Toggle.stories.tsx +6 -4
- package/src/tree/Tree.stories.tsx +6 -4
- package/src/privacy-field/PrivacyField.stories.tsx +0 -29
- package/src/privacy-field/index.tsx +0 -56
- package/src/privacy-field/styles/index.ts +0 -17
package/src/field/index.tsx
CHANGED
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { FieldProvider, useField } from "./hooks";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Fieldset,
|
|
8
|
+
Sup,
|
|
9
|
+
Input,
|
|
10
|
+
HiddenInput,
|
|
11
|
+
Label,
|
|
12
|
+
Def,
|
|
13
|
+
Muted,
|
|
14
|
+
ParentContainer,
|
|
15
|
+
ParentWrapper,
|
|
16
|
+
InnerDivider,
|
|
17
|
+
InnerWrapper,
|
|
18
|
+
InnerTrigger,
|
|
19
|
+
InnerSegment,
|
|
20
|
+
} from "./styles";
|
|
21
|
+
import { Button, Badge } from "../";
|
|
22
|
+
|
|
6
23
|
import {
|
|
7
24
|
IReactChildren,
|
|
8
25
|
IComponentStyling,
|
|
@@ -10,8 +27,11 @@ import {
|
|
|
10
27
|
IComponentSize,
|
|
11
28
|
ComponentVariantEnum,
|
|
12
29
|
IComponentVariant,
|
|
13
|
-
|
|
30
|
+
ComponentShapeEnum,
|
|
31
|
+
IComponentShape,
|
|
14
32
|
} from "../../../../types";
|
|
33
|
+
import { TDateSegmentType, ISegment, SegmentRanges, DateState } from "./types";
|
|
34
|
+
import { dateToState, buildSegments, commitState, clamp } from "./utils";
|
|
15
35
|
|
|
16
36
|
export enum MetaVariantEnum {
|
|
17
37
|
Default = "default",
|
|
@@ -23,29 +43,75 @@ export enum MetaVariantEnum {
|
|
|
23
43
|
export type TMetaVariant = "default" | "hint" | "emphasis" | "error";
|
|
24
44
|
|
|
25
45
|
export interface IField
|
|
26
|
-
extends
|
|
46
|
+
extends
|
|
47
|
+
React.ComponentProps<"input">,
|
|
27
48
|
IComponentSize,
|
|
28
49
|
IComponentVariant,
|
|
50
|
+
IComponentShape,
|
|
29
51
|
IComponentStyling {
|
|
30
|
-
shape?: TComponentShape;
|
|
31
52
|
hint?: string;
|
|
32
53
|
error?: string;
|
|
33
54
|
}
|
|
34
55
|
export interface IFieldLabel
|
|
35
|
-
extends React.ComponentProps<"label">,
|
|
36
|
-
IComponentStyling {
|
|
56
|
+
extends React.ComponentProps<"label">, IComponentStyling {
|
|
37
57
|
optional?: boolean;
|
|
38
58
|
}
|
|
39
59
|
export interface IFieldMeta
|
|
40
|
-
extends React.ComponentProps<"small">,
|
|
41
|
-
IComponentStyling {
|
|
60
|
+
extends React.ComponentProps<"small">, IComponentStyling {
|
|
42
61
|
variant?: TMetaVariant;
|
|
43
62
|
}
|
|
63
|
+
export interface IFieldNumber extends Omit<IField, "type"> {}
|
|
64
|
+
export interface IFieldDate
|
|
65
|
+
extends
|
|
66
|
+
IComponentSize,
|
|
67
|
+
IComponentVariant,
|
|
68
|
+
IComponentShape,
|
|
69
|
+
IComponentStyling {
|
|
70
|
+
value?: Date;
|
|
71
|
+
defaultValue?: Date;
|
|
72
|
+
onChange?: (date: Date) => void;
|
|
73
|
+
hint?: string;
|
|
74
|
+
error?: string;
|
|
75
|
+
locale?: string;
|
|
76
|
+
withTime?: boolean;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
id?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface IFieldFile extends Omit<IField, "type" | "children"> {
|
|
81
|
+
trigger?: React.ReactNode;
|
|
82
|
+
onFileChange?: (files: FileList | null) => void;
|
|
83
|
+
}
|
|
84
|
+
type PrivacyType = "password" | "text";
|
|
85
|
+
interface IFieldPassword extends IField {
|
|
86
|
+
defaultType?: PrivacyType;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface IFieldTag
|
|
90
|
+
extends
|
|
91
|
+
IComponentSize,
|
|
92
|
+
IComponentVariant,
|
|
93
|
+
IComponentShape,
|
|
94
|
+
IComponentStyling {
|
|
95
|
+
value?: string[];
|
|
96
|
+
defaultValue?: string[];
|
|
97
|
+
allowed?: string[];
|
|
98
|
+
onChange?: (tags: string[]) => void;
|
|
99
|
+
error?: string;
|
|
100
|
+
disabled?: boolean;
|
|
101
|
+
placeholder?: string;
|
|
102
|
+
id?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
44
105
|
export interface IFieldComposition {
|
|
45
106
|
Root: typeof FieldRoot;
|
|
46
107
|
Wrapper: typeof FieldWrapper;
|
|
47
108
|
Label: typeof FieldLabel;
|
|
48
109
|
Meta: typeof FieldMeta;
|
|
110
|
+
Number: typeof FieldNumber;
|
|
111
|
+
Date: typeof FieldDate;
|
|
112
|
+
File: typeof FieldFile;
|
|
113
|
+
Password: typeof FieldPassword;
|
|
114
|
+
Tag: typeof FieldTag;
|
|
49
115
|
}
|
|
50
116
|
|
|
51
117
|
/**
|
|
@@ -69,8 +135,8 @@ const Field = (props: IField) => {
|
|
|
69
135
|
const {
|
|
70
136
|
raw,
|
|
71
137
|
sizing = ComponentSizeEnum.Medium,
|
|
72
|
-
variant = ComponentVariantEnum.
|
|
73
|
-
shape =
|
|
138
|
+
variant = ComponentVariantEnum.Secondary,
|
|
139
|
+
shape = ComponentShapeEnum.Smooth,
|
|
74
140
|
error,
|
|
75
141
|
hint,
|
|
76
142
|
...restProps
|
|
@@ -80,7 +146,7 @@ const Field = (props: IField) => {
|
|
|
80
146
|
const { id } = useField();
|
|
81
147
|
|
|
82
148
|
return (
|
|
83
|
-
|
|
149
|
+
<React.Fragment>
|
|
84
150
|
<Input
|
|
85
151
|
id={id}
|
|
86
152
|
aria-invalid={!!error}
|
|
@@ -101,7 +167,7 @@ const Field = (props: IField) => {
|
|
|
101
167
|
{error ?? hint}
|
|
102
168
|
</FieldMeta>
|
|
103
169
|
)}
|
|
104
|
-
|
|
170
|
+
</React.Fragment>
|
|
105
171
|
);
|
|
106
172
|
};
|
|
107
173
|
Field.displayName = "Field";
|
|
@@ -180,9 +246,860 @@ const FieldMeta = (props: IFieldMeta) => {
|
|
|
180
246
|
};
|
|
181
247
|
FieldMeta.displayName = "Field.Meta";
|
|
182
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Field.Number is a numeric input field with increment/decrement controls.
|
|
251
|
+
*
|
|
252
|
+
* **Best practices:**
|
|
253
|
+
*
|
|
254
|
+
* - Provide clear and descriptive labels for all numeric inputs.
|
|
255
|
+
* - Use `min`, `max`, and `step` props to constrain valid values.
|
|
256
|
+
*
|
|
257
|
+
* @param {IFieldNumber} props - The props for the Field.Number component.
|
|
258
|
+
* @param {boolean} props.raw - Define whether the component is styled or not.
|
|
259
|
+
* @param {ComponentSizeEnum} props.sizing - The size of the component. Defaults to `medium`.
|
|
260
|
+
* @param {string} props.variant - The style definition used by the component.
|
|
261
|
+
* @param {TComponentShape} props.shape - The shape of the component. Defaults to `smooth`.
|
|
262
|
+
* @returns {ReactElement} The Field.Number component.
|
|
263
|
+
*/
|
|
264
|
+
const FieldNumber = (props: IFieldNumber) => {
|
|
265
|
+
const {
|
|
266
|
+
raw,
|
|
267
|
+
sizing = ComponentSizeEnum.Medium,
|
|
268
|
+
variant = ComponentVariantEnum.Secondary,
|
|
269
|
+
shape = ComponentShapeEnum.Smooth,
|
|
270
|
+
error,
|
|
271
|
+
step = 1,
|
|
272
|
+
...restProps
|
|
273
|
+
} = props;
|
|
274
|
+
|
|
275
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
276
|
+
|
|
277
|
+
const handleStep = (direction: "up" | "down") => {
|
|
278
|
+
if (!inputRef.current) return;
|
|
279
|
+
direction === "up"
|
|
280
|
+
? inputRef.current.stepUp()
|
|
281
|
+
: inputRef.current.stepDown();
|
|
282
|
+
inputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const ChevronIcon = ({ direction }: { direction: "up" | "down" }) => (
|
|
286
|
+
<svg
|
|
287
|
+
width="8"
|
|
288
|
+
height="4"
|
|
289
|
+
viewBox="0 0 10 6"
|
|
290
|
+
fill="none"
|
|
291
|
+
style={{
|
|
292
|
+
transform: direction === "up" ? "rotate(180deg)" : "none",
|
|
293
|
+
}}
|
|
294
|
+
aria-hidden="true"
|
|
295
|
+
>
|
|
296
|
+
<path
|
|
297
|
+
d="M1 1L5 5L9 1"
|
|
298
|
+
stroke="currentColor"
|
|
299
|
+
strokeWidth="1.5"
|
|
300
|
+
strokeLinecap="round"
|
|
301
|
+
strokeLinejoin="round"
|
|
302
|
+
/>
|
|
303
|
+
</svg>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<ParentContainer data-raw={Boolean(raw)}>
|
|
308
|
+
<Field
|
|
309
|
+
ref={inputRef}
|
|
310
|
+
type="number"
|
|
311
|
+
raw={raw}
|
|
312
|
+
sizing={sizing}
|
|
313
|
+
variant={variant}
|
|
314
|
+
shape={shape}
|
|
315
|
+
error={error}
|
|
316
|
+
step={step}
|
|
317
|
+
{...restProps}
|
|
318
|
+
/>
|
|
319
|
+
<InnerWrapper
|
|
320
|
+
data-raw={Boolean(raw)}
|
|
321
|
+
data-error={Boolean(error)}
|
|
322
|
+
data-variant={variant}
|
|
323
|
+
data-shape={shape}
|
|
324
|
+
data-multiple="true"
|
|
325
|
+
>
|
|
326
|
+
<InnerTrigger
|
|
327
|
+
type="button"
|
|
328
|
+
aria-label="Increment"
|
|
329
|
+
data-raw={Boolean(raw)}
|
|
330
|
+
onClick={() => handleStep("up")}
|
|
331
|
+
tabIndex={-1}
|
|
332
|
+
>
|
|
333
|
+
<ChevronIcon direction="up" />
|
|
334
|
+
</InnerTrigger>
|
|
335
|
+
<InnerDivider data-raw={Boolean(raw)} />
|
|
336
|
+
<InnerTrigger
|
|
337
|
+
type="button"
|
|
338
|
+
aria-label="Decrement"
|
|
339
|
+
data-raw={Boolean(raw)}
|
|
340
|
+
onClick={() => handleStep("down")}
|
|
341
|
+
tabIndex={-1}
|
|
342
|
+
>
|
|
343
|
+
<ChevronIcon direction="down" />
|
|
344
|
+
</InnerTrigger>
|
|
345
|
+
</InnerWrapper>
|
|
346
|
+
</ParentContainer>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
FieldNumber.displayName = "Field.Number";
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Field.Date is a segmented date (and optionally time) input driven by `Intl.DateTimeFormat`.
|
|
353
|
+
*
|
|
354
|
+
* **Best practices:**
|
|
355
|
+
*
|
|
356
|
+
* - Pair with `Field.Label` so screen readers announce the field correctly.
|
|
357
|
+
* - Pass `locale` to match the user's regional date format.
|
|
358
|
+
* - Use `withTime` when you need both date and time selection.
|
|
359
|
+
*
|
|
360
|
+
* @param {IFieldDate} props
|
|
361
|
+
* @param {ComponentSizeEnum} props.sizing - The size of the component. Defaults to `medium`.
|
|
362
|
+
* @param {string} props.variant - The style definition used by the component.
|
|
363
|
+
* @param {TComponentShape} props.shape - The size of the component. Defaults to `smooth`.
|
|
364
|
+
* @param {Date} props.value - Controlled date value.
|
|
365
|
+
* @param {Date} props.defaultValue - Uncontrolled initial value.
|
|
366
|
+
* @param {(date: Date) => void} props.onChange - Called on every segment change.
|
|
367
|
+
* @param {string} props.locale - BCP 47 locale tag. Defaults to browser locale.
|
|
368
|
+
* @param {boolean} props.withTime - Show hour/minute segments. Defaults to false.
|
|
369
|
+
*/
|
|
370
|
+
const FieldDate = (props: IFieldDate) => {
|
|
371
|
+
const {
|
|
372
|
+
raw,
|
|
373
|
+
sizing = ComponentSizeEnum.Medium,
|
|
374
|
+
variant = ComponentVariantEnum.Secondary,
|
|
375
|
+
shape = ComponentShapeEnum.Smooth,
|
|
376
|
+
error,
|
|
377
|
+
value,
|
|
378
|
+
defaultValue,
|
|
379
|
+
onChange,
|
|
380
|
+
locale = typeof globalThis.navigator !== "undefined"
|
|
381
|
+
? globalThis.navigator.language
|
|
382
|
+
: "en-US",
|
|
383
|
+
withTime = false,
|
|
384
|
+
disabled = false,
|
|
385
|
+
id: idProp,
|
|
386
|
+
} = props;
|
|
387
|
+
|
|
388
|
+
const { id: contextId } = useField();
|
|
389
|
+
const id = idProp ?? contextId;
|
|
390
|
+
|
|
391
|
+
const isControlled = value !== undefined;
|
|
392
|
+
|
|
393
|
+
const metaId = React.useId();
|
|
394
|
+
|
|
395
|
+
// Accumulates digit keypresses within a single segment before committing,
|
|
396
|
+
// allowing e.g. typing "1" then "2" to produce "12" for the day segment
|
|
397
|
+
const bufferRef = React.useRef<string>("");
|
|
398
|
+
|
|
399
|
+
// Map of segment type, DOM element for programmatic focus
|
|
400
|
+
const segmentRefs = React.useRef<
|
|
401
|
+
Map<TDateSegmentType, HTMLSpanElement | null>
|
|
402
|
+
>(new Map());
|
|
403
|
+
|
|
404
|
+
const [internalState, setInternalState] = React.useState<DateState>(() =>
|
|
405
|
+
dateToState(defaultValue ?? value ?? new Date()),
|
|
406
|
+
);
|
|
407
|
+
const [focusedSegment, setFocusedSegment] =
|
|
408
|
+
React.useState<TDateSegmentType | null>(null);
|
|
409
|
+
|
|
410
|
+
const segments = buildSegments(internalState, locale, withTime);
|
|
411
|
+
|
|
412
|
+
// Ordered list of focusable segment types, excluding non-interactive literals
|
|
413
|
+
const editableSegments = segments
|
|
414
|
+
.filter(
|
|
415
|
+
(s): s is ISegment & { type: Exclude<TDateSegmentType, "literal"> } =>
|
|
416
|
+
s.type !== "literal",
|
|
417
|
+
)
|
|
418
|
+
.map((s) => s.type);
|
|
419
|
+
|
|
420
|
+
const stepSegment = (
|
|
421
|
+
seg: Exclude<TDateSegmentType, "literal">,
|
|
422
|
+
delta: number,
|
|
423
|
+
) => {
|
|
424
|
+
const { min, max } = SegmentRanges[seg];
|
|
425
|
+
|
|
426
|
+
const current = internalState[seg];
|
|
427
|
+
const range = max(internalState) - min + 1;
|
|
428
|
+
// Wrap around using modulo so incrementing past max rolls back to min
|
|
429
|
+
const next = ((current - min + delta + range) % range) + min;
|
|
430
|
+
|
|
431
|
+
commitState(
|
|
432
|
+
isControlled,
|
|
433
|
+
{ ...internalState, [seg]: next },
|
|
434
|
+
setInternalState,
|
|
435
|
+
onChange,
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const handleSegmentKeyDown = (
|
|
440
|
+
e: React.KeyboardEvent<HTMLSpanElement>,
|
|
441
|
+
seg: Exclude<TDateSegmentType, "literal">,
|
|
442
|
+
) => {
|
|
443
|
+
if (disabled) return;
|
|
444
|
+
|
|
445
|
+
const idx = editableSegments.indexOf(seg);
|
|
446
|
+
|
|
447
|
+
switch (e.key) {
|
|
448
|
+
case "ArrowUp": {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
bufferRef.current = "";
|
|
451
|
+
stepSegment(seg, 1);
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case "ArrowDown": {
|
|
455
|
+
e.preventDefault();
|
|
456
|
+
bufferRef.current = "";
|
|
457
|
+
stepSegment(seg, -1);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
// Move to the previous segment and reset the buffer
|
|
461
|
+
case "ArrowLeft":
|
|
462
|
+
case "Backspace": {
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
bufferRef.current = "";
|
|
465
|
+
if (idx > 0) focusSegmentByType(editableSegments[idx - 1]);
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
// ArrowRight advances manually; Tab is left to bubble for natural focus
|
|
469
|
+
case "ArrowRight":
|
|
470
|
+
case "Tab": {
|
|
471
|
+
if (e.key === "ArrowRight") {
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
bufferRef.current = "";
|
|
474
|
+
if (idx < editableSegments.length - 1)
|
|
475
|
+
focusSegmentByType(editableSegments[idx + 1]);
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
default: {
|
|
480
|
+
if (/^\d$/.test(e.key)) {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
bufferRef.current += e.key;
|
|
483
|
+
const num = parseInt(bufferRef.current, 10);
|
|
484
|
+
const { max } = SegmentRanges[seg];
|
|
485
|
+
const maxVal = max(internalState);
|
|
486
|
+
const clamped = clamp(num, seg, internalState);
|
|
487
|
+
|
|
488
|
+
commitState(
|
|
489
|
+
isControlled,
|
|
490
|
+
{ ...internalState, [seg]: clamped },
|
|
491
|
+
setInternalState,
|
|
492
|
+
onChange,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Auto-advance when adding another digit would inevitably overflow,
|
|
496
|
+
// or when the buffer has reached the maximum digit count for the segment
|
|
497
|
+
const maxDigits = String(maxVal).length;
|
|
498
|
+
const willOverflow =
|
|
499
|
+
parseInt(bufferRef.current + "0", 10) > maxVal ||
|
|
500
|
+
bufferRef.current.length >= maxDigits;
|
|
501
|
+
|
|
502
|
+
if (willOverflow) {
|
|
503
|
+
bufferRef.current = "";
|
|
504
|
+
if (idx < editableSegments.length - 1)
|
|
505
|
+
focusSegmentByType(editableSegments[idx + 1]);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const focusSegmentByType = (type: TDateSegmentType | undefined) => {
|
|
513
|
+
if (!type) return;
|
|
514
|
+
segmentRefs.current.get(type)?.focus();
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Focuses the first editable segment when the user clicks anywhere on the
|
|
519
|
+
* wrapper that is not already a segment span.
|
|
520
|
+
*
|
|
521
|
+
* The `data-segment` attribute on each `InnerSegment` is used as a guard so
|
|
522
|
+
* that clicks directly on a segment are handled by that segment's own
|
|
523
|
+
* `onFocus` without resetting the buffer or stealing focus.
|
|
524
|
+
*
|
|
525
|
+
* `setTimeout(0)` defers the `.focus()` call until after the browser has
|
|
526
|
+
* finished processing the current click event, preventing the programmatic
|
|
527
|
+
* focus from being immediately overridden by native browser behavior.
|
|
528
|
+
*/
|
|
529
|
+
const handleWrapperClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
530
|
+
// Segment already received the click - its onFocus will handle it
|
|
531
|
+
if ((e.target as HTMLElement).dataset.segment) return;
|
|
532
|
+
|
|
533
|
+
const timeout = setTimeout(() => {
|
|
534
|
+
focusSegmentByType(editableSegments[0]);
|
|
535
|
+
}, 0);
|
|
536
|
+
|
|
537
|
+
return () => clearTimeout(timeout);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Sync controlled value - internal state when parent updates it
|
|
541
|
+
React.useEffect(() => {
|
|
542
|
+
if (isControlled && value) setInternalState(dateToState(value));
|
|
543
|
+
}, [isControlled, value]);
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<ParentWrapper
|
|
547
|
+
id={id}
|
|
548
|
+
role="group"
|
|
549
|
+
aria-label="Date input"
|
|
550
|
+
aria-invalid={!!error}
|
|
551
|
+
aria-describedby={metaId}
|
|
552
|
+
data-error={Boolean(error)}
|
|
553
|
+
data-variant={variant}
|
|
554
|
+
data-size={sizing}
|
|
555
|
+
data-shape={shape}
|
|
556
|
+
data-raw={Boolean(raw)}
|
|
557
|
+
data-disabled={disabled}
|
|
558
|
+
// Focus the first segment on wrapper click
|
|
559
|
+
onClick={handleWrapperClick}
|
|
560
|
+
>
|
|
561
|
+
{segments.map((seg, i) => {
|
|
562
|
+
if (seg.type === "literal") {
|
|
563
|
+
return (
|
|
564
|
+
<Muted key={i} data-raw={Boolean(raw)} aria-hidden="true">
|
|
565
|
+
{seg.value}
|
|
566
|
+
</Muted>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const isFocused = focusedSegment === seg.type;
|
|
571
|
+
|
|
572
|
+
return (
|
|
573
|
+
<InnerSegment
|
|
574
|
+
key={seg.type}
|
|
575
|
+
ref={(el: HTMLSpanElement | null) =>
|
|
576
|
+
segmentRefs.current.set(seg.type, el)
|
|
577
|
+
}
|
|
578
|
+
role="spinbutton"
|
|
579
|
+
aria-label={seg.type}
|
|
580
|
+
aria-valuenow={internalState[seg.type]}
|
|
581
|
+
aria-valuemin={SegmentRanges[seg.type].min}
|
|
582
|
+
aria-valuemax={SegmentRanges[seg.type].max(internalState)}
|
|
583
|
+
tabIndex={disabled ? -1 : 0}
|
|
584
|
+
data-raw={Boolean(raw)}
|
|
585
|
+
data-focused={isFocused}
|
|
586
|
+
// Guard attribute checked by handleWrapperClick to avoid
|
|
587
|
+
// double-focusing when the click lands directly on a segment
|
|
588
|
+
data-segment={seg.type}
|
|
589
|
+
onFocus={() => {
|
|
590
|
+
setFocusedSegment(seg.type);
|
|
591
|
+
bufferRef.current = "";
|
|
592
|
+
}}
|
|
593
|
+
onBlur={() => setFocusedSegment(null)}
|
|
594
|
+
onKeyDown={(e: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
595
|
+
if (seg.type === "literal") return;
|
|
596
|
+
handleSegmentKeyDown(e, seg.type);
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
{seg.value}
|
|
600
|
+
</InnerSegment>
|
|
601
|
+
);
|
|
602
|
+
})}
|
|
603
|
+
</ParentWrapper>
|
|
604
|
+
);
|
|
605
|
+
};
|
|
606
|
+
FieldDate.displayName = "Field.Date";
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Field.File is a file upload field composed of a read-only text input that
|
|
610
|
+
* displays the selected filename and a trigger button that opens the native
|
|
611
|
+
* file picker.
|
|
612
|
+
*
|
|
613
|
+
* **Best practices:**
|
|
614
|
+
*
|
|
615
|
+
* - Pair with `Field.Label` so screen readers announce the field correctly.
|
|
616
|
+
* - Use `accept` to constrain the file types shown in the picker.
|
|
617
|
+
* - Reflect allowed formats and size limits in a `Field.Meta` hint.
|
|
618
|
+
*
|
|
619
|
+
* @param {IFieldFile} props
|
|
620
|
+
* @param {ComponentSizeEnum} props.sizing - The size of the component. Defaults to `medium`.
|
|
621
|
+
* @param {string} props.variant - The style definition used by the component.
|
|
622
|
+
* @param {TComponentShape} props.shape - The size of the component. Defaults to `smooth`.
|
|
623
|
+
* @param {React.ReactNode} props.trigger - Content for the upload button.
|
|
624
|
+
* @param {(files: FileList | null) => void} props.onFileChange - Called with the selected `FileList` after the user picks files.
|
|
625
|
+
*/
|
|
626
|
+
const FieldFile = (props: IFieldFile) => {
|
|
627
|
+
const {
|
|
628
|
+
raw,
|
|
629
|
+
sizing = ComponentSizeEnum.Medium,
|
|
630
|
+
variant = ComponentVariantEnum.Secondary,
|
|
631
|
+
shape = ComponentShapeEnum.Smooth,
|
|
632
|
+
error,
|
|
633
|
+
trigger,
|
|
634
|
+
onFileChange,
|
|
635
|
+
disabled,
|
|
636
|
+
accept,
|
|
637
|
+
multiple,
|
|
638
|
+
...restProps
|
|
639
|
+
} = props;
|
|
640
|
+
|
|
641
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
642
|
+
const [fileName, setFileName] = React.useState<string>("");
|
|
643
|
+
|
|
644
|
+
const handleTriggerClick = () => {
|
|
645
|
+
fileInputRef.current?.click();
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
649
|
+
const files = e.target.files;
|
|
650
|
+
|
|
651
|
+
if (files && files.length > 0) {
|
|
652
|
+
const names = Array.from(files)
|
|
653
|
+
.map((f) => f.name)
|
|
654
|
+
.join(", ");
|
|
655
|
+
|
|
656
|
+
setFileName(names);
|
|
657
|
+
} else setFileName("");
|
|
658
|
+
|
|
659
|
+
onFileChange?.(files);
|
|
660
|
+
};
|
|
661
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
662
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
fileInputRef.current?.click();
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
return (
|
|
668
|
+
<React.Fragment>
|
|
669
|
+
<input
|
|
670
|
+
ref={fileInputRef}
|
|
671
|
+
type="file"
|
|
672
|
+
aria-hidden="true"
|
|
673
|
+
tabIndex={-1}
|
|
674
|
+
disabled={disabled}
|
|
675
|
+
accept={accept}
|
|
676
|
+
multiple={multiple}
|
|
677
|
+
onChange={handleFileChange}
|
|
678
|
+
style={{ display: "none" }}
|
|
679
|
+
/>
|
|
680
|
+
|
|
681
|
+
<ParentContainer data-raw={Boolean(raw)}>
|
|
682
|
+
<Field
|
|
683
|
+
type="text"
|
|
684
|
+
readOnly
|
|
685
|
+
raw={raw}
|
|
686
|
+
sizing={sizing}
|
|
687
|
+
variant={variant}
|
|
688
|
+
shape={shape}
|
|
689
|
+
error={error}
|
|
690
|
+
disabled={disabled}
|
|
691
|
+
value={fileName}
|
|
692
|
+
onClick={(e) => {
|
|
693
|
+
handleTriggerClick();
|
|
694
|
+
restProps.onClick?.(e);
|
|
695
|
+
}}
|
|
696
|
+
onKeyDown={(e) => {
|
|
697
|
+
handleKeyDown(e);
|
|
698
|
+
restProps.onKeyDown?.(e);
|
|
699
|
+
}}
|
|
700
|
+
{...restProps}
|
|
701
|
+
/>
|
|
702
|
+
{trigger && (
|
|
703
|
+
<InnerWrapper
|
|
704
|
+
data-raw={Boolean(raw)}
|
|
705
|
+
data-error={Boolean(error)}
|
|
706
|
+
data-variant={variant}
|
|
707
|
+
data-shape={shape}
|
|
708
|
+
>
|
|
709
|
+
<InnerTrigger
|
|
710
|
+
type="button"
|
|
711
|
+
data-raw={Boolean(raw)}
|
|
712
|
+
data-shape={shape}
|
|
713
|
+
data-error={Boolean(error)}
|
|
714
|
+
disabled={disabled}
|
|
715
|
+
variant={variant}
|
|
716
|
+
onClick={handleTriggerClick}
|
|
717
|
+
aria-label={
|
|
718
|
+
typeof trigger === "string" ? trigger : "file-upload-trigger"
|
|
719
|
+
}
|
|
720
|
+
>
|
|
721
|
+
{trigger}
|
|
722
|
+
</InnerTrigger>
|
|
723
|
+
</InnerWrapper>
|
|
724
|
+
)}
|
|
725
|
+
</ParentContainer>
|
|
726
|
+
</React.Fragment>
|
|
727
|
+
);
|
|
728
|
+
};
|
|
729
|
+
FieldFile.displayName = "Field.File";
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Field.Password is a text input that toggles the visibility of its value
|
|
733
|
+
* between plain text and masked characters.
|
|
734
|
+
*
|
|
735
|
+
* **Best practices:**
|
|
736
|
+
*
|
|
737
|
+
* - Pair with `Field.Label` so screen readers announce the field correctly.
|
|
738
|
+
* - Avoid setting `autoComplete` to a value that would expose sensitive data.
|
|
739
|
+
* - Use `defaultType` to control the initial visibility state of the field.
|
|
740
|
+
*
|
|
741
|
+
* @param {IFieldPassword} props - The props for the Field.Password component.
|
|
742
|
+
* @param {boolean} props.raw - Define whether the component is styled or not.
|
|
743
|
+
* @param {ComponentSizeEnum} props.sizing - The size of the component. Defaults to `medium`.
|
|
744
|
+
* @param {string} props.variant - The style definition used by the component.
|
|
745
|
+
* @param {TComponentShape} props.shape - The shape of the component. Defaults to `smooth`.
|
|
746
|
+
* @param {string} props.error - The error message to display.
|
|
747
|
+
* @param {boolean} props.disabled - Whether the input is disabled.
|
|
748
|
+
* @param {PrivacyType} props.defaultType - The initial input type. Defaults to `password`.
|
|
749
|
+
* @returns {ReactElement} The Field.Password component.
|
|
750
|
+
*/
|
|
751
|
+
const FieldPassword = (props: IFieldPassword) => {
|
|
752
|
+
const {
|
|
753
|
+
raw,
|
|
754
|
+
sizing = ComponentSizeEnum.Medium,
|
|
755
|
+
variant = ComponentVariantEnum.Secondary,
|
|
756
|
+
shape = ComponentShapeEnum.Smooth,
|
|
757
|
+
error,
|
|
758
|
+
disabled,
|
|
759
|
+
defaultType,
|
|
760
|
+
...restProps
|
|
761
|
+
} = props;
|
|
762
|
+
|
|
763
|
+
const [type, setType] = React.useState<PrivacyType>(
|
|
764
|
+
defaultType ?? "password",
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const handleChangeType = React.useCallback(() => {
|
|
768
|
+
if (type === "text") setType("password");
|
|
769
|
+
if (type === "password") setType("text");
|
|
770
|
+
}, [type, setType]);
|
|
771
|
+
|
|
772
|
+
const ShowIcon = () => {
|
|
773
|
+
return (
|
|
774
|
+
<React.Fragment>
|
|
775
|
+
<path d="M2.42 12.713c-.136-.215-.204-.323-.242-.49a1.173 1.173 0 0 1 0-.446c.038-.167.106-.274.242-.49C3.546 9.505 6.895 5 12 5s8.455 4.505 9.58 6.287c.137.215.205.323.243.49.029.125.029.322 0 .446-.038.167-.106.274-.242.49C20.455 14.495 17.105 19 12 19c-5.106 0-8.455-4.505-9.58-6.287Z" />
|
|
776
|
+
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
|
777
|
+
</React.Fragment>
|
|
778
|
+
);
|
|
779
|
+
};
|
|
780
|
+
const HideIcon = () => {
|
|
781
|
+
return (
|
|
782
|
+
<React.Fragment>
|
|
783
|
+
<path d="M10.743 5.092C11.149 5.032 11.569 5 12 5c5.105 0 8.455 4.505 9.58 6.287.137.215.205.323.243.49a1.16 1.16 0 0 1 0 .447c-.038.166-.107.274-.244.492-.3.474-.757 1.141-1.363 1.865M6.724 6.715c-2.162 1.467-3.63 3.504-4.303 4.57-.137.217-.205.325-.243.492a1.173 1.173 0 0 0 0 .446c.038.167.106.274.242.49C3.546 14.495 6.895 19 12 19c2.059 0 3.832-.732 5.289-1.723M3 3l18 18M9.88 9.879a3 3 0 1 0 4.243 4.243" />
|
|
784
|
+
</React.Fragment>
|
|
785
|
+
);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
return (
|
|
789
|
+
<ParentContainer data-raw={Boolean(raw)}>
|
|
790
|
+
<Field
|
|
791
|
+
autoComplete="off"
|
|
792
|
+
type={type}
|
|
793
|
+
raw={raw}
|
|
794
|
+
sizing={sizing}
|
|
795
|
+
variant={variant}
|
|
796
|
+
shape={shape}
|
|
797
|
+
error={error}
|
|
798
|
+
disabled={disabled}
|
|
799
|
+
{...restProps}
|
|
800
|
+
/>
|
|
801
|
+
<InnerWrapper
|
|
802
|
+
data-raw={Boolean(raw)}
|
|
803
|
+
data-error={Boolean(error)}
|
|
804
|
+
data-variant={variant}
|
|
805
|
+
data-shape={shape}
|
|
806
|
+
>
|
|
807
|
+
<InnerTrigger
|
|
808
|
+
type="button"
|
|
809
|
+
data-raw={Boolean(raw)}
|
|
810
|
+
data-shape={shape}
|
|
811
|
+
data-error={Boolean(error)}
|
|
812
|
+
disabled={disabled}
|
|
813
|
+
variant={variant}
|
|
814
|
+
onClick={handleChangeType}
|
|
815
|
+
aria-label="password-field-trigger"
|
|
816
|
+
>
|
|
817
|
+
<svg
|
|
818
|
+
viewBox="0 0 24 24"
|
|
819
|
+
width="var(--fontsize-medium-10)"
|
|
820
|
+
height="var(--fontsize-medium-10)"
|
|
821
|
+
stroke="currentColor"
|
|
822
|
+
stroke-width="2"
|
|
823
|
+
fill="none"
|
|
824
|
+
stroke-linecap="round"
|
|
825
|
+
stroke-linejoin="round"
|
|
826
|
+
aria-hidden="true"
|
|
827
|
+
>
|
|
828
|
+
{type === "password" ? <ShowIcon /> : <HideIcon />}
|
|
829
|
+
</svg>
|
|
830
|
+
</InnerTrigger>
|
|
831
|
+
</InnerWrapper>
|
|
832
|
+
</ParentContainer>
|
|
833
|
+
);
|
|
834
|
+
};
|
|
835
|
+
FieldPassword.displayName = "Field.Password";
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Field.Tag is a tag/chip input that lets users build a list of unique
|
|
839
|
+
* string tokens by typing and pressing Enter.
|
|
840
|
+
*
|
|
841
|
+
* **Best practices:**
|
|
842
|
+
*
|
|
843
|
+
* - Pair with `Field.Label` so screen readers announce the field correctly.
|
|
844
|
+
* - Provide a `placeholder` to hint at expected input.
|
|
845
|
+
* - Use `defaultValue` for uncontrolled usage or `value` + `onChange` for controlled.
|
|
846
|
+
* - Use `allowed` to restrict input to a predefined set of values.
|
|
847
|
+
*
|
|
848
|
+
* @param {IFieldTag} props
|
|
849
|
+
* @param {boolean} props.raw - Define whether the component is styled or not.
|
|
850
|
+
* @param {ComponentSizeEnum} props.sizing - The size of the component. Defaults to `medium`.
|
|
851
|
+
* @param {string} props.variant - The style definition used by the component.
|
|
852
|
+
* @param {TComponentShape} props.shape - The shape of the component. Defaults to `smooth`.
|
|
853
|
+
* @param {string[]} props.value - Controlled tag list.
|
|
854
|
+
* @param {string[]} props.defaultValue - Uncontrolled initial tag list.
|
|
855
|
+
* @param {string[]} props.allowed - Optional allowlist; when provided only matching values can be added.
|
|
856
|
+
* @param {(tags: string[]) => void} props.onChange - Called whenever the tag list changes.
|
|
857
|
+
* @param {string} props.error - The error message to display.
|
|
858
|
+
* @param {boolean} props.disabled - Whether the input is disabled.
|
|
859
|
+
* @param {string} props.placeholder - Placeholder shown when the input is empty.
|
|
860
|
+
*/
|
|
861
|
+
const FieldTag = (props: IFieldTag) => {
|
|
862
|
+
const {
|
|
863
|
+
raw,
|
|
864
|
+
sizing = ComponentSizeEnum.Medium,
|
|
865
|
+
variant = ComponentVariantEnum.Secondary,
|
|
866
|
+
shape = ComponentShapeEnum.Smooth,
|
|
867
|
+
error,
|
|
868
|
+
value,
|
|
869
|
+
defaultValue,
|
|
870
|
+
allowed,
|
|
871
|
+
onChange,
|
|
872
|
+
disabled = false,
|
|
873
|
+
placeholder,
|
|
874
|
+
id: idProp,
|
|
875
|
+
} = props;
|
|
876
|
+
|
|
877
|
+
const { id: contextId } = useField();
|
|
878
|
+
const id = idProp ?? contextId;
|
|
879
|
+
const metaId = React.useId();
|
|
880
|
+
|
|
881
|
+
const isControlled = value !== undefined;
|
|
882
|
+
|
|
883
|
+
const [internalTags, setInternalTags] = React.useState<string[]>(
|
|
884
|
+
defaultValue ?? [],
|
|
885
|
+
);
|
|
886
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
887
|
+
const [focusedTagIndex, setFocusedTagIndex] = React.useState<number | null>(
|
|
888
|
+
null,
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
892
|
+
const tagRefs = React.useRef<Map<number, HTMLSpanElement | null>>(new Map());
|
|
893
|
+
|
|
894
|
+
const tags = isControlled ? value : internalTags;
|
|
895
|
+
|
|
896
|
+
const commitTags = React.useCallback(
|
|
897
|
+
(next: string[]) => {
|
|
898
|
+
if (!isControlled) setInternalTags(next);
|
|
899
|
+
onChange?.(next);
|
|
900
|
+
},
|
|
901
|
+
[isControlled, onChange],
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const addTag = React.useCallback(
|
|
905
|
+
(label: string) => {
|
|
906
|
+
const trimmed = label.trim();
|
|
907
|
+
if (!trimmed) return;
|
|
908
|
+
|
|
909
|
+
// Enforce uniqueness (case-insensitive)
|
|
910
|
+
if (tags.some((t) => t.toLowerCase() === trimmed.toLowerCase())) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Enforce allowlist (case-insensitive)
|
|
915
|
+
if (!allowed?.some((a) => a.toLowerCase() === trimmed.toLowerCase())) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
commitTags([...tags, trimmed]);
|
|
920
|
+
setInputValue("");
|
|
921
|
+
},
|
|
922
|
+
[tags, commitTags, allowed],
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
const removeTag = React.useCallback(
|
|
926
|
+
(index: number) => {
|
|
927
|
+
const next = tags.filter((_, i) => i !== index);
|
|
928
|
+
commitTags(next);
|
|
929
|
+
setFocusedTagIndex(null);
|
|
930
|
+
|
|
931
|
+
// Return focus to the text input after removal
|
|
932
|
+
inputRef.current?.focus();
|
|
933
|
+
},
|
|
934
|
+
[tags, commitTags],
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
938
|
+
if (disabled) return;
|
|
939
|
+
|
|
940
|
+
if (e.key === "Enter") {
|
|
941
|
+
e.preventDefault();
|
|
942
|
+
addTag(inputValue);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// When backspace is pressed and input is empty, focus the last tag
|
|
947
|
+
if (
|
|
948
|
+
(e.key === "Backspace" || e.key === "Delete") &&
|
|
949
|
+
inputValue === "" &&
|
|
950
|
+
tags.length > 0
|
|
951
|
+
) {
|
|
952
|
+
e.preventDefault();
|
|
953
|
+
const lastIndex = tags.length - 1;
|
|
954
|
+
setFocusedTagIndex(lastIndex);
|
|
955
|
+
tagRefs.current.get(lastIndex)?.focus();
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const handleTagKeyDown = (
|
|
960
|
+
e: React.KeyboardEvent<HTMLSpanElement>,
|
|
961
|
+
index: number,
|
|
962
|
+
) => {
|
|
963
|
+
if (disabled) return;
|
|
964
|
+
|
|
965
|
+
switch (e.key) {
|
|
966
|
+
case "Backspace":
|
|
967
|
+
case "Delete": {
|
|
968
|
+
e.preventDefault();
|
|
969
|
+
removeTag(index);
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
case "ArrowLeft": {
|
|
973
|
+
e.preventDefault();
|
|
974
|
+
if (index > 0) {
|
|
975
|
+
const prev = index - 1;
|
|
976
|
+
setFocusedTagIndex(prev);
|
|
977
|
+
tagRefs.current.get(prev)?.focus();
|
|
978
|
+
}
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
case "ArrowRight": {
|
|
982
|
+
e.preventDefault();
|
|
983
|
+
if (index < tags.length - 1) {
|
|
984
|
+
const next = index + 1;
|
|
985
|
+
setFocusedTagIndex(next);
|
|
986
|
+
tagRefs.current.get(next)?.focus();
|
|
987
|
+
} else {
|
|
988
|
+
// Move focus back to input when going past the last tag
|
|
989
|
+
setFocusedTagIndex(null);
|
|
990
|
+
inputRef.current?.focus();
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
case "Escape": {
|
|
995
|
+
e.preventDefault();
|
|
996
|
+
setFocusedTagIndex(null);
|
|
997
|
+
inputRef.current?.focus();
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const handleWrapperClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
1004
|
+
// If the click wasn't on a tag or its remove button, focus the input
|
|
1005
|
+
const target = e.target as HTMLElement;
|
|
1006
|
+
if (!target.closest("[data-tag]")) {
|
|
1007
|
+
inputRef.current?.focus();
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
React.useEffect(() => {
|
|
1012
|
+
if (isControlled && value) setInternalTags(value);
|
|
1013
|
+
}, [isControlled, value]);
|
|
1014
|
+
|
|
1015
|
+
return (
|
|
1016
|
+
<ParentWrapper
|
|
1017
|
+
id={id}
|
|
1018
|
+
role="group"
|
|
1019
|
+
aria-invalid={!!error}
|
|
1020
|
+
aria-describedby={metaId}
|
|
1021
|
+
data-error={Boolean(error)}
|
|
1022
|
+
data-variant={variant}
|
|
1023
|
+
data-size={sizing}
|
|
1024
|
+
data-shape={shape}
|
|
1025
|
+
data-raw={Boolean(raw)}
|
|
1026
|
+
data-disabled={disabled}
|
|
1027
|
+
data-wrap="true"
|
|
1028
|
+
onClick={handleWrapperClick}
|
|
1029
|
+
>
|
|
1030
|
+
{tags.map((tag, index) => (
|
|
1031
|
+
<InnerSegment
|
|
1032
|
+
key={`${tag}-${index}`}
|
|
1033
|
+
ref={(el: HTMLSpanElement | null) => tagRefs.current.set(index, el)}
|
|
1034
|
+
role="option"
|
|
1035
|
+
aria-label={tag}
|
|
1036
|
+
aria-selected={focusedTagIndex === index}
|
|
1037
|
+
tabIndex={disabled ? -1 : -1}
|
|
1038
|
+
data-raw={Boolean(raw)}
|
|
1039
|
+
data-focused={focusedTagIndex === index}
|
|
1040
|
+
data-tag="true"
|
|
1041
|
+
onFocus={() => setFocusedTagIndex(index)}
|
|
1042
|
+
onBlur={() => setFocusedTagIndex(null)}
|
|
1043
|
+
onKeyDown={(e: React.KeyboardEvent<HTMLSpanElement>) =>
|
|
1044
|
+
handleTagKeyDown(e, index)
|
|
1045
|
+
}
|
|
1046
|
+
>
|
|
1047
|
+
<Badge sizing="small" variant="border">
|
|
1048
|
+
{tag}
|
|
1049
|
+
{!disabled && (
|
|
1050
|
+
<Button
|
|
1051
|
+
variant="ghost"
|
|
1052
|
+
sizing="small"
|
|
1053
|
+
aria-label={`Remove ${tag}`}
|
|
1054
|
+
data-tag="true"
|
|
1055
|
+
className="m-l-small-60 "
|
|
1056
|
+
onClick={(e) => {
|
|
1057
|
+
e.stopPropagation();
|
|
1058
|
+
removeTag(index);
|
|
1059
|
+
}}
|
|
1060
|
+
>
|
|
1061
|
+
×
|
|
1062
|
+
</Button>
|
|
1063
|
+
)}
|
|
1064
|
+
</Badge>
|
|
1065
|
+
</InnerSegment>
|
|
1066
|
+
))}
|
|
1067
|
+
|
|
1068
|
+
<HiddenInput
|
|
1069
|
+
ref={inputRef}
|
|
1070
|
+
type="text"
|
|
1071
|
+
value={inputValue}
|
|
1072
|
+
disabled={disabled}
|
|
1073
|
+
placeholder={tags.length === 0 ? placeholder : undefined}
|
|
1074
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
1075
|
+
setInputValue(e.target.value)
|
|
1076
|
+
}
|
|
1077
|
+
onKeyDown={handleInputKeyDown}
|
|
1078
|
+
/>
|
|
1079
|
+
</ParentWrapper>
|
|
1080
|
+
);
|
|
1081
|
+
};
|
|
1082
|
+
FieldTag.displayName = "Field.Tag";
|
|
1083
|
+
|
|
183
1084
|
Field.Root = FieldRoot;
|
|
184
1085
|
Field.Wrapper = FieldWrapper;
|
|
185
1086
|
Field.Label = FieldLabel;
|
|
186
1087
|
Field.Meta = FieldMeta;
|
|
1088
|
+
Field.Number = FieldNumber;
|
|
1089
|
+
Field.Date = FieldDate;
|
|
1090
|
+
Field.File = FieldFile;
|
|
1091
|
+
Field.Password = FieldPassword;
|
|
1092
|
+
Field.Tag = FieldTag;
|
|
187
1093
|
|
|
188
|
-
export {
|
|
1094
|
+
export {
|
|
1095
|
+
Field,
|
|
1096
|
+
FieldRoot,
|
|
1097
|
+
FieldWrapper,
|
|
1098
|
+
FieldLabel,
|
|
1099
|
+
FieldMeta,
|
|
1100
|
+
FieldNumber,
|
|
1101
|
+
FieldDate,
|
|
1102
|
+
FieldFile,
|
|
1103
|
+
FieldPassword,
|
|
1104
|
+
FieldTag,
|
|
1105
|
+
};
|