@mdbase-dev/ui 0.1.0-beta.117
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 +28 -0
- package/css/brand.css +93 -0
- package/css/controls.css +611 -0
- package/css/fonts.css +6 -0
- package/css/motion.css +198 -0
- package/css/palette.css +196 -0
- package/css/screens.css +155 -0
- package/css/tokens.css +141 -0
- package/dist/AppSwitcher.d.ts +13 -0
- package/dist/AppSwitcher.js +38 -0
- package/dist/CommandPalette.d.ts +37 -0
- package/dist/CommandPalette.js +130 -0
- package/dist/Dialog.d.ts +12 -0
- package/dist/Dialog.js +23 -0
- package/dist/SaveNotice.d.ts +17 -0
- package/dist/SaveNotice.js +15 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +158 -0
- package/dist/SelectList.d.ts +19 -0
- package/dist/SelectList.js +21 -0
- package/dist/ThemeSelect.d.ts +12 -0
- package/dist/ThemeSelect.js +12 -0
- package/dist/apps.d.ts +20 -0
- package/dist/apps.js +47 -0
- package/dist/brand.d.ts +99 -0
- package/dist/brand.js +61 -0
- package/dist/codemirror.d.ts +9 -0
- package/dist/codemirror.js +98 -0
- package/dist/popover.d.ts +50 -0
- package/dist/popover.js +162 -0
- package/dist/screens.d.ts +29 -0
- package/dist/screens.js +18 -0
- package/dist/select-model.d.ts +34 -0
- package/dist/select-model.js +53 -0
- package/dist/theme.d.ts +18 -0
- package/dist/theme.js +59 -0
- package/package.json +105 -0
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type JSX } from "react";
|
|
2
|
+
import type { MdbaseAppId } from "./apps.js";
|
|
3
|
+
export type MdbaseMarkRect = Readonly<{
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
rx: number;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const mdbaseMarkMotions: readonly ["bootstrap", "unfold", "rebalance", "conveyor"];
|
|
11
|
+
export type MdbaseMarkMotion = typeof mdbaseMarkMotions[number];
|
|
12
|
+
export declare function mdbaseMarkMotionClass(motion?: MdbaseMarkMotion): string;
|
|
13
|
+
export declare const MDBASE_MARK_VIEW_BOX = "18 18 84 84";
|
|
14
|
+
export declare const mdbaseMarkInkRects: readonly [{
|
|
15
|
+
readonly x: 22;
|
|
16
|
+
readonly y: 22;
|
|
17
|
+
readonly width: 20;
|
|
18
|
+
readonly height: 10;
|
|
19
|
+
readonly rx: 2;
|
|
20
|
+
}, {
|
|
21
|
+
readonly x: 50;
|
|
22
|
+
readonly y: 22;
|
|
23
|
+
readonly width: 20;
|
|
24
|
+
readonly height: 10;
|
|
25
|
+
readonly rx: 2;
|
|
26
|
+
}, {
|
|
27
|
+
readonly x: 78;
|
|
28
|
+
readonly y: 22;
|
|
29
|
+
readonly width: 20;
|
|
30
|
+
readonly height: 10;
|
|
31
|
+
readonly rx: 2;
|
|
32
|
+
}, {
|
|
33
|
+
readonly x: 22;
|
|
34
|
+
readonly y: 44;
|
|
35
|
+
readonly width: 12;
|
|
36
|
+
readonly height: 10;
|
|
37
|
+
readonly rx: 2;
|
|
38
|
+
}, {
|
|
39
|
+
readonly x: 22;
|
|
40
|
+
readonly y: 66;
|
|
41
|
+
readonly width: 28;
|
|
42
|
+
readonly height: 10;
|
|
43
|
+
readonly rx: 2;
|
|
44
|
+
}, {
|
|
45
|
+
readonly x: 58;
|
|
46
|
+
readonly y: 66;
|
|
47
|
+
readonly width: 40;
|
|
48
|
+
readonly height: 10;
|
|
49
|
+
readonly rx: 2;
|
|
50
|
+
}, {
|
|
51
|
+
readonly x: 22;
|
|
52
|
+
readonly y: 88;
|
|
53
|
+
readonly width: 20;
|
|
54
|
+
readonly height: 10;
|
|
55
|
+
readonly rx: 2;
|
|
56
|
+
}, {
|
|
57
|
+
readonly x: 50;
|
|
58
|
+
readonly y: 88;
|
|
59
|
+
readonly width: 20;
|
|
60
|
+
readonly height: 10;
|
|
61
|
+
readonly rx: 2;
|
|
62
|
+
}, {
|
|
63
|
+
readonly x: 78;
|
|
64
|
+
readonly y: 88;
|
|
65
|
+
readonly width: 20;
|
|
66
|
+
readonly height: 10;
|
|
67
|
+
readonly rx: 2;
|
|
68
|
+
}];
|
|
69
|
+
export declare const mdbaseMarkAccentRect: {
|
|
70
|
+
readonly x: 42;
|
|
71
|
+
readonly y: 44;
|
|
72
|
+
readonly width: 56;
|
|
73
|
+
readonly height: 10;
|
|
74
|
+
readonly rx: 2;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The platform's Frontmatter mark. Editor and Connect's own surfaces use it; the
|
|
78
|
+
* segments and clipped conveyor exist so `motion.css` can animate the same SVG.
|
|
79
|
+
*/
|
|
80
|
+
export declare function MdbaseMark({ motion, className }: {
|
|
81
|
+
motion?: MdbaseMarkMotion;
|
|
82
|
+
className?: string;
|
|
83
|
+
}): JSX.Element;
|
|
84
|
+
/**
|
|
85
|
+
* An app's mark: the platform mark inverted, so the bars take the app's colour and
|
|
86
|
+
* the highlighted line stays ink. The plain mark stays the platform's own.
|
|
87
|
+
*/
|
|
88
|
+
export declare function MdbaseAppMark({ app, className }: {
|
|
89
|
+
app: MdbaseAppId;
|
|
90
|
+
className?: string;
|
|
91
|
+
}): JSX.Element;
|
|
92
|
+
/**
|
|
93
|
+
* `mdbase` followed by the product's quieter name. Editor carries the platform mark
|
|
94
|
+
* as the canonical app; Reader and Writer carry their app marks.
|
|
95
|
+
*/
|
|
96
|
+
export declare function Wordmark({ app, motion }: {
|
|
97
|
+
app: MdbaseAppId;
|
|
98
|
+
motion?: MdbaseMarkMotion;
|
|
99
|
+
}): JSX.Element;
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
export const mdbaseMarkMotions = [
|
|
4
|
+
"bootstrap",
|
|
5
|
+
"unfold",
|
|
6
|
+
"rebalance",
|
|
7
|
+
"conveyor"
|
|
8
|
+
];
|
|
9
|
+
export function mdbaseMarkMotionClass(motion) {
|
|
10
|
+
return motion ? ` mdbase-motion-${motion}` : "";
|
|
11
|
+
}
|
|
12
|
+
export const MDBASE_MARK_VIEW_BOX = "18 18 84 84";
|
|
13
|
+
export const mdbaseMarkInkRects = [
|
|
14
|
+
{ x: 22, y: 22, width: 20, height: 10, rx: 2 },
|
|
15
|
+
{ x: 50, y: 22, width: 20, height: 10, rx: 2 },
|
|
16
|
+
{ x: 78, y: 22, width: 20, height: 10, rx: 2 },
|
|
17
|
+
{ x: 22, y: 44, width: 12, height: 10, rx: 2 },
|
|
18
|
+
{ x: 22, y: 66, width: 28, height: 10, rx: 2 },
|
|
19
|
+
{ x: 58, y: 66, width: 40, height: 10, rx: 2 },
|
|
20
|
+
{ x: 22, y: 88, width: 20, height: 10, rx: 2 },
|
|
21
|
+
{ x: 50, y: 88, width: 20, height: 10, rx: 2 },
|
|
22
|
+
{ x: 78, y: 88, width: 20, height: 10, rx: 2 }
|
|
23
|
+
];
|
|
24
|
+
export const mdbaseMarkAccentRect = {
|
|
25
|
+
x: 42,
|
|
26
|
+
y: 44,
|
|
27
|
+
width: 56,
|
|
28
|
+
height: 10,
|
|
29
|
+
rx: 2
|
|
30
|
+
};
|
|
31
|
+
const conveyorXs = [-6, 22, 50, 78, 106];
|
|
32
|
+
function classes(...names) {
|
|
33
|
+
return names.filter(Boolean).join(" ");
|
|
34
|
+
}
|
|
35
|
+
function Segment({ rect, index, accent = false }) {
|
|
36
|
+
return _jsx("rect", { className: `mdbase-mark-segment mdbase-mark-segment-${index} ${accent ? "mdbase-mark-accent" : "mdbase-mark-ink"}`, pathLength: 1, ...rect });
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The platform's Frontmatter mark. Editor and Connect's own surfaces use it; the
|
|
40
|
+
* segments and clipped conveyor exist so `motion.css` can animate the same SVG.
|
|
41
|
+
*/
|
|
42
|
+
export function MdbaseMark({ motion, className }) {
|
|
43
|
+
const clipId = `mdbase-fences-${useId().replaceAll(":", "")}`;
|
|
44
|
+
return _jsxs("svg", { className: classes("mdbase-mark", className) + mdbaseMarkMotionClass(motion), viewBox: MDBASE_MARK_VIEW_BOX, "aria-hidden": "true", focusable: "false", children: [_jsx("defs", { children: _jsxs("clipPath", { id: clipId, children: [_jsx("rect", { x: "22", y: "22", width: "76", height: "10", rx: "2" }), _jsx("rect", { x: "22", y: "88", width: "76", height: "10", rx: "2" })] }) }), _jsx("g", { className: "mdbase-mark-fence mdbase-mark-fence-top", children: mdbaseMarkInkRects.slice(0, 3).map((rect, index) => _jsx(Segment, { rect: rect, index: index + 1 }, `${rect.x}-${rect.y}`)) }), _jsxs("g", { className: "mdbase-mark-row mdbase-mark-row-top", children: [_jsx(Segment, { rect: mdbaseMarkInkRects[3], index: 4 }), _jsx(Segment, { rect: mdbaseMarkAccentRect, index: 5, accent: true })] }), _jsxs("g", { className: "mdbase-mark-row mdbase-mark-row-bottom", children: [_jsx(Segment, { rect: mdbaseMarkInkRects[4], index: 6 }), _jsx(Segment, { rect: mdbaseMarkInkRects[5], index: 7 })] }), _jsx("g", { className: "mdbase-mark-fence mdbase-mark-fence-bottom", children: mdbaseMarkInkRects.slice(6).map((rect, index) => _jsx(Segment, { rect: rect, index: index + 8 }, `${rect.x}-${rect.y}`)) }), _jsx("g", { clipPath: `url(#${clipId})`, children: _jsx("g", { className: "mdbase-mark-conveyor-track", children: conveyorXs.flatMap((x) => [22, 88].map((y) => _jsx("rect", { x: x, y: y, width: "20", height: "10", rx: "2" }, `${x}-${y}`))) }) })] });
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* An app's mark: the platform mark inverted, so the bars take the app's colour and
|
|
48
|
+
* the highlighted line stays ink. The plain mark stays the platform's own.
|
|
49
|
+
*/
|
|
50
|
+
export function MdbaseAppMark({ app, className }) {
|
|
51
|
+
return _jsxs("svg", { className: classes("mdbase-app-mark", `is-${app}`, className), viewBox: MDBASE_MARK_VIEW_BOX, "aria-hidden": "true", focusable: "false", children: [_jsx("g", { className: "mdbase-app-mark-bars", children: mdbaseMarkInkRects.map((rect) => _jsx("rect", { ...rect }, `${rect.x}-${rect.y}`)) }), _jsx("rect", { className: "mdbase-app-mark-line", ...mdbaseMarkAccentRect })] });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `mdbase` followed by the product's quieter name. Editor carries the platform mark
|
|
55
|
+
* as the canonical app; Reader and Writer carry their app marks.
|
|
56
|
+
*/
|
|
57
|
+
export function Wordmark({ app, motion }) {
|
|
58
|
+
return _jsxs("span", { className: "wordmark", children: [app === "editor"
|
|
59
|
+
? _jsx(MdbaseMark, { motion: motion, className: "wordmark-mark" })
|
|
60
|
+
: _jsx(MdbaseAppMark, { app: app, className: "wordmark-mark" }), _jsxs("span", { className: "wordmark-label", children: [_jsx("span", { children: "mdbase" }), _jsx("strong", { children: app })] })] });
|
|
61
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The popups an mdbase editor shows: completion lists, their info panels, hover cards and
|
|
3
|
+
* lint diagnostics. Reader's completion picker, in Editor's quiet treatment: a fine outline,
|
|
4
|
+
* 2px corners and no shadow. Tooltips mounted outside the editor still carry this theme.
|
|
5
|
+
*
|
|
6
|
+
* Completion `type`s map to icons: `citation` (@) and `annotation` (❝) reference sources,
|
|
7
|
+
* `source` and `text` are records, `keyword` is a command, `namespace` a folder, `type` a type.
|
|
8
|
+
*/
|
|
9
|
+
export declare const mdbasePopupTheme: import("@codemirror/state").Extension;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
/**
|
|
3
|
+
* The popups an mdbase editor shows: completion lists, their info panels, hover cards and
|
|
4
|
+
* lint diagnostics. Reader's completion picker, in Editor's quiet treatment: a fine outline,
|
|
5
|
+
* 2px corners and no shadow. Tooltips mounted outside the editor still carry this theme.
|
|
6
|
+
*
|
|
7
|
+
* Completion `type`s map to icons: `citation` (@) and `annotation` (❝) reference sources,
|
|
8
|
+
* `source` and `text` are records, `keyword` is a command, `namespace` a folder, `type` a type.
|
|
9
|
+
*/
|
|
10
|
+
export const mdbasePopupTheme = EditorView.theme({
|
|
11
|
+
".cm-tooltip": {
|
|
12
|
+
border: "1px solid var(--color-border-strong)",
|
|
13
|
+
borderRadius: "var(--control-radius, 2px)",
|
|
14
|
+
color: "var(--color-text)",
|
|
15
|
+
backgroundColor: "var(--color-surface)",
|
|
16
|
+
boxShadow: "none",
|
|
17
|
+
fontFamily: "var(--sans)"
|
|
18
|
+
},
|
|
19
|
+
".cm-tooltip.cm-tooltip-autocomplete": { padding: "4px" },
|
|
20
|
+
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
|
21
|
+
width: "min(440px, calc(100vw - 24px))",
|
|
22
|
+
minWidth: "0",
|
|
23
|
+
maxWidth: "none",
|
|
24
|
+
maxHeight: "min(252px, 45vh)",
|
|
25
|
+
fontFamily: "var(--sans)",
|
|
26
|
+
whiteSpace: "normal"
|
|
27
|
+
},
|
|
28
|
+
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
|
|
29
|
+
display: "grid",
|
|
30
|
+
gridTemplateColumns: "20px minmax(0, 1fr)",
|
|
31
|
+
columnGap: "8px",
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
padding: "6px 8px",
|
|
34
|
+
borderRadius: "var(--control-radius, 2px)",
|
|
35
|
+
lineHeight: "1.35"
|
|
36
|
+
},
|
|
37
|
+
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
|
38
|
+
color: "var(--color-text)",
|
|
39
|
+
background: "var(--color-selected)"
|
|
40
|
+
},
|
|
41
|
+
".cm-tooltip.cm-tooltip-autocomplete > ul > li > *": {
|
|
42
|
+
minWidth: "0",
|
|
43
|
+
overflow: "hidden",
|
|
44
|
+
textOverflow: "ellipsis",
|
|
45
|
+
whiteSpace: "nowrap"
|
|
46
|
+
},
|
|
47
|
+
".cm-completionIcon": {
|
|
48
|
+
gridRow: "1 / span 2",
|
|
49
|
+
width: "20px",
|
|
50
|
+
padding: "0",
|
|
51
|
+
color: "var(--color-text-muted)",
|
|
52
|
+
fontSize: "13px",
|
|
53
|
+
lineHeight: "20px",
|
|
54
|
+
textAlign: "center",
|
|
55
|
+
opacity: "1"
|
|
56
|
+
},
|
|
57
|
+
".cm-completionIcon-citation::after": { content: '"@"', color: "var(--color-accent)" },
|
|
58
|
+
".cm-completionIcon-annotation::after": { content: '"❝"', color: "var(--color-accent)" },
|
|
59
|
+
".cm-completionIcon-source::after, .cm-completionIcon-text::after": { content: '"§"' },
|
|
60
|
+
".cm-completionIcon-keyword::after": { content: '"/"' },
|
|
61
|
+
".cm-completionIcon-namespace::after": { content: '"▤"' },
|
|
62
|
+
".cm-completionIcon-type::after": { content: '"T"' },
|
|
63
|
+
".cm-completionLabel": {
|
|
64
|
+
gridColumn: "2",
|
|
65
|
+
color: "var(--color-text)",
|
|
66
|
+
fontSize: "13px",
|
|
67
|
+
fontWeight: "700"
|
|
68
|
+
},
|
|
69
|
+
".cm-completionMatchedText": {
|
|
70
|
+
textDecoration: "none",
|
|
71
|
+
color: "var(--color-accent)"
|
|
72
|
+
},
|
|
73
|
+
".cm-completionDetail": {
|
|
74
|
+
gridColumn: "2",
|
|
75
|
+
marginLeft: "0",
|
|
76
|
+
color: "var(--color-text-muted)",
|
|
77
|
+
fontSize: "11px",
|
|
78
|
+
fontStyle: "normal"
|
|
79
|
+
},
|
|
80
|
+
".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after": {
|
|
81
|
+
color: "var(--color-text-faint)",
|
|
82
|
+
fontSize: "11px"
|
|
83
|
+
},
|
|
84
|
+
".cm-tooltip.cm-completionInfo": {
|
|
85
|
+
maxWidth: "min(320px, calc(100vw - 24px))",
|
|
86
|
+
margin: "0 4px",
|
|
87
|
+
padding: "10px 12px",
|
|
88
|
+
color: "var(--color-text-soft)",
|
|
89
|
+
fontSize: "12px",
|
|
90
|
+
lineHeight: "1.5",
|
|
91
|
+
whiteSpace: "pre-line"
|
|
92
|
+
},
|
|
93
|
+
".cm-tooltip.cm-tooltip-lint": { padding: "4px", fontSize: "12px" },
|
|
94
|
+
".cm-diagnostic": { padding: "4px 8px", borderRadius: "var(--control-radius, 2px)", fontFamily: "var(--sans)" },
|
|
95
|
+
".cm-diagnostic-error": { borderLeft: "3px solid var(--color-danger)" },
|
|
96
|
+
".cm-diagnostic-warning": { borderLeft: "3px solid var(--color-warning)" },
|
|
97
|
+
".cm-diagnostic-info, .cm-diagnostic-hint": { borderLeft: "3px solid var(--color-accent)" }
|
|
98
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type KeyboardEvent, type RefObject } from "react";
|
|
2
|
+
interface Box {
|
|
3
|
+
readonly top: number;
|
|
4
|
+
readonly bottom: number;
|
|
5
|
+
readonly left: number;
|
|
6
|
+
readonly width: number;
|
|
7
|
+
}
|
|
8
|
+
interface Size {
|
|
9
|
+
readonly width: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Where a popover opens beside its trigger: below when it fits, otherwise on whichever side
|
|
14
|
+
* has more room, lined up with the trigger's start (or end) edge and kept inside the viewport.
|
|
15
|
+
*/
|
|
16
|
+
export declare function anchoredPlacement(trigger: Box, popover: Size, viewport: Size, { gap, margin, align }?: {
|
|
17
|
+
gap?: number;
|
|
18
|
+
margin?: number;
|
|
19
|
+
align?: "start" | "end";
|
|
20
|
+
}): {
|
|
21
|
+
readonly top: number;
|
|
22
|
+
readonly left: number;
|
|
23
|
+
readonly maxHeight: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Shows a select's list in the top layer beside its trigger, focuses it, and dismisses it when
|
|
27
|
+
* the pointer lands elsewhere or the page scrolls or resizes underneath it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function useSelectPopover(open: boolean, triggerRef: RefObject<HTMLElement | null>, listRef: RefObject<HTMLElement | null>, onDismiss: () => void): void;
|
|
30
|
+
export interface MenuPopoverOptions {
|
|
31
|
+
/** Preferred width; narrow viewports shrink it. Omit it to leave the width to CSS. */
|
|
32
|
+
readonly width?: number | undefined;
|
|
33
|
+
/** Which edge of the trigger the menu lines up with. */
|
|
34
|
+
readonly align?: "start" | "end" | undefined;
|
|
35
|
+
/** Element focused when the menu opens; the menu itself when nothing matches. */
|
|
36
|
+
readonly focus?: string | undefined;
|
|
37
|
+
/** While true, outside presses, Escape and Tab leave the menu open (an action is running). */
|
|
38
|
+
readonly busy?: boolean | undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Keeps a mounted menu in the top layer beside its trigger. It closes on an outside press,
|
|
42
|
+
* Escape (returning focus to the trigger), Tab or a resize.
|
|
43
|
+
*/
|
|
44
|
+
export declare function useMenuPopover(menuRef: RefObject<HTMLElement | null>, triggerRef: RefObject<HTMLElement | null>, onClose: (refocus: boolean) => void, { width, align, focus, busy }: MenuPopoverOptions): void;
|
|
45
|
+
/**
|
|
46
|
+
* Arrow keys, Home and End move between a menu's enabled items. From a text field inside the
|
|
47
|
+
* menu (a filter), Home and End stay in the field and the arrows enter the list.
|
|
48
|
+
*/
|
|
49
|
+
export declare function moveMenuFocus(event: KeyboardEvent, menu: HTMLElement | null): void;
|
|
50
|
+
export {};
|
package/dist/popover.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useEffect, useEffectEvent, useLayoutEffect } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Where a popover opens beside its trigger: below when it fits, otherwise on whichever side
|
|
4
|
+
* has more room, lined up with the trigger's start (or end) edge and kept inside the viewport.
|
|
5
|
+
*/
|
|
6
|
+
export function anchoredPlacement(trigger, popover, viewport, { gap = 4, margin = 8, align = "start" } = {}) {
|
|
7
|
+
const below = viewport.height - trigger.bottom - gap - margin;
|
|
8
|
+
const above = trigger.top - gap - margin;
|
|
9
|
+
const opensUp = popover.height > below && above > below;
|
|
10
|
+
const room = Math.max(120, opensUp ? above : below);
|
|
11
|
+
const height = Math.min(popover.height, room);
|
|
12
|
+
const width = Math.max(popover.width, trigger.width);
|
|
13
|
+
const start = align === "end" ? trigger.left + trigger.width - width : trigger.left;
|
|
14
|
+
return {
|
|
15
|
+
top: opensUp ? trigger.top - gap - height : trigger.bottom + gap,
|
|
16
|
+
left: Math.max(margin, Math.min(start, viewport.width - width - margin)),
|
|
17
|
+
maxHeight: room
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function place(trigger, popover, options) {
|
|
21
|
+
const box = trigger.getBoundingClientRect();
|
|
22
|
+
if (options.matchWidth)
|
|
23
|
+
popover.style.minWidth = `${box.width}px`;
|
|
24
|
+
popover.style.maxHeight = "";
|
|
25
|
+
const placement = anchoredPlacement(box, { width: popover.offsetWidth, height: popover.scrollHeight }, { width: window.innerWidth, height: window.innerHeight }, options);
|
|
26
|
+
popover.style.top = `${placement.top}px`;
|
|
27
|
+
popover.style.left = `${placement.left}px`;
|
|
28
|
+
popover.style.maxHeight = `${placement.maxHeight}px`;
|
|
29
|
+
}
|
|
30
|
+
// Browsers without the Popover API (and jsdom) keep the element in place instead.
|
|
31
|
+
function showInTopLayer(element) {
|
|
32
|
+
if (typeof element.showPopover === "function")
|
|
33
|
+
element.showPopover();
|
|
34
|
+
}
|
|
35
|
+
function hideFromTopLayer(element) {
|
|
36
|
+
if (typeof element.hidePopover === "function" && element.matches(":popover-open"))
|
|
37
|
+
element.hidePopover();
|
|
38
|
+
}
|
|
39
|
+
function onScreen(element) {
|
|
40
|
+
const box = element.getBoundingClientRect();
|
|
41
|
+
return box.width > 0 && box.bottom > 0 && box.top < window.innerHeight
|
|
42
|
+
&& box.right > 0 && box.left < window.innerWidth;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Shows a select's list in the top layer beside its trigger, focuses it, and dismisses it when
|
|
46
|
+
* the pointer lands elsewhere or the page scrolls or resizes underneath it.
|
|
47
|
+
*/
|
|
48
|
+
export function useSelectPopover(open, triggerRef, listRef, onDismiss) {
|
|
49
|
+
const dismiss = useEffectEvent(onDismiss);
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
const list = listRef.current;
|
|
52
|
+
const trigger = triggerRef.current;
|
|
53
|
+
if (!open || !list || !trigger)
|
|
54
|
+
return undefined;
|
|
55
|
+
showInTopLayer(list);
|
|
56
|
+
place(trigger, list, { matchWidth: true });
|
|
57
|
+
list.focus({ preventScroll: true });
|
|
58
|
+
return () => {
|
|
59
|
+
hideFromTopLayer(list);
|
|
60
|
+
};
|
|
61
|
+
}, [open, triggerRef, listRef]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const list = listRef.current;
|
|
64
|
+
const trigger = triggerRef.current;
|
|
65
|
+
if (!open || !list || !trigger)
|
|
66
|
+
return undefined;
|
|
67
|
+
const outside = (event) => {
|
|
68
|
+
const target = event.target;
|
|
69
|
+
if (target && !list.contains(target) && !trigger.contains(target))
|
|
70
|
+
dismiss();
|
|
71
|
+
};
|
|
72
|
+
// Content under the list can move (a panel scrolls, a list re-renders): the list follows
|
|
73
|
+
// its trigger, and closes only once the trigger has left the screen.
|
|
74
|
+
const follow = (event) => {
|
|
75
|
+
if (list.contains(event.target))
|
|
76
|
+
return;
|
|
77
|
+
if (onScreen(trigger))
|
|
78
|
+
place(trigger, list, { matchWidth: true });
|
|
79
|
+
else
|
|
80
|
+
dismiss();
|
|
81
|
+
};
|
|
82
|
+
const onResize = () => dismiss();
|
|
83
|
+
document.addEventListener("pointerdown", outside, true);
|
|
84
|
+
document.addEventListener("scroll", follow, true);
|
|
85
|
+
window.addEventListener("resize", onResize);
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener("pointerdown", outside, true);
|
|
88
|
+
document.removeEventListener("scroll", follow, true);
|
|
89
|
+
window.removeEventListener("resize", onResize);
|
|
90
|
+
};
|
|
91
|
+
}, [open, triggerRef, listRef]);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Keeps a mounted menu in the top layer beside its trigger. It closes on an outside press,
|
|
95
|
+
* Escape (returning focus to the trigger), Tab or a resize.
|
|
96
|
+
*/
|
|
97
|
+
export function useMenuPopover(menuRef, triggerRef, onClose, { width, align = "start", focus = '[aria-checked="true"], [role^="menuitem"]', busy = false }) {
|
|
98
|
+
const close = useEffectEvent(onClose);
|
|
99
|
+
const locked = useEffectEvent(() => busy);
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
const menu = menuRef.current;
|
|
102
|
+
const trigger = triggerRef.current;
|
|
103
|
+
if (!menu || !trigger)
|
|
104
|
+
return undefined;
|
|
105
|
+
showInTopLayer(menu);
|
|
106
|
+
if (width !== undefined)
|
|
107
|
+
menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`;
|
|
108
|
+
place(trigger, menu, { gap: 6, align, matchWidth: false });
|
|
109
|
+
(menu.querySelector(focus) ?? menu).focus({ preventScroll: true });
|
|
110
|
+
return () => {
|
|
111
|
+
hideFromTopLayer(menu);
|
|
112
|
+
};
|
|
113
|
+
}, [menuRef, triggerRef, width, align, focus]);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const onPointerDown = (event) => {
|
|
116
|
+
const target = event.target;
|
|
117
|
+
if (target && !locked() && !menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
|
|
118
|
+
close(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const onKeyDown = (event) => {
|
|
122
|
+
if ((event.key === "Escape" || event.key === "Tab") && !locked()) {
|
|
123
|
+
if (event.key === "Escape") {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
event.stopPropagation();
|
|
126
|
+
}
|
|
127
|
+
close(event.key === "Escape");
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const onResize = () => close(false);
|
|
131
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
132
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
133
|
+
window.addEventListener("resize", onResize);
|
|
134
|
+
return () => {
|
|
135
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
136
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
137
|
+
window.removeEventListener("resize", onResize);
|
|
138
|
+
};
|
|
139
|
+
}, [menuRef, triggerRef]);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Arrow keys, Home and End move between a menu's enabled items. From a text field inside the
|
|
143
|
+
* menu (a filter), Home and End stay in the field and the arrows enter the list.
|
|
144
|
+
*/
|
|
145
|
+
export function moveMenuFocus(event, menu) {
|
|
146
|
+
if (!menu || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
|
|
147
|
+
return;
|
|
148
|
+
const items = [...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)')];
|
|
149
|
+
const inField = document.activeElement instanceof HTMLInputElement && menu.contains(document.activeElement);
|
|
150
|
+
if (inField && (event.key === "Home" || event.key === "End"))
|
|
151
|
+
return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
const current = items.indexOf(document.activeElement);
|
|
154
|
+
const next = event.key === "Home"
|
|
155
|
+
? 0
|
|
156
|
+
: event.key === "End"
|
|
157
|
+
? items.length - 1
|
|
158
|
+
: inField
|
|
159
|
+
? 0
|
|
160
|
+
: (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
|
161
|
+
items[next]?.focus({ preventScroll: true });
|
|
162
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from "react";
|
|
2
|
+
import type { MdbaseAppId } from "./apps.js";
|
|
3
|
+
/**
|
|
4
|
+
* What an app shows while it opens a collection: its wordmark, held still, and a quiet line
|
|
5
|
+
* saying what it is waiting for. A failure keeps the same place and offers a retry.
|
|
6
|
+
*/
|
|
7
|
+
export declare function OpeningScreen({ app, title, detail, error, onRetry }: {
|
|
8
|
+
readonly app: MdbaseAppId;
|
|
9
|
+
readonly title: string;
|
|
10
|
+
readonly detail?: string | undefined;
|
|
11
|
+
readonly error?: string | null | undefined;
|
|
12
|
+
readonly onRetry?: (() => void) | undefined;
|
|
13
|
+
}): JSX.Element;
|
|
14
|
+
/**
|
|
15
|
+
* The screen before a collection is open: the app's wordmark, a headline, what the app will
|
|
16
|
+
* do, what went wrong if anything, and then the actions (children) for connecting. A status
|
|
17
|
+
* and an error that say the same thing are said once; technical detail waits behind a
|
|
18
|
+
* disclosure.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ConnectLayout({ app, title, lead, status, error, detail, footnote, children }: {
|
|
21
|
+
readonly app: MdbaseAppId;
|
|
22
|
+
readonly title: ReactNode;
|
|
23
|
+
readonly lead?: ReactNode | undefined;
|
|
24
|
+
readonly status?: string | null | undefined;
|
|
25
|
+
readonly error?: string | null | undefined;
|
|
26
|
+
readonly detail?: string | null | undefined;
|
|
27
|
+
readonly footnote?: ReactNode | undefined;
|
|
28
|
+
readonly children?: ReactNode | undefined;
|
|
29
|
+
}): JSX.Element;
|
package/dist/screens.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Wordmark } from "./brand.js";
|
|
3
|
+
/**
|
|
4
|
+
* What an app shows while it opens a collection: its wordmark, held still, and a quiet line
|
|
5
|
+
* saying what it is waiting for. A failure keeps the same place and offers a retry.
|
|
6
|
+
*/
|
|
7
|
+
export function OpeningScreen({ app, title, detail, error, onRetry }) {
|
|
8
|
+
return _jsx("main", { className: "mdbase-opening", "data-loading-state": error ? "failed" : "opening", "aria-label": title, "aria-busy": !error, children: _jsxs("div", { className: "mdbase-opening-message", role: error ? "alert" : "status", children: [_jsx(Wordmark, { app: app }), _jsxs("div", { children: [_jsx("p", { children: error ?? title }), !error && detail && _jsx("small", { children: detail })] }), error && onRetry && _jsx("button", { type: "button", className: "mdbase-button", onClick: onRetry, children: "Try again" })] }) });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The screen before a collection is open: the app's wordmark, a headline, what the app will
|
|
12
|
+
* do, what went wrong if anything, and then the actions (children) for connecting. A status
|
|
13
|
+
* and an error that say the same thing are said once; technical detail waits behind a
|
|
14
|
+
* disclosure.
|
|
15
|
+
*/
|
|
16
|
+
export function ConnectLayout({ app, title, lead, status, error, detail, footnote, children }) {
|
|
17
|
+
return _jsx("main", { className: "mdbase-connect-screen", children: _jsxs("section", { children: [_jsx(Wordmark, { app: app }), _jsx("h1", { children: title }), lead && _jsx("p", { className: "mdbase-connect-lead", children: lead }), status && status !== error && _jsx("p", { className: "mdbase-connect-status", role: "status", children: status }), error && _jsx("p", { className: "mdbase-connect-error", role: "alert", children: error }), detail && _jsxs("details", { className: "mdbase-connect-detail", children: [_jsx("summary", { children: "Details" }), _jsx("code", { children: detail })] }), children, footnote && _jsx("p", { className: "mdbase-connect-footnote", children: footnote })] }) });
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface SelectOption<Value extends string = string> {
|
|
2
|
+
readonly value: Value;
|
|
3
|
+
readonly label: string;
|
|
4
|
+
readonly disabled?: boolean | undefined;
|
|
5
|
+
}
|
|
6
|
+
export interface SelectOptionGroup<Value extends string = string> {
|
|
7
|
+
readonly label: string;
|
|
8
|
+
readonly options: readonly SelectOption<Value>[];
|
|
9
|
+
}
|
|
10
|
+
export type SelectItems<Value extends string = string> = readonly (SelectOption<Value> | SelectOptionGroup<Value>)[];
|
|
11
|
+
export interface FlatSelectOption<Value extends string = string> extends SelectOption<Value> {
|
|
12
|
+
/** The group heading this option sits under, if any. */
|
|
13
|
+
readonly group?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare function isOptionGroup<Value extends string>(item: SelectOption<Value> | SelectOptionGroup<Value>): item is SelectOptionGroup<Value>;
|
|
16
|
+
/** Options in display order, each remembering its group. */
|
|
17
|
+
export declare function flattenOptions<Value extends string>(items: SelectItems<Value>): readonly FlatSelectOption<Value>[];
|
|
18
|
+
/** The next enabled option from `from` in `direction`, stopping at the ends like a native list. */
|
|
19
|
+
export declare function stepOption(options: readonly SelectOption[], from: number, direction: 1 | -1): number;
|
|
20
|
+
/** The first (or last) enabled option, or -1 when every option is disabled. */
|
|
21
|
+
export declare function edgeOption(options: readonly SelectOption[], edge: "first" | "last"): number;
|
|
22
|
+
/**
|
|
23
|
+
* Type-to-select: the next enabled option whose label starts with what was typed. Typing one
|
|
24
|
+
* letter repeatedly cycles through the options that start with it, as a native select does.
|
|
25
|
+
*/
|
|
26
|
+
export declare function typeaheadOption(options: readonly SelectOption[], typed: string, from: number): number;
|
|
27
|
+
/** Where each item's options start in the flattened list, so groups can render in place. */
|
|
28
|
+
export declare function itemOffsets<Value extends string>(items: SelectItems<Value>): readonly number[];
|
|
29
|
+
export interface TypeaheadBuffer {
|
|
30
|
+
readonly text: string;
|
|
31
|
+
readonly at: number;
|
|
32
|
+
}
|
|
33
|
+
/** Appends a typed key, starting over after a pause, as native selects do. */
|
|
34
|
+
export declare function extendTypeahead(buffer: TypeaheadBuffer, key: string, at: number): TypeaheadBuffer;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function isOptionGroup(item) {
|
|
2
|
+
return "options" in item;
|
|
3
|
+
}
|
|
4
|
+
/** Options in display order, each remembering its group. */
|
|
5
|
+
export function flattenOptions(items) {
|
|
6
|
+
return items.flatMap((item) => isOptionGroup(item) ? item.options.map((option) => ({ ...option, group: item.label })) : [item]);
|
|
7
|
+
}
|
|
8
|
+
/** The next enabled option from `from` in `direction`, stopping at the ends like a native list. */
|
|
9
|
+
export function stepOption(options, from, direction) {
|
|
10
|
+
for (let index = from + direction; index >= 0 && index < options.length; index += direction) {
|
|
11
|
+
if (!options[index]?.disabled) {
|
|
12
|
+
return index;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return from;
|
|
16
|
+
}
|
|
17
|
+
/** The first (or last) enabled option, or -1 when every option is disabled. */
|
|
18
|
+
export function edgeOption(options, edge) {
|
|
19
|
+
const start = edge === "first" ? -1 : options.length;
|
|
20
|
+
const found = stepOption(options, start, edge === "first" ? 1 : -1);
|
|
21
|
+
return found === start ? -1 : found;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Type-to-select: the next enabled option whose label starts with what was typed. Typing one
|
|
25
|
+
* letter repeatedly cycles through the options that start with it, as a native select does.
|
|
26
|
+
*/
|
|
27
|
+
export function typeaheadOption(options, typed, from) {
|
|
28
|
+
const query = typed.toLocaleLowerCase();
|
|
29
|
+
const prefix = /^(.)\1+$/su.test(query) ? query.slice(0, 1) : query;
|
|
30
|
+
const offset = prefix.length === 1 ? 1 : 0;
|
|
31
|
+
for (let step = 0; step < options.length; step += 1) {
|
|
32
|
+
const index = (from + offset + step + options.length) % options.length;
|
|
33
|
+
const option = options[index];
|
|
34
|
+
if (option && !option.disabled && option.label.toLocaleLowerCase().startsWith(prefix)) {
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return -1;
|
|
39
|
+
}
|
|
40
|
+
/** Where each item's options start in the flattened list, so groups can render in place. */
|
|
41
|
+
export function itemOffsets(items) {
|
|
42
|
+
const offsets = [];
|
|
43
|
+
let next = 0;
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
offsets.push(next);
|
|
46
|
+
next += isOptionGroup(item) ? item.options.length : 1;
|
|
47
|
+
}
|
|
48
|
+
return offsets;
|
|
49
|
+
}
|
|
50
|
+
/** Appends a typed key, starting over after a pause, as native selects do. */
|
|
51
|
+
export function extendTypeahead(buffer, key, at) {
|
|
52
|
+
return { text: at - buffer.at > 600 ? key : buffer.text + key, at };
|
|
53
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Every mdbase surface stores an explicit System, Light or Dark choice under this key. */
|
|
2
|
+
export declare const THEME_STORAGE_KEY = "mdbase:theme";
|
|
3
|
+
export declare const themePreferences: readonly ["system", "light", "dark"];
|
|
4
|
+
export type ThemePreference = (typeof themePreferences)[number];
|
|
5
|
+
/** Browser chrome colours matching the light and dark canvas tokens. */
|
|
6
|
+
export declare const themeColors: {
|
|
7
|
+
readonly light: "#fcfcfd";
|
|
8
|
+
readonly dark: "#1b1d23";
|
|
9
|
+
};
|
|
10
|
+
export type ThemeStorage = Pick<Storage, "getItem" | "setItem">;
|
|
11
|
+
export type ThemeRoot = Pick<HTMLElement, "dataset" | "removeAttribute">;
|
|
12
|
+
export declare function normalizeThemePreference(value: unknown): ThemePreference;
|
|
13
|
+
/** Storage may be absent (server rendering, extension pages) or blocked; both mean System. */
|
|
14
|
+
export declare function loadThemePreference(storage?: ThemeStorage): ThemePreference;
|
|
15
|
+
export declare function resolveDarkTheme(root?: Pick<HTMLElement, "dataset">): boolean;
|
|
16
|
+
export declare function observeTheme(listener: () => void, root?: HTMLElement): () => void;
|
|
17
|
+
export declare function applyThemePreference(preference: ThemePreference, root?: ThemeRoot): void;
|
|
18
|
+
export declare function saveThemePreference(preference: ThemePreference, storage?: ThemeStorage, root?: ThemeRoot): void;
|