@mlola-ui/registry 1.0.0

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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/components/accordion.json +54 -0
  4. package/components/activity-heatmap.json +54 -0
  5. package/components/alert.json +56 -0
  6. package/components/app-shell.json +53 -0
  7. package/components/avatar.json +54 -0
  8. package/components/badge.json +62 -0
  9. package/components/breadcrumb.json +49 -0
  10. package/components/button.json +62 -0
  11. package/components/calendar.json +55 -0
  12. package/components/card.json +53 -0
  13. package/components/carousel.json +52 -0
  14. package/components/checkbox.json +50 -0
  15. package/components/color-picker.json +60 -0
  16. package/components/combobox.json +64 -0
  17. package/components/command-menu.json +64 -0
  18. package/components/context-menu.json +61 -0
  19. package/components/date-picker.json +59 -0
  20. package/components/dropdown-menu.json +51 -0
  21. package/components/dropzone.json +45 -0
  22. package/components/empty-state.json +45 -0
  23. package/components/hover-card.json +59 -0
  24. package/components/input.json +56 -0
  25. package/components/kbd.json +45 -0
  26. package/components/modal.json +63 -0
  27. package/components/number-input.json +58 -0
  28. package/components/otp-input.json +49 -0
  29. package/components/pagination.json +50 -0
  30. package/components/popover.json +58 -0
  31. package/components/priority-icon.json +53 -0
  32. package/components/progress.json +57 -0
  33. package/components/radio-group.json +48 -0
  34. package/components/rating.json +48 -0
  35. package/components/resizable.json +46 -0
  36. package/components/segmented-control.json +51 -0
  37. package/components/select.json +67 -0
  38. package/components/sheet.json +66 -0
  39. package/components/skeleton.json +48 -0
  40. package/components/slider.json +53 -0
  41. package/components/status-icon.json +53 -0
  42. package/components/stepper.json +50 -0
  43. package/components/table.json +53 -0
  44. package/components/tabs.json +54 -0
  45. package/components/tag-input.json +53 -0
  46. package/components/textarea.json +48 -0
  47. package/components/time-picker.json +58 -0
  48. package/components/timeline.json +54 -0
  49. package/components/toast.json +56 -0
  50. package/components/toggle.json +52 -0
  51. package/components/tooltip.json +54 -0
  52. package/components/tour.json +62 -0
  53. package/components/virtual-list.json +54 -0
  54. package/generated/catalog.ts +2037 -0
  55. package/index.json +413 -0
  56. package/package.json +47 -0
  57. package/schema/mlola.config.schema.json +51 -0
  58. package/schema/registry-index.schema.json +116 -0
  59. package/schema/registry-item.schema.json +80 -0
  60. package/snapshot/index.json +2938 -0
  61. package/snapshot/items/components/accordion.json +54 -0
  62. package/snapshot/items/components/activity-heatmap.json +54 -0
  63. package/snapshot/items/components/alert.json +56 -0
  64. package/snapshot/items/components/app-shell.json +53 -0
  65. package/snapshot/items/components/avatar.json +54 -0
  66. package/snapshot/items/components/badge.json +62 -0
  67. package/snapshot/items/components/breadcrumb.json +49 -0
  68. package/snapshot/items/components/button.json +62 -0
  69. package/snapshot/items/components/calendar.json +55 -0
  70. package/snapshot/items/components/card.json +53 -0
  71. package/snapshot/items/components/carousel.json +52 -0
  72. package/snapshot/items/components/checkbox.json +50 -0
  73. package/snapshot/items/components/color-picker.json +60 -0
  74. package/snapshot/items/components/combobox.json +64 -0
  75. package/snapshot/items/components/command-menu.json +64 -0
  76. package/snapshot/items/components/context-menu.json +61 -0
  77. package/snapshot/items/components/date-picker.json +59 -0
  78. package/snapshot/items/components/dropdown-menu.json +51 -0
  79. package/snapshot/items/components/dropzone.json +45 -0
  80. package/snapshot/items/components/empty-state.json +45 -0
  81. package/snapshot/items/components/hover-card.json +59 -0
  82. package/snapshot/items/components/input.json +56 -0
  83. package/snapshot/items/components/kbd.json +45 -0
  84. package/snapshot/items/components/modal.json +63 -0
  85. package/snapshot/items/components/number-input.json +58 -0
  86. package/snapshot/items/components/otp-input.json +49 -0
  87. package/snapshot/items/components/pagination.json +50 -0
  88. package/snapshot/items/components/popover.json +58 -0
  89. package/snapshot/items/components/priority-icon.json +53 -0
  90. package/snapshot/items/components/progress.json +57 -0
  91. package/snapshot/items/components/radio-group.json +48 -0
  92. package/snapshot/items/components/rating.json +48 -0
  93. package/snapshot/items/components/resizable.json +46 -0
  94. package/snapshot/items/components/segmented-control.json +51 -0
  95. package/snapshot/items/components/select.json +67 -0
  96. package/snapshot/items/components/sheet.json +66 -0
  97. package/snapshot/items/components/skeleton.json +48 -0
  98. package/snapshot/items/components/slider.json +53 -0
  99. package/snapshot/items/components/status-icon.json +53 -0
  100. package/snapshot/items/components/stepper.json +50 -0
  101. package/snapshot/items/components/table.json +53 -0
  102. package/snapshot/items/components/tabs.json +54 -0
  103. package/snapshot/items/components/tag-input.json +53 -0
  104. package/snapshot/items/components/textarea.json +48 -0
  105. package/snapshot/items/components/time-picker.json +58 -0
  106. package/snapshot/items/components/timeline.json +54 -0
  107. package/snapshot/items/components/toast.json +56 -0
  108. package/snapshot/items/components/toggle.json +52 -0
  109. package/snapshot/items/components/tooltip.json +54 -0
  110. package/snapshot/items/components/tour.json +62 -0
  111. package/snapshot/items/components/virtual-list.json +54 -0
  112. package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
  113. package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
  114. package/snapshot/source/packages/components/_internal/floating.ts +73 -0
  115. package/snapshot/source/packages/components/_internal/match.ts +39 -0
  116. package/snapshot/source/packages/components/_internal/react.ts +40 -0
  117. package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
  118. package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
  119. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  120. package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
  121. package/snapshot/source/packages/components/alert/alert.tsx +83 -0
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
  124. package/snapshot/source/packages/components/badge/badge.tsx +39 -0
  125. package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  126. package/snapshot/source/packages/components/button/button.tsx +42 -0
  127. package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
  128. package/snapshot/source/packages/components/calendar/dates.ts +163 -0
  129. package/snapshot/source/packages/components/card/card.tsx +39 -0
  130. package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
  131. package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
  132. package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
  133. package/snapshot/source/packages/components/color-picker/color.ts +124 -0
  134. package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
  135. package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
  136. package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
  137. package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
  138. package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  139. package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
  140. package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
  141. package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
  142. package/snapshot/source/packages/components/input/input.tsx +101 -0
  143. package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
  144. package/snapshot/source/packages/components/modal/modal.tsx +100 -0
  145. package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
  146. package/snapshot/source/packages/components/number-input/number.ts +50 -0
  147. package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
  148. package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
  149. package/snapshot/source/packages/components/popover/popover.tsx +107 -0
  150. package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
  151. package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  152. package/snapshot/source/packages/components/progress/progress.tsx +110 -0
  153. package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
  154. package/snapshot/source/packages/components/rating/rating.tsx +94 -0
  155. package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
  156. package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  157. package/snapshot/source/packages/components/select/select.tsx +332 -0
  158. package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
  159. package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
  160. package/snapshot/source/packages/components/slider/slider.tsx +99 -0
  161. package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
  162. package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
  163. package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
  164. package/snapshot/source/packages/components/table/table.tsx +66 -0
  165. package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
  166. package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
  167. package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
  168. package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
  169. package/snapshot/source/packages/components/time-picker/times.ts +76 -0
  170. package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
  171. package/snapshot/source/packages/components/toast/toast.tsx +317 -0
  172. package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
  173. package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
  174. package/snapshot/source/packages/components/tour/tour.tsx +193 -0
  175. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  176. package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
  177. package/snapshot/source-manifest.json +67 -0
