mlola-ui 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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/bin/index.js +4 -0
  4. package/package.json +44 -0
  5. package/registry/catalog.json +97 -0
  6. package/registry/index.json +2938 -0
  7. package/registry/source/packages/components/_internal/anchor.ts +54 -0
  8. package/registry/source/packages/components/_internal/dialog.ts +142 -0
  9. package/registry/source/packages/components/_internal/floating.ts +73 -0
  10. package/registry/source/packages/components/_internal/match.ts +39 -0
  11. package/registry/source/packages/components/_internal/react.ts +40 -0
  12. package/registry/source/packages/components/_internal/scroll.ts +15 -0
  13. package/registry/source/packages/components/accordion/accordion.tsx +164 -0
  14. package/registry/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  15. package/registry/source/packages/components/activity-heatmap/calendar.ts +92 -0
  16. package/registry/source/packages/components/alert/alert.tsx +83 -0
  17. package/registry/source/packages/components/app-shell/app-shell.tsx +186 -0
  18. package/registry/source/packages/components/avatar/avatar.tsx +56 -0
  19. package/registry/source/packages/components/badge/badge.tsx +39 -0
  20. package/registry/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  21. package/registry/source/packages/components/button/button.tsx +42 -0
  22. package/registry/source/packages/components/calendar/calendar.tsx +309 -0
  23. package/registry/source/packages/components/calendar/dates.ts +163 -0
  24. package/registry/source/packages/components/card/card.tsx +39 -0
  25. package/registry/source/packages/components/carousel/carousel.tsx +248 -0
  26. package/registry/source/packages/components/checkbox/checkbox.tsx +66 -0
  27. package/registry/source/packages/components/color-picker/color-picker.tsx +261 -0
  28. package/registry/source/packages/components/color-picker/color.ts +124 -0
  29. package/registry/source/packages/components/combobox/combobox.tsx +320 -0
  30. package/registry/source/packages/components/command-menu/command-menu.tsx +199 -0
  31. package/registry/source/packages/components/context-menu/context-menu.tsx +192 -0
  32. package/registry/source/packages/components/date-picker/date-picker.tsx +185 -0
  33. package/registry/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  34. package/registry/source/packages/components/dropzone/dropzone.tsx +185 -0
  35. package/registry/source/packages/components/empty-state/empty-state.tsx +32 -0
  36. package/registry/source/packages/components/hover-card/hover-card.tsx +103 -0
  37. package/registry/source/packages/components/input/input.tsx +101 -0
  38. package/registry/source/packages/components/kbd/kbd.tsx +54 -0
  39. package/registry/source/packages/components/modal/modal.tsx +100 -0
  40. package/registry/source/packages/components/number-input/number-input.tsx +184 -0
  41. package/registry/source/packages/components/number-input/number.ts +50 -0
  42. package/registry/source/packages/components/otp-input/otp-input.tsx +116 -0
  43. package/registry/source/packages/components/pagination/pagination.tsx +65 -0
  44. package/registry/source/packages/components/popover/popover.tsx +107 -0
  45. package/registry/source/packages/components/priority-icon/labels.ts +13 -0
  46. package/registry/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  47. package/registry/source/packages/components/progress/progress.tsx +110 -0
  48. package/registry/source/packages/components/radio-group/radio-group.tsx +106 -0
  49. package/registry/source/packages/components/rating/rating.tsx +94 -0
  50. package/registry/source/packages/components/resizable/resizable.tsx +171 -0
  51. package/registry/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  52. package/registry/source/packages/components/select/select.tsx +332 -0
  53. package/registry/source/packages/components/sheet/sheet.tsx +109 -0
  54. package/registry/source/packages/components/skeleton/skeleton.tsx +84 -0
  55. package/registry/source/packages/components/slider/slider.tsx +99 -0
  56. package/registry/source/packages/components/status-icon/labels.ts +14 -0
  57. package/registry/source/packages/components/status-icon/status-icon.tsx +76 -0
  58. package/registry/source/packages/components/stepper/stepper.tsx +87 -0
  59. package/registry/source/packages/components/table/table.tsx +66 -0
  60. package/registry/source/packages/components/tabs/tabs.tsx +163 -0
  61. package/registry/source/packages/components/tag-input/tag-input.tsx +273 -0
  62. package/registry/source/packages/components/textarea/textarea.tsx +70 -0
  63. package/registry/source/packages/components/time-picker/time-picker.tsx +286 -0
  64. package/registry/source/packages/components/time-picker/times.ts +76 -0
  65. package/registry/source/packages/components/timeline/timeline.tsx +115 -0
  66. package/registry/source/packages/components/toast/toast.tsx +317 -0
  67. package/registry/source/packages/components/toggle/toggle.tsx +80 -0
  68. package/registry/source/packages/components/tooltip/tooltip.tsx +70 -0
  69. package/registry/source/packages/components/tour/tour.tsx +193 -0
  70. package/registry/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  71. package/registry/source/packages/components/virtual-list/virtual.ts +33 -0
  72. package/registry/source-manifest.json +67 -0
  73. package/src/cli.js +262 -0
  74. package/src/config.js +114 -0
  75. package/src/doctor.js +156 -0
  76. package/src/installer.js +131 -0
  77. package/src/pro.js +160 -0
  78. package/src/registry.js +91 -0
  79. package/src/theme-pull.js +91 -0
