@openpresentation/opf-editor 0.29.0 → 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.
package/dist/icons.js ADDED
@@ -0,0 +1,88 @@
1
+ // CE-29: icons the host can replace. Every editor part that draws an icon builds it through `resolveIcon`, so a host that passes
2
+ // `icons: { <name>: <icon> }` swaps it for its own (an SVG element, a function returning one, or SVG path data) and the default stays
3
+ // otherwise. Plain DOM, no framework, no network. The built-in icons are Lucide paths (ISC license), inlined by the parts.
4
+ const SVG_NS = "http://www.w3.org/2000/svg";
5
+ // SVG path data: commands, numbers, separators. Anything else (markup, a URL) is not a path and is rejected.
6
+ const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9eE+\-.,\s]+$/;
7
+
8
+ const isSvgElement = (value) => Boolean(value) && typeof value === "object" && value.nodeType === 1 && String(value.localName).toLowerCase() === "svg";
9
+ const isPathData = (value) => typeof value === "string" && value.trim() !== "" && PATH_DATA.test(value);
10
+
11
+ function describeError(label) {
12
+ return `${label} must be an SVG element, a function returning one, or SVG path data (24x24, stroked, currentColor).`;
13
+ }
14
+
15
+ /**
16
+ * Check a part's `icons` option: an object whose keys are in `names` and whose values are an SVG element, a function, path data or null.
17
+ * Returns a plain copy ({} for undefined); throws a TypeError for a bad shape or value and a RangeError for a name the part does not have,
18
+ * so a typo does not silently do nothing.
19
+ */
20
+ export function normalizeIcons(icons, names, label = "icons") {
21
+ if (icons === undefined || icons === null) return {};
22
+ if (typeof icons !== "object" || Array.isArray(icons) || isSvgElement(icons)) throw new TypeError(`${label} must be an object of icon names and icons.`);
23
+ const out = {};
24
+ for (const [name, value] of Object.entries(icons)) {
25
+ if (!names.includes(name)) throw new RangeError(`${label}: "${name}" is not an icon of this part. Use one of ${names.join(", ")}.`);
26
+ if (value === undefined || value === null) continue;
27
+ if (typeof value !== "function" && !isSvgElement(value) && !isPathData(value)) throw new TypeError(describeError(`${label}.${name}`));
28
+ out[name] = value;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function decorate(svg, name, className) {
34
+ svg.classList.add("opf-icon");
35
+ if (className) for (const extra of String(className).split(/\s+/).filter(Boolean)) svg.classList.add(extra);
36
+ svg.setAttribute("aria-hidden", "true");
37
+ svg.setAttribute("focusable", "false");
38
+ if (name) svg.setAttribute("data-opf-icon", name);
39
+ return svg;
40
+ }
41
+
42
+ function builtin(doc, shapes) {
43
+ const svg = doc.createElementNS(SVG_NS, "svg");
44
+ svg.setAttribute("viewBox", "0 0 24 24");
45
+ svg.setAttribute("fill", "none");
46
+ svg.setAttribute("stroke", "currentColor");
47
+ svg.setAttribute("stroke-width", "2");
48
+ svg.setAttribute("stroke-linecap", "round");
49
+ svg.setAttribute("stroke-linejoin", "round");
50
+ for (const [tag, attributes] of shapes) {
51
+ const node = doc.createElementNS(SVG_NS, tag);
52
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));
53
+ svg.append(node);
54
+ }
55
+ return svg;
56
+ }
57
+
58
+ /**
59
+ * The icon for one place in a part, as `<svg class="opf-icon" aria-hidden="true" focusable="false">`.
60
+ *
61
+ * `given` is the host's choice: an SVG element (cloned into `doc`, never moved), a function that returns one (called with `doc`; it may also
62
+ * return path data), or SVG path data for a 24x24 icon drawn as a 2px round stroke in `currentColor`. `null` or `undefined` (or a function
63
+ * that returns nothing) means the default, `fallback`: path data, or a list of `[tag, attributes]` shapes for the few built-in icons that
64
+ * are more than one stroked path (a filled dot, a circle with a dash). With neither, the result is null.
65
+ *
66
+ * `options`: `name` (written as `data-opf-icon`, so CSS can target one icon), `className` (added after `opf-icon`, for the part's own class)
67
+ * and `size` (the `width` and `height` attributes, in px; a host element that sets its own keeps them, and the part's CSS overrides either).
68
+ * Throws a TypeError for a given value that is none of the three forms.
69
+ */
70
+ export function resolveIcon(doc, given, fallback, options = {}) {
71
+ const { name, className, size } = options;
72
+ let source = given;
73
+ if (typeof source === "function") source = source(doc);
74
+ const finish = (svg) => {
75
+ if (size !== undefined && !svg.hasAttribute("width") && !svg.hasAttribute("height")) {
76
+ svg.setAttribute("width", String(size));
77
+ svg.setAttribute("height", String(size));
78
+ }
79
+ return decorate(svg, name, className);
80
+ };
81
+ if (source !== undefined && source !== null) {
82
+ if (isSvgElement(source)) return finish(doc.importNode(source, true));
83
+ if (isPathData(source)) return finish(builtin(doc, [["path", { d: source }]]));
84
+ throw new TypeError(describeError("An icon"));
85
+ }
86
+ if (fallback === undefined || fallback === null) return null;
87
+ return finish(builtin(doc, typeof fallback === "string" ? [["path", { d: fallback }]] : fallback));
88
+ }
@@ -1,3 +1,4 @@
1
+ import type { OpfIcons } from "./icons.js";
1
2
  import type { EditorSession } from "./index.js";
