mlola-ui 1.0.4 → 1.0.6
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/package.json +1 -1
- package/registry/agents.md +2 -0
- package/registry/assets.json +44 -44
- package/registry/catalog.json +1 -1
- package/registry/examples.json +2538 -0
- package/registry/index.json +876 -225
- package/registry/source/packages/components/_internal/anchor.ts +3 -52
- package/registry/source/packages/components/_internal/dialog.ts +25 -1
- package/registry/source/packages/components/avatar/avatar.tsx +6 -1
- package/registry/source/packages/components/calendar/calendar.tsx +2 -1
- package/registry/source/packages/components/kbd/kbd.tsx +3 -1
- package/registry/source/packages/components/modal/modal.tsx +5 -2
- package/registry/source/packages/components/resizable/resizable.tsx +2 -1
- package/registry/source/packages/components/table/table.tsx +28 -6
- package/registry/source/packages/components/tag-input/tag-input.tsx +1 -0
- package/registry/source-manifest.json +9 -9
- package/src/assets.js +7 -2
- package/src/knowledge.js +90 -26
- package/src/mcp.js +1 -1
|
@@ -1,54 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where a floating layer goes next to its anchor
|
|
3
|
-
*
|
|
4
|
-
* along the edge to stay inside the viewport. No DOM, so it is testable.
|
|
2
|
+
* Where a floating layer goes next to its anchor. The math is shared with the
|
|
3
|
+
* framework-free runtime, so it lives in @mlola-ui/behavior/logic.
|
|
5
4
|
*/
|
|
6
|
-
export type
|
|
7
|
-
export type Align = "start" | "center" | "end";
|
|
8
|
-
|
|
9
|
-
export interface Rect {
|
|
10
|
-
x: number;
|
|
11
|
-
y: number;
|
|
12
|
-
width: number;
|
|
13
|
-
height: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface Placement {
|
|
17
|
-
x: number;
|
|
18
|
-
y: number;
|
|
19
|
-
side: Side;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
|
23
|
-
|
|
24
|
-
export function placeFloating(
|
|
25
|
-
anchor: Rect,
|
|
26
|
-
floating: { width: number; height: number },
|
|
27
|
-
viewport: { width: number; height: number },
|
|
28
|
-
{ side = "bottom", align = "center", offset = 8, padding = 8 }: { side?: Side; align?: Align; offset?: number; padding?: number } = {},
|
|
29
|
-
): Placement {
|
|
30
|
-
const room: Record<Side, number> = {
|
|
31
|
-
top: anchor.y - padding,
|
|
32
|
-
bottom: viewport.height - (anchor.y + anchor.height) - padding,
|
|
33
|
-
left: anchor.x - padding,
|
|
34
|
-
right: viewport.width - (anchor.x + anchor.width) - padding,
|
|
35
|
-
};
|
|
36
|
-
const needs = (candidate: Side) => (candidate === "top" || candidate === "bottom" ? floating.height : floating.width) + offset;
|
|
37
|
-
let chosen = side;
|
|
38
|
-
if (room[side] < needs(side) && room[OPPOSITE[side]] > room[side]) chosen = OPPOSITE[side];
|
|
39
|
-
|
|
40
|
-
const vertical = chosen === "top" || chosen === "bottom";
|
|
41
|
-
let x: number;
|
|
42
|
-
let y: number;
|
|
43
|
-
if (vertical) {
|
|
44
|
-
y = chosen === "bottom" ? anchor.y + anchor.height + offset : anchor.y - floating.height - offset;
|
|
45
|
-
x = align === "start" ? anchor.x : align === "end" ? anchor.x + anchor.width - floating.width : anchor.x + anchor.width / 2 - floating.width / 2;
|
|
46
|
-
} else {
|
|
47
|
-
x = chosen === "right" ? anchor.x + anchor.width + offset : anchor.x - floating.width - offset;
|
|
48
|
-
y = align === "start" ? anchor.y : align === "end" ? anchor.y + anchor.height - floating.height : anchor.y + anchor.height / 2 - floating.height / 2;
|
|
49
|
-
}
|
|
50
|
-
// Shift along the edge to stay on screen.
|
|
51
|
-
x = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - floating.width - padding));
|
|
52
|
-
y = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - floating.height - padding));
|
|
53
|
-
return { x: Math.round(x), y: Math.round(y), side: chosen };
|
|
54
|
-
}
|
|
5
|
+
export { placeFloating, type Align, type Placement, type Rect, type Side } from "@mlola-ui/behavior/logic";
|
|
@@ -44,6 +44,24 @@ function hideBackground(portal: HTMLElement) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Safari does not focus a button it clicks, so when a dialog opens with
|
|
48
|
+
// nothing focused, focus returns on close to the control just pressed.
|
|
49
|
+
let lastPressed: HTMLElement | null = null;
|
|
50
|
+
let pressedAt = 0;
|
|
51
|
+
let tracking = false;
|
|
52
|
+
function trackPresses() {
|
|
53
|
+
if (tracking) return;
|
|
54
|
+
tracking = true;
|
|
55
|
+
document.addEventListener(
|
|
56
|
+
"pointerdown",
|
|
57
|
+
(event) => {
|
|
58
|
+
lastPressed = event.target instanceof Element ? event.target.closest<HTMLElement>(FOCUSABLE) : null;
|
|
59
|
+
pressedAt = performance.now();
|
|
60
|
+
},
|
|
61
|
+
true,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
export function useDialogLayer({
|
|
48
66
|
open,
|
|
49
67
|
onClose,
|
|
@@ -61,6 +79,7 @@ export function useDialogLayer({
|
|
|
61
79
|
closeRef.current = onClose;
|
|
62
80
|
|
|
63
81
|
React.useEffect(() => {
|
|
82
|
+
trackPresses();
|
|
64
83
|
const node = document.createElement("div");
|
|
65
84
|
node.setAttribute("data-ml-portal", "");
|
|
66
85
|
document.body.appendChild(node);
|
|
@@ -73,8 +92,13 @@ export function useDialogLayer({
|
|
|
73
92
|
|
|
74
93
|
React.useEffect(() => {
|
|
75
94
|
if (!open || !portal) return;
|
|
95
|
+
const active = document.activeElement;
|
|
76
96
|
restoreFocusRef.current =
|
|
77
|
-
|
|
97
|
+
active instanceof HTMLElement && active !== document.body
|
|
98
|
+
? active
|
|
99
|
+
: lastPressed?.isConnected && performance.now() - pressedAt < 2000
|
|
100
|
+
? lastPressed
|
|
101
|
+
: null;
|
|
78
102
|
const releaseScroll = lockScroll();
|
|
79
103
|
const restoreBackground = hideBackground(portal);
|
|
80
104
|
const focusPanel = () => {
|
|
@@ -47,7 +47,12 @@ const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
|
|
|
47
47
|
return (
|
|
48
48
|
<div ref={ref} role="group" aria-label={`Avatar group, ${items.length} people`} className={cx("ml-avatar-group", className)} {...props}>
|
|
49
49
|
{visible.map((child, index) => <span key={index} className="ml-avatar-group-item">{child}</span>)}
|
|
50
|
-
{overflow ?
|
|
50
|
+
{overflow ? (
|
|
51
|
+
<span className="ml-avatar-overflow">
|
|
52
|
+
<span aria-hidden="true">+{overflow}</span>
|
|
53
|
+
<span className="ml-visually-hidden">{`${overflow} more ${overflow === 1 ? "person" : "people"}`}</span>
|
|
54
|
+
</span>
|
|
55
|
+
) : null}
|
|
51
56
|
</div>
|
|
52
57
|
);
|
|
53
58
|
}
|
|
@@ -257,7 +257,8 @@ export function Calendar(props: CalendarProps) {
|
|
|
257
257
|
))}
|
|
258
258
|
</div>
|
|
259
259
|
{monthGrid(first, weekStart).map((week) => (
|
|
260
|
-
|
|
260
|
+
// A week drawn only to keep months aligned has no day to read, so it is hidden whole.
|
|
261
|
+
<div key={week[0].date} role="row" className="ml-calendar-row" aria-hidden={(week.every(({ inMonth }) => !inMonth) && (!showOutsideDays || months > 1)) || undefined}>
|
|
261
262
|
{week.map(({ date, inMonth }) => {
|
|
262
263
|
if (!inMonth && (!showOutsideDays || months > 1)) return <span key={date} role="gridcell" className="ml-calendar-cell" aria-hidden="true" />;
|
|
263
264
|
const selected = isSelected(date);
|
|
@@ -43,12 +43,14 @@ export function Shortcut({ keys, className, ...props }: ShortcutProps) {
|
|
|
43
43
|
const shown = parts.map((key) => SYMBOLS[key]?.[apple ? "apple" : "other"] ?? key.toUpperCase());
|
|
44
44
|
const spoken = parts.map((key) => (apple ? SYMBOLS[key]?.spoken : SYMBOLS[key]?.spokenOther ?? SYMBOLS[key]?.spoken) ?? key.toUpperCase()).join(" ");
|
|
45
45
|
return (
|
|
46
|
-
<kbd className={cx("ml-shortcut", className)}
|
|
46
|
+
<kbd className={cx("ml-shortcut", className)} {...props}>
|
|
47
47
|
{shown.map((key, index) => (
|
|
48
48
|
<kbd key={index} className="ml-kbd" aria-hidden="true">
|
|
49
49
|
{key}
|
|
50
50
|
</kbd>
|
|
51
51
|
))}
|
|
52
|
+
{/* A kbd cannot carry a label, so the words are text a screen reader reads. */}
|
|
53
|
+
<span className="ml-visually-hidden">{spoken}</span>
|
|
52
54
|
</kbd>
|
|
53
55
|
);
|
|
54
56
|
}
|
|
@@ -12,6 +12,8 @@ export interface ModalProps {
|
|
|
12
12
|
open: boolean;
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
size?: ModalSize;
|
|
15
|
+
/** "alertdialog" for a confirmation that interrupts: it is announced at once, and the backdrop does not dismiss it. */
|
|
16
|
+
role?: "dialog" | "alertdialog";
|
|
15
17
|
closeOnBackdrop?: boolean;
|
|
16
18
|
closeOnEscape?: boolean;
|
|
17
19
|
label?: string;
|
|
@@ -24,7 +26,8 @@ export function Modal({
|
|
|
24
26
|
open,
|
|
25
27
|
onClose,
|
|
26
28
|
size = "md",
|
|
27
|
-
|
|
29
|
+
role = "dialog",
|
|
30
|
+
closeOnBackdrop = role !== "alertdialog",
|
|
28
31
|
closeOnEscape = true,
|
|
29
32
|
label = "Dialog",
|
|
30
33
|
description,
|
|
@@ -45,7 +48,7 @@ export function Modal({
|
|
|
45
48
|
>
|
|
46
49
|
<div
|
|
47
50
|
ref={panelRef}
|
|
48
|
-
role=
|
|
51
|
+
role={role}
|
|
49
52
|
aria-modal="true"
|
|
50
53
|
aria-label={label}
|
|
51
54
|
aria-describedby={description ? descriptionId : undefined}
|
|
@@ -134,7 +134,8 @@ export function Resizable({
|
|
|
134
134
|
aria-valuemin={collapsible ? 0 : min}
|
|
135
135
|
aria-valuemax={max}
|
|
136
136
|
aria-valuenow={Math.round(shown)}
|
|
137
|
-
|
|
137
|
+
// A separator has no expanded state: folded is its value at 0.
|
|
138
|
+
aria-valuetext={isFolded ? "Collapsed" : undefined}
|
|
138
139
|
className="ml-resizable-handle"
|
|
139
140
|
onPointerDown={(event) => {
|
|
140
141
|
dragging.current = true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { cx } from "../_internal/react";
|
|
4
|
+
import { composeRefs, cx } from "../_internal/react";
|
|
5
5
|
|
|
6
6
|
type TableSize = "sm" | "md";
|
|
7
7
|
type TableAlign = "left" | "center" | "right";
|
|
@@ -9,11 +9,33 @@ export type SortedState = "asc" | "desc" | false;
|
|
|
9
9
|
interface Sortable { sorted: SortedState; onToggle: () => void; label?: string; }
|
|
10
10
|
interface TableProps extends React.HTMLAttributes<HTMLDivElement> { size?: TableSize; striped?: boolean; }
|
|
11
11
|
const Table = React.forwardRef<HTMLDivElement, TableProps>(
|
|
12
|
-
({ size = "md", striped = false, className, children, ...props }, ref) =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
({ size = "md", striped = false, className, children, ...props }, ref) => {
|
|
13
|
+
// A table wider than its container scrolls sideways, and then the keyboard must reach it too.
|
|
14
|
+
const container = React.useRef<HTMLDivElement | null>(null);
|
|
15
|
+
const [scrolls, setScrolls] = React.useState(false);
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
const element = container.current;
|
|
18
|
+
if (!element || typeof ResizeObserver === "undefined") return;
|
|
19
|
+
const measure = () => setScrolls(element.scrollWidth > element.clientWidth + 1);
|
|
20
|
+
const observer = new ResizeObserver(measure);
|
|
21
|
+
observer.observe(element);
|
|
22
|
+
if (element.firstElementChild) observer.observe(element.firstElementChild);
|
|
23
|
+
measure();
|
|
24
|
+
return () => observer.disconnect();
|
|
25
|
+
}, []);
|
|
26
|
+
const named = Boolean(props["aria-label"] || props["aria-labelledby"]);
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
ref={composeRefs(ref, container)}
|
|
30
|
+
className={cx("ml-table-container", className)}
|
|
31
|
+
tabIndex={scrolls ? 0 : undefined}
|
|
32
|
+
role={scrolls && named ? "region" : undefined}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
<table data-size={size} data-striped={striped ? "" : undefined} className="ml-table">{children}</table>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
17
39
|
);
|
|
18
40
|
Table.displayName = "Table";
|
|
19
41
|
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
|
@@ -181,6 +181,7 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
|
|
|
181
181
|
ref={control}
|
|
182
182
|
className="ml-tag-input-control"
|
|
183
183
|
data-disabled={disabled || undefined}
|
|
184
|
+
aria-disabled={disabled || undefined}
|
|
184
185
|
data-invalid={message ? "" : undefined}
|
|
185
186
|
onMouseDown={(event) => {
|
|
186
187
|
// Clicking the field's empty space puts the caret in the input.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"packages/components/_internal/anchor.ts": "sha256-
|
|
3
|
-
"packages/components/_internal/dialog.ts": "sha256-
|
|
2
|
+
"packages/components/_internal/anchor.ts": "sha256-OPm/4Jf4UUo8IG+/4TAKpGDHtYpvxvGwyq3/RSZQIGc=",
|
|
3
|
+
"packages/components/_internal/dialog.ts": "sha256-796y3oNOVWarLvICTdi+d0/2axqSrzLVJiyBeh9X7bU=",
|
|
4
4
|
"packages/components/_internal/floating.ts": "sha256-+WxgC4CtAlSr2qA1EkoDTgto535XLIPsvtOfdOK6CHk=",
|
|
5
5
|
"packages/components/_internal/match.ts": "sha256-nKBpKWeow5ubAHUhOW8yOjlrKs0lqYdjbaYYfE8VoII=",
|
|
6
6
|
"packages/components/_internal/react.ts": "sha256-SH63/haiB4Q93uFjMIMe5xZbaxENu3VI+GcidV8HELM=",
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
"packages/components/activity-heatmap/calendar.ts": "sha256-NB+AjXY5Xc+KzR8MMTRBgZxdzOeoHhkW0KQOW7WJPuQ=",
|
|
11
11
|
"packages/components/alert/alert.tsx": "sha256-j680akXvCIh4MTVnUo79acvVrv51XyQ3UBJ5FBAy8Dw=",
|
|
12
12
|
"packages/components/app-shell/app-shell.tsx": "sha256-XAq82XOTH3ZR+hxTkYFu8P7Ih2aI6ERy/TpXc2VMUwM=",
|
|
13
|
-
"packages/components/avatar/avatar.tsx": "sha256-
|
|
13
|
+
"packages/components/avatar/avatar.tsx": "sha256-EjqC8ytKqRtsTVdK6vncaDfhO/i7ICe+jwaYCg5bjIw=",
|
|
14
14
|
"packages/components/badge/badge.tsx": "sha256-flEH8S2jZ/64n3Y/ROhC9+9rvF7wXaN0xLcMMwqNOVQ=",
|
|
15
15
|
"packages/components/breadcrumb/breadcrumb.tsx": "sha256-Mt9mvmwwDgdp5/+mj5byIrh8+hicsp7R6UcvUlK0yog=",
|
|
16
16
|
"packages/components/button/button.tsx": "sha256-khqYxcV3hlZ+Q+cYNTRXEpAfIijDFSvkuDJXMPtlk7g=",
|
|
17
|
-
"packages/components/calendar/calendar.tsx": "sha256-
|
|
17
|
+
"packages/components/calendar/calendar.tsx": "sha256-rWiS4YAueZfVoo6j5Fi3uN7A+yApe5U6HMm1woTVjys=",
|
|
18
18
|
"packages/components/calendar/dates.ts": "sha256-mygYkoC70av2ZQ0vkxf885trwW97Wd10I4dptYQe+4A=",
|
|
19
19
|
"packages/components/card/card.tsx": "sha256-aMKbv5DWYYWq7GZAFR9O1fuYE0H61WCjkvEbRMOd894=",
|
|
20
20
|
"packages/components/carousel/carousel.tsx": "sha256-pamugpB6Ujt9Ylxd37jNF/ZwSb8iRkW/MoYe12nY3i4=",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"packages/components/empty-state/empty-state.tsx": "sha256-uCKqYpr9vkozXZHpdl4GFtdX9ijcGOriCHvgyarBqRw=",
|
|
31
31
|
"packages/components/hover-card/hover-card.tsx": "sha256-AyRbxfqDOYBgGlgjgrtQZm5DipArLEzQn6XnzjPd0ro=",
|
|
32
32
|
"packages/components/input/input.tsx": "sha256-lbbheo+ydQTzyujTf0W7DD96TogOjifCyqj9c3h3W0g=",
|
|
33
|
-
"packages/components/kbd/kbd.tsx": "sha256-
|
|
34
|
-
"packages/components/modal/modal.tsx": "sha256-
|
|
33
|
+
"packages/components/kbd/kbd.tsx": "sha256-e8EO2Gw6JiUyrU0YcEZOaQNMel1pe/EcJrxwwgd1Ggk=",
|
|
34
|
+
"packages/components/modal/modal.tsx": "sha256-rIytVY+hz8aabJ53UGQ0VADgSlW0sAG920iMQaoak+0=",
|
|
35
35
|
"packages/components/number-input/number-input.tsx": "sha256-BDOyvCNtMULD/HK7uWHsYbGg79lrRDrVqtGQQrYHpmM=",
|
|
36
36
|
"packages/components/number-input/number.ts": "sha256-IrHCQrEhI5ein9ifMFuSViSoxQUBoT3tNmmCu/paGGM=",
|
|
37
37
|
"packages/components/otp-input/otp-input.tsx": "sha256-QURn8g5HqCQXBTdeENPbVxoPlqs3ZtoAPLRlFjQDxmI=",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"packages/components/progress/progress.tsx": "sha256-9OjecRNPWn7Bfe+C5IyXaKsW1NJae6X5gco1o0X5XkE=",
|
|
43
43
|
"packages/components/radio-group/radio-group.tsx": "sha256-jhnSEBLkdpe8dv5us/Q1sMsQF4sKGRgDXWGKm2JSFew=",
|
|
44
44
|
"packages/components/rating/rating.tsx": "sha256-zgPOQXzAiRE4qGNPQ8qat3SP1XSI/7567kA5VaT51P0=",
|
|
45
|
-
"packages/components/resizable/resizable.tsx": "sha256-
|
|
45
|
+
"packages/components/resizable/resizable.tsx": "sha256-Yxlq88qElhv2Q5DooPdriZkawRS0QWx08nd6ybz5oeY=",
|
|
46
46
|
"packages/components/segmented-control/segmented-control.tsx": "sha256-MCBG5XHFWCwbACctsYKbqe8mvSSR6pf60tyRgdCDKDA=",
|
|
47
47
|
"packages/components/select/select.tsx": "sha256-m8QmKUGC4386XcklosSntal0XwUAFjKosTBQSF0dduQ=",
|
|
48
48
|
"packages/components/sheet/sheet.tsx": "sha256-EIn3OeRDmXGHbYf/uihRh8aw3LmzarIFiyC9DUsL1jg=",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"packages/components/status-icon/labels.ts": "sha256-EE8q+geQBbJUs7I3RmaHZnPwn7Vjk2GjCfxvI4ruXKM=",
|
|
52
52
|
"packages/components/status-icon/status-icon.tsx": "sha256-TtrTeoePyfH1VhKuWC0v12FuwfgND1CHnH5x8fyf4AM=",
|
|
53
53
|
"packages/components/stepper/stepper.tsx": "sha256-uI/dRmkbijlixHiGUceWcpaDzpEixrzQ1UTmoPbGZAY=",
|
|
54
|
-
"packages/components/table/table.tsx": "sha256-
|
|
54
|
+
"packages/components/table/table.tsx": "sha256-xJlWzCH76Lrgg/HrlnVTHkG5dfCkbzjWXEdTVG5Jk4w=",
|
|
55
55
|
"packages/components/tabs/tabs.tsx": "sha256-kWAQjE9mshqZL7V3NJTUiurp6M5Nu3vx5O1LsIgdV5Y=",
|
|
56
|
-
"packages/components/tag-input/tag-input.tsx": "sha256-
|
|
56
|
+
"packages/components/tag-input/tag-input.tsx": "sha256-lc4ofz1/lUnb7B/GlFElVKXLbPwtJFAjE5vWwAY99fA=",
|
|
57
57
|
"packages/components/textarea/textarea.tsx": "sha256-C9jMYZ63w+m6bgdLvHjvULRuoCe26q5zbgS4+ahWzr8=",
|
|
58
58
|
"packages/components/time-picker/time-picker.tsx": "sha256-og8Cbf+e8oFYewfagUgjRzLHSIIlAZh+1OpAn9yNq70=",
|
|
59
59
|
"packages/components/time-picker/times.ts": "sha256-V/4uqFZPn9gVnyx8cB9vwp/RocmTYaPmLxjfy8DPQOI=",
|
package/src/assets.js
CHANGED
|
@@ -10,6 +10,9 @@ import { BUNDLED_REGISTRY_ROOT, readJson } from "./registry.js";
|
|
|
10
10
|
* Their files are not bundled with the CLI: a project wants three of them,
|
|
11
11
|
* not every model. The bundled index lists each file with its integrity; the
|
|
12
12
|
* files download from the site and are checked before anything is written.
|
|
13
|
+
* Each request names the integrity it expects, and the site answers with
|
|
14
|
+
* exactly those bytes, kept for every released version: an asset redrawn
|
|
15
|
+
* after this CLI was published still installs the version this CLI lists.
|
|
13
16
|
*
|
|
14
17
|
* Static files (SVG, GLB, posters) land in the `assets` target, public/mlola
|
|
15
18
|
* by default, so a web app serves them as /mlola/2d/… and /mlola/3d/…. A 2D
|
|
@@ -43,7 +46,7 @@ export async function addAssets(cwd, config, ids, { source, fetcher, overwrite =
|
|
|
43
46
|
for (const id of ids) {
|
|
44
47
|
const item = known.get(id);
|
|
45
48
|
for (const file of item.files) {
|
|
46
|
-
const url = `${source}/${item.kind}/${item.id}/${file.path}`;
|
|
49
|
+
const url = `${source}/${item.kind}/${item.id}/${file.path}?integrity=${encodeURIComponent(file.integrity)}`;
|
|
47
50
|
let response;
|
|
48
51
|
try {
|
|
49
52
|
response = await fetcher(url);
|
|
@@ -52,7 +55,9 @@ export async function addAssets(cwd, config, ids, { source, fetcher, overwrite =
|
|
|
52
55
|
}
|
|
53
56
|
if (!response.ok) throw new Error(`Could not download ${url}: the server answered ${response.status}.`);
|
|
54
57
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
55
|
-
if (integrityOf(bytes) !== file.integrity)
|
|
58
|
+
if (integrityOf(bytes) !== file.integrity) {
|
|
59
|
+
throw new Error(`${item.id}/${file.path} failed integrity validation; nothing was written. The server has a different version of this file: update the CLI (npx mlola-ui@latest) and try again.`);
|
|
60
|
+
}
|
|
56
61
|
planned.push({ item, file, bytes, relative: assetTarget(config, item, file) });
|
|
57
62
|
}
|
|
58
63
|
}
|
package/src/knowledge.js
CHANGED
|
@@ -117,12 +117,25 @@ export function describeItem(name, { cwd = process.cwd(), includeSource = false
|
|
|
117
117
|
category: item.category,
|
|
118
118
|
variants: item.variants ?? [],
|
|
119
119
|
sizes: item.sizes ?? [],
|
|
120
|
+
// Every prop with a fixed set of values, read from the TypeScript source.
|
|
121
|
+
options: item.options ?? [],
|
|
120
122
|
usage,
|
|
121
123
|
install: `npx mlola-ui add ${name}`,
|
|
122
124
|
dependsOn: item.registryDependencies ?? [],
|
|
123
125
|
engineDependencies: item.engineDependencies ?? {},
|
|
124
|
-
|
|
126
|
+
// Every class the component's stylesheet defines, when its example lists them.
|
|
127
|
+
elements: bundled("examples.json")?.[name]?.elements ?? elementsOf(name),
|
|
125
128
|
};
|
|
129
|
+
// The same example the docs show: React, the HTML it renders, and the
|
|
130
|
+
// framework-free markup when @mlola-ui/behavior drives it.
|
|
131
|
+
const example = bundled("examples.json")?.[name];
|
|
132
|
+
if (example) {
|
|
133
|
+
result.example = {
|
|
134
|
+
react: example.react,
|
|
135
|
+
html: example.behavior?.html ?? example.html,
|
|
136
|
+
script: example.needs === "behavior" ? `@mlola-ui/behavior, data-ml="${example.behavior.name}"` : example.needs === "nothing" ? "none" : "React, or your own script setting the attributes",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
126
139
|
if (includeSource) {
|
|
127
140
|
result.source = (item.files ?? [])
|
|
128
141
|
.filter((file) => !file.path.includes("/_internal/"))
|
|
@@ -159,6 +172,63 @@ const SHARED = new Set(["data-tone", "data-variant", "data-size", "data-state",
|
|
|
159
172
|
const UTILITY = /^(-?(m|p)[trblxy]?-\d|flex$|grid$|block$|inline|hidden$|items-|justify-|gap-|w-|h-|min-|max-|text-(xs|sm|base|lg|xl|\d|[a-z]+-\d)|font-(bold|medium|semibold|light)|bg-|border-|rounded|shadow|ring-|space-[xy]-|leading-|tracking-|opacity-|z-\d|absolute$|relative$|fixed$|sticky$|overflow-|col-span|row-span|sm:|md:|lg:|xl:|hover:|focus:|dark:)/;
|
|
160
173
|
const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i;
|
|
161
174
|
|
|
175
|
+
/** Every opening tag in HTML or JSX, with its string-valued attributes. */
|
|
176
|
+
function tagsOf(markup) {
|
|
177
|
+
const tags = [];
|
|
178
|
+
for (const match of markup.matchAll(/<([A-Za-z][\w.-]*)((?:\s+(?:[^\s"'>={}]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}))?))*)\s*\/?>/g)) {
|
|
179
|
+
const attributes = new Map();
|
|
180
|
+
for (const attribute of match[2].matchAll(/([^\s"'>={}]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\{(?:[^{}]|\{[^{}]*\})*\})))?/g)) {
|
|
181
|
+
const [, name, double, single, expression] = attribute;
|
|
182
|
+
const value = double ?? single ?? (expression ? /^\{\s*["'`]([^"'`$]*)["'`]\s*\}$/.exec(expression)?.[1] ?? null : "");
|
|
183
|
+
attributes.set(name, value);
|
|
184
|
+
if (expression) attributes.set(`${name}:raw`, expression);
|
|
185
|
+
}
|
|
186
|
+
const classValue = attributes.get("class") ?? attributes.get("className");
|
|
187
|
+
tags.push({ tag: match[0], attributes, classes: typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [] });
|
|
188
|
+
}
|
|
189
|
+
return tags;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let knownValues = null;
|
|
193
|
+
/**
|
|
194
|
+
* The values each element may carry on each shared attribute: the ones its
|
|
195
|
+
* stylesheet draws, the ones its component's props declare, and the ones the
|
|
196
|
+
* component renders in its example. A default such as data-size="md" is
|
|
197
|
+
* rendered but not drawn, and is as correct as any other.
|
|
198
|
+
*/
|
|
199
|
+
function valuesByElement() {
|
|
200
|
+
if (knownValues) return knownValues;
|
|
201
|
+
knownValues = new Map();
|
|
202
|
+
const add = (cls, attribute, values) => {
|
|
203
|
+
if (!knownValues.has(cls)) knownValues.set(cls, new Map());
|
|
204
|
+
const byAttribute = knownValues.get(cls);
|
|
205
|
+
if (!byAttribute.has(attribute)) byAttribute.set(attribute, new Set());
|
|
206
|
+
for (const value of values) byAttribute.get(attribute).add(value);
|
|
207
|
+
};
|
|
208
|
+
for (const [cls, attributes] of Object.entries(bundled("contract.json")?.elements ?? {})) {
|
|
209
|
+
for (const [attribute, values] of Object.entries(attributes)) add(cls, attribute, values);
|
|
210
|
+
}
|
|
211
|
+
const examples = bundled("examples.json") ?? {};
|
|
212
|
+
for (const item of loadRegistry().items) {
|
|
213
|
+
const classes = examples[item.name]?.elements?.map((element) => element.class) ?? [];
|
|
214
|
+
for (const option of item.options ?? []) {
|
|
215
|
+
for (const cls of classes) add(cls, `data-${option.prop}`, option.values);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const example of Object.values(examples)) {
|
|
219
|
+
for (const html of [example.html, example.behavior?.html]) {
|
|
220
|
+
if (!html) continue;
|
|
221
|
+
for (const { attributes, classes } of tagsOf(html)) {
|
|
222
|
+
for (const [name, value] of attributes) {
|
|
223
|
+
if (!SHARED.has(name) || typeof value !== "string") continue;
|
|
224
|
+
for (const cls of classes) if (cls.startsWith("ml-")) add(cls, name, [value]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return knownValues;
|
|
230
|
+
}
|
|
231
|
+
|
|
162
232
|
/**
|
|
163
233
|
* Checks HTML or JSX against the element contract. It reads string-valued
|
|
164
234
|
* class, className, style and data-* attributes; expressions in braces are
|
|
@@ -169,20 +239,12 @@ export function checkMarkup(markup) {
|
|
|
169
239
|
const known = new Set(contract?.classes ?? []);
|
|
170
240
|
const proPrefixes = (bundled("catalog.json")?.items ?? []).map((item) => `ml-${item.name}`);
|
|
171
241
|
const themeIds = new Set(themes().map((theme) => theme.id));
|
|
242
|
+
const tokenNames = new Set((designData()?.tokens ?? []).flatMap((group) => group.names));
|
|
243
|
+
const values = valuesByElement();
|
|
172
244
|
const issues = [];
|
|
173
245
|
const note = (severity, tag, message, fix) => issues.push({ severity, element: tag.slice(0, 120), message, ...(fix ? { fix } : {}) });
|
|
174
246
|
|
|
175
|
-
for (const
|
|
176
|
-
const tag = match[0];
|
|
177
|
-
const attributes = new Map();
|
|
178
|
-
for (const attribute of match[2].matchAll(/([^\s"'>={}]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\{(?:[^{}]|\{[^{}]*\})*\})))?/g)) {
|
|
179
|
-
const [, name, double, single, expression] = attribute;
|
|
180
|
-
const value = double ?? single ?? (expression ? /^\{\s*["'`]([^"'`$]*)["'`]\s*\}$/.exec(expression)?.[1] ?? null : "");
|
|
181
|
-
attributes.set(name, value);
|
|
182
|
-
if (expression) attributes.set(`${name}:raw`, expression);
|
|
183
|
-
}
|
|
184
|
-
const classValue = attributes.get("class") ?? attributes.get("className");
|
|
185
|
-
const classes = typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [];
|
|
247
|
+
for (const { tag, attributes, classes } of tagsOf(markup)) {
|
|
186
248
|
const mlola = classes.filter((cls) => cls.startsWith("ml-"));
|
|
187
249
|
|
|
188
250
|
for (const cls of mlola) {
|
|
@@ -210,25 +272,27 @@ export function checkMarkup(markup) {
|
|
|
210
272
|
}
|
|
211
273
|
if (!SHARED.has(name) || !mlola.length) continue;
|
|
212
274
|
const allowed = new Set();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
reacts = true;
|
|
218
|
-
for (const entry of values) allowed.add(entry);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
if (reacts && allowed.size && !allowed.has(value)) {
|
|
222
|
-
note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} reacts to.`, `Allowed: ${[...allowed].sort().join(", ")}.`);
|
|
223
|
-
} else if (!reacts && mlola.every((cls) => known.has(cls))) {
|
|
275
|
+
for (const cls of mlola) for (const entry of values.get(cls)?.get(name) ?? []) allowed.add(entry);
|
|
276
|
+
if (allowed.size && !allowed.has(value)) {
|
|
277
|
+
note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} takes.`, `Allowed: ${[...allowed].sort().join(", ")}.`);
|
|
278
|
+
} else if (!allowed.size && mlola.every((cls) => known.has(cls))) {
|
|
224
279
|
note("warning", tag, `${name} has no effect on ${mlola.join(" ")}.`, `See get_component for the attributes it reacts to.`);
|
|
225
280
|
}
|
|
226
281
|
}
|
|
227
282
|
|
|
228
|
-
// A style string
|
|
283
|
+
// A color written by hand in a style string or a JSX style object. A
|
|
284
|
+
// component's own custom property carries data (a swatch's color); a
|
|
285
|
+
// theme token set inline overrides the theme and is flagged too.
|
|
229
286
|
const style = attributes.get("style") ?? attributes.get("style:raw");
|
|
230
|
-
if (typeof style === "string"
|
|
231
|
-
|
|
287
|
+
if (typeof style === "string") {
|
|
288
|
+
const overridden = [...style.matchAll(/(--ml-[\w-]+)\s*:/g)].map((match) => match[1]).filter((token) => tokenNames.has(token));
|
|
289
|
+
const declarations = style.replace(/--[\w-]+\s*:[^;,}]*/g, "");
|
|
290
|
+
if (COLOR.test(declarations)) {
|
|
291
|
+
note("error", tag, "A color is written by hand in style.", "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens).");
|
|
292
|
+
}
|
|
293
|
+
if (overridden.length) {
|
|
294
|
+
note("error", tag, `${overridden.join(", ")} is a theme token; setting it inline overrides the theme.`, "Pick a theme, or change the theme's spec (mlola.theme.json), instead of one element's tokens.");
|
|
295
|
+
}
|
|
232
296
|
}
|
|
233
297
|
}
|
|
234
298
|
return issues;
|
package/src/mcp.js
CHANGED
|
@@ -66,7 +66,7 @@ function tools({ cwd, run }) {
|
|
|
66
66
|
{
|
|
67
67
|
name: "get_component",
|
|
68
68
|
title: "How to use a Mlola component",
|
|
69
|
-
description: "Everything needed to use one item: import,
|
|
69
|
+
description: "Everything needed to use one item: import, every prop with fixed values (variant, tone, size, side…) and its default, the classes it styles and the data-* values each reacts to, dependencies and the install command. Set include_source to read a free component's source.",
|
|
70
70
|
inputSchema: {
|
|
71
71
|
type: "object",
|
|
72
72
|
properties: { name: { type: "string" }, include_source: { type: "boolean", default: false } },
|