@@ -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 };
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconChevronDown, IconMenu } from "@mlola-ui/icons";
5
+ import { Resizable } from "../resizable/resizable";
6
+ import { Sheet } from "../sheet/sheet";
7
+ import { cx } from "../_internal/react";
8
+
9
+ interface ShellContext {
10
+ /** Fold the sidebar on wide screens; open the drawer on narrow ones. */
11
+ toggle: () => void;
12
+ folded: boolean;
13
+ narrow: boolean;
14
+ /** Close the drawer, after a navigation on a phone. */
15
+ closeDrawer: () => void;
16
+ }
17
+
18
+ const Context = React.createContext<ShellContext>({ toggle: () => undefined, folded: false, narrow: false, closeDrawer: () => undefined });
19
+
20
+ /** The shell's controls, for a menu button in a page header or a link that should close the drawer. */
21
+ export const useAppShell = () => React.useContext(Context);
22
+
23
+ function useNarrow(query: string) {
24
+ return React.useSyncExternalStore(
25
+ (notify) => {
26
+ const list = window.matchMedia(query);
27
+ list.addEventListener("change", notify);
28
+ return () => list.removeEventListener("change", notify);
29
+ },
30
+ () => window.matchMedia(query).matches,
31
+ () => false,
32
+ );
33
+ }
34
+
35
+ export interface AppShellProps {
36
+ sidebar: React.ReactNode;
37
+ children: React.ReactNode;
38
+ /** Names the navigation region and the drawer. */
39
+ label?: string;
40
+ defaultWidth?: number;
41
+ minWidth?: number;
42
+ maxWidth?: number;
43
+ /** Remembers the width and the fold between visits. */
44
+ storageKey?: string;
45
+ /** Below this width the sidebar becomes a drawer. */
46
+ drawerQuery?: string;
47
+ className?: string;
48
+ }
49
+
50
+ /**
51
+ * The frame of an app: a sidebar that resizes and folds, and the page beside
52
+ * it; on phones the sidebar becomes a drawer. Anything inside can reach the
53
+ * controls with `useAppShell`, and `AppShellMenuButton` shows itself only
54
+ * when the sidebar is out of view.
55
+ */
56
+ export function AppShell({ sidebar, children, label = "Navigation", defaultWidth = 248, minWidth = 200, maxWidth = 360, storageKey, drawerQuery = "(max-width: 48rem)", className }: AppShellProps) {
57
+ const narrow = useNarrow(drawerQuery);
58
+ const [folded, setFolded] = React.useState(false);
59
+ const [drawer, setDrawer] = React.useState(false);
60
+ const context = React.useMemo<ShellContext>(
61
+ () => ({ toggle: () => (narrow ? setDrawer(true) : setFolded((value) => !value)), folded, narrow, closeDrawer: () => setDrawer(false) }),
62
+ [folded, narrow],
63
+ );
64
+ React.useEffect(() => {
65
+ if (!narrow) setDrawer(false);
66
+ }, [narrow]);
67
+ return (
68
+ <Context.Provider value={context}>
69
+ <div className={cx("ml-app-shell", className)} data-folded={folded || undefined} data-narrow={narrow || undefined}>
70
+ <Resizable
71
+ className="ml-app-shell-frame"
72
+ units="pixels"
73
+ defaultSize={defaultWidth}
74
+ min={minWidth}
75
+ max={maxWidth}
76
+ collapsible
77
+ collapsed={folded}
78
+ onCollapsedChange={setFolded}
79
+ storageKey={storageKey}
80
+ label={`Resize ${label.toLowerCase()}`}
81
+ first={
82
+ <nav className="ml-app-shell-sidebar" aria-label={label}>
83
+ {sidebar}
84
+ </nav>
85
+ }
86
+ second={<div className="ml-app-shell-main">{children}</div>}
87
+ />
88
+ <Sheet open={drawer} onClose={() => setDrawer(false)} side="left" size="sm" title={label} className="ml-app-shell-drawer">
89
+ <nav className="ml-app-shell-sidebar" aria-label={label}>
90
+ {sidebar}
91
+ </nav>
92
+ </Sheet>
93
+ </div>
94
+ </Context.Provider>
95
+ );
96
+ }
97
+
98
+ /** A menu button that appears only when the sidebar is folded or on a phone. */
99
+ export function AppShellMenuButton({ label = "Open navigation", className }: { label?: string; className?: string }) {
100
+ const { toggle, folded, narrow } = useAppShell();
101
+ if (!folded && !narrow) return null;
102
+ return (
103
+ <button type="button" className={cx("ml-app-shell-menu", className)} aria-label={label} onClick={toggle}>
104
+ <IconMenu aria-hidden="true" size="1.05em" />
105
+ </button>
106
+ );
107
+ }
108
+
109
+ export function Sidebar({ header, footer, children, className }: { header?: React.ReactNode; footer?: React.ReactNode; children: React.ReactNode; className?: string }) {
110
+ return (
111
+ <div className={cx("ml-sidebar", className)}>
112
+ {header ? <div className="ml-sidebar-header">{header}</div> : null}
113
+ <div className="ml-sidebar-body">{children}</div>
114
+ {footer ? <div className="ml-sidebar-footer">{footer}</div> : null}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /** A titled group of items; with `collapsible`, the title folds it. */
120
+ export function SidebarSection({ title, collapsible = false, defaultOpen = true, children }: { title?: React.ReactNode; collapsible?: boolean; defaultOpen?: boolean; children: React.ReactNode }) {
121
+ const [open, setOpen] = React.useState(defaultOpen);
122
+ const id = React.useId();
123
+ return (
124
+ <section className="ml-sidebar-section">
125
+ {title ? (
126
+ collapsible ? (
127
+ <button type="button" className="ml-sidebar-title" data-collapsible="" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
128
+ {title}
129
+ <IconChevronDown aria-hidden="true" size="0.85em" className="ml-sidebar-chevron" />
130
+ </button>
131
+ ) : (
132
+ <h2 className="ml-sidebar-title">{title}</h2>
133
+ )
134
+ ) : null}
135
+ <ul id={id} className="ml-sidebar-list" hidden={!open}>
136
+ {children}
137
+ </ul>
138
+ </section>
139
+ );
140
+ }
141
+
142
+ export interface SidebarItemProps {
143
+ label: string;
144
+ icon?: React.ReactNode;
145
+ /** A number at the end: unread, open, due. */
146
+ count?: number;
147
+ /** The page being shown. */
148
+ current?: boolean;
149
+ href?: string;
150
+ onClick?: () => void;
151
+ /** Controls revealed on hover, such as a + or a menu. */
152
+ actions?: React.ReactNode;
153
+ }
154
+
155
+ export function SidebarItem({ label, icon, count, current, href, onClick, actions }: SidebarItemProps) {
156
+ const { narrow, closeDrawer } = useAppShell();
157
+ const content = (
158
+ <>
159
+ {icon ? (
160
+ <span className="ml-sidebar-icon" aria-hidden="true">
161
+ {icon}
162
+ </span>
163
+ ) : null}
164
+ <span className="ml-sidebar-label">{label}</span>
165
+ {count ? <span className="ml-sidebar-count">{count}</span> : null}
166
+ </>
167
+ );
168
+ const choose = () => {
169
+ onClick?.();
170
+ if (narrow) closeDrawer();
171
+ };
172
+ return (
173
+ <li className="ml-sidebar-entry">
174
+ {href ? (
175
+ <a className="ml-sidebar-item" href={href} aria-current={current ? "page" : undefined} onClick={choose}>
176
+ {content}
177
+ </a>
178
+ ) : (
179
+ <button type="button" className="ml-sidebar-item" aria-current={current ? "page" : undefined} onClick={choose}>
180
+ {content}
181
+ </button>
182
+ )}
183
+ {actions ? <span className="ml-sidebar-actions">{actions}</span> : null}
184
+ </li>
185
+ );
186
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+
6
+ export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
7
+ export type AvatarStatus = "online" | "offline" | "away" | "busy";
8
+ const initials = (name?: string) => {
9
+ const words = name?.replace(/[^\p{L}\p{N}\s]/gu, "").trim().split(/\s+/).filter(Boolean) ?? [];
10
+ return words.length > 1 ? `${words[0][0]}${words[words.length - 1][0]}`.toUpperCase() : words[0]?.slice(0, 2).toUpperCase() ?? "?";
11
+ };
12
+ interface AvatarProps extends React.HTMLAttributes<HTMLSpanElement> {
13
+ src?: string;
14
+ alt?: string;
15
+ name?: string;
16
+ size?: AvatarSize;
17
+ status?: AvatarStatus;
18
+ }
19
+ const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
20
+ ({ src, alt, name, size = "md", status, className, ...props }, ref) => {
21
+ const [failed, setFailed] = React.useState(false);
22
+ React.useEffect(() => setFailed(false), [src]);
23
+ const label = alt ?? name ?? "Avatar";
24
+ // alt="" marks the avatar as decorative, as it is for images: the name is
25
+ // already on screen beside it and must not be read twice.
26
+ const decorative = alt === "";
27
+ return (
28
+ <span ref={ref} data-size={size} className={cx("ml-avatar-root", className)} {...props}>
29
+ <span className="ml-avatar">
30
+ {src && !failed
31
+ ? <img src={src} alt={label} loading="lazy" className="ml-avatar-image" onError={() => setFailed(true)} />
32
+ : <span role={decorative ? undefined : "img"} aria-label={decorative ? undefined : label} aria-hidden={decorative || undefined} className="ml-avatar-fallback">{initials(name)}</span>}
33
+ </span>
34
+ {status ? <span role="status" aria-label={`${label} is ${status}`} data-status={status} className="ml-avatar-status" /> : null}
35
+ </span>
36
+ );
37
+ }
38
+ );
39
+ Avatar.displayName = "Avatar";
40
+
41
+ interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> { max?: number; }
42
+ const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
43
+ ({ max = 4, className, children, ...props }, ref) => {
44
+ const items = React.Children.toArray(children);
45
+ const visible = items.slice(0, Math.max(0, max));
46
+ const overflow = items.length - visible.length;
47
+ return (
48
+ <div ref={ref} role="group" aria-label={`Avatar group, ${items.length} people`} className={cx("ml-avatar-group", className)} {...props}>
49
+ {visible.map((child, index) => <span key={index} className="ml-avatar-group-item">{child}</span>)}
50
+ {overflow ? <span aria-label={`${overflow} more people`} className="ml-avatar-overflow">+{overflow}</span> : null}
51
+ </div>
52
+ );
53
+ }
54
+ );
55
+ AvatarGroup.displayName = "AvatarGroup";
56
+ export { Avatar, AvatarGroup };
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+ import { IconX } from "@mlola-ui/icons";
6
+
7
+ /** The meaning of the colour, from the theme's roles. */
8
+ export type BadgeTone = "neutral" | "primary" | "info" | "success" | "warning" | "danger";
9
+ /** The form: a tint, a solid fill, or an outline. */
10
+ export type BadgeVariant = "soft" | "solid" | "outline";
11
+ type BadgeSize = "sm" | "md" | "lg";
12
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
13
+ tone?: BadgeTone;
14
+ variant?: BadgeVariant;
15
+ size?: BadgeSize;
16
+ dot?: boolean;
17
+ onRemove?: () => void;
18
+ }
19
+ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
20
+ ({ tone = "neutral", variant = "soft", size = "md", dot = false, onRemove, className, children, ...props }, ref) => (
21
+ <span ref={ref} data-tone={tone} data-variant={variant} data-size={size} className={cx("ml-badge", className)} {...props}>
22
+ {dot ? <span aria-hidden="true" className="ml-badge-dot" /> : null}
23
+ <span className="ml-badge-label">{children}</span>
24
+ {onRemove ? (
25
+ <button
26
+ type="button"
27
+ onClick={onRemove}
28
+ aria-label={`Remove${typeof children === "string" ? ` ${children}` : " badge"}`}
29
+ className="ml-badge-remove"
30
+ >
31
+ <IconX aria-hidden="true" size="0.75em" />
32
+ </button>
33
+ ) : null}
34
+ </span>
35
+ )
36
+ );
37
+ Badge.displayName = "Badge";
38
+ export { Badge };
39
+ export type { BadgeSize };