@openpresentation/opf-editor 0.29.1 → 0.30.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,462 @@
1
+ // CE-29: the Design quick menu. A toolbar button that opens a compact popover with the deck-level design choices people reach for most: Theme,
2
+ // Colors (color scheme), Fonts (font scheme) and, for the current slide, its Layout. It is for small embeds that have no room for the full
3
+ // Design panel (`design-controls.js`); a host that has one passes `onOpenPanel` and the menu adds a "More design options…" row for it.
4
+ //
5
+ // The choices come from the same place the panel's pickers do: core's catalog records through `listSwitchOptions` / `currentSwitchValue`, and every
6
+ // choice is applied with `switchDimension` (`switches.js`) as ONE validated, undoable edit with `meta.source: "design-menu"`. Nothing else writes the
7
+ // document. The popover is a `role="dialog"` holding a `role="menu"` whose rows are `menuitemradio` (arrow keys move, Enter or Space applies), it stays
8
+ // open after a choice so people can try several, and on a phone (`(max-width: 600px)`) it is a bottom sheet. Plain DOM; styles in
9
+ // src/styles/design-menu.css (classes `opf-design-menu-*`); touch sizes in touch.css.
10
+ import { currentSwitchValue, listSwitchOptions, switchDimension } from "./switches.js";
11
+ import { normalizeIcons, resolveIcon } from "./icons.js";
12
+
13
+ const SHEET_QUERY = "(max-width: 600px)";
14
+ const GAP = 4;
15
+ const MARGIN = 8;
16
+ const SOURCE = "design-menu";
17
+
18
+ const SECTIONS = Object.freeze([
19
+ { key: "theme", dimension: "themes", labelKey: "theme" },
20
+ { key: "colors", dimension: "color-schemes", labelKey: "colors" },
21
+ { key: "fonts", dimension: "font-schemes", labelKey: "fonts" },
22
+ { key: "layout", dimension: "layouts", labelKey: "layout", slide: true },
23
+ ]);
24
+
25
+ const DEFAULT_LABELS = {
26
+ /** The visible text of the toolbar button. */
27
+ trigger: "Design",
28
+ /** The accessible name of the popover (the sheet's title on a phone). */
29
+ title: "Design",
30
+ theme: "Theme",
31
+ colors: "Colors",
32
+ fonts: "Fonts",
33
+ layout: "This slide: Layout",
34
+ more: "More design options…",
35
+ /** What the toolbar's overflow menu lists. */
36
+ menuItem: "Design…",
37
+ close: "Close",
38
+ empty: "No design choices are available. Register a catalog to offer themes, colors, fonts and layouts.",
39
+ /** `kind` is "theme", "colors", "fonts" or "layout". */
40
+ applied: (kind, name) => `${{ theme: "Theme", colors: "Colors", fonts: "Fonts", layout: "Layout" }[kind] ?? kind} set to ${name}.`,
41
+ already: (name) => `${name} is already set. Nothing changed.`,
42
+ shadowed: (count) => `${count === 1 ? "1 slide keeps" : `${count} slides keep`} its own.`,
43
+ };
44
+
45
+ const ICONS = {
46
+ Palette: [
47
+ ["circle", { cx: "13.5", cy: "6.5", r: ".5", fill: "currentColor" }],
48
+ ["circle", { cx: "17.5", cy: "10.5", r: ".5", fill: "currentColor" }],
49
+ ["circle", { cx: "8.5", cy: "7.5", r: ".5", fill: "currentColor" }],
50
+ ["circle", { cx: "6.5", cy: "12.5", r: ".5", fill: "currentColor" }],
51
+ ["path", { d: "M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" }],
52
+ ],
53
+ Check: [["path", { d: "M20 6 9 17l-5-5" }]],
54
+ X: [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]],
55
+ };
56
+ // The built-in icon behind each `icons` name.
57
+ const ICON_KEYS = { Palette: "trigger", X: "close", Check: "check" };
58
+ const ICON_NAMES = Object.values(ICON_KEYS);
59
+ let counter = 0;
60
+
61
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
62
+ const HEX = /^#[0-9a-f]{3,8}$/i;
63
+
64
+ function el(doc, tag, props = {}, ...children) {
65
+ const node = doc.createElement(tag);
66
+ for (const [key, value] of Object.entries(props)) {
67
+ if (value === undefined || value === null || value === false) continue;
68
+ if (key === "class") node.className = value;
69
+ else if (key === "text") node.textContent = value;
70
+ else node.setAttribute(key, value === true ? "" : String(value));
71
+ }
72
+ node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
73
+ return node;
74
+ }
75
+
76
+ // An inline Lucide icon (ISC license; the path data is inlined, nothing is fetched). Decorative: the control that holds it carries the name.
77
+ // `given` is the host's replacement from `options.icons` (see src/icons.js); the part's own names are trigger, close and check.
78
+ function icon(doc, name, size, given) {
79
+ return resolveIcon(doc, given, ICONS[name], { name: ICON_KEYS[name], size });
80
+ }
81
+
82
+ const idOf = (reference) => (isObject(reference) ? reference.id : reference);
83
+ const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
84
+
85
+ /**
86
+ * Mount the Design quick menu in `container`. See `design-menu.d.ts` for every option. Returns
87
+ * `{ element, trigger, setDensity, setDisabled, setReadOnly, refresh, open, close, isOpen, menuItems, destroy }`.
88
+ */
89
+ export function createDesignMenu(container, options = {}) {
90
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
91
+ const { editor } = options;
92
+ if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw new TypeError("createDesignMenu needs an editor session.");
93
+ const doc = container.ownerDocument;
94
+ const win = doc.defaultView;
95
+ const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
96
+ const icons = normalizeIcons(options.icons, ICON_NAMES, "createDesignMenu icons");
97
+ const id = `opf-design-menu-${++counter}`;
98
+ let density = options.density === "touch" ? "touch" : "comfortable";
99
+ let readOnly = options.readOnly === true;
100
+ let disabled = false;
101
+ let isOpen = false;
102
+ let destroyed = false;
103
+ let mode = "popover";
104
+ let opener = null;
105
+ let typed = "";
106
+ let typedTimer;
107
+
108
+ const catalogsNow = () => (typeof options.catalogs === "function" ? options.catalogs() : (options.catalogs ?? editor.catalogs));
109
+ const slideNow = () => {
110
+ const value = Number(options.getSlideIndex?.());
111
+ return Number.isInteger(value) && value >= 0 ? value : 0;
112
+ };
113
+ const deckNow = () => editor.snapshot?.().presentation ?? editor.presentation;
114
+ const say = (message) => {
115
+ live.textContent = "";
116
+ live.textContent = message;
117
+ options.onStatus?.(message);
118
+ };
119
+
120
+ // ---- the DOM ------------------------------------------------------------------------------------------------------------------------
121
+ const trigger = el(doc, "button", { type: "button", class: "opf-design-menu-trigger", "aria-haspopup": "dialog", "aria-expanded": "false", "aria-controls": `${id}-popover` },
122
+ icon(doc, "Palette", 14, icons.trigger), el(doc, "span", { class: "opf-design-menu-trigger-label", text: labels.trigger }));
123
+ const closeButton = el(doc, "button", { type: "button", class: "opf-design-menu-close", "aria-label": labels.close }, icon(doc, "X", 18, icons.close));
124
+ const head = el(doc, "div", { class: "opf-design-menu-head" }, el(doc, "span", { class: "opf-design-menu-title", text: labels.title }), closeButton);
125
+ const menu = el(doc, "div", { class: "opf-design-menu-menu", role: "menu", "aria-label": labels.title, id: `${id}-menu` });
126
+ const empty = el(doc, "p", { class: "opf-design-menu-empty", text: labels.empty, hidden: true });
127
+ const moreRow = el(doc, "button", { type: "button", class: "opf-design-menu-item opf-design-menu-more", role: "menuitem", tabindex: "-1", text: labels.more, hidden: typeof options.onOpenPanel !== "function" });
128
+ menu.append(empty);
129
+ const popover = el(doc, "div", { class: "opf-design-menu-popover", role: "dialog", "aria-label": labels.title, id: `${id}-popover`, hidden: true },
130
+ el(doc, "div", { class: "opf-design-menu-handle", "aria-hidden": "true" }), head, menu);
131
+ const scrim = el(doc, "div", { class: "opf-design-menu-scrim", hidden: true });
132
+ const live = el(doc, "div", { class: "opf-design-menu-live", role: "status", "aria-live": "polite", "aria-atomic": "true" });
133
+ const root = el(doc, "div", { class: "opf-design-menu opf-ui", "data-density": density, "data-mode": mode }, trigger, popover, scrim, live);
134
+ container.append(root);
135
+
136
+ // One entry per section: its heading, list and rows (by option id), rebuilt only when the choices themselves change.
137
+ const sections = SECTIONS.map((spec) => {
138
+ const heading = el(doc, "div", { class: "opf-design-menu-group-label", id: `${id}-${spec.key}`, text: labels[spec.labelKey] });
139
+ const list = el(doc, "div", { class: "opf-design-menu-list" });
140
+ const group = el(doc, "div", { class: "opf-design-menu-group", role: "group", "aria-labelledby": heading.id, "data-section": spec.key, hidden: true }, heading, list);
141
+ menu.append(group);
142
+ return { ...spec, group, list, rows: new Map(), signature: undefined };
143
+ });
144
+ menu.append(moreRow);
145
+
146
+ // ---- rows ---------------------------------------------------------------------------------------------------------------------------
147
+ const cssColor = (value) => (typeof value === "string" && HEX.test(value) ? value : undefined);
148
+ function swatches(record, slots) {
149
+ const colors = slots.map((slot) => cssColor(record?.[slot])).filter(Boolean);
150
+ if (!colors.length) return null;
151
+ const strip = el(doc, "span", { class: "opf-design-menu-swatches", "aria-hidden": "true" });
152
+ for (const color of colors) {
153
+ const chip = el(doc, "span", { class: "opf-design-menu-swatch" });
154
+ chip.style.backgroundColor = color;
155
+ strip.append(chip);
156
+ }
157
+ return strip;
158
+ }
159
+ const schemeRecords = new Map();
160
+ const loadedFamilies = () => {
161
+ const families = new Set();
162
+ try {
163
+ for (const face of doc.fonts ?? []) if (face.status === "loaded") families.add(String(face.family).replace(/^["']|["']$/g, ""));
164
+ } catch { /* an old engine without iterable FontFaceSet: names stay plain */ }
165
+ return families;
166
+ };
167
+ // A font scheme's name in its own heading face, once that face is loaded in this document. Otherwise the name stays in the UI font.
168
+ function paintFaces() {
169
+ const fonts = sections.find((section) => section.key === "fonts");
170
+ const loaded = loadedFamilies();
171
+ for (const row of fonts.rows.values()) {
172
+ const family = row.dataset.major;
173
+ if (family && loaded.has(family)) row.querySelector(".opf-design-menu-label").style.fontFamily = `"${family.replace(/["\\]/g, "")}", var(--opf-ui-font)`;
174
+ else row.querySelector(".opf-design-menu-label").style.removeProperty("font-family");
175
+ }
176
+ }
177
+
178
+ function buildRow(section, option) {
179
+ const record = isObject(option.record) ? option.record : {};
180
+ let hint = null;
181
+ if (section.key === "colors") hint = swatches(record, ["accent1", "accent2", "accent3", "accent4"]);
182
+ else if (section.key === "theme") hint = swatches(schemeRecords.get(idOf(record.colorScheme)), ["accent1", "accent2", "accent3"]);
183
+ const tip = [record.summary, record.description].find((text) => typeof text === "string" && text) ?? "";
184
+ const row = el(doc, "button", { type: "button", class: "opf-design-menu-item", role: "menuitemradio", "aria-checked": "false", tabindex: "-1", "data-section": section.key, "data-value": option.id, title: tip || false },
185
+ hint, el(doc, "span", { class: "opf-design-menu-label", text: option.label }), el(doc, "span", { class: "opf-design-menu-check" }, icon(doc, "Check", 14, icons.check)));
186
+ if (section.key === "fonts" && typeof record.major === "string") row.dataset.major = record.major;
187
+ row.addEventListener("click", () => choose(section, option));
188
+ return row;
189
+ }
190
+
191
+ // The current value of a section. An unset color or font scheme is the one the theme brings.
192
+ function currentOf(deck, section, options) {
193
+ if (section.key === "layout") return currentSwitchValue(deck, "layouts", { slideIndex: slideNow(), catalogs: catalogsNow() }).value;
194
+ const own = currentSwitchValue(deck, section.dimension, { catalogs: catalogsNow() }).value;
195
+ if (own !== undefined || section.key === "theme") return own;
196
+ const theme = currentSwitchValue(deck, "themes", { catalogs: catalogsNow() }).value;
197
+ const record = theme === undefined ? undefined : options.themes.find((entry) => entry.id === theme)?.record;
198
+ return idOf(section.key === "colors" ? record?.colorScheme : record?.fontScheme);
199
+ }
200
+
201
+ function paint() {
202
+ if (destroyed) return;
203
+ const deck = deckNow();
204
+ const catalogs = catalogsNow();
205
+ const lists = {};
206
+ for (const section of sections) {
207
+ lists[section.key] = listSwitchOptions(deck, section.dimension, { catalogs });
208
+ if (section.key === "colors") for (const entry of lists.colors) schemeRecords.set(entry.id, entry.record);
209
+ }
210
+ // Colors are read before the theme row is drawn, so its swatches come from the scheme it brings.
211
+ const focused = popover.contains(doc.activeElement) ? doc.activeElement : null;
212
+ const focusedKey = focused?.dataset?.section ? `${focused.dataset.section}|${focused.dataset.value}` : focused === moreRow ? "more" : null;
213
+ const hasSlides = Array.isArray(deck?.slides) && deck.slides.length > 0;
214
+ let any = false;
215
+ for (const section of sections) {
216
+ const entries = lists[section.key];
217
+ const visible = entries.length > 0 && (!section.slide || hasSlides);
218
+ section.group.hidden = !visible;
219
+ if (!visible) continue;
220
+ any = true;
221
+ const signature = JSON.stringify(entries.map((entry) => [entry.id, entry.label, section.key === "theme" ? idOf(entry.record?.colorScheme) : undefined]));
222
+ if (signature !== section.signature) {
223
+ section.signature = signature;
224
+ section.rows = new Map(entries.map((entry) => [entry.id, buildRow(section, entry)]));
225
+ section.list.replaceChildren(...section.rows.values());
226
+ }
227
+ const current = currentOf(deck, section, { themes: lists.theme });
228
+ for (const [value, row] of section.rows) row.setAttribute("aria-checked", String(value === current));
229
+ section.current = current;
230
+ }
231
+ empty.hidden = any;
232
+ paintFaces();
233
+ root.toggleAttribute("data-empty", !any);
234
+ if (focusedKey && popover.contains(doc.activeElement) === false) {
235
+ const row = focusedKey === "more" ? moreRow : [...menu.querySelectorAll("[data-value]")].find((item) => `${item.dataset.section}|${item.dataset.value}` === focusedKey);
236
+ row?.focus({ preventScroll: true });
237
+ }
238
+ }
239
+
240
+ // ---- applying a choice ---------------------------------------------------------------------------------------------------------------
241
+ function choose(section, option) {
242
+ if (destroyed || disabled || readOnly) return;
243
+ // The checked row is what is in effect: choosing it again changes nothing (a theme would otherwise reset its bundled colors and fonts).
244
+ if (option.id === section.current) {
245
+ say(labels.already(option.label));
246
+ return;
247
+ }
248
+ try {
249
+ const change = switchDimension(editor, section.dimension, option.id, {
250
+ catalogs: catalogsNow(),
251
+ ...(section.slide ? { slideIndex: slideNow() } : {}),
252
+ meta: { source: SOURCE },
253
+ });
254
+ if (change.changed === false) say(labels.already(option.label));
255
+ else say(`${labels.applied(section.key, option.label)}${change.shadowed?.length ? ` ${labels.shadowed(change.shadowed.length)}` : ""}`);
256
+ } catch (error) {
257
+ say(error?.message ?? String(error));
258
+ }
259
+ if (isOpen) paint();
260
+ }
261
+
262
+ // ---- open, close, position ---------------------------------------------------------------------------------------------------------
263
+ const media = win?.matchMedia?.(SHEET_QUERY);
264
+ // A button that is hidden (the toolbar moved it into its overflow menu) cannot anchor a popover, and a fixed child of a hidden parent is not drawn either.
265
+ const anchored = () => trigger.getClientRects().length > 0 && win.getComputedStyle(trigger).visibility !== "hidden";
266
+ // The popover then borrows a surface of its own on the page body, and goes back into the part when it closes.
267
+ let portal;
268
+ function usePortal(on) {
269
+ if (on && !portal) {
270
+ portal = el(doc, "div", { class: "opf-design-menu opf-ui", "data-density": density, "data-mode": "sheet" });
271
+ const themed = root.closest("[data-theme]");
272
+ if (themed) portal.dataset.theme = themed.dataset.theme;
273
+ if (root.closest(".dark")) portal.classList.add("dark");
274
+ doc.body.append(portal);
275
+ portal.append(popover, scrim, live);
276
+ } else if (!on && portal) {
277
+ root.append(popover, scrim, live);
278
+ portal.remove();
279
+ portal = undefined;
280
+ }
281
+ }
282
+ function setMode() {
283
+ mode = media?.matches || !anchored() ? "sheet" : "popover";
284
+ (portal ?? root).dataset.mode = mode;
285
+ root.dataset.mode = mode;
286
+ popover.setAttribute("aria-modal", String(mode === "sheet"));
287
+ scrim.hidden = !(isOpen && mode === "sheet");
288
+ }
289
+ function place() {
290
+ if (!isOpen) return;
291
+ setMode();
292
+ if (mode === "sheet") {
293
+ for (const property of ["left", "top", "max-height"]) popover.style.removeProperty(property);
294
+ return;
295
+ }
296
+ popover.style.removeProperty("max-height");
297
+ const viewWidth = win.innerWidth;
298
+ const viewHeight = win.innerHeight;
299
+ const anchor = trigger.getBoundingClientRect();
300
+ const width = popover.offsetWidth;
301
+ const natural = popover.offsetHeight;
302
+ const below = viewHeight - anchor.bottom - GAP - MARGIN;
303
+ const above = anchor.top - GAP - MARGIN;
304
+ const flip = natural > below && above > below;
305
+ const room = Math.max(120, Math.min(flip ? above : below, viewHeight - 2 * MARGIN));
306
+ const height = Math.min(natural, room);
307
+ const rtl = win.getComputedStyle(trigger).direction === "rtl";
308
+ const left = clamp(rtl ? anchor.right - width : anchor.left, MARGIN, Math.max(MARGIN, viewWidth - width - MARGIN));
309
+ popover.style.left = `${left}px`;
310
+ popover.style.top = `${flip ? anchor.top - GAP - height : anchor.bottom + GAP}px`;
311
+ if (natural > room) popover.style.maxHeight = `${room}px`;
312
+ }
313
+ const items = () => [...menu.querySelectorAll("[role^='menuitem']")].filter((item) => !item.closest("[hidden]") && !item.hidden);
314
+ function focusItem(item) {
315
+ item?.focus();
316
+ item?.scrollIntoView?.({ block: "nearest" });
317
+ }
318
+ const onDocPointer = (event) => {
319
+ if (!root.contains(event.target) && !portal?.contains(event.target)) close({ restoreFocus: false });
320
+ };
321
+ const onDocKey = (event) => {
322
+ if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing) {
323
+ event.preventDefault();
324
+ close();
325
+ }
326
+ };
327
+ const onReflow = () => place();
328
+ const onFaces = () => paintFaces();
329
+
330
+ function open() {
331
+ if (destroyed || disabled || readOnly || isOpen) return;
332
+ opener = doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : null;
333
+ isOpen = true;
334
+ usePortal(!anchored());
335
+ paint();
336
+ popover.hidden = false;
337
+ trigger.setAttribute("aria-expanded", "true");
338
+ place();
339
+ doc.addEventListener("pointerdown", onDocPointer, true);
340
+ doc.addEventListener("keydown", onDocKey);
341
+ win?.addEventListener("resize", onReflow);
342
+ win?.addEventListener("scroll", onReflow, true);
343
+ doc.fonts?.addEventListener?.("loadingdone", onFaces);
344
+ const current = menu.querySelector("[aria-checked='true']");
345
+ focusItem(current ?? items()[0] ?? closeButton);
346
+ }
347
+ function close({ restoreFocus = true } = {}) {
348
+ if (!isOpen) return;
349
+ isOpen = false;
350
+ popover.hidden = true;
351
+ scrim.hidden = true;
352
+ trigger.setAttribute("aria-expanded", "false");
353
+ doc.removeEventListener("pointerdown", onDocPointer, true);
354
+ doc.removeEventListener("keydown", onDocKey);
355
+ win?.removeEventListener("resize", onReflow);
356
+ win?.removeEventListener("scroll", onReflow, true);
357
+ doc.fonts?.removeEventListener?.("loadingdone", onFaces);
358
+ const returnTarget = anchored() && !trigger.disabled ? trigger : opener?.isConnected ? opener : null;
359
+ usePortal(false);
360
+ if (restoreFocus && !destroyed) {
361
+ // The button when it is on screen, else what had focus when the menu opened (it came from the toolbar's overflow menu).
362
+ returnTarget?.focus();
363
+ }
364
+ opener = null;
365
+ }
366
+
367
+ // ---- events -------------------------------------------------------------------------------------------------------------------------
368
+ trigger.addEventListener("click", () => (isOpen ? close() : open()));
369
+ trigger.addEventListener("keydown", (event) => {
370
+ if (event.key === "ArrowDown" && !isOpen && !event.altKey && !event.ctrlKey && !event.metaKey) {
371
+ event.preventDefault();
372
+ open();
373
+ }
374
+ });
375
+ closeButton.addEventListener("click", () => close());
376
+ scrim.addEventListener("click", () => close());
377
+ moreRow.addEventListener("click", () => {
378
+ close();
379
+ options.onOpenPanel?.();
380
+ });
381
+ popover.addEventListener("keydown", (event) => {
382
+ if (event.altKey || event.ctrlKey || event.metaKey || event.isComposing) return;
383
+ const list = items();
384
+ const index = list.indexOf(doc.activeElement);
385
+ let target;
386
+ if (event.key === "ArrowDown") target = list[index < 0 ? 0 : Math.min(list.length - 1, index + 1)];
387
+ else if (event.key === "ArrowUp") target = list[index < 0 ? list.length - 1 : Math.max(0, index - 1)];
388
+ else if (event.key === "Home") target = list[0];
389
+ else if (event.key === "End") target = list[list.length - 1];
390
+ else if (event.key === "Tab") {
391
+ // Tab leaves the menu: close and put focus on the button so the next Tab continues from it.
392
+ close();
393
+ return;
394
+ } else if (event.key.length === 1 && /\S/.test(event.key)) {
395
+ // Typeahead: the next row whose label starts with what was typed.
396
+ typed += event.key.toLowerCase();
397
+ win.clearTimeout(typedTimer);
398
+ typedTimer = win.setTimeout(() => { typed = ""; }, 600);
399
+ const from = typed.length === 1 ? index + 1 : Math.max(index, 0);
400
+ const ordered = [...list.slice(from), ...list.slice(0, from)];
401
+ target = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(typed));
402
+ if (!target) return;
403
+ } else return;
404
+ event.preventDefault();
405
+ if (target) focusItem(target);
406
+ });
407
+
408
+ const unsubscribe = editor.subscribe(() => {
409
+ if (destroyed) return;
410
+ if (isOpen) paint();
411
+ });
412
+ const onMedia = () => place();
413
+ media?.addEventListener?.("change", onMedia);
414
+ syncDisabled();
415
+
416
+ function syncDisabled() {
417
+ const off = disabled || readOnly;
418
+ trigger.disabled = off;
419
+ root.toggleAttribute("data-disabled", off);
420
+ if (off && isOpen) close({ restoreFocus: false });
421
+ }
422
+
423
+ return {
424
+ element: root,
425
+ trigger,
426
+ setDensity(value) {
427
+ if (destroyed) return;
428
+ density = value === "touch" ? "touch" : "comfortable";
429
+ root.dataset.density = density;
430
+ if (portal) portal.dataset.density = density;
431
+ if (isOpen) place();
432
+ },
433
+ setDisabled(value) {
434
+ if (destroyed) return;
435
+ disabled = Boolean(value);
436
+ syncDisabled();
437
+ },
438
+ setReadOnly(value) {
439
+ if (destroyed) return;
440
+ readOnly = Boolean(value);
441
+ syncDisabled();
442
+ },
443
+ /** Read the document and the catalogs again (the menu follows the editor session on its own while it is open). */
444
+ refresh() {
445
+ if (destroyed) return;
446
+ if (isOpen) { paint(); place(); }
447
+ },
448
+ open,
449
+ close,
450
+ isOpen: () => isOpen,
451
+ menuItems: () => [{ id: "design", label: labels.menuItem, disabled: destroyed || disabled || readOnly, run: open }],
452
+ destroy() {
453
+ if (destroyed) return;
454
+ close({ restoreFocus: false });
455
+ destroyed = true;
456
+ unsubscribe?.();
457
+ media?.removeEventListener?.("change", onMedia);
458
+ win?.clearTimeout(typedTimer);
459
+ root.remove();
460
+ },
461
+ };
462
+ }
@@ -0,0 +1,102 @@
1
+ import type { OpfIcons } from "./icons.js";
2
+ export interface ExpandLabels {
3
+ /** The accessible name of the button while the editor is not expanded. Default: "Expand editor". */
4
+ expand?: string;
5
+ /** The accessible name of the button while the editor is expanded. Default: "Exit expanded editor". */
6
+ collapse?: string;
7
+ /** The key named in the tooltip of the expanded button, "Exit expanded editor (Esc)". Default: "Esc". */
8
+ escape?: string;
9
+ /** The status line (`onStatus`) when the editor expands. Default: "Editor expanded. Press Escape to exit.". */
10
+ expanded?: string;
11
+ /** The status line (`onStatus`) when it returns. Default: "Editor restored.". */
12
+ collapsed?: string;
13
+ }
14
+
15
+ export interface ExpandControllerOptions {
16
+ /** Called after the editor expands or returns, however that happened (the button, `expand()`, Escape, the browser leaving full screen, another editor expanding). Not called by `destroy()`. */
17
+ onChange?: (change: { expanded: boolean }) => void;
18
+ /**
19
+ * Also ask the browser for full screen on the target (best effort). It is refused without a user gesture, in an iframe without
20
+ * `allow="fullscreen"` and on iPhone Safari; the fixed overlay is then the expanded editor, silently. When the browser leaves full screen by
21
+ * itself (Esc, the host), the editor collapses. Default false.
22
+ */
23
+ fullscreen?: boolean;
24
+ labels?: ExpandLabels;
25
+ /**
26
+ * While expanded, make everything on the page outside the target `inert` (default true), so Tab and screen readers stay inside the editor. It is
27
+ * applied to the siblings of the target and of each ancestor up to `body`; elements that are already `inert`, `script`, `style`, `template` and a
28
+ * mounted presenter (`.opf-presenter`) are skipped, and exactly the elements marked are restored on collapse, destroy and hand-over to another editor.
29
+ * It is a snapshot: an element added to the page while expanded (a menu appended to `body`) is not inert.
30
+ */
31
+ inertOutside?: boolean;
32
+ /**
33
+ * Elements that must stay interactive while the rest of the page is inert: for example a toolbar the host mounted outside the target
34
+ * (`slots`). An array, or a function returning one (read each time the editor expands). A container of a kept element is not made inert; its
35
+ * other children are.
36
+ */
37
+ keep?: Element[] | (() => Iterable<Element> | null | undefined);
38
+ /** Screen-reader status lines for the two transitions (`labels.expanded`, `labels.collapsed`). */
39
+ onStatus?: (message: string) => void;
40
+ }
41
+
42
+ export interface ExpandController {
43
+ /** True while the editor is expanded. */
44
+ readonly expanded: boolean;
45
+ /** Expand (default) or, with `false`, collapse. Returns the resulting state: true when the editor is now expanded. */
46
+ expand(on?: boolean): boolean;
47
+ /** Return the editor to its place. Returns the resulting state (false). */
48
+ collapse(): boolean;
49
+ /** Returns the resulting state. */
50
+ toggle(): boolean;
51
+ /** Collapses if expanded (restoring the attribute, scroll, focus and full screen) without calling `onChange`, and removes every listener. Safe twice. */
52
+ destroy(): void;
53
+ }
54
+
55
+ /**
56
+ * Make `target` (the editor's host element) fill the viewport for editing. Not the presenter: this edits, it does not present.
57
+ *
58
+ * Expanding sets `data-opf-expanded` on `target` (`src/styles/expand.css` turns it into a fixed overlay; `--opf-ui-z-overlay` sets its z-index, default
59
+ * 1000; the older `--opf-expand-z` still wins when set), locks page scroll on the document element (the previous inline `overflow` is restored exactly), makes the rest of the page `inert` (`inertOutside`, `keep`),
60
+ * moves the focus into `target` and listens for Escape. The element is never moved in the DOM, so the editor keeps its state. Collapsing returns the focus to the control that expanded.
61
+ *
62
+ * Escape collapses unless the event was already handled (`defaultPrevented`), the focus is in a text input, textarea, select, contenteditable,
63
+ * CodeMirror (`.cm-editor`), an open menu, listbox, combobox or dialog, or the presenter is running (`.opf-presenter:not([hidden])`).
64
+ *
65
+ * Works in iframes (`target.ownerDocument`). Only one controller per document is expanded at a time: expanding another collapses the first.
66
+ */
67
+ export declare function createExpandController(target: HTMLElement, options?: ExpandControllerOptions): ExpandController;
68
+
69
+ export interface ExpandButtonOptions {
70
+ /** The controller the button drives and follows. Required. */
71
+ controller: ExpandController;
72
+ /** `"comfortable"` (32px, default) or `"touch"` (40px). A coarse pointer makes the button 44px either way. */
73
+ density?: "comfortable" | "touch";
74
+ /** Overrides the controller's labels (`expand`, `collapse`, `escape`). */
75
+ labels?: ExpandLabels;
76
+ /** Replace the button's icons: `expand` (shown while the editor is not expanded) and `collapse` (while it is). See `OpfIcon`. */
77
+ icons?: OpfIcons<"expand" | "collapse">;
78
+ }
79
+
80
+ export interface ExpandMenuItem {
81
+ id: "expand";
82
+ label: string;
83
+ pressed: boolean;
84
+ disabled?: boolean;
85
+ run(): void;
86
+ }
87
+
88
+ export interface ExpandButton {
89
+ readonly element: HTMLButtonElement;
90
+ setDensity(density: "comfortable" | "touch"): void;
91
+ /** A disabled button stays focusable (`aria-disabled`) and cannot expand the editor; it can always exit an expanded one. */
92
+ setDisabled(disabled: boolean): void;
93
+ /** The toolbar overflow menu's entry: one toggle item. */
94
+ menuItems(): ExpandMenuItem[];
95
+ destroy(): void;
96
+ }
97
+
98
+ /**
99
+ * The expand button: an icon-only toggle (Lucide Maximize2 / Minimize2) with `aria-pressed`, the name "Expand editor" or "Exit expanded editor",
100
+ * and a tooltip that names Escape while expanded. It follows the controller. CSS: `src/styles/expand.css`, classes `opf-expand-*`.
101
+ */
102
+ export declare function createExpandButton(container: HTMLElement, options: ExpandButtonOptions): ExpandButton;