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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/index.js +4 -0
- package/package.json +44 -0
- package/registry/catalog.json +97 -0
- package/registry/index.json +2938 -0
- package/registry/source/packages/components/_internal/anchor.ts +54 -0
- package/registry/source/packages/components/_internal/dialog.ts +142 -0
- package/registry/source/packages/components/_internal/floating.ts +73 -0
- package/registry/source/packages/components/_internal/match.ts +39 -0
- package/registry/source/packages/components/_internal/react.ts +40 -0
- package/registry/source/packages/components/_internal/scroll.ts +15 -0
- package/registry/source/packages/components/accordion/accordion.tsx +164 -0
- package/registry/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
- package/registry/source/packages/components/activity-heatmap/calendar.ts +92 -0
- package/registry/source/packages/components/alert/alert.tsx +83 -0
- package/registry/source/packages/components/app-shell/app-shell.tsx +186 -0
- package/registry/source/packages/components/avatar/avatar.tsx +56 -0
- package/registry/source/packages/components/badge/badge.tsx +39 -0
- package/registry/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
- package/registry/source/packages/components/button/button.tsx +42 -0
- package/registry/source/packages/components/calendar/calendar.tsx +309 -0
- package/registry/source/packages/components/calendar/dates.ts +163 -0
- package/registry/source/packages/components/card/card.tsx +39 -0
- package/registry/source/packages/components/carousel/carousel.tsx +248 -0
- package/registry/source/packages/components/checkbox/checkbox.tsx +66 -0
- package/registry/source/packages/components/color-picker/color-picker.tsx +261 -0
- package/registry/source/packages/components/color-picker/color.ts +124 -0
- package/registry/source/packages/components/combobox/combobox.tsx +320 -0
- package/registry/source/packages/components/command-menu/command-menu.tsx +199 -0
- package/registry/source/packages/components/context-menu/context-menu.tsx +192 -0
- package/registry/source/packages/components/date-picker/date-picker.tsx +185 -0
- package/registry/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
- package/registry/source/packages/components/dropzone/dropzone.tsx +185 -0
- package/registry/source/packages/components/empty-state/empty-state.tsx +32 -0
- package/registry/source/packages/components/hover-card/hover-card.tsx +103 -0
- package/registry/source/packages/components/input/input.tsx +101 -0
- package/registry/source/packages/components/kbd/kbd.tsx +54 -0
- package/registry/source/packages/components/modal/modal.tsx +100 -0
- package/registry/source/packages/components/number-input/number-input.tsx +184 -0
- package/registry/source/packages/components/number-input/number.ts +50 -0
- package/registry/source/packages/components/otp-input/otp-input.tsx +116 -0
- package/registry/source/packages/components/pagination/pagination.tsx +65 -0
- package/registry/source/packages/components/popover/popover.tsx +107 -0
- package/registry/source/packages/components/priority-icon/labels.ts +13 -0
- package/registry/source/packages/components/priority-icon/priority-icon.tsx +60 -0
- package/registry/source/packages/components/progress/progress.tsx +110 -0
- package/registry/source/packages/components/radio-group/radio-group.tsx +106 -0
- package/registry/source/packages/components/rating/rating.tsx +94 -0
- package/registry/source/packages/components/resizable/resizable.tsx +171 -0
- package/registry/source/packages/components/segmented-control/segmented-control.tsx +78 -0
- package/registry/source/packages/components/select/select.tsx +332 -0
- package/registry/source/packages/components/sheet/sheet.tsx +109 -0
- package/registry/source/packages/components/skeleton/skeleton.tsx +84 -0
- package/registry/source/packages/components/slider/slider.tsx +99 -0
- package/registry/source/packages/components/status-icon/labels.ts +14 -0
- package/registry/source/packages/components/status-icon/status-icon.tsx +76 -0
- package/registry/source/packages/components/stepper/stepper.tsx +87 -0
- package/registry/source/packages/components/table/table.tsx +66 -0
- package/registry/source/packages/components/tabs/tabs.tsx +163 -0
- package/registry/source/packages/components/tag-input/tag-input.tsx +273 -0
- package/registry/source/packages/components/textarea/textarea.tsx +70 -0
- package/registry/source/packages/components/time-picker/time-picker.tsx +286 -0
- package/registry/source/packages/components/time-picker/times.ts +76 -0
- package/registry/source/packages/components/timeline/timeline.tsx +115 -0
- package/registry/source/packages/components/toast/toast.tsx +317 -0
- package/registry/source/packages/components/toggle/toggle.tsx +80 -0
- package/registry/source/packages/components/tooltip/tooltip.tsx +70 -0
- package/registry/source/packages/components/tour/tour.tsx +193 -0
- package/registry/source/packages/components/virtual-list/virtual-list.tsx +121 -0
- package/registry/source/packages/components/virtual-list/virtual.ts +33 -0
- package/registry/source-manifest.json +67 -0
- package/src/cli.js +262 -0
- package/src/config.js +114 -0
- package/src/doctor.js +156 -0
- package/src/installer.js +131 -0
- package/src/pro.js +160 -0
- package/src/registry.js +91 -0
- 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 };
|