@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,164 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+ import { rovingIndex } from "@mlola-ui/behavior/logic";
6
+ import { IconChevronDown } from "@mlola-ui/icons";
7
+
8
+ type AccordionType = "single" | "multiple";
9
+ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
10
+ type?: AccordionType;
11
+ defaultValue?: string | string[];
12
+ value?: string | string[];
13
+ onValueChange?: (value: string | string[] | undefined) => void;
14
+ collapsible?: boolean;
15
+ }
16
+ interface AccordionContextValue {
17
+ openValues: string[];
18
+ toggle: (value: string) => void;
19
+ }
20
+ interface ItemContextValue {
21
+ open: boolean;
22
+ disabled: boolean;
23
+ triggerId: string;
24
+ contentId: string;
25
+ toggle: () => void;
26
+ }
27
+ const AccordionContext = React.createContext<AccordionContextValue | null>(null);
28
+ const ItemContext = React.createContext<ItemContextValue | null>(null);
29
+ const normalize = (value: string | string[] | undefined) =>
30
+ value === undefined || value === "" ? [] : Array.isArray(value) ? value : [value];
31
+
32
+ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
33
+ ({ type = "single", defaultValue, value, onValueChange, collapsible = true, className, children, ...props }, ref) => {
34
+ const controlled = value !== undefined;
35
+ const [internal, setInternal] = React.useState(() => normalize(defaultValue));
36
+ const openValues = controlled ? normalize(value) : internal;
37
+ const rootRef = React.useRef<HTMLDivElement>(null);
38
+ const setRef = React.useCallback(
39
+ (node: HTMLDivElement | null) => {
40
+ rootRef.current = node;
41
+ if (typeof ref === "function") ref(node);
42
+ else if (ref) ref.current = node;
43
+ },
44
+ [ref]
45
+ );
46
+ const toggle = React.useCallback((itemValue: string) => {
47
+ const open = openValues.includes(itemValue);
48
+ if (type === "single") {
49
+ if (open && !collapsible) return;
50
+ const next = open ? [] : [itemValue];
51
+ if (!controlled) setInternal(next);
52
+ onValueChange?.(next[0]);
53
+ } else {
54
+ const next = open ? openValues.filter((item) => item !== itemValue) : [...openValues, itemValue];
55
+ if (!controlled) setInternal(next);
56
+ onValueChange?.(next);
57
+ }
58
+ }, [collapsible, controlled, onValueChange, openValues, type]);
59
+
60
+ // Arrow, Home and End move focus between triggers. The framework-free
61
+ // runtime makes the same decision with the same shared function.
62
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
63
+ props.onKeyDown?.(event);
64
+ if (event.defaultPrevented || !rootRef.current) return;
65
+ const triggers = [...rootRef.current.querySelectorAll<HTMLButtonElement>(".ml-accordion-trigger")];
66
+ const target = rovingIndex(
67
+ triggers.length,
68
+ triggers.indexOf(document.activeElement as HTMLButtonElement),
69
+ event.key,
70
+ { horizontal: false, enabled: (index) => !triggers[index].disabled },
71
+ );
72
+ if (target < 0) return;
73
+ event.preventDefault();
74
+ triggers[target].focus();
75
+ };
76
+
77
+ return (
78
+ <AccordionContext.Provider value={{ openValues, toggle }}>
79
+ <div ref={setRef} className={cx("ml-accordion", className)} onKeyDown={onKeyDown} {...props}>{children}</div>
80
+ </AccordionContext.Provider>
81
+ );
82
+ }
83
+ );
84
+ Accordion.displayName = "Accordion";
85
+
86
+ interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
87
+ value: string;
88
+ disabled?: boolean;
89
+ }
90
+ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
91
+ ({ value, disabled = false, className, children, ...props }, ref) => {
92
+ const accordion = React.useContext(AccordionContext);
93
+ if (!accordion) throw new Error("AccordionItem must be used inside Accordion");
94
+ const open = accordion.openValues.includes(value);
95
+ const id = React.useId();
96
+ const item = React.useMemo(() => ({
97
+ open,
98
+ disabled,
99
+ triggerId: `${id}-trigger`,
100
+ contentId: `${id}-content`,
101
+ toggle: () => !disabled && accordion.toggle(value),
102
+ }), [accordion, disabled, id, open, value]);
103
+ return (
104
+ <ItemContext.Provider value={item}>
105
+ <div ref={ref} data-state={open ? "open" : "closed"} data-disabled={disabled ? "" : undefined} className={cx("ml-accordion-item", className)} {...props}>
106
+ {children}
107
+ </div>
108
+ </ItemContext.Provider>
109
+ );
110
+ }
111
+ );
112
+ AccordionItem.displayName = "AccordionItem";
113
+
114
+ interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
115
+ const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
116
+ ({ className, children, ...props }, ref) => {
117
+ const item = React.useContext(ItemContext);
118
+ if (!item) throw new Error("AccordionTrigger must be used inside AccordionItem");
119
+ return (
120
+ <h3 className="ml-accordion-heading">
121
+ <button
122
+ ref={ref}
123
+ id={item.triggerId}
124
+ type="button"
125
+ aria-expanded={item.open}
126
+ aria-controls={item.contentId}
127
+ disabled={item.disabled}
128
+ data-state={item.open ? "open" : "closed"}
129
+ className={cx("ml-accordion-trigger", className)}
130
+ onClick={item.toggle}
131
+ {...props}
132
+ >
133
+ <span className="ml-accordion-trigger-label">{children}</span>
134
+ <IconChevronDown aria-hidden="true" className="ml-accordion-chevron" />
135
+ </button>
136
+ </h3>
137
+ );
138
+ }
139
+ );
140
+ AccordionTrigger.displayName = "AccordionTrigger";
141
+
142
+ interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
143
+ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
144
+ ({ className, children, ...props }, ref) => {
145
+ const item = React.useContext(ItemContext);
146
+ if (!item) throw new Error("AccordionContent must be used inside AccordionItem");
147
+ return (
148
+ <div
149
+ ref={ref}
150
+ id={item.contentId}
151
+ role="region"
152
+ aria-labelledby={item.triggerId}
153
+ hidden={!item.open}
154
+ data-state={item.open ? "open" : "closed"}
155
+ className={cx("ml-accordion-panel", className)}
156
+ {...props}
157
+ >
158
+ <div className="ml-accordion-panel-inner">{children}</div>
159
+ </div>
160
+ );
161
+ }
162
+ );
163
+ AccordionContent.displayName = "AccordionContent";
164
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+ import { buildCalendar, type DayCount, type HeatCell } from "./calendar";
6
+
7
+ export type { DayCount, HeatCell };
8
+
9
+ export interface ActivityHeatmapProps extends Omit<React.HTMLAttributes<HTMLElement>, "onSelect"> {
10
+ /** Counts per day, YYYY-MM-DD. Missing days are zero. */
11
+ data: DayCount[];
12
+ /** Last day shown. Defaults to the latest day in `data`, so server and client agree. */
13
+ end?: string;
14
+ weeks?: number;
15
+ /** 1 starts weeks on Monday, 0 on Sunday. */
16
+ weekStart?: 0 | 1;
17
+ /** What is counted, in the plural: "tokens", "commits". */
18
+ unit?: string;
19
+ /** Formats a count for the tooltip and labels. */
20
+ formatCount?: (count: number) => string;
21
+ tone?: "primary" | "info" | "success" | "warning";
22
+ onSelect?: (cell: HeatCell) => void;
23
+ /** Left of the legend. Defaults to the total over the range. */
24
+ caption?: React.ReactNode;
25
+ showLegend?: boolean;
26
+ locale?: string;
27
+ label?: string;
28
+ }
29
+
30
+ const today = (data: DayCount[]) => data.reduce((latest, day) => (day.date > latest ? day.date : latest), data[0]?.date ?? "1970-01-01");
31
+
32
+ /**
33
+ * Activity by day, the way GitHub draws contributions: a week per column, a
34
+ * shade per level. Levels are quantiles of the active days, so one huge day
35
+ * never washes the rest out. It is a real grid: arrows move between days,
36
+ * each day is announced with its count, and hovering or focusing shows it.
37
+ */
38
+ export function ActivityHeatmap({
39
+ data,
40
+ end,
41
+ weeks = 26,
42
+ weekStart = 1,
43
+ unit = "activities",
44
+ formatCount,
45
+ tone = "primary",
46
+ onSelect,
47
+ caption,
48
+ showLegend = true,
49
+ locale = "en",
50
+ label = "Activity",
51
+ className,
52
+ style,
53
+ ...props
54
+ }: ActivityHeatmapProps) {
55
+ const last = end ?? today(data);
56
+ const calendar = React.useMemo(() => buildCalendar(data, { end: last, weeks, weekStart, locale }), [data, last, weeks, weekStart, locale]);
57
+ const format = React.useMemo(() => formatCount ?? ((count: number) => new Intl.NumberFormat(locale).format(count)), [formatCount, locale]);
58
+ const dayName = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }), [locale]);
59
+ const fullDate = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }), [locale]);
60
+ const shortDate = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }), [locale]);
61
+
62
+ const lastWeek = calendar.weeks.length - 1;
63
+ const lastDay = Math.max(0, (calendar.weeks[lastWeek] ?? []).map((cell) => !cell.outside).lastIndexOf(true));
64
+ const [focus, setFocus] = React.useState<[number, number]>([lastWeek, lastDay]);
65
+ const [shown, setShown] = React.useState<[number, number] | null>(null);
66
+ const root = React.useRef<HTMLDivElement>(null);
67
+ const tip = React.useRef<HTMLDivElement>(null);
68
+ const cells = React.useRef(new Map<string, HTMLElement>());
69
+ const key = (week: number, day: number) => `${week}:${day}`;
70
+
71
+ // Place the tooltip over its cell, kept inside the component's edges.
72
+ React.useLayoutEffect(() => {
73
+ if (!shown || !root.current || !tip.current) return;
74
+ const cell = cells.current.get(key(...shown));
75
+ if (!cell) return;
76
+ const box = root.current.getBoundingClientRect();
77
+ const rect = cell.getBoundingClientRect();
78
+ const half = tip.current.offsetWidth / 2;
79
+ const center = rect.left - box.left + rect.width / 2;
80
+ tip.current.style.left = `${Math.min(Math.max(center, half), box.width - half)}px`;
81
+ tip.current.style.top = `${rect.top - box.top}px`;
82
+ }, [shown]);
83
+
84
+ const move = (week: number, day: number) => {
85
+ const target = calendar.weeks[week]?.[day];
86
+ if (!target || target.outside) return;
87
+ setFocus([week, day]);
88
+ setShown([week, day]);
89
+ cells.current.get(key(week, day))?.focus();
90
+ };
91
+
92
+ const onKeyDown = (event: React.KeyboardEvent, week: number, day: number) => {
93
+ const moves: Record<string, [number, number]> = {
94
+ ArrowLeft: [week - 1, day],
95
+ ArrowRight: [week + 1, day],
96
+ ArrowUp: [week, day - 1],
97
+ ArrowDown: [week, day + 1],
98
+ Home: [0, day],
99
+ End: [lastWeek, day],
100
+ };
101
+ if (moves[event.key]) {
102
+ event.preventDefault();
103
+ let [nextWeek, nextDay] = moves[event.key];
104
+ // Home and End land on the nearest day inside the range.
105
+ if (event.key === "Home") while (calendar.weeks[nextWeek]?.[nextDay]?.outside && nextWeek < lastWeek) nextWeek += 1;
106
+ if (event.key === "End") while (calendar.weeks[nextWeek]?.[nextDay]?.outside && nextWeek > 0) nextWeek -= 1;
107
+ move(nextWeek, nextDay);
108
+ } else if ((event.key === "Enter" || event.key === " ") && onSelect) {
109
+ event.preventDefault();
110
+ onSelect(calendar.weeks[week][day]);
111
+ }
112
+ };
113
+
114
+ const count = (value: number) => `${format(value)} ${unit}`;
115
+ const active = shown ? calendar.weeks[shown[0]]?.[shown[1]] : null;
116
+
117
+ return (
118
+ <div
119
+ {...props}
120
+ ref={root}
121
+ className={cx("ml-heatmap", className)}
122
+ data-tone={tone}
123
+ style={{ ["--ml-heatmap-weeks" as string]: calendar.weeks.length, ...style }}
124
+ onPointerLeave={() => setShown(null)}
125
+ >
126
+ <div role="grid" aria-label={label} aria-readonly="true" className="ml-heatmap-grid">
127
+ <div className="ml-heatmap-months" aria-hidden="true">
128
+ {calendar.months.map((month) => (
129
+ <span key={`${month.label}-${month.week}`} className="ml-heatmap-month" style={{ gridColumn: month.week + 2 }}>
130
+ {month.label}
131
+ </span>
132
+ ))}
133
+ </div>
134
+ {Array.from({ length: 7 }, (_, day) => {
135
+ const name = dayName.format(new Date(`${calendar.weeks[0][day].date}T00:00:00Z`));
136
+ const labelled = [1, 3, 5].includes(new Date(`${calendar.weeks[0][day].date}T00:00:00Z`).getUTCDay());
137
+ return (
138
+ <div key={day} role="row" className="ml-heatmap-row">
139
+ <span role="rowheader" className="ml-heatmap-weekday" style={{ gridRow: day + 2 }}>
140
+ <span aria-hidden={!labelled || undefined} data-hidden={!labelled || undefined}>
141
+ {name}
142
+ </span>
143
+ </span>
144
+ {calendar.weeks.map((column, week) => {
145
+ const cell = column[day];
146
+ const focused = focus[0] === week && focus[1] === day;
147
+ return (
148
+ <span
149
+ key={cell.date}
150
+ ref={(element) => {
151
+ if (element) cells.current.set(key(week, day), element);
152
+ else cells.current.delete(key(week, day));
153
+ }}
154
+ role="gridcell"
155
+ className="ml-heatmap-cell"
156
+ data-level={cell.level}
157
+ data-outside={cell.outside || undefined}
158
+ data-active={shown?.[0] === week && shown?.[1] === day ? "" : undefined}
159
+ aria-label={cell.outside ? undefined : `${count(cell.count)} on ${fullDate.format(new Date(`${cell.date}T00:00:00Z`))}`}
160
+ aria-hidden={cell.outside || undefined}
161
+ tabIndex={cell.outside ? undefined : focused ? 0 : -1}
162
+ style={{ gridColumn: week + 2, gridRow: day + 2 }}
163
+ onPointerEnter={() => !cell.outside && setShown([week, day])}
164
+ onFocus={() => {
165
+ setFocus([week, day]);
166
+ setShown([week, day]);
167
+ }}
168
+ onBlur={() => setShown(null)}
169
+ onClick={() => !cell.outside && onSelect?.(cell)}
170
+ onKeyDown={(event) => onKeyDown(event, week, day)}
171
+ />
172
+ );
173
+ })}
174
+ </div>
175
+ );
176
+ })}
177
+ </div>
178
+
179
+ <div ref={tip} className="ml-heatmap-tooltip" role="presentation" hidden={!active}>
180
+ {active ? (
181
+ <>
182
+ <strong>{active.count ? count(active.count) : `No ${unit}`}</strong>
183
+ <span>{shortDate.format(new Date(`${active.date}T00:00:00Z`))}</span>
184
+ </>
185
+ ) : null}
186
+ </div>
187
+
188
+ {showLegend || caption !== null ? (
189
+ <div className="ml-heatmap-footer">
190
+ <span className="ml-heatmap-caption">{caption === undefined ? `${count(calendar.total)} over ${calendar.activeDays} active days` : caption}</span>
191
+ {showLegend ? (
192
+ <span className="ml-heatmap-legend" aria-hidden="true">
193
+ Less
194
+ {[0, 1, 2, 3, 4].map((level) => (
195
+ <span key={level} className="ml-heatmap-cell" data-level={level} />
196
+ ))}
197
+ More
198
+ </span>
199
+ ) : null}
200
+ </div>
201
+ ) : null}
202
+ </div>
203
+ );
204
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The calendar behind an activity heatmap: whole weeks as columns ending on
3
+ * `end`, a level per day, and where each month's label goes. Levels come from
4
+ * quantiles of the active days, so one extreme day cannot wash every other
5
+ * day out to the palest shade.
6
+ */
7
+ export interface DayCount {
8
+ /** YYYY-MM-DD */
9
+ date: string;
10
+ count: number;
11
+ }
12
+
13
+ export interface HeatCell {
14
+ date: string;
15
+ count: number;
16
+ level: 0 | 1 | 2 | 3 | 4;
17
+ /** Outside the requested range (padding in the first or last week). */
18
+ outside: boolean;
19
+ }
20
+
21
+ export interface HeatCalendar {
22
+ weeks: HeatCell[][];
23
+ /** Month labels with the week column they start in. */
24
+ months: Array<{ label: string; week: number }>;
25
+ total: number;
26
+ activeDays: number;
27
+ max: number;
28
+ }
29
+
30
+ const DAY = 86_400_000;
31
+ const iso = (time: number) => new Date(time).toISOString().slice(0, 10);
32
+ const utc = (date: string) => Date.parse(`${date}T00:00:00Z`);
33
+
34
+ /** Thresholds splitting non-zero counts into four equal-sized groups. */
35
+ export function quantileThresholds(counts: number[]): [number, number, number] {
36
+ const active = counts.filter((count) => count > 0).sort((a, b) => a - b);
37
+ if (!active.length) return [1, 1, 1];
38
+ const at = (fraction: number) => active[Math.min(active.length - 1, Math.floor(fraction * active.length))];
39
+ return [at(0.25), at(0.5), at(0.75)];
40
+ }
41
+
42
+ export function levelFor(count: number, [q1, q2, q3]: [number, number, number]): HeatCell["level"] {
43
+ if (count <= 0) return 0;
44
+ if (count <= q1) return 1;
45
+ if (count <= q2) return 2;
46
+ if (count <= q3) return 3;
47
+ return 4;
48
+ }
49
+
50
+ export function buildCalendar(
51
+ data: DayCount[],
52
+ { end, weeks = 26, weekStart = 1, locale = "en" }: { end: string; weeks?: number; weekStart?: 0 | 1; locale?: string },
53
+ ): HeatCalendar {
54
+ const counts = new Map(data.map((day) => [day.date, day.count]));
55
+ const endTime = utc(end);
56
+ const endWeekday = (new Date(endTime).getUTCDay() - weekStart + 7) % 7;
57
+ const lastColumnEnd = endTime + (6 - endWeekday) * DAY;
58
+ const firstDay = lastColumnEnd - (weeks * 7 - 1) * DAY;
59
+ const rangeStart = endTime - (weeks * 7 - 1 - (6 - endWeekday)) * DAY;
60
+ const inRange = data.filter((day) => utc(day.date) >= rangeStart && utc(day.date) <= endTime).map((day) => day.count);
61
+ const thresholds = quantileThresholds(inRange);
62
+ const format = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
63
+
64
+ const columns: HeatCell[][] = [];
65
+ const months: HeatCalendar["months"] = [];
66
+ let lastMonth = -1;
67
+ for (let week = 0; week < weeks; week += 1) {
68
+ const column: HeatCell[] = [];
69
+ for (let day = 0; day < 7; day += 1) {
70
+ const time = firstDay + (week * 7 + day) * DAY;
71
+ const date = iso(time);
72
+ const count = counts.get(date) ?? 0;
73
+ const outside = time > endTime || time < rangeStart;
74
+ column.push({ date, count: outside ? 0 : count, level: outside ? 0 : levelFor(count, thresholds), outside });
75
+ }
76
+ const month = new Date(firstDay + week * 7 * DAY).getUTCMonth();
77
+ if (month !== lastMonth) {
78
+ // Skip a label squeezed into the very first column by a partial month.
79
+ if (week > 0 || new Date(firstDay).getUTCDate() <= 7) months.push({ label: format.format(new Date(firstDay + week * 7 * DAY)), week });
80
+ lastMonth = month;
81
+ }
82
+ columns.push(column);
83
+ }
84
+ const active = columns.flat().filter((cell) => !cell.outside && cell.count > 0);
85
+ return {
86
+ weeks: columns,
87
+ months,
88
+ total: active.reduce((sum, cell) => sum + cell.count, 0),
89
+ activeDays: active.length,
90
+ max: active.reduce((max, cell) => Math.max(max, cell.count), 0),
91
+ };
92
+ }
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+ import { IconCircleCheck, IconCircleX, IconInfo, IconTriangleAlert, IconX } from "@mlola-ui/icons";
6
+
7
+ /** The meaning of the message, from the theme's colour roles. */
8
+ export type AlertTone = "neutral" | "info" | "success" | "warning" | "danger";
9
+ /** "card" is a neutral card with a solid icon mark; "soft" tints the whole alert, for banners across a page. */
10
+ export type AlertVariant = "card" | "soft";
11
+ interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
12
+ tone?: AlertTone;
13
+ variant?: AlertVariant;
14
+ onDismiss?: () => void;
15
+ action?: React.ReactNode;
16
+ /** Replaces the tone's icon. `null` shows none. */
17
+ icon?: React.ReactNode;
18
+ }
19
+
20
+ const TONE_ICONS: Record<AlertTone, React.ReactNode> = {
21
+ neutral: <IconInfo size="1em" />,
22
+ info: <IconInfo size="1em" />,
23
+ success: <IconCircleCheck size="1em" />,
24
+ warning: <IconTriangleAlert size="1em" />,
25
+ danger: <IconCircleX size="1em" />,
26
+ };
27
+
28
+ /**
29
+ * A message in the page. Only warnings and errors interrupt a screen reader
30
+ * (role="alert"); calmer notices are announced politely when they appear.
31
+ * It eases in when it appears and, when dismissed, eases out before
32
+ * `onDismiss` runs, so the layout closes smoothly around it.
33
+ */
34
+ const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
35
+ ({ tone = "neutral", variant = "card", onDismiss, action, icon, className, children, ...props }, ref) => {
36
+ const [leaving, setLeaving] = React.useState(false);
37
+ const timer = React.useRef<number | undefined>(undefined);
38
+ React.useEffect(() => () => window.clearTimeout(timer.current), []);
39
+ const dismiss = () => {
40
+ if (!onDismiss || leaving) return;
41
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return onDismiss();
42
+ setLeaving(true);
43
+ timer.current = window.setTimeout(onDismiss, 200);
44
+ };
45
+ return (
46
+ <div
47
+ ref={ref}
48
+ role={tone === "danger" || tone === "warning" ? "alert" : "status"}
49
+ data-tone={tone}
50
+ data-variant={variant}
51
+ data-state={leaving ? "closing" : "open"}
52
+ className={cx("ml-alert", className)}
53
+ {...props}
54
+ >
55
+ {icon === null ? <span aria-hidden="true" /> : <span aria-hidden="true" className="ml-alert-icon">{icon ?? TONE_ICONS[tone]}</span>}
56
+ <div className="ml-alert-content">{children}</div>
57
+ {action || onDismiss ? (
58
+ <div className="ml-alert-actions">
59
+ {action}
60
+ {onDismiss ? (
61
+ <button type="button" onClick={dismiss} aria-label="Dismiss alert" className="ml-alert-dismiss">
62
+ <IconX aria-hidden="true" size="1em" />
63
+ </button>
64
+ ) : null}
65
+ </div>
66
+ ) : null}
67
+ </div>
68
+ );
69
+ }
70
+ );
71
+ Alert.displayName = "Alert";
72
+ // Not a heading: an alert sits inside whatever outline the page already has,
73
+ // so it must not claim a level. Wrap it in a heading yourself when it needs one.
74
+ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
75
+ ({ className, ...props }, ref) => <p ref={ref} className={cx("ml-alert-title", className)} {...props} />
76
+ );
77
+ AlertTitle.displayName = "AlertTitle";
78
+ const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
79
+ ({ className, ...props }, ref) => <div ref={ref} className={cx("ml-alert-description", className)} {...props} />
80
+ );
81
+ AlertDescription.displayName = "AlertDescription";
82
+ export { Alert, AlertTitle, AlertDescription };
83
+ export type { AlertProps };