@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +380 -52
  3. package/dist/index.d.ts +380 -52
  4. package/dist/index.js +2532 -511
  5. package/dist/index.mjs +2518 -508
  6. package/package.json +3 -3
  7. package/src/__tests__/Avatar.test.tsx +55 -55
  8. package/src/accordion/Accordion.stories.tsx +6 -4
  9. package/src/accordion/index.tsx +1 -2
  10. package/src/avatar/Avatar.stories.tsx +37 -7
  11. package/src/avatar/index.tsx +90 -19
  12. package/src/avatar/styles/index.ts +58 -12
  13. package/src/badge/Badge.stories.tsx +27 -5
  14. package/src/badge/index.tsx +21 -13
  15. package/src/badge/styles/index.ts +69 -40
  16. package/src/button/Button.stories.tsx +40 -27
  17. package/src/button/index.tsx +13 -9
  18. package/src/button/styles/index.ts +308 -47
  19. package/src/card/index.tsx +2 -4
  20. package/src/checkbox/Checkbox.stories.tsx +72 -33
  21. package/src/checkbox/index.tsx +8 -6
  22. package/src/checkbox/styles/index.ts +239 -19
  23. package/src/collapsible/Collapsible.stories.tsx +6 -4
  24. package/src/dialog/Dialog.stories.tsx +173 -31
  25. package/src/dialog/styles/index.ts +13 -8
  26. package/src/dropdown/Dropdown.stories.tsx +61 -23
  27. package/src/dropdown/index.tsx +42 -31
  28. package/src/dropdown/styles/index.ts +30 -19
  29. package/src/field/Field.stories.tsx +183 -24
  30. package/src/field/index.tsx +930 -13
  31. package/src/field/styles/index.ts +246 -14
  32. package/src/field/types/index.ts +31 -0
  33. package/src/field/utils/index.ts +201 -0
  34. package/src/index.ts +2 -1
  35. package/src/message-bubble/MessageBubble.stories.tsx +59 -12
  36. package/src/message-bubble/index.tsx +22 -4
  37. package/src/message-bubble/styles/index.ts +4 -7
  38. package/src/otp-field/OTPField.stories.tsx +22 -24
  39. package/src/otp-field/index.tsx +9 -0
  40. package/src/otp-field/styles/index.ts +114 -16
  41. package/src/otp-field/types/index.ts +9 -1
  42. package/src/overlay/styles/index.ts +1 -0
  43. package/src/ruler/Ruler.stories.tsx +43 -0
  44. package/src/ruler/constants/index.ts +3 -0
  45. package/src/ruler/hooks/index.tsx +53 -0
  46. package/src/ruler/index.tsx +239 -0
  47. package/src/ruler/styles/index.tsx +154 -0
  48. package/src/ruler/types/index.ts +17 -0
  49. package/src/select/Select.stories.tsx +91 -0
  50. package/src/select/hooks/index.tsx +71 -0
  51. package/src/select/index.tsx +331 -0
  52. package/src/select/styles/index.tsx +156 -0
  53. package/src/shimmer/Shimmer.stories.tsx +6 -4
  54. package/src/skeleton/index.tsx +7 -6
  55. package/src/spinner/Spinner.stories.tsx +29 -4
  56. package/src/spinner/index.tsx +16 -6
  57. package/src/spinner/styles/index.ts +41 -22
  58. package/src/switch/Switch.stories.tsx +46 -17
  59. package/src/switch/index.tsx +5 -8
  60. package/src/switch/styles/index.ts +45 -45
  61. package/src/tabs/Tabs.stories.tsx +43 -15
  62. package/src/text-area/Textarea.stories.tsx +45 -8
  63. package/src/text-area/index.tsx +9 -6
  64. package/src/text-area/styles/index.ts +1 -1
  65. package/src/toggle/Toggle.stories.tsx +6 -4
  66. package/src/tree/Tree.stories.tsx +6 -4
  67. package/src/privacy-field/PrivacyField.stories.tsx +0 -29
  68. package/src/privacy-field/index.tsx +0 -56
  69. package/src/privacy-field/styles/index.ts +0 -17
@@ -2,7 +2,24 @@
2
2
 
3
3
  import React from "react";
4
4
  import { FieldProvider, useField } from "./hooks";
5
- import { Fieldset, Sup, Input, Label, Def } from "./styles";
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
- TComponentShape,
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 React.ComponentProps<"input">,
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.Primary,
73
- shape = "smooth",
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 { Field, FieldRoot, FieldWrapper, FieldLabel, FieldMeta };
1094
+ export {
1095
+ Field,
1096
+ FieldRoot,
1097
+ FieldWrapper,
1098
+ FieldLabel,
1099
+ FieldMeta,
1100
+ FieldNumber,
1101
+ FieldDate,
1102
+ FieldFile,
1103
+ FieldPassword,
1104
+ FieldTag,
1105
+ };