@@ -0,0 +1,76 @@
1
+ import * as React from "react";
2
+ import { cx } from "../_internal/react";
3
+ import { STATUS_LABELS, type WorkStatus } from "./labels";
4
+
5
+ export type { WorkStatus } from "./labels";
6
+ export { STATUS_LABELS, WORK_STATUSES } from "./labels";
7
+
8
+ export interface StatusIconProps extends Omit<React.SVGAttributes<SVGSVGElement>, "children"> {
9
+ status: WorkStatus;
10
+ /** How far along an active status is, 0 to 1; the ring fills to match. */
11
+ progress?: number;
12
+ /** The accessible name; defaults to the status label. Pass "" when a visible label sits beside it. */
13
+ label?: string;
14
+ size?: string;
15
+ }
16
+
17
+ const RADIUS = 2.4;
18
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
19
+
20
+ /**
21
+ * A work item's status as one glyph: a dashed ring for backlog, an empty
22
+ * ring for todo, a ring that fills as the work moves through progress and
23
+ * review, a solid check for done and a solid cross for canceled.
24
+ */
25
+ export function StatusIcon({ status, progress, label, size = "1em", className, ...props }: StatusIconProps) {
26
+ const name = label ?? STATUS_LABELS[status];
27
+ const fill = progress ?? (status === "in-progress" ? 0.5 : status === "in-review" ? 0.75 : 0);
28
+ return (
29
+ <svg
30
+ viewBox="0 0 14 14"
31
+ width={size}
32
+ height={size}
33
+ className={cx("ml-status-icon", className)}
34
+ data-status={status}
35
+ role={name ? "img" : undefined}
36
+ aria-label={name || undefined}
37
+ aria-hidden={name ? undefined : true}
38
+ {...props}
39
+ >
40
+ {status === "done" || status === "canceled" ? (
41
+ <>
42
+ <circle cx="7" cy="7" r="6" fill="currentColor" />
43
+ {status === "done" ? (
44
+ <path d="M4.4 7.2 6.2 9 9.7 5.2" fill="none" stroke="var(--ml-status-mark)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
45
+ ) : (
46
+ <path d="M5 5 9 9M9 5 5 9" fill="none" stroke="var(--ml-status-mark)" strokeWidth="1.5" strokeLinecap="round" />
47
+ )}
48
+ </>
49
+ ) : (
50
+ <>
51
+ <circle
52
+ cx="7"
53
+ cy="7"
54
+ r="5.75"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth="1.5"
58
+ strokeDasharray={status === "backlog" ? "1.6 1.4" : undefined}
59
+ />
60
+ {fill > 0 ? (
61
+ <circle
62
+ cx="7"
63
+ cy="7"
64
+ r={RADIUS}
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeWidth={RADIUS * 2}
68
+ strokeDasharray={`${Math.min(1, fill) * CIRCUMFERENCE} ${CIRCUMFERENCE}`}
69
+ transform="rotate(-90 7 7)"
70
+ />
71
+ ) : null}
72
+ </>
73
+ )}
74
+ </svg>
75
+ );
76
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconCheck } from "@mlola-ui/icons";
5
+ import { cx } from "../_internal/react";
6
+
7
+ export interface StepperStep {
8
+ label: string;
9
+ description?: string;
10
+ optional?: boolean;
11
+ /** Mark a step that needs attention. */
12
+ error?: boolean;
13
+ }
14
+
15
+ export type StepStatus = "complete" | "current" | "upcoming" | "error";
16
+
17
+ export interface StepperProps {
18
+ steps: StepperStep[];
19
+ /** Index of the step in progress. */
20
+ current: number;
21
+ /** Makes steps clickable. */
22
+ onStepChange?: (index: number) => void;
23
+ /** Linear flows only go back, or one step forward; free ones go anywhere. */
24
+ linear?: boolean;
25
+ orientation?: "horizontal" | "vertical";
26
+ /** The accessible name, such as "Checkout". */
27
+ label?: string;
28
+ className?: string;
29
+ }
30
+
31
+ export function stepStatus(step: StepperStep, index: number, current: number): StepStatus {
32
+ if (step.error) return "error";
33
+ if (index < current) return "complete";
34
+ return index === current ? "current" : "upcoming";
35
+ }
36
+
37
+ /**
38
+ * Where someone is in a flow of steps: done steps check off, the current one
39
+ * is marked, and the line between them fills as they go. Steps can be
40
+ * clickable, and in a narrow container the row condenses to "Step 2 of 4".
41
+ */
42
+ export function Stepper({ steps, current, onStepChange, linear = true, orientation = "horizontal", label = "Progress", className }: StepperProps) {
43
+ const reachable = (index: number) => Boolean(onStepChange) && index !== current && (!linear || index <= current + 1);
44
+ const progress = steps.length > 1 ? current / (steps.length - 1) : 1;
45
+ const now = steps[current];
46
+ return (
47
+ <nav className={cx("ml-stepper", className)} aria-label={label} data-orientation={orientation}>
48
+ <p className="ml-stepper-summary" aria-hidden="true">
49
+ <span>
50
+ Step {Math.min(current + 1, steps.length)} of {steps.length}
51
+ </span>
52
+ <strong>{now?.label}</strong>
53
+ <span className="ml-stepper-meter" style={{ "--ml-stepper-progress": progress } as React.CSSProperties} />
54
+ </p>
55
+ <ol className="ml-stepper-list">
56
+ {steps.map((step, index) => {
57
+ const status = stepStatus(step, index, current);
58
+ const content = (
59
+ <>
60
+ <span className="ml-stepper-marker" aria-hidden="true">
61
+ {status === "complete" ? <IconCheck size="0.8em" /> : status === "error" ? "!" : index + 1}
62
+ </span>
63
+ <span className="ml-stepper-text">
64
+ <span className="ml-stepper-label">{step.label}</span>
65
+ {step.description || step.optional ? (
66
+ <span className="ml-stepper-description">{step.optional ? (step.description ? `${step.description} · Optional` : "Optional") : step.description}</span>
67
+ ) : null}
68
+ </span>
69
+ <span className="ml-visually-hidden">{status === "complete" ? ", completed" : status === "error" ? ", needs attention" : status === "upcoming" ? ", not started" : ""}</span>
70
+ </>
71
+ );
72
+ return (
73
+ <li key={`${step.label}-${index}`} className="ml-stepper-step" data-status={status} aria-current={status === "current" ? "step" : undefined}>
74
+ {reachable(index) ? (
75
+ <button type="button" className="ml-stepper-target" onClick={() => onStepChange?.(index)}>
76
+ {content}
77
+ </button>
78
+ ) : (
79
+ <span className="ml-stepper-target">{content}</span>
80
+ )}
81
+ </li>
82
+ );
83
+ })}
84
+ </ol>
85
+ </nav>
86
+ );
87
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+
6
+ type TableSize = "sm" | "md";
7
+ type TableAlign = "left" | "center" | "right";
8
+ export type SortedState = "asc" | "desc" | false;
9
+ interface Sortable { sorted: SortedState; onToggle: () => void; label?: string; }
10
+ interface TableProps extends React.HTMLAttributes<HTMLDivElement> { size?: TableSize; striped?: boolean; }
11
+ const Table = React.forwardRef<HTMLDivElement, TableProps>(
12
+ ({ size = "md", striped = false, className, children, ...props }, ref) => (
13
+ <div ref={ref} className={cx("ml-table-container", className)} {...props}>
14
+ <table data-size={size} data-striped={striped ? "" : undefined} className="ml-table">{children}</table>
15
+ </div>
16
+ )
17
+ );
18
+ Table.displayName = "Table";
19
+ const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
20
+ ({ className, ...props }, ref) => <caption ref={ref} className={cx("ml-table-caption", className)} {...props} />
21
+ );
22
+ TableCaption.displayName = "TableCaption";
23
+ const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
24
+ ({ className, ...props }, ref) => <thead ref={ref} className={cx("ml-table-header", className)} {...props} />
25
+ );
26
+ TableHeader.displayName = "TableHeader";
27
+ const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
28
+ ({ className, ...props }, ref) => <tbody ref={ref} className={cx("ml-table-body", className)} {...props} />
29
+ );
30
+ TableBody.displayName = "TableBody";
31
+ const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
32
+ ({ className, ...props }, ref) => <tfoot ref={ref} className={cx("ml-table-footer", className)} {...props} />
33
+ );
34
+ TableFooter.displayName = "TableFooter";
35
+ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
36
+ ({ className, ...props }, ref) => <tr ref={ref} className={cx("ml-table-row", className)} {...props} />
37
+ );
38
+ TableRow.displayName = "TableRow";
39
+
40
+ interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
41
+ align?: TableAlign;
42
+ sortable?: Sortable;
43
+ }
44
+ const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
45
+ ({ align = "left", sortable, scope = "col", className, children, ...props }, ref) => {
46
+ const ariaSort = !sortable ? undefined : sortable.sorted === "asc" ? "ascending" : sortable.sorted === "desc" ? "descending" : "none";
47
+ return (
48
+ <th ref={ref} scope={scope} aria-sort={ariaSort} data-align={align} className={cx("ml-table-head", className)} {...props}>
49
+ {sortable ? (
50
+ <button type="button" onClick={sortable.onToggle} aria-label={sortable.label ?? `Sort by ${String(children)}`} className="ml-table-sort">
51
+ <span>{children}</span><span aria-hidden="true" data-sort={sortable.sorted || "none"} className="ml-table-sort-icon">{sortable.sorted === "asc" ? "↑" : sortable.sorted === "desc" ? "↓" : "↕"}</span>
52
+ </button>
53
+ ) : children}
54
+ </th>
55
+ );
56
+ }
57
+ );
58
+ TableHead.displayName = "TableHead";
59
+ interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> { align?: TableAlign; numeric?: boolean; }
60
+ const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
61
+ ({ align = "left", numeric = false, className, ...props }, ref) => <td ref={ref} data-align={numeric ? "right" : align} data-numeric={numeric ? "" : undefined} className={cx("ml-table-cell", className)} {...props} />
62
+ );
63
+ TableCell.displayName = "TableCell";
64
+
65
+ export { Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell };
66
+ export type { TableProps, TableHeadProps, TableCellProps, TableSize, TableAlign, Sortable };
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { rovingIndex } from "@mlola-ui/behavior/logic";
5
+ import { cx, useControllableState } from "../_internal/react";
6
+
7
+ export type TabsVariant = "default" | "pills" | "enclosed";
8
+ type TabsOrientation = "horizontal" | "vertical";
9
+
10
+ interface TabsContextValue {
11
+ value: string;
12
+ variant: TabsVariant;
13
+ orientation: TabsOrientation;
14
+ prefix: string;
15
+ select: (value: string) => void;
16
+ triggers: React.MutableRefObject<Map<string, HTMLButtonElement>>;
17
+ }
18
+
19
+ const TabsContext = React.createContext<TabsContextValue | null>(null);
20
+ const useTabs = () => {
21
+ const context = React.useContext(TabsContext);
22
+ if (!context) throw new Error("Tabs components must be used within Tabs");
23
+ return context;
24
+ };
25
+
26
+ export interface TabsProps {
27
+ defaultValue?: string;
28
+ value?: string;
29
+ onValueChange?: (value: string) => void;
30
+ variant?: TabsVariant;
31
+ orientation?: TabsOrientation;
32
+ children: React.ReactNode;
33
+ className?: string;
34
+ }
35
+
36
+ export function Tabs({
37
+ defaultValue = "",
38
+ value,
39
+ onValueChange,
40
+ variant = "default",
41
+ orientation = "horizontal",
42
+ children,
43
+ className,
44
+ }: TabsProps) {
45
+ const [current, select] = useControllableState({ value, defaultValue, onChange: onValueChange });
46
+ const triggers = React.useRef(new Map<string, HTMLButtonElement>());
47
+ const prefix = `tabs-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;
48
+ return (
49
+ <TabsContext.Provider value={{ value: current, variant, orientation, prefix, select, triggers }}>
50
+ <div className={cx("ml-tabs", className)} data-variant={variant} data-orientation={orientation}>
51
+ {children}
52
+ </div>
53
+ </TabsContext.Provider>
54
+ );
55
+ }
56
+
57
+ export interface TabsListProps {
58
+ children: React.ReactNode;
59
+ className?: string;
60
+ "aria-label"?: string;
61
+ }
62
+
63
+ export function TabsList({ children, className, "aria-label": ariaLabel }: TabsListProps) {
64
+ const { orientation } = useTabs();
65
+ return (
66
+ <div role="tablist" aria-label={ariaLabel} aria-orientation={orientation} className={cx("ml-tabs-list", className)}>
67
+ {children}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export interface TabsTriggerProps {
73
+ value: string;
74
+ disabled?: boolean;
75
+ children: React.ReactNode;
76
+ className?: string;
77
+ }
78
+
79
+ export function TabsTrigger({ value, disabled = false, children, className }: TabsTriggerProps) {
80
+ const context = useTabs();
81
+ const active = context.value === value;
82
+ const register = React.useCallback((node: HTMLButtonElement | null) => {
83
+ if (node) context.triggers.current.set(value, node);
84
+ else context.triggers.current.delete(value);
85
+ }, [context.triggers, value]);
86
+
87
+ const ordered = () =>
88
+ Array.from(context.triggers.current.entries()).sort((left, right) =>
89
+ left[1].compareDocumentPosition(right[1]) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
90
+ );
91
+
92
+ // The same decision the framework-free runtime makes, so both skip disabled
93
+ // tabs and wrap identically.
94
+ const move = (key: string) => {
95
+ const items = ordered();
96
+ const index = items.findIndex(([entry]) => entry === value);
97
+ const target = rovingIndex(items.length, index, key, {
98
+ horizontal: context.orientation === "horizontal",
99
+ enabled: (at) => !items[at][1].disabled,
100
+ });
101
+ if (target < 0) return;
102
+ const [nextValue, element] = items[target];
103
+ element.focus();
104
+ context.select(nextValue);
105
+ };
106
+
107
+ return (
108
+ <button
109
+ ref={register}
110
+ id={`${context.prefix}-tab-${value}`}
111
+ type="button"
112
+ role="tab"
113
+ aria-selected={active}
114
+ aria-controls={`${context.prefix}-panel-${value}`}
115
+ tabIndex={active ? 0 : -1}
116
+ disabled={disabled}
117
+ data-state={active ? "active" : "inactive"}
118
+ className={cx("ml-tabs-trigger", className)}
119
+ onClick={() => !disabled && context.select(value)}
120
+ onKeyDown={(event) => {
121
+ const before = event.key;
122
+ const items = ordered();
123
+ const index = items.findIndex(([entry]) => entry === value);
124
+ if (
125
+ rovingIndex(items.length, index, before, {
126
+ horizontal: context.orientation === "horizontal",
127
+ enabled: (at) => !items[at][1].disabled,
128
+ }) < 0
129
+ ) {
130
+ return;
131
+ }
132
+ event.preventDefault();
133
+ move(before);
134
+ }}
135
+ >
136
+ {children}
137
+ </button>
138
+ );
139
+ }
140
+
141
+ export interface TabsContentProps {
142
+ value: string;
143
+ children: React.ReactNode;
144
+ className?: string;
145
+ }
146
+
147
+ export function TabsContent({ value, children, className }: TabsContentProps) {
148
+ const context = useTabs();
149
+ const active = context.value === value;
150
+ return (
151
+ <div
152
+ id={`${context.prefix}-panel-${value}`}
153
+ role="tabpanel"
154
+ aria-labelledby={`${context.prefix}-tab-${value}`}
155
+ tabIndex={0}
156
+ hidden={!active}
157
+ data-state={active ? "active" : "inactive"}
158
+ className={cx("ml-tabs-content", className)}
159
+ >
160
+ {children}
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconX } from "@mlola-ui/icons";
5
+ import { ComboboxList, filterOptions, sideFor, type ComboboxOption } from "../combobox/combobox";
6
+ import { Field, fieldDescription } from "../input/input";
7
+ import { cx } from "../_internal/react";
8
+
9
+ export interface TagInputProps {
10
+ value?: string[];
11
+ defaultValue?: string[];
12
+ onValueChange?: (tags: string[]) => void;
13
+ label?: React.ReactNode;
14
+ hint?: React.ReactNode;
15
+ error?: React.ReactNode;
16
+ placeholder?: string;
17
+ /** Tags to suggest as you type; their labels become the tags. */
18
+ suggestions?: ComboboxOption[];
19
+ /** Accept only suggested tags. */
20
+ restrict?: boolean;
21
+ /** Return a message to refuse a tag: "Use lowercase letters and dashes". */
22
+ validate?: (tag: string) => string | null;
23
+ /** Shape each tag before it is added, such as trimming or lowercasing. */
24
+ transform?: (tag: string) => string;
25
+ max?: number;
26
+ disabled?: boolean;
27
+ required?: boolean;
28
+ id?: string;
29
+ className?: string;
30
+ }
31
+
32
+ /** Split pasted or typed text into tags on commas, semicolons and new lines. */
33
+ export function splitTags(text: string) {
34
+ return text
35
+ .split(/[,;\n\r\t]+/)
36
+ .map((part) => part.trim())
37
+ .filter(Boolean);
38
+ }
39
+
40
+ /**
41
+ * Many short values in one field: emails, labels, keywords. Enter or a comma
42
+ * adds a tag, pasting a list adds them all, Backspace on an empty field
43
+ * marks the last tag and then removes it, and arrows move between tags.
44
+ * Suggestions, validation and a limit are optional.
45
+ */
46
+ export function TagInput({
47
+ value,
48
+ defaultValue = [],
49
+ onValueChange,
50
+ label,
51
+ hint,
52
+ error,
53
+ placeholder = "Add…",
54
+ suggestions,
55
+ restrict = false,
56
+ validate,
57
+ transform = (tag) => tag.trim(),
58
+ max,
59
+ disabled,
60
+ required,
61
+ id,
62
+ className,
63
+ }: TagInputProps) {
64
+ const autoId = React.useId();
65
+ const fieldId = id ?? autoId;
66
+ const listId = `${fieldId}-list`;
67
+ const [inner, setInner] = React.useState(defaultValue);
68
+ const tags = value ?? inner;
69
+ const [text, setText] = React.useState("");
70
+ const [armed, setArmed] = React.useState<number | null>(null);
71
+ const [problem, setProblem] = React.useState<string | null>(null);
72
+ const [announcement, setAnnouncement] = React.useState("");
73
+ const [open, setOpen] = React.useState(false);
74
+ const [active, setActive] = React.useState(0);
75
+ const [side, setSide] = React.useState<"top" | "bottom">("bottom");
76
+ const control = React.useRef<HTMLDivElement>(null);
77
+ const input = React.useRef<HTMLInputElement>(null);
78
+
79
+ const full = max !== undefined && tags.length >= max;
80
+ const available = (suggestions ?? []).filter((option) => !tags.some((tag) => tag.toLowerCase() === option.label.toLowerCase()));
81
+ const shown = filterOptions(available, text);
82
+
83
+ const commit = (next: string[]) => {
84
+ if (value === undefined) setInner(next);
85
+ onValueChange?.(next);
86
+ };
87
+
88
+ /** Add every candidate that passes; report the first that does not. */
89
+ const add = (candidates: string[]) => {
90
+ const next = [...tags];
91
+ const added: string[] = [];
92
+ for (const raw of candidates) {
93
+ const tag = transform(raw);
94
+ if (!tag) continue;
95
+ if (max !== undefined && next.length >= max) {
96
+ setProblem(`Up to ${max} ${max === 1 ? "item" : "items"}.`);
97
+ break;
98
+ }
99
+ if (next.some((existing) => existing.toLowerCase() === tag.toLowerCase())) {
100
+ setProblem(`“${tag}” is already added.`);
101
+ continue;
102
+ }
103
+ if (restrict && !suggestions?.some((option) => option.label.toLowerCase() === tag.toLowerCase())) {
104
+ setProblem(`“${tag}” is not one of the options.`);
105
+ continue;
106
+ }
107
+ const message = validate?.(tag);
108
+ if (message) {
109
+ setProblem(message);
110
+ continue;
111
+ }
112
+ next.push(tag);
113
+ added.push(tag);
114
+ }
115
+ if (added.length) {
116
+ commit(next);
117
+ setAnnouncement(`Added ${added.join(", ")}.`);
118
+ }
119
+ return added.length > 0;
120
+ };
121
+
122
+ const remove = (index: number) => {
123
+ const tag = tags[index];
124
+ commit(tags.filter((_, position) => position !== index));
125
+ setAnnouncement(`Removed ${tag}.`);
126
+ setArmed(null);
127
+ setProblem(null);
128
+ input.current?.focus();
129
+ };
130
+
131
+ const submitText = () => {
132
+ if (open && shown[active] && text.trim()) {
133
+ add([shown[active].label]);
134
+ setText("");
135
+ return;
136
+ }
137
+ if (!text.trim()) return;
138
+ if (add(splitTags(text)) || !restrict) setText("");
139
+ };
140
+
141
+ const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
142
+ const caretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
143
+ if (event.key === "Enter" || event.key === ",") {
144
+ if (event.nativeEvent.isComposing) return;
145
+ if (event.key === "Enter" && !text.trim() && !open) return;
146
+ event.preventDefault();
147
+ submitText();
148
+ } else if (event.key === "Backspace" && !text) {
149
+ event.preventDefault();
150
+ if (armed !== null) remove(armed);
151
+ else if (tags.length) setArmed(tags.length - 1);
152
+ } else if (event.key === "Delete" && armed !== null) {
153
+ event.preventDefault();
154
+ remove(armed);
155
+ } else if (event.key === "ArrowLeft" && caretAtStart && tags.length) {
156
+ event.preventDefault();
157
+ setArmed((current) => (current === null ? tags.length - 1 : Math.max(0, current - 1)));
158
+ } else if (event.key === "ArrowRight" && armed !== null) {
159
+ event.preventDefault();
160
+ setArmed((current) => (current === null || current >= tags.length - 1 ? null : current + 1));
161
+ } else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && suggestions?.length) {
162
+ event.preventDefault();
163
+ if (!open) {
164
+ setSide(sideFor(control.current));
165
+ setOpen(true);
166
+ setActive(0);
167
+ } else if (shown.length) setActive((index) => (index + (event.key === "ArrowDown" ? 1 : -1) + shown.length) % shown.length);
168
+ } else if (event.key === "Escape") {
169
+ if (open) {
170
+ event.preventDefault();
171
+ setOpen(false);
172
+ } else setArmed(null);
173
+ }
174
+ };
175
+
176
+ const message = problem ?? error;
177
+
178
+ return (
179
+ <Field id={fieldId} label={label} hint={hint} error={message ?? undefined} required={required} disabled={disabled} className={cx("ml-tag-input", className)}>
180
+ <div
181
+ ref={control}
182
+ className="ml-tag-input-control"
183
+ data-disabled={disabled || undefined}
184
+ data-invalid={message ? "" : undefined}
185
+ onMouseDown={(event) => {
186
+ // Clicking the field's empty space puts the caret in the input.
187
+ if (event.target === event.currentTarget) {
188
+ event.preventDefault();
189
+ input.current?.focus();
190
+ }
191
+ }}
192
+ >
193
+ <ul className="ml-tag-input-tags" aria-label="Added">
194
+ {tags.map((tag, index) => (
195
+ <li key={tag} className="ml-tag-input-tag" data-armed={armed === index || undefined}>
196
+ <span className="ml-tag-input-text">{tag}</span>
197
+ {disabled ? null : (
198
+ <button type="button" className="ml-tag-input-remove" tabIndex={-1} aria-label={`Remove ${tag}`} onMouseDown={(event) => event.preventDefault()} onClick={() => remove(index)}>
199
+ <IconX aria-hidden="true" size="0.8em" />
200
+ </button>
201
+ )}
202
+ </li>
203
+ ))}
204
+ </ul>
205
+ <input
206
+ ref={input}
207
+ id={fieldId}
208
+ className="ml-tag-input-field"
209
+ role={suggestions ? "combobox" : undefined}
210
+ aria-expanded={suggestions ? open : undefined}
211
+ aria-controls={suggestions ? listId : undefined}
212
+ aria-autocomplete={suggestions ? "list" : undefined}
213
+ aria-activedescendant={open && shown.length ? `${listId}-${active}` : undefined}
214
+ aria-invalid={message ? true : undefined}
215
+ aria-describedby={fieldDescription(fieldId, { hint, error: message })}
216
+ value={text}
217
+ placeholder={full ? "" : placeholder}
218
+ disabled={disabled || full}
219
+ autoComplete="off"
220
+ onChange={(event) => {
221
+ setText(event.target.value);
222
+ setArmed(null);
223
+ setProblem(null);
224
+ setActive(0);
225
+ if (suggestions?.length && event.target.value.trim()) {
226
+ if (!open) setSide(sideFor(control.current));
227
+ setOpen(true);
228
+ } else setOpen(false);
229
+ }}
230
+ onKeyDown={onKeyDown}
231
+ onPaste={(event) => {
232
+ const pasted = event.clipboardData.getData("text/plain");
233
+ if (splitTags(pasted).length > 1) {
234
+ event.preventDefault();
235
+ add(splitTags(pasted));
236
+ }
237
+ }}
238
+ onBlur={() => {
239
+ setOpen(false);
240
+ setArmed(null);
241
+ // A half-typed tag is kept, as people expect when they click away.
242
+ if (text.trim() && !restrict && add(splitTags(text))) setText("");
243
+ }}
244
+ />
245
+ {max !== undefined ? (
246
+ <span className="ml-tag-input-count" aria-hidden="true">
247
+ {tags.length}/{max}
248
+ </span>
249
+ ) : null}
250
+ {open && suggestions ? (
251
+ <ComboboxList
252
+ id={listId}
253
+ options={shown}
254
+ active={active}
255
+ onActiveChange={setActive}
256
+ onPick={(option) => {
257
+ add([option.label]);
258
+ setText("");
259
+ setOpen(false);
260
+ input.current?.focus();
261
+ }}
262
+ empty={restrict ? "No matching options" : `Press Enter to add “${text.trim()}”`}
263
+ side={side}
264
+ grouped={false}
265
+ />
266
+ ) : null}
267
+ </div>
268
+ <p className="ml-visually-hidden" role="status" aria-live="polite">
269
+ {announcement}
270
+ </p>
271
+ </Field>
272
+ );
273
+ }