@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.
@@ -0,0 +1,38 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef, useState } from "react";
3
+ import { mdbaseAppHref, withAppUrls } from "./apps.js";
4
+ import { MdbaseAppMark, Wordmark } from "./brand.js";
5
+ import { moveMenuFocus, useMenuPopover } from "./popover.js";
6
+ /**
7
+ * The product wordmark as a menu for opening the current collection in the other mdbase apps.
8
+ * Each opens in a new tab so the current app stays where it is.
9
+ */
10
+ export function AppSwitcher({ current, urls = {}, motion }) {
11
+ const menuId = useId();
12
+ const triggerRef = useRef(null);
13
+ const [open, setOpen] = useState(false);
14
+ const close = (refocus) => {
15
+ setOpen(false);
16
+ if (refocus)
17
+ triggerRef.current?.focus();
18
+ };
19
+ return _jsxs(_Fragment, { children: [_jsxs("button", { ref: triggerRef, type: "button", className: "mdbase-app-switcher", "aria-label": `mdbase ${current}: open this collection in another app`, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? menuId : undefined, title: "Open in another mdbase app", onClick: () => (open ? close(false) : setOpen(true)), onKeyDown: (event) => {
20
+ if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
21
+ event.preventDefault();
22
+ setOpen(true);
23
+ }
24
+ }, children: [_jsx(Wordmark, { app: current, motion: motion }), _jsx("svg", { className: "mdbase-app-switcher-chevron", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "m7 10 5 5 5-5" }) })] }), open ? _jsx(AppMenu, { id: menuId, apps: withAppUrls(urls), current: current, triggerRef: triggerRef, onClose: close }) : null] });
25
+ }
26
+ function AppMenu({ id, apps, current, triggerRef, onClose }) {
27
+ const menuRef = useRef(null);
28
+ useMenuPopover(menuRef, triggerRef, onClose, { width: 320, focus: 'a[role="menuitem"]' });
29
+ const heading = new URL(location.href).searchParams.has("collection") ? "Open this collection in" : "mdbase apps";
30
+ return _jsxs("div", { ref: menuRef, id: id, className: "mdbase-menu", popover: "manual", role: "menu", "aria-label": heading, tabIndex: -1, onKeyDown: (event) => moveMenuFocus(event, menuRef.current), children: [_jsx("div", { className: "mdbase-menu-heading", role: "presentation", children: heading }), _jsx("div", { className: "mdbase-menu-list", children: apps.map((app) => _jsx(AppItem, { app: app, current: app.id === current, onOpen: () => onClose(false) }, app.id)) }), _jsx("p", { className: "mdbase-menu-note", children: "Opens in a new tab" })] });
31
+ }
32
+ function AppItem({ app, current, onOpen }) {
33
+ const copy = _jsxs(_Fragment, { children: [_jsx(MdbaseAppMark, { app: app.id, className: "mdbase-app-menu-mark" }), _jsxs("span", { className: "mdbase-menu-copy", children: [_jsx("strong", { children: app.name }), _jsx("small", { children: app.description })] })] });
34
+ if (current) {
35
+ return _jsxs("div", { className: "mdbase-menu-item is-current", role: "menuitem", "aria-current": "page", "aria-disabled": "true", tabIndex: -1, children: [copy, _jsx("span", { className: "mdbase-app-menu-current", children: "Current" })] });
36
+ }
37
+ return _jsxs("a", { className: "mdbase-menu-item", role: "menuitem", href: mdbaseAppHref(app.url, location.href), target: "_blank", rel: "noopener", onClick: onOpen, children: [copy, _jsx("svg", { className: "mdbase-app-menu-external", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M10 5.5H5.5v13h13V14M13.5 5.5h5v5M18.5 5.5 11 13" }) }), _jsx("span", { className: "mdbase-visually-hidden", children: " (opens in a new tab)" })] });
38
+ }
@@ -0,0 +1,37 @@
1
+ import { type JSX } from "react";
2
+ export interface Command {
3
+ readonly id: string;
4
+ readonly label: string;
5
+ readonly detail?: string | undefined;
6
+ /** Commands show under their group's heading, in the order the groups first appear. */
7
+ readonly group: string;
8
+ readonly keywords?: string | undefined;
9
+ /** For example "mod+shift+f"; see shortcutLabel. */
10
+ readonly shortcut?: string | undefined;
11
+ readonly run: () => void;
12
+ /** A variant run with Shift+Enter or Shift+click, such as opening beside the document. */
13
+ readonly alternate?: {
14
+ readonly label: string;
15
+ readonly run: () => void;
16
+ } | undefined;
17
+ }
18
+ export declare function isApplePlatform(): boolean;
19
+ /** "mod+shift+f" as the platform writes it: ⌘⇧F on Apple systems, Ctrl+Shift+F elsewhere. */
20
+ export declare function shortcutLabel(shortcut: string): string;
21
+ /**
22
+ * Without a query, show a few of each group (every command of the groups in `showAll`); with
23
+ * one, show every command matching all its terms, grouped in their original order.
24
+ */
25
+ export declare function matchingCommands(commands: readonly Command[], query: string, showAll?: readonly string[]): readonly Command[];
26
+ /**
27
+ * Find or run anything: every mdbase app opens it with Mod+K. Typing filters commands; the
28
+ * arrows move, Enter runs, Shift+Enter runs a command's alternate, Escape closes.
29
+ */
30
+ export declare function CommandPalette({ open, commands, onClose, label, placeholder, showAll }: {
31
+ readonly open: boolean;
32
+ readonly commands: readonly Command[];
33
+ readonly onClose: () => void;
34
+ readonly label?: string | undefined;
35
+ readonly placeholder?: string | undefined;
36
+ readonly showAll?: readonly string[] | undefined;
37
+ }): JSX.Element | null;
@@ -0,0 +1,130 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
3
+ export function isApplePlatform() {
4
+ return typeof navigator !== "undefined" && /Mac|iPhone|iPad/u.test(navigator.platform || navigator.userAgent);
5
+ }
6
+ /** "mod+shift+f" as the platform writes it: ⌘⇧F on Apple systems, Ctrl+Shift+F elsewhere. */
7
+ export function shortcutLabel(shortcut) {
8
+ const apple = isApplePlatform();
9
+ return shortcut
10
+ .split("+")
11
+ .map((part) => {
12
+ switch (part.toLowerCase()) {
13
+ case "mod": return apple ? "⌘" : "Ctrl";
14
+ case "shift": return apple ? "⇧" : "Shift";
15
+ case "alt": return apple ? "⌥" : "Alt";
16
+ default: return part.length === 1 ? part.toUpperCase() : part;
17
+ }
18
+ })
19
+ .join(apple ? "" : "+");
20
+ }
21
+ const browseLimit = 6;
22
+ const searchLimit = 40;
23
+ /**
24
+ * Without a query, show a few of each group (every command of the groups in `showAll`); with
25
+ * one, show every command matching all its terms, grouped in their original order.
26
+ */
27
+ export function matchingCommands(commands, query, showAll = []) {
28
+ const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
29
+ if (terms.length === 0) {
30
+ const counts = new Map();
31
+ return commands.filter((command) => {
32
+ const count = counts.get(command.group) ?? 0;
33
+ counts.set(command.group, count + 1);
34
+ return showAll.includes(command.group) || count < browseLimit;
35
+ });
36
+ }
37
+ const matches = commands.filter((command) => {
38
+ const content = `${command.label} ${command.detail ?? ""} ${command.group} ${command.keywords ?? ""}`.toLocaleLowerCase();
39
+ return terms.every((term) => content.includes(term));
40
+ });
41
+ const groups = [...new Set(matches.map(({ group }) => group))];
42
+ return groups.flatMap((group) => matches.filter((command) => command.group === group)).slice(0, searchLimit);
43
+ }
44
+ /**
45
+ * Find or run anything: every mdbase app opens it with Mod+K. Typing filters commands; the
46
+ * arrows move, Enter runs, Shift+Enter runs a command's alternate, Escape closes.
47
+ */
48
+ export function CommandPalette({ open, commands, onClose, label = "Commands", placeholder = "Search commands", showAll }) {
49
+ return open ? _jsx(OpenCommandPalette, { commands: commands, onClose: onClose, label: label, placeholder: placeholder, showAll: showAll }) : null;
50
+ }
51
+ function OpenCommandPalette({ commands, onClose, label, placeholder, showAll }) {
52
+ const [query, setQuery] = useState("");
53
+ const [active, setActive] = useState(0);
54
+ const inputRef = useRef(null);
55
+ const paletteRef = useRef(null);
56
+ const matches = useMemo(() => matchingCommands(commands, query, showAll), [commands, query, showAll]);
57
+ useEffect(() => {
58
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
59
+ globalThis.setTimeout(() => inputRef.current?.focus(), 0);
60
+ return () => previous?.focus();
61
+ }, []);
62
+ const run = (command, alternate = false) => {
63
+ onClose();
64
+ (alternate && command.alternate ? command.alternate.run : command.run)();
65
+ };
66
+ const hintId = `${label.replace(/\W+/gu, "-").toLowerCase()}-hint`;
67
+ return _jsxs("dialog", { className: "mdbase-command-backdrop", open: true, children: [_jsx("button", { className: "mdbase-command-dismiss", type: "button", "aria-label": `Close ${label.toLowerCase()}`, onClick: onClose }), _jsxs("section", { ref: paletteRef, className: "mdbase-command-palette", role: "dialog", "aria-modal": "true", "aria-label": label, onKeyDown: (event) => {
68
+ if (event.key === "Escape") {
69
+ event.preventDefault();
70
+ onClose();
71
+ }
72
+ else if (event.key === "Tab") {
73
+ trapFocus(event, paletteRef.current);
74
+ }
75
+ }, children: [_jsxs("label", { className: "mdbase-command-search", children: [_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "1.6", children: [_jsx("circle", { cx: "11", cy: "11", r: "6.5" }), _jsx("path", { d: "m16 16 4 4" })] }), _jsx("span", { className: "mdbase-visually-hidden", children: placeholder }), _jsx("input", { ref: inputRef, value: query, placeholder: placeholder, onChange: (event) => {
76
+ setQuery(event.target.value);
77
+ setActive(0);
78
+ }, onKeyDown: (event) => handleKeys(event, matches, active, setActive, run, onClose) })] }), _jsx("div", { className: "mdbase-command-results", role: "listbox", "aria-label": "Results", "aria-describedby": hintId, children: matches.length > 0
79
+ ? matches.map((command, index) => _jsxs(Fragment, { children: [command.group !== matches[index - 1]?.group
80
+ ? _jsx("div", { className: "mdbase-command-group", role: "presentation", children: command.group })
81
+ : null, _jsx(CommandRow, { command: command, active: index === active, onRun: run })] }, command.id))
82
+ : _jsx("div", { className: "mdbase-command-empty", children: "Nothing matches" }) }), _jsxs("footer", { className: "mdbase-command-footer", id: hintId, children: [_jsxs("span", { children: [_jsx("kbd", { children: "\u2191" }), _jsx("kbd", { children: "\u2193" }), " to move"] }), _jsxs("span", { children: [_jsx("kbd", { children: "\u21B5" }), " to open"] }), matches[active]?.alternate
83
+ ? _jsxs("span", { children: [_jsx("kbd", { children: shortcutLabel("shift") }), _jsx("kbd", { children: "\u21B5" }), " ", matches[active].alternate.label.toLocaleLowerCase()] })
84
+ : null, _jsxs("span", { children: [_jsx("kbd", { children: "Esc" }), " to close"] })] })] })] });
85
+ }
86
+ function trapFocus(event, container) {
87
+ if (!container)
88
+ return;
89
+ const items = [...container.querySelectorAll("input, button, [tabindex]:not([tabindex='-1'])")];
90
+ const first = items[0];
91
+ const last = items.at(-1);
92
+ if (!first || !last)
93
+ return;
94
+ if (event.shiftKey && document.activeElement === first) {
95
+ event.preventDefault();
96
+ last.focus();
97
+ }
98
+ else if (!event.shiftKey && document.activeElement === last) {
99
+ event.preventDefault();
100
+ first.focus();
101
+ }
102
+ }
103
+ function CommandRow({ command, active, onRun }) {
104
+ const ref = useRef(null);
105
+ useEffect(() => {
106
+ if (active)
107
+ ref.current?.scrollIntoView({ block: "nearest" });
108
+ }, [active]);
109
+ return _jsxs("button", { ref: ref, type: "button", role: "option", "aria-selected": active, onMouseDown: (event) => onRun(command, event.shiftKey), children: [_jsxs("span", { children: [_jsx("strong", { children: command.label }), command.detail ? _jsx("small", { children: command.detail }) : null] }), command.alternate && active
110
+ ? _jsxs("span", { className: "mdbase-command-alternate", children: [shortcutLabel("shift"), "\u21B5 ", command.alternate.label] })
111
+ : null, command.shortcut ? _jsx("kbd", { children: shortcutLabel(command.shortcut) }) : null] });
112
+ }
113
+ function handleKeys(event, matches, active, setActive, run, close) {
114
+ if (event.key === "Escape") {
115
+ event.preventDefault();
116
+ close();
117
+ }
118
+ else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
119
+ event.preventDefault();
120
+ const direction = event.key === "ArrowDown" ? 1 : -1;
121
+ setActive((active + direction + matches.length) % Math.max(1, matches.length));
122
+ }
123
+ else if (event.key === "Enter") {
124
+ const command = matches[active];
125
+ if (command) {
126
+ event.preventDefault();
127
+ run(command, event.shiftKey);
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,12 @@
1
+ import { type JSX, type ReactNode } from "react";
2
+ /**
3
+ * A modal dialog on the native <dialog>: the browser keeps focus inside it and the page
4
+ * behind it inert. Escape, the close button or a press on the backdrop closes it.
5
+ */
6
+ export declare function Dialog({ open, onClose, title, className, children }: {
7
+ readonly open: boolean;
8
+ readonly onClose: () => void;
9
+ readonly title: string;
10
+ readonly className?: string | undefined;
11
+ readonly children: ReactNode;
12
+ }): JSX.Element;
package/dist/Dialog.js ADDED
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useRef } from "react";
3
+ /**
4
+ * A modal dialog on the native <dialog>: the browser keeps focus inside it and the page
5
+ * behind it inert. Escape, the close button or a press on the backdrop closes it.
6
+ */
7
+ export function Dialog({ open, onClose, title, className, children }) {
8
+ const ref = useRef(null);
9
+ // A layout effect, so the dialog is open before fields inside it ask to be focused.
10
+ useLayoutEffect(() => {
11
+ const dialog = ref.current;
12
+ if (!dialog)
13
+ return;
14
+ if (open && !dialog.open)
15
+ dialog.showModal();
16
+ else if (!open && dialog.open)
17
+ dialog.close();
18
+ }, [open]);
19
+ return _jsx("dialog", { ref: ref, className: ["mdbase-dialog", className].filter(Boolean).join(" "), "aria-label": title, onClose: onClose, onMouseDown: (event) => {
20
+ if (event.target === event.currentTarget)
21
+ onClose();
22
+ }, children: open && _jsxs("div", { className: "mdbase-dialog-body", children: [_jsxs("header", { className: "mdbase-dialog-header", children: [_jsx("h2", { children: title }), _jsx("button", { type: "button", className: "mdbase-icon-button", onClick: onClose, "aria-label": "Close", children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", children: _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) }) })] }), children] }) });
23
+ }
@@ -0,0 +1,17 @@
1
+ import type { JSX } from "react";
2
+ /**
3
+ * saved: confirmed, so it settles away (see .mdbase-settle in controls.css).
4
+ * saving: work in progress. pending: changed but not yet saved.
5
+ * attention: a conflict or failure, which never fades.
6
+ */
7
+ export type SaveTone = "saved" | "saving" | "pending" | "attention";
8
+ /**
9
+ * The minimal save notice every mdbase app shows beside what it edits. Put it inside an
10
+ * element with `mdbase-settle-host` so hovering or focusing that element brings a settled
11
+ * notice back.
12
+ */
13
+ export declare function SaveNotice({ tone, label, className }: {
14
+ readonly tone: SaveTone;
15
+ readonly label?: string | undefined;
16
+ readonly className?: string | undefined;
17
+ }): JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const defaultLabels = {
3
+ saved: "Saved",
4
+ saving: "Saving",
5
+ pending: "Unsaved",
6
+ attention: "Needs attention"
7
+ };
8
+ /**
9
+ * The minimal save notice every mdbase app shows beside what it edits. Put it inside an
10
+ * element with `mdbase-settle-host` so hovering or focusing that element brings a settled
11
+ * notice back.
12
+ */
13
+ export function SaveNotice({ tone, label = defaultLabels[tone], className }) {
14
+ return _jsxs("span", { className: ["mdbase-save-notice", `is-${tone}`, tone === "saved" && "mdbase-settle", className].filter(Boolean).join(" "), "aria-live": "polite", children: [tone === "saved" && _jsx("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", children: _jsx("path", { d: "m3.5 8.5 3 3 6-7" }) }), label] });
15
+ }
@@ -0,0 +1,20 @@
1
+ import { type ButtonHTMLAttributes, type JSX, type Ref } from "react";
2
+ import { type SelectItems } from "./select-model.js";
3
+ export type { SelectItems, SelectOption, SelectOptionGroup } from "./select-model.js";
4
+ type TriggerAttributes = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange" | "children" | "type" | "role">;
5
+ export interface SelectProps<Value extends string> extends TriggerAttributes {
6
+ readonly value: Value | "";
7
+ readonly options: SelectItems<Value>;
8
+ readonly onChange: (value: Value) => void;
9
+ /** Shown while the value matches no option, like a native select's disabled first option. */
10
+ readonly placeholder?: string | undefined;
11
+ /** The trigger button, for focusing the control from elsewhere. */
12
+ readonly ref?: Ref<HTMLButtonElement> | undefined;
13
+ }
14
+ /**
15
+ * A styled single-choice dropdown following the ARIA select-only combobox pattern. The list
16
+ * opens in the browser's top layer, so clipped or transformed containers (virtual rows, side
17
+ * panels, menus) never cut it off, while it stays in the DOM beside its trigger: a click in
18
+ * the list still counts as inside any menu that contains the select.
19
+ */
20
+ export declare function Select<Value extends string>({ value, options: items, onChange, placeholder, className, disabled, onKeyDown, onClick, ref, ...trigger }: SelectProps<Value>): JSX.Element;
package/dist/Select.js ADDED
@@ -0,0 +1,158 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useLayoutEffect, useRef, useState, } from "react";
3
+ import { edgeOption, extendTypeahead, flattenOptions, stepOption, typeaheadOption, } from "./select-model.js";
4
+ import { SelectList } from "./SelectList.js";
5
+ import { useSelectPopover } from "./popover.js";
6
+ /**
7
+ * A styled single-choice dropdown following the ARIA select-only combobox pattern. The list
8
+ * opens in the browser's top layer, so clipped or transformed containers (virtual rows, side
9
+ * panels, menus) never cut it off, while it stays in the DOM beside its trigger: a click in
10
+ * the list still counts as inside any menu that contains the select.
11
+ */
12
+ export function Select({ value, options: items, onChange, placeholder, className, disabled, onKeyDown, onClick, ref, ...trigger }) {
13
+ const id = useId();
14
+ const triggerRef = useRef(null);
15
+ const listRef = useRef(null);
16
+ const options = flattenOptions(items);
17
+ const selected = options.findIndex((option) => option.value === value);
18
+ const state = useSelectState(options, selected, value, onChange, disabled, triggerRef);
19
+ useSelectPopover(state.open, triggerRef, listRef, () => state.close(false));
20
+ useLayoutEffect(() => {
21
+ if (state.open && listRef.current) {
22
+ revealOption(listRef.current, state.active);
23
+ }
24
+ }, [state.active, state.open]);
25
+ const label = selected >= 0 ? options[selected]?.label : placeholder;
26
+ return (_jsxs(_Fragment, { children: [_jsxs("button", { ...trigger, ref: (node) => {
27
+ triggerRef.current = node;
28
+ if (typeof ref === "function")
29
+ ref(node);
30
+ else if (ref)
31
+ ref.current = node;
32
+ }, type: "button", role: "combobox", className: ["mdbase-select", className].filter(Boolean).join(" "), "data-value": value, disabled: disabled, "aria-haspopup": "listbox", "aria-expanded": state.open, "aria-controls": `${id}-list`, onClick: (event) => {
33
+ onClick?.(event);
34
+ if (!event.defaultPrevented) {
35
+ state.toggle();
36
+ }
37
+ }, onKeyDown: (event) => {
38
+ onKeyDown?.(event);
39
+ if (!event.defaultPrevented) {
40
+ state.onTriggerKey(event);
41
+ }
42
+ }, children: [_jsx("span", { className: selected >= 0 ? "mdbase-select-value" : "mdbase-select-value is-empty", children: label ?? "" }), _jsx("svg", { className: "mdbase-select-chevron", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "m7 10 5 5 5-5" }) })] }), _jsx(SelectList, { id: id, listRef: listRef, items: items, selected: selected, active: state.active, open: state.open, label: trigger["aria-label"], labelledBy: trigger["aria-labelledby"], onActivate: state.setActive, onChoose: state.choose, onKeyDown: state.onListKey })] }));
43
+ }
44
+ function useSelectState(options, selected, value, onChange, disabled, triggerRef) {
45
+ const [open, setOpen] = useState(false);
46
+ const [active, setActive] = useState(-1);
47
+ const typed = useRef({ text: "", at: 0 });
48
+ const typeahead = (event, from) => {
49
+ typed.current = extendTypeahead(typed.current, event.key, event.timeStamp);
50
+ return typeaheadOption(options, typed.current.text, from);
51
+ };
52
+ const show = (index) => {
53
+ if (!disabled && options.length) {
54
+ setActive(index);
55
+ setOpen(true);
56
+ }
57
+ };
58
+ const close = (refocus) => {
59
+ setOpen(false);
60
+ if (refocus) {
61
+ triggerRef.current?.focus();
62
+ }
63
+ };
64
+ const commit = (index) => {
65
+ const option = options[index];
66
+ if (option && !option.disabled && option.value !== value) {
67
+ onChange(option.value);
68
+ }
69
+ };
70
+ const choose = (index) => {
71
+ if (options[index] && !options[index].disabled) {
72
+ close(true);
73
+ commit(index);
74
+ }
75
+ };
76
+ const initial = (key) => selected >= 0 ? selected : edgeOption(options, key === "ArrowUp" ? "last" : "first");
77
+ const onTriggerKey = (event) => {
78
+ if (disabled) {
79
+ return;
80
+ }
81
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
82
+ event.preventDefault();
83
+ show(initial(event.key));
84
+ }
85
+ else if (isPrintable(event)) {
86
+ // Like a native select, typing on the closed control changes the value directly.
87
+ commit(typeahead(event, selected));
88
+ }
89
+ };
90
+ const moves = {
91
+ ArrowDown: () => stepOption(options, active, 1),
92
+ ArrowUp: () => stepOption(options, active, -1),
93
+ Home: () => edgeOption(options, "first"),
94
+ End: () => edgeOption(options, "last"),
95
+ PageDown: () => stepPage(options, active, 1),
96
+ PageUp: () => stepPage(options, active, -1),
97
+ };
98
+ const onListKey = (event) => {
99
+ event.stopPropagation();
100
+ const move = moves[event.key];
101
+ if (move) {
102
+ event.preventDefault();
103
+ setActive(move());
104
+ }
105
+ else if (event.key === "Enter" || event.key === " ") {
106
+ event.preventDefault();
107
+ choose(active);
108
+ }
109
+ else if (event.key === "Escape" || event.key === "Tab") {
110
+ if (event.key === "Escape") {
111
+ event.preventDefault();
112
+ }
113
+ close(event.key === "Escape");
114
+ }
115
+ else if (isPrintable(event)) {
116
+ const match = typeahead(event, active);
117
+ setActive(match >= 0 ? match : active);
118
+ }
119
+ };
120
+ return {
121
+ open,
122
+ active,
123
+ setActive,
124
+ close,
125
+ choose,
126
+ toggle: () => (open ? close(false) : show(initial("ArrowDown"))),
127
+ onTriggerKey,
128
+ onListKey,
129
+ };
130
+ }
131
+ function stepPage(options, from, direction) {
132
+ let index = from;
133
+ for (let step = 0; step < 8; step += 1) {
134
+ index = stepOption(options, index, direction);
135
+ }
136
+ return index;
137
+ }
138
+ /**
139
+ * Scrolls only the list to show an option. scrollIntoView would also scroll the panels the
140
+ * list sits inside in the DOM, which the open list treats as the page moving and closes.
141
+ */
142
+ function revealOption(list, index) {
143
+ const option = list.querySelector(`[data-index="${String(index)}"]`);
144
+ if (!option) {
145
+ return;
146
+ }
147
+ const top = option.offsetTop;
148
+ const bottom = top + option.offsetHeight;
149
+ if (top < list.scrollTop) {
150
+ list.scrollTop = top;
151
+ }
152
+ else if (bottom > list.scrollTop + list.clientHeight) {
153
+ list.scrollTop = bottom - list.clientHeight;
154
+ }
155
+ }
156
+ function isPrintable(event) {
157
+ return (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey);
158
+ }
@@ -0,0 +1,19 @@
1
+ import { type SelectItems } from "./select-model.js";
2
+ import type { JSX, KeyboardEvent, RefObject } from "react";
3
+ /**
4
+ * The listbox half of a select. Focus stays on the listbox, which names its active option with
5
+ * aria-activedescendant, so options take pointer input only; the keyboard goes to the list.
6
+ */
7
+ export declare function SelectList<Value extends string>({ id, listRef, items, selected, active, open, label, labelledBy, onActivate, onChoose, onKeyDown, }: {
8
+ readonly id: string;
9
+ readonly listRef: RefObject<HTMLDivElement | null>;
10
+ readonly items: SelectItems<Value>;
11
+ readonly selected: number;
12
+ readonly active: number;
13
+ readonly open: boolean;
14
+ readonly label: string | undefined;
15
+ readonly labelledBy: string | undefined;
16
+ readonly onActivate: (index: number) => void;
17
+ readonly onChoose: (index: number) => void;
18
+ readonly onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
19
+ }): JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isOptionGroup, itemOffsets } from "./select-model.js";
3
+ /**
4
+ * The listbox half of a select. Focus stays on the listbox, which names its active option with
5
+ * aria-activedescendant, so options take pointer input only; the keyboard goes to the list.
6
+ */
7
+ export function SelectList({ id, listRef, items, selected, active, open, label, labelledBy, onActivate, onChoose, onKeyDown, }) {
8
+ const offsets = itemOffsets(items);
9
+ const option = (value, index) => (_jsx("div", { id: `${id}-option-${String(index)}`, "data-index": index, "data-value": value.value, role: "option", "aria-selected": index === selected, "aria-disabled": value.disabled ? true : undefined, className: index === active ? "mdbase-select-option is-active" : "mdbase-select-option", onPointerMove: () => {
10
+ if (!value.disabled && index !== active) {
11
+ onActivate(index);
12
+ }
13
+ }, onClick: () => onChoose(index), children: value.label }, value.value));
14
+ return (_jsx("div", { ref: listRef, id: `${id}-list`, className: "mdbase-select-list", popover: "manual", role: "listbox", tabIndex: -1, "aria-label": label, "aria-labelledby": labelledBy, "aria-activedescendant": open && active >= 0 ? `${id}-option-${String(active)}` : undefined, onKeyDown: onKeyDown, onClick: (event) => {
15
+ event.stopPropagation();
16
+ event.preventDefault();
17
+ }, onDoubleClick: (event) => event.stopPropagation(), children: items.map((item, itemIndex) => {
18
+ const first = offsets[itemIndex] ?? 0;
19
+ return isOptionGroup(item) ? (_jsxs("div", { role: "group", "aria-label": item.label, children: [_jsx("div", { className: "mdbase-select-group", "aria-hidden": "true", children: item.label }), item.options.map((value, offset) => option(value, first + offset))] }, `group:${item.label}`)) : (option(item, first));
20
+ }) }));
21
+ }
@@ -0,0 +1,12 @@
1
+ import type { JSX } from "react";
2
+ import { type ThemePreference } from "./theme.js";
3
+ /**
4
+ * The one control for choosing System, Light or Dark. The app owns the preference (with
5
+ * loadThemePreference and saveThemePreference) because commands and menus may change it too.
6
+ */
7
+ export declare function ThemeSelect({ value, onChange, className, label }: {
8
+ readonly value: ThemePreference;
9
+ readonly onChange: (value: ThemePreference) => void;
10
+ readonly className?: string | undefined;
11
+ readonly label?: string | undefined;
12
+ }): JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Select } from "./Select.js";
3
+ import { themePreferences } from "./theme.js";
4
+ const themeLabels = { system: "System", light: "Light", dark: "Dark" };
5
+ const themeOptions = themePreferences.map((value) => ({ value, label: themeLabels[value] }));
6
+ /**
7
+ * The one control for choosing System, Light or Dark. The app owns the preference (with
8
+ * loadThemePreference and saveThemePreference) because commands and menus may change it too.
9
+ */
10
+ export function ThemeSelect({ value, onChange, className, label = "Color theme" }) {
11
+ return _jsx(Select, { "aria-label": label, className: className, value: value, options: themeOptions, onChange: onChange });
12
+ }
package/dist/apps.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The mdbase apps a collection can be opened in. Editor is the canonical app for a
3
+ * collection; Reader and Writer serve particular workflows on the same records.
4
+ */
5
+ export type MdbaseAppId = "editor" | "reader" | "writer";
6
+ export interface MdbaseApp {
7
+ readonly id: MdbaseAppId;
8
+ readonly name: string;
9
+ readonly description: string;
10
+ /** Production origin. Lab and local builds substitute their own with `withAppUrls`. */
11
+ readonly url: string;
12
+ }
13
+ export declare const mdbaseApps: readonly MdbaseApp[];
14
+ /**
15
+ * Replaces app URLs for a lab or local build, for example from
16
+ * `import.meta.env.VITE_MDBASE_WRITER_URL`. Unset entries keep production.
17
+ */
18
+ export declare function withAppUrls(urls: Partial<Record<MdbaseAppId, string | undefined>>, apps?: readonly MdbaseApp[]): readonly MdbaseApp[];
19
+ /** A link that opens the current page's collection in another app. */
20
+ export declare function mdbaseAppHref(appUrl: string, currentHref: string): string;
package/dist/apps.js ADDED
@@ -0,0 +1,47 @@
1
+ export const mdbaseApps = [
2
+ {
3
+ id: "editor",
4
+ name: "Editor",
5
+ description: "Browse and edit records",
6
+ url: "https://editor.mdbase.dev/"
7
+ },
8
+ {
9
+ id: "reader",
10
+ name: "Reader",
11
+ description: "Read and annotate sources",
12
+ url: "https://reader.mdbase.dev/"
13
+ },
14
+ {
15
+ id: "writer",
16
+ name: "Writer",
17
+ description: "Write manuscripts that cite your sources",
18
+ url: "https://writer.mdbase.dev/"
19
+ }
20
+ ];
21
+ /**
22
+ * Replaces app URLs for a lab or local build, for example from
23
+ * `import.meta.env.VITE_MDBASE_WRITER_URL`. Unset entries keep production.
24
+ */
25
+ export function withAppUrls(urls, apps = mdbaseApps) {
26
+ return apps.map((app) => {
27
+ const url = urls[app.id];
28
+ return url ? { ...app, url } : app;
29
+ });
30
+ }
31
+ /**
32
+ * Parameters every mdbase app reads the same way: `collection` is the Connect SDK's
33
+ * selection and `server` picks a non-default Connect server. App-specific parameters
34
+ * (Reader's `source`, Writer's `manuscript`) mean nothing to another app, so they stay behind.
35
+ */
36
+ const sharedParameters = ["collection", "server"];
37
+ /** A link that opens the current page's collection in another app. */
38
+ export function mdbaseAppHref(appUrl, currentHref) {
39
+ const target = new URL(appUrl);
40
+ const current = new URL(currentHref);
41
+ for (const key of sharedParameters) {
42
+ const value = current.searchParams.get(key);
43
+ if (value)
44
+ target.searchParams.set(key, value);
45
+ }
46
+ return target.href;
47
+ }