2
3
  import type { ToSvgOptions } from "@openpresentation/opf-render";
3
4
  import type { EditorFonts } from "./canvas.js";
@@ -85,6 +86,8 @@ export interface PresentButtonOptions {
85
86
  label?: string;
86
87
  /** `"quiet"` (default): a transparent icon button like the toolbar's undo and redo. `"primary"`: the filled accent look of the Export trigger. */
87
88
  variant?: "quiet" | "primary";
89
+ /** Replace the icon: `present` (the slide on a stand). See `OpfIcon`. */
90
+ icons?: OpfIcons<"present">;
88
91
  }
89
92
 
90
93
  export interface PresentButton {
package/dist/presenter.js CHANGED
@@ -7,6 +7,7 @@ import { toSvg } from "@openpresentation/opf-render/svg";
7
7
  import { mergeCatalogs } from "./catalogs.js";
8
8
  import { el, icon } from "./export-dom.js";
9
9
  import { whenFontsReady } from "./font-gate.js";
10
+ import { normalizeIcons, resolveIcon } from "./icons.js";
10
11
  import { formatSlideCounter } from "./slide-navigator.js";
11
12
 
12
13
  const COUNTER_MS = 2000;
@@ -439,7 +440,7 @@ function isInteractiveTarget(target) {
439
440
  *
440
441
  * Options: `onPresent(event)`, `disabled` (`true`, or a string that says why and is the title), `density` (`"comfortable"` 32px, or `"touch"` 40px)
441
442
  * `label` (the accessible name, default "Present") and `variant` (`"quiet"`, the default: a transparent icon button like the toolbar's undo and redo,
442
- * or `"primary"`: the filled accent look of the Export trigger). A disabled button stays focusable (`aria-disabled`) and does nothing.
443
+ * or `"primary"`: the filled accent look of the Export trigger) and `icons` (`{ present }`, see `src/icons.js`). A disabled button stays focusable (`aria-disabled`) and does nothing.
443
444
  *
444
445
  * Returns `{ element, setDisabled(reason), setDensity(density), setVariant(variant), destroy() }`.
445
446
  */
@@ -447,12 +448,15 @@ export function createPresentButton(container, options = {}) {
447
448
  if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
448
449
  const doc = container.ownerDocument;
449
450
  const label = options.label ?? "Present";
451
+ const icons = normalizeIcons(options.icons, ["present"], "createPresentButton icons");
452
+ const glyph = resolveIcon(doc, icons.present, null, { name: "present", size: 16 }) ?? icon(doc, "Presentation", 16);
453
+ glyph.setAttribute("data-opf-icon", "present");
450
454
  let disabledReason = options.disabled === true ? "Nothing to present." : typeof options.disabled === "string" && options.disabled ? options.disabled : undefined;
451
455
  let destroyed = false;
452
456
  const button = el(doc, "button", {
453
457
  type: "button", class: "opf-present-button opf-ui", "aria-label": label, title: "Present (F5)", "aria-keyshortcuts": "F5",
454
458
  "data-density": options.density === "touch" ? "touch" : "comfortable", "data-variant": options.variant === "primary" ? "primary" : "quiet",
455
- }, icon(doc, "Presentation", 16));
459
+ }, glyph);
456
460
  const paint = () => {
457
461
  if (disabledReason) { button.setAttribute("aria-disabled", "true"); button.title = disabledReason; }
458
462
  else { button.removeAttribute("aria-disabled"); button.title = "Present (F5)"; }
package/dist/react.d.ts CHANGED
@@ -76,3 +76,12 @@ export declare function useOpfCommands(editor: OpfEditorHandle | null | undefine
76
76
  export declare function useOpfCursor(editor: OpfEditorHandle | null | undefined): SourceCursor | null;
77
77
 
78
78
  export type { OpfEditorHandle };
79
+
80
+ /**
81
+ * CE-28: the deck's name (`""` without one), read again on every `deckName` event, and its setter (`editor.setDeckName`: one undoable edit, an
82
+ * empty name removes it; false when it was refused). `["", () => false]` while `editor` is `null`.
83
+ */
84
+ export declare function useOpfDeckName(editor: OpfEditorHandle | null | undefined): [name: string, setName: (name: string) => boolean];
85
+
86
+ /** CE-28 wave 2: whether the editor has unsaved changes (the `dirty` event); false while there is no editor. */
87
+ export declare function useOpfDirty(editor: OpfEditorHandle | null | undefined): boolean;
package/dist/react.js CHANGED
@@ -257,3 +257,21 @@ export function useOpfCursor(editor) {
257
257
  const subscribe = useCallback(subscribeTo(editor, "cursor"), [editor]);
258
258
  return useSyncExternalStore(subscribe, () => (editor ? editor.sourceCursor : null), () => null);
259
259
  }
260
+
261
+ /** CE-28 wave 2: whether the editor has unsaved changes, following its `dirty` event. */
262
+ export function useOpfDirty(editor) {
263
+ const subscribe = useCallback(subscribeTo(editor, "dirty"), [editor]);
264
+ return useSyncExternalStore(subscribe, () => Boolean(editor?.dirty), () => false);
265
+ }
266
+
267
+ /**
268
+ * CE-28: the deck's name and a setter, `[name, setName]`. `name` is the document's `name` (`""` without one), read again on every `deckName`
269
+ * event (a rename from the title, the source pane, undo or the host); `setName(next)` is `editor.setDeckName(next)`: one undoable edit through
270
+ * the session (an empty name removes it), queued while the editor is not mounted yet. Returns `["", () => false]` while there is no editor.
271
+ */
272
+ export function useOpfDeckName(editor) {
273
+ const subscribe = useCallback(subscribeTo(editor, "deckName"), [editor]);
274
+ const name = useSyncExternalStore(subscribe, () => (editor ? editor.getDeckName() : ""), () => "");
275
+ const setName = useCallback((next) => (editor ? editor.setDeckName(next) : false), [editor]);
276
+ return [name, setName];
277
+ }
@@ -0,0 +1,83 @@
1
+ import type { OpfIcons } from "./icons.js";
2
+ /** What the workspace's `review` event carries (and the Review panel's `onChange`). */
3
+ export interface ReviewPillCounts {
4
+ error: number;
5
+ warning: number;
6
+ info: number;
7
+ /** Findings shown in the panel. 0 reads "Valid". */
8
+ total: number;
9
+ /** Every finding, shown or not. The pill reads `total`. */
10
+ all?: number;
11
+ sources?: string[];
12
+ /** True when the review could not run: the pill reads "Review unavailable" and is disabled. */
13
+ unavailable?: boolean;
14
+ }
15
+ /** What the pill shows, from `describeReview`. */
16
+ export interface ReviewPillView {
17
+ state: "checking" | "unavailable" | "valid" | "issues";
18
+ /** The most severe level present, or null. */
19
+ severity: "error" | "warning" | "info" | null;
20
+ total: number;
21
+ /** The visible text. */
22
+ text: string;
23
+ /** The non-zero severities, for example `["2 errors", "1 warning"]`. */
24
+ parts: string[];
25
+ }
26
+ export interface ReviewPillLabels {
27
+ /** Default: "Checking…". */
28
+ checking?: string;
29
+ /** Default: "Valid". */
30
+ valid?: string;
31
+ /** Default: "Review unavailable". */
32
+ unavailable?: string;
33
+ /** The visible text with findings. Default: "1 issue" / "3 issues". */
34
+ issues?: (total: number) => string;
35
+ /** One severity in the accessible name. Defaults: "1 error" / "2 errors". */
36
+ error?: (count: number) => string;
37
+ /** Default: "1 warning" / "2 warnings". */
38
+ warning?: (count: number) => string;
39
+ /** Default: "1 note" / "2 notes". */
40
+ info?: (count: number) => string;
41
+ /** The accessible name. Default: "Review: Valid", "Review: 3 issues (2 errors, 1 warning)", "Review unavailable". */
42
+ name?: (view: ReviewPillView) => string;
43
+ /** The overflow menu label. Default: "Review" or "Review (3)". */
44
+ menu?: (total: number) => string;
45
+ }
46
+ export interface ReviewPillOptions {
47
+ /** Called when the pill is pressed or its menu item runs. The workspace opens the Review panel here. Not called while the pill is disabled. */
48
+ onOpen?: () => void;
49
+ /** `"comfortable"` (32px, default) or `"touch"` (40px; 44px under a coarse pointer either way). */
50
+ density?: "comfortable" | "touch";
51
+ labels?: ReviewPillLabels;
52
+ /**
53
+ * Replace the glyph of a state: `valid` (no findings), `error`, `warning` and `info` (the most severe level present), `checking` (before the
54
+ * first update) and `unavailable` (the review could not run). See `OpfIcon`. The glyph is `.opf-review-pill-icon`; `data-opf-icon` names it.
55
+ */
56
+ icons?: OpfIcons<"valid" | "error" | "warning" | "info" | "checking" | "unavailable">;
57
+ }
58
+ export interface ReviewPillMenuItem {
59
+ id: "review";
60
+ label: string;
61
+ disabled: boolean;
62
+ run(): void;
63
+ }
64
+ export interface ReviewPill {
65
+ /** The status button. `data-state` is `checking`, `valid`, `issues` or `unavailable`; `data-severity` is `error`, `warning` or `info` with issues. */
66
+ readonly element: HTMLButtonElement;
67
+ /** Show the counts of a `review` event; null or undefined goes back to "Checking…". */
68
+ update(counts: ReviewPillCounts | null | undefined): void;
69
+ setDensity(density: "comfortable" | "touch"): void;
70
+ setDisabled(disabled: boolean): void;
71
+ /** The overflow menu entry: `[{ id: "review", label: "Review (3)", disabled, run }]`. */
72
+ menuItems(): ReviewPillMenuItem[];
73
+ /** Removes the element and listener. Safe twice. */
74
+ destroy(): void;
75
+ }
76
+ /** What the pill shows for a `review` event payload, without a DOM. */
77
+ export declare function describeReview(counts: ReviewPillCounts | null | undefined, labels?: ReviewPillLabels): ReviewPillView;
78
+ /**
79
+ * The review pill: a compact status button ("Valid", "3 issues", "Checking…", "Review unavailable") with a glyph and the color of the most severe
80
+ * level. The accessible name carries the counts. When the review is unavailable the pill stays visible, muted and disabled (hiding it would make a
81
+ * broken check look like a clean deck).
82
+ */
83
+ export declare function createReviewPill(container: HTMLElement, options?: ReviewPillOptions): ReviewPill;
@@ -0,0 +1,137 @@
1
+ // CE-29, the review pill: the toolbar's `review` item. One compact status button that says how the deck checks out ("Valid", "3 issues", "Checking…")
2
+ // and opens the Review panel when pressed. It owns no findings: the host feeds it the counts that the workspace's `review` event carries
3
+ // (`{ error, warning, info, total, all, sources, unavailable }`) through `update(counts)`. Not color alone: every state has a glyph and words.
4
+ // Plain DOM, no framework, no network. Icons are Lucide paths (ISC license), inlined.
5
+ import { normalizeIcons, resolveIcon } from "./icons.js";
6
+
7
+ // Each glyph is a list of [tag, attributes]. "valid" is a filled success dot (the words carry the meaning), "checking" a ring.
8
+ const GLYPHS = {
9
+ error: [["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "m15 9-6 6" }], ["path", { d: "m9 9 6 6" }]],
10
+ warning: [["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }], ["path", { d: "M12 9v4" }], ["path", { d: "M12 17h.01" }]],
11
+ info: [["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 16v-4" }], ["path", { d: "M12 8h.01" }]],
12
+ valid: [["circle", { cx: 12, cy: 12, r: 5, fill: "currentColor", stroke: "none" }]],
13
+ checking: [["circle", { cx: 12, cy: 12, r: 8, "stroke-dasharray": "3 3" }]],
14
+ unavailable: [["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M8 12h8" }]],
15
+ };
16
+
17
+ const ICON_NAMES = Object.keys(GLYPHS);
18
+
19
+ const count = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Math.trunc(Number(value)) : 0);
20
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
21
+
22
+ const DEFAULT_LABELS = {
23
+ /** The visible text before the first `update()`. */
24
+ checking: "Checking…",
25
+ /** The visible text when the deck has no findings. */
26
+ valid: "Valid",
27
+ /** The visible text when the review could not run. */
28
+ unavailable: "Review unavailable",
29
+ /** The visible text with findings, for example "3 issues". */
30
+ issues: (total) => plural(total, "issue", "issues"),
31
+ /** One severity in the accessible name, for example "2 errors". */
32
+ error: (n) => plural(n, "error", "errors"),
33
+ warning: (n) => plural(n, "warning", "warnings"),
34
+ info: (n) => plural(n, "note", "notes"),
35
+ /** The accessible name of the button, from the state: "Review: Valid", "Review: 3 issues (2 errors, 1 warning)". `view` is what `describeReview` returns. */
36
+ name: ({ state, text, parts }) => (state === "issues" ? `Review: ${text} (${parts.join(", ")})` : state === "unavailable" ? text : `Review: ${text}`),
37
+ /** The overflow menu item: "Review" while there is nothing to count, "Review (3)" with findings. */
38
+ menu: (total) => (total > 0 ? `Review (${total})` : "Review"),
39
+ };
40
+
41
+ function glyph(doc, name, icons) {
42
+ return resolveIcon(doc, icons[name], GLYPHS[name], { name, className: "opf-review-pill-icon" });
43
+ }
44
+
45
+ /**
46
+ * What the pill shows for a `review` event payload, without a DOM: `{ state, severity, total, text, parts }`.
47
+ * `state` is `"checking"` (no counts yet), `"unavailable"`, `"valid"` (`total` is 0) or `"issues"`; `severity` is the most severe level present
48
+ * (`"error"`, `"warning"` or `"info"`, else null); `parts` lists the non-zero severities for the accessible name.
49
+ */
50
+ export function describeReview(counts, labels = DEFAULT_LABELS) {
51
+ const l = { ...DEFAULT_LABELS, ...labels };
52
+ if (!counts) return { state: "checking", severity: null, total: 0, text: l.checking, parts: [] };
53
+ const error = count(counts.error), warning = count(counts.warning), info = count(counts.info);
54
+ const total = count(counts.total);
55
+ if (counts.unavailable) return { state: "unavailable", severity: null, total, text: l.unavailable, parts: [] };
56
+ if (total === 0) return { state: "valid", severity: null, total: 0, text: l.valid, parts: [] };
57
+ const severity = error ? "error" : warning ? "warning" : info ? "info" : null;
58
+ const parts = [error && l.error(error), warning && l.warning(warning), info && l.info(info)].filter(Boolean);
59
+ return { state: "issues", severity, total, text: l.issues(total), parts };
60
+ }
61
+
62
+ /**
63
+ * Mount the review pill into `container`.
64
+ *
65
+ * Options: `onOpen()` (called when the pill is pressed or its menu item runs; the workspace opens the Review panel), `density` (`"comfortable"` or
66
+ * `"touch"`), `labels` (every string; see `ReviewPillLabels`).
67
+ *
68
+ * Returns `{ element, update(counts), setDensity(density), setDisabled(disabled), menuItems(), destroy() }`.
69
+ *
70
+ * States: before the first `update()` it reads "Checking…". `update(counts)` with `total` 0 reads "Valid" with a success dot; with findings it reads
71
+ * "N issues" with the glyph and color of the most severe level. When `counts.unavailable` is true (the review could not run) the pill stays visible,
72
+ * muted and disabled, and reads "Review unavailable": hiding it would make a broken check look like a clean deck. The accessible name carries the
73
+ * counts, for example "Review: 3 issues (2 errors, 1 warning)".
74
+ */
75
+ export function createReviewPill(container, options = {}) {
76
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
77
+ const doc = container.ownerDocument;
78
+ const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
79
+ const icons = normalizeIcons(options.icons, ICON_NAMES, "createReviewPill icons");
80
+ let density = options.density === "touch" ? "touch" : "comfortable";
81
+ let disabled = false;
82
+ let destroyed = false;
83
+ let view = describeReview(null, labels);
84
+
85
+ const element = doc.createElement("button");
86
+ element.type = "button";
87
+ element.className = "opf-review-pill";
88
+ element.dataset.density = density;
89
+ const text = doc.createElement("span");
90
+ text.className = "opf-review-pill-text";
91
+ let icon = null;
92
+
93
+ const blocked = () => disabled || view.state === "unavailable";
94
+ function draw() {
95
+ element.dataset.state = view.state;
96
+ if (view.severity) element.dataset.severity = view.severity; else delete element.dataset.severity;
97
+ const next = glyph(doc, view.state === "issues" ? view.severity ?? "info" : view.state, icons);
98
+ if (icon) icon.replaceWith(next); else element.prepend(next);
99
+ icon = next;
100
+ text.textContent = view.text;
101
+ element.setAttribute("aria-label", labels.name(view));
102
+ element.disabled = blocked();
103
+ }
104
+ element.append(text);
105
+ draw();
106
+ container.append(element);
107
+
108
+ const open = () => { if (!destroyed && !blocked()) options.onOpen?.(); };
109
+ element.addEventListener("click", open);
110
+
111
+ return {
112
+ element,
113
+ /** Show the counts of a `review` event. Without counts (null or undefined) the pill goes back to "Checking…". */
114
+ update(counts) {
115
+ if (destroyed) return;
116
+ view = describeReview(counts, labels);
117
+ draw();
118
+ },
119
+ setDensity(next) {
120
+ density = next === "touch" ? "touch" : "comfortable";
121
+ element.dataset.density = density;
122
+ },
123
+ setDisabled(next) {
124
+ disabled = Boolean(next);
125
+ element.disabled = blocked();
126
+ },
127
+ menuItems() {
128
+ return [{ id: "review", label: labels.menu(view.total), disabled: blocked(), run: open }];
129
+ },
130
+ destroy() {
131
+ if (destroyed) return;
132
+ destroyed = true;
133
+ element.removeEventListener("click", open);
134
+ element.remove();
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,82 @@
1
+ import type { OpfIcons } from "./icons.js";
2
+ /** One row of the sheet. Keys are CodeMirror-style names ("Mod-z", "Shift-Mod-z", "Ctrl-y", "F1", "?"); `Mod` is Cmd on a Mac and Ctrl elsewhere. */
3
+ export interface HelpShortcut {
4
+ label: string;
5
+ /** The alternatives, for example `["Mod-z"]` or `["Mod-y", "Shift-Mod-z"]`. A chord of strokes is space separated: `"Mod-k Mod-s"`. */
6
+ keys: string[];
7
+ /** The heading the row is listed under. Default: "General". */
8
+ group?: string;
9
+ /** Optional stable id (a `data-id` on the row). */
10
+ id?: string;
11
+ }
12
+ export interface HelpButtonLabels {
13
+ /** The accessible name of the button. Default: "Keyboard shortcuts". */
14
+ button?: string;
15
+ /** The tooltip (" (?)" is added when the key is bound). Default: "Keyboard shortcuts". */
16
+ title?: string;
17
+ /** The dialog heading. Default: "Keyboard shortcuts". */
18
+ heading?: string;
19
+ /** Default: "Close". */
20
+ close?: string;
21
+ /** The accessible name of the filter field. Default: "Filter the keyboard shortcuts". */
22
+ search?: string;
23
+ /** Default: "Filter shortcuts". */
24
+ searchPlaceholder?: string;
25
+ /** The group of a shortcut that names none. Default: "General". */
26
+ defaultGroup?: string;
27
+ /** Default: "No shortcut matches." */
28
+ none?: string;
29
+ /** Default: "No keyboard shortcuts are available." */
30
+ empty?: string;
31
+ /** The sentence under the heading; `platform` is "macOS" or "Windows or Linux". */
32
+ description?: (platform: string) => string;
33
+ /** The live status line, for example "3 of 12 shortcuts match". */
34
+ status?: (shown: number, total: number) => string;
35
+ }
36
+ export interface HelpButtonOptions {
37
+ /** The element the dialog is added to. Default: the closest `.opf-ui` ancestor of the container, else `document.body`. */
38
+ overlay?: HTMLElement;
39
+ /** The shortcuts to list. Called on every open, so the list is current. */
40
+ getShortcuts?: () => HelpShortcut[];
41
+ /** `"comfortable"` (32px, default) or `"touch"` (40px; 44px under a coarse pointer either way). */
42
+ density?: "comfortable" | "touch";
43
+ labels?: HelpButtonLabels;
44
+ /**
45
+ * Replace an icon. `help` is the question mark on the toolbar button (`.opf-help-icon`); `close` replaces the "×" in the dialog's close
46
+ * button (`.opf-help-close-icon`; without it the button reads "×"). See `OpfIcon`.
47
+ */
48
+ icons?: OpfIcons<"help" | "close">;
49
+ /** Bind "?" and "Shift-?" on the document to open the sheet when focus is not in a field, an editor, a menu or a dialog. Default true. */
50
+ bindKey?: boolean;
51
+ }
52
+ export interface HelpButtonMenuItem {
53
+ id: "help";
54
+ label: string;
55
+ disabled: boolean;
56
+ run(): boolean;
57
+ }
58
+ export interface HelpButton {
59
+ /** The toolbar button (`aria-haspopup="dialog"`, `aria-expanded` follows the sheet). */
60
+ readonly element: HTMLButtonElement;
61
+ /** Open the shortcuts sheet. Returns false when the button is disabled or destroyed. Focus goes to the filter field. */
62
+ open(): boolean;
63
+ /** Close the sheet; focus returns to where it was. Escape does the same. */
64
+ close(): void;
65
+ setDensity(density: "comfortable" | "touch"): void;
66
+ /** Disables the button and the "?" key, and closes an open sheet. */
67
+ setDisabled(disabled: boolean): void;
68
+ /** `[{ id: "help", label: "Keyboard shortcuts", disabled, run }]` for the toolbar's overflow menu. */
69
+ menuItems(): HelpButtonMenuItem[];
70
+ /** Closes the sheet, removes the button and the key listener. Safe twice. */
71
+ destroy(): void;
72
+ }
73
+ /** Strokes as people read them, one string per alternative: `["Mod-z"]` is `["⌘Z"]` on a Mac and `["Ctrl+Z"]` elsewhere. `win` selects the platform. */
74
+ export declare function formatShortcutKeys(keys: string[] | string, win?: Window | null): string[];
75
+ /** The sheet's rows (`{ id, title, group, keys }`, keys formatted for `win`). */
76
+ export declare function shortcutRows(shortcuts: HelpShortcut[] | undefined, win?: Window | null, defaultGroup?: string): { id: string; title: string; group: string; keys: string[] }[];
77
+ /** The rows that match every word of `query` (in the label, group or keys). An empty query keeps all rows. */
78
+ export declare function filterShortcutRows<T extends { title: string; group: string; id: string; keys: string[] }>(rows: T[], query: string): T[];
79
+ /** Whether a "?" key press opens the sheet: a bare "?" (Shift allowed) that is not typed into a field, an editor, a menu or a dialog, and not already handled. */
80
+ export declare function opensOnKey(event: KeyboardEvent): boolean;
81
+ /** The help item: a button and the "?" key that open a modal sheet of the keyboard shortcuts, grouped and filterable by typing. */
82
+ export declare function createHelpButton(container: HTMLElement, options?: HelpButtonOptions): HelpButton;