@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,185 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconUpload, IconX } from "@mlola-ui/icons";
5
+ import { Field } from "../input/input";
6
+
7
+ export interface DropzoneFile {
8
+ id: string;
9
+ name: string;
10
+ size: number;
11
+ /** 0..100 while uploading. */
12
+ progress?: number;
13
+ status?: "uploading" | "done" | "error";
14
+ error?: string;
15
+ }
16
+
17
+ export interface DropzoneProps {
18
+ /** Called with the files that passed validation. */
19
+ onFiles: (files: File[]) => void;
20
+ /** Files to list under the zone, e.g. uploads in progress. */
21
+ files?: DropzoneFile[];
22
+ onRemove?: (file: DropzoneFile) => void;
23
+ /** As the input's accept attribute: "image/*,.pdf". */
24
+ accept?: string;
25
+ /** Largest file in bytes. */
26
+ maxSize?: number;
27
+ maxFiles?: number;
28
+ multiple?: boolean;
29
+ label?: string;
30
+ hint?: string;
31
+ disabled?: boolean;
32
+ id?: string;
33
+ className?: string;
34
+ }
35
+
36
+ export function formatBytes(bytes: number) {
37
+ if (bytes < 1024) return `${bytes} B`;
38
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
39
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
40
+ }
41
+
42
+ /** "image/*,.pdf" → "Images, PDF": what a person would call the types. */
43
+ function describeAccept(accept: string) {
44
+ const words: Record<string, string> = { "image/*": "Images", "video/*": "Videos", "audio/*": "Audio", "text/*": "Text files" };
45
+ return accept
46
+ .split(",")
47
+ .map((rule) => rule.trim().toLowerCase())
48
+ .map((rule) => words[rule] ?? (rule.startsWith(".") ? rule.slice(1).toUpperCase() : rule.split("/").pop()?.toUpperCase() ?? rule))
49
+ .join(", ");
50
+ }
51
+
52
+ function accepts(file: File, accept: string | undefined) {
53
+ if (!accept) return true;
54
+ return accept.split(",").map((rule) => rule.trim().toLowerCase()).some((rule) => {
55
+ if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
56
+ if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
57
+ return file.type === rule;
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Drop files, choose them, or paste them. Files are checked for type, size
63
+ * and count before they are handed over, and every rejection is explained.
64
+ */
65
+ export function Dropzone({ onFiles, files = [], onRemove, accept, maxSize, maxFiles, multiple = true, label, hint, disabled, id, className }: DropzoneProps) {
66
+ const input = React.useRef<HTMLInputElement>(null);
67
+ const generated = React.useId();
68
+ const fieldId = id ?? `dropzone-${generated}`;
69
+ const [dragging, setDragging] = React.useState(false);
70
+ const [rejected, setRejected] = React.useState<string[]>([]);
71
+ const depth = React.useRef(0);
72
+
73
+ const take = (list: FileList | File[] | null) => {
74
+ if (!list || disabled) return;
75
+ const incoming = Array.from(list);
76
+ const problems: string[] = [];
77
+ const room = maxFiles !== undefined ? Math.max(0, maxFiles - files.length) : Infinity;
78
+ const kept = incoming.filter((file) => {
79
+ if (!accepts(file, accept)) problems.push(`${file.name} is not an accepted file type.`);
80
+ else if (maxSize !== undefined && file.size > maxSize) problems.push(`${file.name} is larger than ${formatBytes(maxSize)}.`);
81
+ else return true;
82
+ return false;
83
+ });
84
+ if (kept.length > room) problems.push(`Only ${maxFiles} ${maxFiles === 1 ? "file" : "files"} can be added.`);
85
+ setRejected(problems);
86
+ const accepted = kept.slice(0, room === Infinity ? undefined : room);
87
+ if (accepted.length) onFiles(multiple ? accepted : accepted.slice(0, 1));
88
+ };
89
+
90
+ const rules = [accept && describeAccept(accept), maxSize && `up to ${formatBytes(maxSize)}`].filter(Boolean).join(" · ");
91
+
92
+ return (
93
+ <div className={["ml-dropzone-root", className].filter(Boolean).join(" ")}>
94
+ <Field id={fieldId} label={label} hint={hint} disabled={disabled}>
95
+ <div
96
+ id={fieldId}
97
+ role="button"
98
+ tabIndex={disabled ? -1 : 0}
99
+ aria-disabled={disabled || undefined}
100
+ aria-label={`${label ? `${label}: ` : ""}drop files here, or press Enter to choose`}
101
+ aria-describedby={hint ? `${fieldId}-hint` : undefined}
102
+ className="ml-dropzone"
103
+ data-dragging={dragging || undefined}
104
+ onClick={() => input.current?.click()}
105
+ onKeyDown={(event) => {
106
+ if (event.key === "Enter" || event.key === " ") {
107
+ event.preventDefault();
108
+ input.current?.click();
109
+ }
110
+ }}
111
+ onPaste={(event) => take(event.clipboardData.files)}
112
+ onDragEnter={(event) => {
113
+ event.preventDefault();
114
+ depth.current += 1;
115
+ setDragging(true);
116
+ }}
117
+ onDragOver={(event) => event.preventDefault()}
118
+ onDragLeave={() => {
119
+ depth.current = Math.max(0, depth.current - 1);
120
+ if (depth.current === 0) setDragging(false);
121
+ }}
122
+ onDrop={(event) => {
123
+ event.preventDefault();
124
+ depth.current = 0;
125
+ setDragging(false);
126
+ take(event.dataTransfer.files);
127
+ }}
128
+ >
129
+ <span className="ml-dropzone-icon" aria-hidden="true">
130
+ <IconUpload size="1em" />
131
+ </span>
132
+ <span className="ml-dropzone-title">{dragging ? "Drop to upload" : "Drop files here, or choose"}</span>
133
+ {rules ? <span className="ml-dropzone-rules">{rules}</span> : null}
134
+ <input
135
+ ref={input}
136
+ type="file"
137
+ hidden
138
+ accept={accept}
139
+ multiple={multiple}
140
+ disabled={disabled}
141
+ onChange={(event) => {
142
+ take(event.target.files);
143
+ event.target.value = "";
144
+ }}
145
+ />
146
+ </div>
147
+ {rejected.length ? (
148
+ <ul className="ml-dropzone-rejected" role="alert">
149
+ {rejected.map((problem) => (
150
+ <li key={problem}>{problem}</li>
151
+ ))}
152
+ </ul>
153
+ ) : null}
154
+ </Field>
155
+ {files.length ? (
156
+ <ul className="ml-dropzone-files" aria-label="Files">
157
+ {files.map((file) => (
158
+ <li key={file.id} className="ml-dropzone-file" data-status={file.status ?? "done"}>
159
+ <span className="ml-dropzone-file-name">{file.name}</span>
160
+ <span className="ml-dropzone-file-meta">
161
+ {file.status === "error" ? file.error ?? "Upload failed" : file.status === "uploading" ? `${Math.round(file.progress ?? 0)}%` : formatBytes(file.size)}
162
+ </span>
163
+ {onRemove ? (
164
+ <button type="button" className="ml-dropzone-remove" aria-label={`Remove ${file.name}`} onClick={() => onRemove(file)}>
165
+ <IconX aria-hidden="true" size="0.875em" />
166
+ </button>
167
+ ) : null}
168
+ {file.status === "uploading" ? (
169
+ <span
170
+ className="ml-dropzone-progress"
171
+ role="progressbar"
172
+ aria-label={`Uploading ${file.name}`}
173
+ aria-valuemin={0}
174
+ aria-valuemax={100}
175
+ aria-valuenow={Math.round(file.progress ?? 0)}
176
+ style={{ ["--ml-dropzone-progress" as string]: `${file.progress ?? 0}%` }}
177
+ />
178
+ ) : null}
179
+ </li>
180
+ ))}
181
+ </ul>
182
+ ) : null}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ import { cx } from "../_internal/react";
3
+
4
+ export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
5
+ /** An icon or small illustration. */
6
+ icon?: React.ReactNode;
7
+ title: React.ReactNode;
8
+ description?: React.ReactNode;
9
+ /** The way forward: usually one primary action and one secondary. */
10
+ actions?: React.ReactNode;
11
+ /** "page" centres in a large area; "inline" sits inside a card or table. */
12
+ size?: "inline" | "page";
13
+ }
14
+
15
+ /**
16
+ * What a screen says when there is nothing to show yet: why, and what to do
17
+ * next. Never a blank area and never an apology without a way forward.
18
+ */
19
+ export function EmptyState({ icon, title, description, actions, size = "inline", className, ...props }: EmptyStateProps) {
20
+ return (
21
+ <section className={cx("ml-empty", className)} data-size={size} {...props}>
22
+ {icon ? (
23
+ <span className="ml-empty-icon" aria-hidden="true">
24
+ {icon}
25
+ </span>
26
+ ) : null}
27
+ <h3 className="ml-empty-title">{title}</h3>
28
+ {description ? <p className="ml-empty-description">{description}</p> : null}
29
+ {actions ? <div className="ml-empty-actions">{actions}</div> : null}
30
+ </section>
31
+ );
32
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { cx } from "../_internal/react";
6
+ import { useFloating, usePortalNode, type Align, type Side } from "../_internal/floating";
7
+
8
+ export interface HoverCardProps {
9
+ /** The link or name that shows the card; it keeps its own behaviour. */
10
+ children: React.ReactElement<Record<string, unknown>>;
11
+ /** The preview: a profile, a page summary, a repository. */
12
+ content: React.ReactNode;
13
+ side?: Side;
14
+ align?: Align;
15
+ /** Milliseconds the pointer must rest before the card opens. */
16
+ openDelay?: number;
17
+ /** Milliseconds of grace after the pointer leaves, so it can reach the card. */
18
+ closeDelay?: number;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * A preview that appears when the pointer rests on a link or a name, or when
24
+ * it takes keyboard focus: who someone is, what a page holds. The pointer can
25
+ * move into the card and use what is inside; Escape closes it. It adds to the
26
+ * trigger and never replaces what the trigger does.
27
+ */
28
+ export function HoverCard({ children, content, side = "bottom", align = "start", openDelay = 450, closeDelay = 180, className }: HoverCardProps) {
29
+ const [open, setOpen] = React.useState(false);
30
+ const anchor = React.useRef<HTMLElement>(null);
31
+ const layer = React.useRef<HTMLDivElement>(null);
32
+ const timer = React.useRef<number | undefined>(undefined);
33
+ const portal = usePortalNode();
34
+ const id = React.useId();
35
+ useFloating(anchor, layer, open && Boolean(portal), { side, align, offset: 8 });
36
+
37
+ const schedule = (next: boolean) => {
38
+ window.clearTimeout(timer.current);
39
+ timer.current = window.setTimeout(() => setOpen(next), next ? openDelay : closeDelay);
40
+ };
41
+ React.useEffect(() => () => window.clearTimeout(timer.current), []);
42
+
43
+ React.useEffect(() => {
44
+ if (!open) return;
45
+ const onKey = (event: KeyboardEvent) => {
46
+ if (event.key === "Escape") setOpen(false);
47
+ };
48
+ document.addEventListener("keydown", onKey);
49
+ return () => document.removeEventListener("keydown", onKey);
50
+ }, [open]);
51
+
52
+ const child = children.props as Record<string, unknown> & { ref?: React.Ref<HTMLElement> };
53
+ const call = (name: string, event: unknown) => (child[name] as ((value: unknown) => void) | undefined)?.(event);
54
+ const trigger = React.cloneElement(children, {
55
+ ref: (node: HTMLElement | null) => {
56
+ anchor.current = node;
57
+ const own = child.ref;
58
+ if (typeof own === "function") own(node);
59
+ else if (own && typeof own === "object") (own as React.MutableRefObject<HTMLElement | null>).current = node;
60
+ },
61
+ "aria-describedby": open ? id : child["aria-describedby"],
62
+ onPointerEnter: (event: React.PointerEvent) => {
63
+ call("onPointerEnter", event);
64
+ if (event.pointerType === "mouse") schedule(true);
65
+ },
66
+ onPointerLeave: (event: React.PointerEvent) => {
67
+ call("onPointerLeave", event);
68
+ schedule(false);
69
+ },
70
+ onFocus: (event: React.FocusEvent) => {
71
+ call("onFocus", event);
72
+ schedule(true);
73
+ },
74
+ onBlur: (event: React.FocusEvent) => {
75
+ call("onBlur", event);
76
+ if (!layer.current?.contains(event.relatedTarget as Node)) schedule(false);
77
+ },
78
+ });
79
+
80
+ return (
81
+ <>
82
+ {trigger}
83
+ {open && portal
84
+ ? createPortal(
85
+ <div
86
+ ref={layer}
87
+ id={id}
88
+ role="tooltip"
89
+ className={cx("ml-hover-card", className)}
90
+ onPointerEnter={() => window.clearTimeout(timer.current)}
91
+ onPointerLeave={() => schedule(false)}
92
+ onBlur={(event) => {
93
+ if (!layer.current?.contains(event.relatedTarget as Node) && event.relatedTarget !== anchor.current) schedule(false);
94
+ }}
95
+ >
96
+ {content}
97
+ </div>,
98
+ portal,
99
+ )
100
+ : null}
101
+ </>
102
+ );
103
+ }
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+
6
+ export type InputVariant = "default" | "filled" | "subtle";
7
+ type InputSize = "sm" | "md" | "lg";
8
+ export type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> & {
9
+ label?: string;
10
+ hint?: string;
11
+ error?: string;
12
+ variant?: InputVariant;
13
+ size?: InputSize;
14
+ containerClassName?: string;
15
+ /** Decoration inside the field, before the text, such as a search glyph. */
16
+ leading?: React.ReactNode;
17
+ /** A control rendered inside the field, at the end of the input. */
18
+ trailing?: React.ReactNode;
19
+ };
20
+
21
+ export interface FieldProps {
22
+ /** The control's id; the label, hint and error are tied to it. */
23
+ id: string;
24
+ label?: React.ReactNode;
25
+ hint?: React.ReactNode;
26
+ error?: React.ReactNode;
27
+ required?: boolean;
28
+ disabled?: boolean;
29
+ className?: string;
30
+ children: React.ReactNode;
31
+ }
32
+
33
+ /** Ids for a control's description: merge, never replace, so a caller's own description never hides the error. */
34
+ export function fieldDescription(id: string, { hint, error, describedBy }: { hint?: unknown; error?: unknown; describedBy?: string }) {
35
+ return [describedBy, hint ? `${id}-hint` : null, error ? `${id}-error` : null].filter(Boolean).join(" ") || undefined;
36
+ }
37
+
38
+ /**
39
+ * The anatomy every form control shares: a label, the control, a hint and an
40
+ * error announced when it appears. Input, Textarea, OTP Input and Dropzone
41
+ * all use it, so labels and errors behave identically everywhere.
42
+ */
43
+ export function Field({ id, label, hint, error, required, disabled, className, children }: FieldProps) {
44
+ return (
45
+ <div className={cx("ml-input-field", className)} data-disabled={disabled ? "" : undefined} data-invalid={error ? "" : undefined}>
46
+ {label ? (
47
+ <label htmlFor={id} className="ml-input-label">
48
+ {label}
49
+ {required ? <span aria-hidden="true" className="ml-required-mark">*</span> : null}
50
+ </label>
51
+ ) : null}
52
+ {children}
53
+ {hint ? <p id={`${id}-hint`} className="ml-input-hint">{hint}</p> : null}
54
+ {error ? <p id={`${id}-error`} role="alert" className="ml-input-error">{error}</p> : null}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
60
+ ({ label, hint, error, variant = "default", size = "md", containerClassName, leading, trailing, className, id, disabled, required, "aria-describedby": describedBy, ...props }, ref) => {
61
+ const generated = React.useId();
62
+ const inputId = id ?? `input-${generated}`;
63
+ const control = React.useRef<HTMLDivElement>(null);
64
+ const hasLeading = Boolean(leading);
65
+ const hasTrailing = Boolean(trailing);
66
+ // Pad the text past whatever sits beside it: an icon, a prefix like "https://", a badge.
67
+ React.useLayoutEffect(() => {
68
+ const element = control.current;
69
+ if (!element || (!hasLeading && !hasTrailing)) return;
70
+ const affixes = Array.from(element.querySelectorAll<HTMLElement>(":scope > .ml-input-leading, :scope > .ml-input-trailing"));
71
+ const measure = () => {
72
+ for (const affix of affixes) element.style.setProperty(affix.classList.contains("ml-input-leading") ? "--ml-input-leading" : "--ml-input-trailing", `${affix.offsetWidth}px`);
73
+ };
74
+ measure();
75
+ const observer = new ResizeObserver(measure);
76
+ affixes.forEach((affix) => observer.observe(affix));
77
+ return () => observer.disconnect();
78
+ }, [hasLeading, hasTrailing]);
79
+ return (
80
+ <Field id={inputId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={containerClassName}>
81
+ <div ref={control} className="ml-input-control" data-leading={leading ? "" : undefined} data-trailing={trailing ? "" : undefined}>
82
+ {leading ? <span aria-hidden="true" className="ml-input-leading">{leading}</span> : null}
83
+ <input
84
+ ref={ref}
85
+ id={inputId}
86
+ disabled={disabled}
87
+ required={required}
88
+ aria-invalid={error ? true : undefined}
89
+ aria-describedby={fieldDescription(inputId, { hint, error, describedBy })}
90
+ data-variant={variant}
91
+ data-size={size}
92
+ className={cx("ml-input", className)}
93
+ {...props}
94
+ />
95
+ {trailing ? <span className="ml-input-trailing">{trailing}</span> : null}
96
+ </div>
97
+ </Field>
98
+ );
99
+ }
100
+ );
101
+ Input.displayName = "Input";
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+
6
+ /** A single key, as printed on a keyboard. */
7
+ export function Kbd({ className, ...props }: React.HTMLAttributes<HTMLElement>) {
8
+ return <kbd className={cx("ml-kbd", className)} {...props} />;
9
+ }
10
+
11
+ function useIsApple() {
12
+ return React.useSyncExternalStore(
13
+ () => () => undefined,
14
+ () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent),
15
+ () => true,
16
+ );
17
+ }
18
+
19
+ const SYMBOLS: Record<string, { apple: string; other: string; spoken: string; spokenOther?: string }> = {
20
+ mod: { apple: "⌘", other: "Ctrl", spoken: "Command", spokenOther: "Control" },
21
+ ctrl: { apple: "⌃", other: "Ctrl", spoken: "Control" },
22
+ alt: { apple: "⌥", other: "Alt", spoken: "Option", spokenOther: "Alt" },
23
+ shift: { apple: "⇧", other: "Shift", spoken: "Shift" },
24
+ enter: { apple: "↵", other: "Enter", spoken: "Enter" },
25
+ esc: { apple: "Esc", other: "Esc", spoken: "Escape" },
26
+ up: { apple: "↑", other: "↑", spoken: "Up arrow" },
27
+ down: { apple: "↓", other: "↓", spoken: "Down arrow" },
28
+ backspace: { apple: "⌫", other: "Backspace", spoken: "Backspace" },
29
+ };
30
+
31
+ export interface ShortcutProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
32
+ /** Keys joined with "+", e.g. "mod+k" or "shift+enter". "mod" is ⌘ on Apple devices and Ctrl elsewhere. */
33
+ keys: string;
34
+ }
35
+
36
+ /**
37
+ * A keyboard shortcut for the visitor's platform: "mod+k" reads ⌘K on a Mac
38
+ * and Ctrl K elsewhere, and is spoken as words, not symbols.
39
+ */
40
+ export function Shortcut({ keys, className, ...props }: ShortcutProps) {
41
+ const apple = useIsApple();
42
+ const parts = keys.toLowerCase().split("+").map((key) => key.trim()).filter(Boolean);
43
+ const shown = parts.map((key) => SYMBOLS[key]?.[apple ? "apple" : "other"] ?? key.toUpperCase());
44
+ const spoken = parts.map((key) => (apple ? SYMBOLS[key]?.spoken : SYMBOLS[key]?.spokenOther ?? SYMBOLS[key]?.spoken) ?? key.toUpperCase()).join(" ");
45
+ return (
46
+ <kbd className={cx("ml-shortcut", className)} aria-label={spoken} {...props}>
47
+ {shown.map((key, index) => (
48
+ <kbd key={index} className="ml-kbd" aria-hidden="true">
49
+ {key}
50
+ </kbd>
51
+ ))}
52
+ </kbd>
53
+ );
54
+ }
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { useDialogLayer } from "../_internal/dialog";
6
+ import { cx } from "../_internal/react";
7
+ import { IconX } from "@mlola-ui/icons";
8
+
9
+ export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
10
+
11
+ export interface ModalProps {
12
+ open: boolean;
13
+ onClose: () => void;
14
+ size?: ModalSize;
15
+ closeOnBackdrop?: boolean;
16
+ closeOnEscape?: boolean;
17
+ label?: string;
18
+ description?: string;
19
+ className?: string;
20
+ children: React.ReactNode;
21
+ }
22
+
23
+ export function Modal({
24
+ open,
25
+ onClose,
26
+ size = "md",
27
+ closeOnBackdrop = true,
28
+ closeOnEscape = true,
29
+ label = "Dialog",
30
+ description,
31
+ className,
32
+ children,
33
+ }: ModalProps) {
34
+ const panelRef = React.useRef<HTMLDivElement>(null);
35
+ const portal = useDialogLayer({ open, onClose, closeOnEscape, panelRef });
36
+ const descriptionId = React.useId();
37
+ if (!portal || !open) return null;
38
+ return createPortal(
39
+ <div
40
+ className="ml-modal-overlay"
41
+ data-state="open"
42
+ onMouseDown={(event) => {
43
+ if (closeOnBackdrop && event.target === event.currentTarget) onClose();
44
+ }}
45
+ >
46
+ <div
47
+ ref={panelRef}
48
+ role="dialog"
49
+ aria-modal="true"
50
+ aria-label={label}
51
+ aria-describedby={description ? descriptionId : undefined}
52
+ tabIndex={-1}
53
+ data-size={size}
54
+ data-state="open"
55
+ className={cx("ml-modal", className)}
56
+ >
57
+ {description ? <p id={descriptionId} className="ml-visually-hidden">{description}</p> : null}
58
+ {children}
59
+ </div>
60
+ </div>,
61
+ portal
62
+ );
63
+ }
64
+
65
+ export interface ModalHeaderProps {
66
+ title: string;
67
+ onClose?: () => void;
68
+ className?: string;
69
+ }
70
+
71
+ export function ModalHeader({ title, onClose, className }: ModalHeaderProps) {
72
+ return (
73
+ <div className={cx("ml-modal-header", className)}>
74
+ <h2 className="ml-modal-title">{title}</h2>
75
+ {onClose ? (
76
+ <button type="button" onClick={onClose} aria-label="Close dialog" className="ml-modal-close">
77
+ <IconX aria-hidden="true" />
78
+ </button>
79
+ ) : null}
80
+ </div>
81
+ );
82
+ }
83
+
84
+ export interface ModalBodyProps {
85
+ className?: string;
86
+ children: React.ReactNode;
87
+ }
88
+
89
+ export function ModalBody({ className, children }: ModalBodyProps) {
90
+ return <div className={cx("ml-modal-body", className)}>{children}</div>;
91
+ }
92
+
93
+ export interface ModalFooterProps {
94
+ className?: string;
95
+ children: React.ReactNode;
96
+ }
97
+
98
+ export function ModalFooter({ className, children }: ModalFooterProps) {
99
+ return <div className={cx("ml-modal-footer", className)}>{children}</div>;
100
+ }