@openpresentation/opf-editor 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ import type { OpfIcon } from "./icons.js";
2
+ /** One host button in the toolbar. */
3
+ export interface ToolbarAction {
4
+ /** Unique within the list. Becomes `data-action` on the button and `actions:<id>` in the overflow menu. */
5
+ id: string;
6
+ /** The visible text of the button (and its accessible name). */
7
+ label: string;
8
+ /**
9
+ * The button's icon: an SVG element (copied into the toolbar's document), a function returning one, or SVG path data for a 24x24 stroked icon
10
+ * (`"M5 12h14"`); see `OpfIcon`. Optional. The icon is `.opf-actions-icon`; its name is the action id (`data-opf-icon`).
11
+ */
12
+ icon?: OpfIcon;
13
+ /** The tooltip. */
14
+ title?: string;
15
+ /** What the button does. Called on a click, and from the overflow menu, only while the action is enabled. The button refreshes afterwards. */
16
+ run(): void | Promise<void>;
17
+ /** Whether the button can be used. Default: always. Read again by `refresh()`. A throw counts as false. */
18
+ enabled?(): boolean;
19
+ /** Makes it a toggle: `aria-pressed` follows this. Read again by `refresh()`. Without it the button is not a toggle. */
20
+ pressed?(): boolean;
21
+ /** Style the button with the accent color. */
22
+ primary?: boolean;
23
+ }
24
+ export interface ToolbarActionsLabels {
25
+ /** The accessible name of the group of buttons. Default: "Actions". */
26
+ group?: string;
27
+ }
28
+ export interface ToolbarActionsOptions {
29
+ actions?: ToolbarAction[];
30
+ /** `"comfortable"` (32px, default) or `"touch"` (40px; 44px under a coarse pointer either way). */
31
+ density?: "comfortable" | "touch";
32
+ labels?: ToolbarActionsLabels;
33
+ }
34
+ export interface ToolbarActionsMenuItem {
35
+ /** `actions:<action id>`. */
36
+ id: string;
37
+ label: string;
38
+ disabled: boolean;
39
+ /** Present for toggles. */
40
+ pressed?: boolean;
41
+ run(): void | Promise<void> | undefined;
42
+ }
43
+ export interface ToolbarActions {
44
+ /** The group element (`role="group"`); hidden while there are no actions. */
45
+ readonly element: HTMLElement;
46
+ /** Replace the buttons. Throws a TypeError for an action without an id, a label or `run`, or for a repeated id. A focused button keeps the focus. */
47
+ setActions(actions: ToolbarAction[]): void;
48
+ /** Read every `enabled()` and `pressed()` again. The state is not watched; call this when the host's state changes. */
49
+ refresh(): void;
50
+ setDensity(density: "comfortable" | "touch"): void;
51
+ /** Disables every button (the editor does this while the source is invalid or read-only). */
52
+ setDisabled(disabled: boolean): void;
53
+ /** One entry per action, for the toolbar's overflow menu. */
54
+ menuItems(): ToolbarActionsMenuItem[];
55
+ /** Removes the element. Safe twice. */
56
+ destroy(): void;
57
+ }
58
+ /** The host's own buttons in the toolbar: labelled, optionally iconed, with enabled and pressed state read on `refresh()`. */
59
+ export declare function createToolbarActions(container: HTMLElement, options?: ToolbarActionsOptions): ToolbarActions;
@@ -0,0 +1,136 @@
1
+ // CE-29, toolbar actions: the `actions` item. The host's own buttons in the editor's toolbar ("Share", "Publish", "Save as template"), each with a visible
2
+ // label, an optional icon and optional enabled and pressed state. The editor never decides what they do: `run()` is the host's. Plain DOM, no framework,
3
+ // no network. An icon is an SVG element (copied into the toolbar's document) or SVG path data (24x24, stroked like the editor's own icons).
4
+ import { resolveIcon } from "./icons.js";
5
+
6
+ const DEFAULT_LABELS = {
7
+ /** The accessible name of the group of buttons. */
8
+ group: "Actions",
9
+ };
10
+
11
+ function checkAction(action, seen) {
12
+ if (!action || typeof action !== "object") throw new TypeError("An action must be an object.");
13
+ if (typeof action.id !== "string" || !action.id) throw new TypeError("An action needs a string id.");
14
+ if (typeof action.label !== "string" || !action.label) throw new TypeError(`The action "${action.id}" needs a label.`);
15
+ if (typeof action.run !== "function") throw new TypeError(`The action "${action.id}" needs a run() function.`);
16
+ if (seen.has(action.id)) throw new TypeError(`Two actions have the id "${action.id}".`);
17
+ seen.add(action.id);
18
+ }
19
+
20
+ function iconNode(doc, action) {
21
+ return action.icon ? resolveIcon(doc, action.icon, null, { name: action.id, className: "opf-actions-icon" }) : null;
22
+ }
23
+
24
+ /**
25
+ * Mount the host's action buttons into `container`.
26
+ *
27
+ * Options: `actions` (`[{ id, label, icon?, title?, run(), enabled?(), pressed?(), primary? }]`), `density` (`"comfortable"` or `"touch"`), `labels`
28
+ * (`group`).
29
+ *
30
+ * Returns `{ element, setActions(actions), refresh(), setDensity(density), setDisabled(disabled), menuItems(), destroy() }`. `refresh()` reads every
31
+ * `enabled()` and `pressed()` again (they are not watched); a button also refreshes after its own `run()`.
32
+ */
33
+ export function createToolbarActions(container, options = {}) {
34
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
35
+ const doc = container.ownerDocument;
36
+ const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
37
+ let density = options.density === "touch" ? "touch" : "comfortable";
38
+ let disabled = false;
39
+ let destroyed = false;
40
+ let actions = [];
41
+ let buttons = new Map();
42
+
43
+ const element = doc.createElement("div");
44
+ element.className = "opf-actions";
45
+ element.dataset.density = density;
46
+ element.setAttribute("role", "group");
47
+ element.setAttribute("aria-label", labels.group);
48
+ container.append(element);
49
+
50
+ const isEnabled = (action) => {
51
+ if (typeof action.enabled !== "function") return true;
52
+ try { return action.enabled() !== false; } catch { return false; }
53
+ };
54
+ const isPressed = (action) => {
55
+ if (typeof action.pressed !== "function") return undefined;
56
+ try { return Boolean(action.pressed()); } catch { return false; }
57
+ };
58
+
59
+ function refresh() {
60
+ if (destroyed) return;
61
+ for (const action of actions) {
62
+ const button = buttons.get(action.id);
63
+ if (!button) continue;
64
+ button.disabled = disabled || !isEnabled(action);
65
+ const pressed = isPressed(action);
66
+ if (pressed === undefined) button.removeAttribute("aria-pressed"); else button.setAttribute("aria-pressed", String(pressed));
67
+ }
68
+ }
69
+
70
+ function run(action) {
71
+ if (destroyed || disabled || !isEnabled(action)) return undefined;
72
+ const result = action.run();
73
+ refresh();
74
+ return result;
75
+ }
76
+
77
+ function build(action) {
78
+ const button = doc.createElement("button");
79
+ button.type = "button";
80
+ button.className = `opf-actions-button${action.primary ? " is-primary" : ""}`;
81
+ button.dataset.action = action.id;
82
+ if (action.title) button.title = action.title;
83
+ const icon = iconNode(doc, action);
84
+ if (icon) button.append(icon);
85
+ const label = doc.createElement("span");
86
+ label.className = "opf-actions-label";
87
+ label.textContent = action.label;
88
+ button.append(label);
89
+ button.addEventListener("click", () => { run(action); });
90
+ return button;
91
+ }
92
+
93
+ function setActions(next) {
94
+ if (destroyed) return;
95
+ const list = Array.isArray(next) ? next : [];
96
+ const seen = new Set();
97
+ for (const action of list) checkAction(action, seen);
98
+ // A focused button keeps the focus across the rebuild.
99
+ const focused = element.contains(doc.activeElement) ? doc.activeElement.dataset.action : null;
100
+ actions = list.slice();
101
+ buttons = new Map(actions.map((action) => [action.id, build(action)]));
102
+ element.replaceChildren(...buttons.values());
103
+ element.hidden = actions.length === 0;
104
+ refresh();
105
+ if (focused && buttons.has(focused) && !buttons.get(focused).disabled) buttons.get(focused).focus({ preventScroll: true });
106
+ }
107
+ setActions(options.actions);
108
+
109
+ return {
110
+ element,
111
+ setActions,
112
+ refresh,
113
+ setDensity(next) {
114
+ density = next === "touch" ? "touch" : "comfortable";
115
+ element.dataset.density = density;
116
+ },
117
+ setDisabled(next) {
118
+ disabled = Boolean(next);
119
+ refresh();
120
+ },
121
+ /** `[{ id: "actions:<id>", label, disabled, pressed?, run }]`, one per action, in order. */
122
+ menuItems() {
123
+ return actions.map((action) => {
124
+ const pressed = isPressed(action);
125
+ return { id: `actions:${action.id}`, label: action.label, disabled: disabled || !isEnabled(action), ...(pressed === undefined ? {} : { pressed }), run: () => run(action) };
126
+ });
127
+ },
128
+ destroy() {
129
+ if (destroyed) return;
130
+ destroyed = true;
131
+ element.remove();
132
+ buttons.clear();
133
+ actions = [];
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,113 @@
1
+ import type { OpfIcons } from "./icons.js";
2
+ /**
3
+ * CE-28: the editor's title bar. Named items in order, each in its own slot element; when the row does not fit the width of the bar (its
4
+ * container decides, not the window), the collapsible items are hidden lowest priority first and what they offer is listed in the `more` (⋯)
5
+ * menu, built from each mounted part's `menuItems()`. `createOpfEditor` builds one for its default layouts (`toolbar` option); a host can build
6
+ * its own and mount the editor's parts in its slots. CSS: `src/styles/toolbar.css`, classes `opf-tb-*`.
7
+ */
8
+
9
+ /** The item names. `zoom` is the Studio's own (the workspace leaves it out). */
10
+ export type ToolbarItemName =
11
+ | "title"
12
+ | "history"
13
+ | "counter"
14
+ | "views"
15
+ | "find"
16
+ | "zoom"
17
+ | "design"
18
+ | "review"
19
+ | "autosave"
20
+ | "export"
21
+ | "present"
22
+ | "expand"
23
+ | "actions"
24
+ | "help"
25
+ | "more"
26
+ | "spacer"
27
+ | "|";
28
+
29
+ /** An item name, or the name of a host's own item. */
30
+ export type ToolbarItemId = ToolbarItemName | (string & {});
31
+
32
+ /**
33
+ * An item by name, or with its own priority (higher stays visible longer) and whether it may move into the more menu. A host's own item is
34
+ * `{ id, priority }` with a name that is not one of the above (lowercase letters, digits and dashes): its slot starts empty, the host fills it
35
+ * (`slot(id)`) and registers what it offers in the more menu with `mount(id, part)`. A bare unknown name throws, so a misspelt item is caught.
36
+ */
37
+ export type ToolbarItemSpec =
38
+ | ToolbarItemName
39
+ | { id: ToolbarItemName; priority?: number; collapsible?: boolean }
40
+ | { id: string; priority: number; collapsible?: boolean };
41
+
42
+ export interface ToolbarItem {
43
+ id: ToolbarItemId;
44
+ priority: number;
45
+ collapsible: boolean;
46
+ }
47
+
48
+ /** The defaults of every item: `more` and `title` stay (the title shrinks instead), `spacer` and `|` are layout. */
49
+ export declare const TOOLBAR_ITEMS: Readonly<Record<ToolbarItemName, Readonly<{ priority: number; collapsible: boolean }>>>;
50
+
51
+ /** The items with their priorities. Throws on a name that is not an item (or a host item), and on a named item given twice (`spacer` and `|` may repeat). */
52
+ export declare function normalizeToolbarItems(items: readonly ToolbarItemSpec[]): ToolbarItem[];
53
+
54
+ /** One entry of the more menu. `pressed` makes it a checkbox item (`aria-checked`); a `disabled` item is listed but does not run. */
55
+ export interface ToolbarMenuItem {
56
+ id?: string;
57
+ label: string;
58
+ disabled?: boolean;
59
+ pressed?: boolean;
60
+ run(): void;
61
+ }
62
+
63
+ /** What a part registers with `mount`: the part contract's `menuItems()` is all the toolbar reads. */
64
+ export interface ToolbarPart {
65
+ menuItems?(): ToolbarMenuItem[];
66
+ [key: string]: unknown;
67
+ }
68
+
69
+ export interface ToolbarOptions {
70
+ items?: readonly ToolbarItemSpec[];
71
+ /** `"comfortable"` (default) or `"touch"`; under `(pointer: coarse)` the trigger and the menu items are 44px either way. */
72
+ density?: "comfortable" | "touch";
73
+ /** `more`: the name of the ⋯ button (default "More"); `menu`: the name of its menu (default "More actions"). */
74
+ labels?: { more?: string; menu?: string };
75
+ /** Replace the ⋯ glyph: `more` (`.opf-tb-more-icon`). See `OpfIcon`. */
76
+ icons?: OpfIcons<"more">;
77
+ /** The set of collapsed items changed. */
78
+ onCollapse?(detail: { collapsed: ToolbarItemId[] }): void;
79
+ /** A part's `menuItems()` or an item's `run()` threw. */
80
+ onError?(error: unknown): void;
81
+ }
82
+
83
+ export interface Toolbar {
84
+ /** The row (`.opf-tb`), appended to the container. */
85
+ readonly element: HTMLElement;
86
+ readonly items: ToolbarItem[];
87
+ /** One element per named item (`data-opf-toolbar-item`), in bar order, host items included. Mount a part in it, then `mount(name, part)`. */
88
+ readonly slots: Partial<Record<ToolbarItemId, HTMLElement>>;
89
+ slot(name: ToolbarItemId): HTMLElement | undefined;
90
+ /** The items in the more menu now, in bar order. */
91
+ readonly collapsed: ToolbarItemId[];
92
+ readonly menuOpen: boolean;
93
+ /** Change an item's priority or whether it collapses; false for an item that is not in the bar (or `more`, `spacer`, `|`). */
94
+ setItem(name: ToolbarItemId, change: { priority?: number; collapsible?: boolean }): boolean;
95
+ /** Register the part mounted in a slot, for its menu items. Returns the function that unregisters it. */
96
+ mount(name: ToolbarItemId, part: ToolbarPart): () => void;
97
+ /** What the more menu would list now, one group per collapsed part. */
98
+ menuItems(): { id: ToolbarItemId; items: ToolbarMenuItem[] }[];
99
+ /** Fit the row now (it also fits itself on the next frame after a resize). Returns the collapsed items. */
100
+ update(): ToolbarItemId[];
101
+ /** Open the more menu and focus its first (or last) item. False when there is nothing to list. */
102
+ openMenu(which?: "first" | "last"): boolean;
103
+ closeMenu(options?: { restoreFocus?: boolean }): void;
104
+ setDensity(density: "comfortable" | "touch"): void;
105
+ /** Remove the row, its observers and listeners. Safe twice. */
106
+ destroy(): void;
107
+ }
108
+
109
+ /**
110
+ * Build a toolbar in `container`. The menu is a `role="menu"`: Arrow keys, Home and End move, Enter and Space run an item, Escape closes it and
111
+ * returns the focus to the ⋯ button, Tab leaves it.
112
+ */
113
+ export declare function createToolbar(container: HTMLElement, options?: ToolbarOptions): Toolbar;