@arraypress/extension-kit 1.0.0 → 1.2.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,185 @@
1
+ /**
2
+ * Chrome's one-field dialog (cr-dialog with a cr-input), as chrome://bookmarks
3
+ * asks for a folder's name: a title, a labelled field, Cancel and an action
4
+ * button. The action button stays disabled while the text is blank or isn't
5
+ * valid, with the reason under the field. Enter means the action button, Esc
6
+ * and Cancel cancel, and the focus goes back to what opened it. The page
7
+ * needs no markup: the dialog (`#text-dialog`) is made the first time.
8
+ *
9
+ * @module ui/text-dialog
10
+ */
11
+
12
+ /**
13
+ * What the dialog asks.
14
+ *
15
+ * @typedef {object} TextRequest
16
+ * @property {string} title The dialog's title ("Rename folder").
17
+ * @property {string} label The field's label ("Name").
18
+ * @property {string} [value] The text to start with (selected). Default ''.
19
+ * @property {string} [action] The action button's text. Default "Save".
20
+ * @property {string} [cancel] The cancel button's text. Default "Cancel".
21
+ * @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
22
+ * shown under the field; null when it can. Default: anything but blank.
23
+ * @property {number} [maxLength] The field's `maxlength`. Default none.
24
+ * @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
25
+ * goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
26
+ * Default what had the focus when it opened.
27
+ * @property {Document} [doc] The page. Default `document`.
28
+ */
29
+
30
+ /**
31
+ * Whether a text can be used, and the reason to show when it can't. Pure.
32
+ *
33
+ * @param {string} text - What's typed.
34
+ * @param {(text: string) => string | null} [validate] - Why a non-blank text can't be used, or null.
35
+ * @returns {{ok: boolean, reason: string}} `ok` when it can be used; `reason` is '' for blank text
36
+ * (nothing to say yet) and for a usable one.
37
+ * @example
38
+ * checkText(' '); // → {ok: false, reason: ''}
39
+ * checkText('x', () => 'Enter a site'); // → {ok: false, reason: 'Enter a site'}
40
+ */
41
+ export function checkText(text, validate = () => null) {
42
+ if (!text.trim()) return { ok: false, reason: '' };
43
+ const reason = validate(text);
44
+ return { ok: reason === null, reason: reason ?? '' };
45
+ }
46
+
47
+ /**
48
+ * The dialog's parts.
49
+ *
50
+ * @typedef {object} Parts
51
+ * @property {HTMLDialogElement} dialog The `<dialog>`.
52
+ * @property {HTMLFormElement} form Its form.
53
+ * @property {HTMLElement} title The title.
54
+ * @property {HTMLLabelElement} label The field's label.
55
+ * @property {HTMLInputElement} input The field.
56
+ * @property {HTMLElement} error The reason under the field.
57
+ * @property {HTMLButtonElement} cancel The cancel button.
58
+ * @property {HTMLButtonElement} confirm The action button.
59
+ */
60
+
61
+ /** Each page's dialog, made on first use. @type {WeakMap<Document, Parts>} */
62
+ const made = new WeakMap();
63
+
64
+ /**
65
+ * The page's dialog, made the first time.
66
+ *
67
+ * @param {Document} doc - The page.
68
+ * @returns {Parts} Its parts.
69
+ */
70
+ function partsFor(doc) {
71
+ const existing = made.get(doc);
72
+ if (existing) return existing;
73
+ /**
74
+ * @template {keyof HTMLElementTagNameMap} K
75
+ * @param {K} tag
76
+ * @param {Record<string, string>} [attributes]
77
+ * @returns {HTMLElementTagNameMap[K]}
78
+ */
79
+ const el = (tag, attributes = {}) => {
80
+ const node = doc.createElement(tag);
81
+ for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
82
+ return node;
83
+ };
84
+ const dialog = el('dialog', { id: 'text-dialog', class: 'dialog', 'aria-labelledby': 'text-dialog-title' });
85
+ const form = el('form', { novalidate: '' });
86
+ const title = el('h2', { id: 'text-dialog-title', class: 'dialog-title' });
87
+ const body = el('div', { class: 'dialog-body' });
88
+ const field = el('div', { class: 'field' });
89
+ const label = el('label', { id: 'text-dialog-label', for: 'text-dialog-input' });
90
+ const input = el('input', { id: 'text-dialog-input', type: 'text', autocomplete: 'off', 'aria-describedby': 'text-dialog-error' });
91
+ const error = el('span', { id: 'text-dialog-error', class: 'hint error' });
92
+ error.hidden = true;
93
+ const buttons = el('div', { class: 'dialog-buttons' });
94
+ const cancel = el('button', { type: 'button', class: 'cancel' });
95
+ const confirm = el('button', { id: 'text-dialog-confirm', type: 'submit', class: 'action' });
96
+ field.append(label, input, error);
97
+ body.append(field);
98
+ buttons.append(cancel, confirm);
99
+ form.append(title, body, buttons);
100
+ dialog.append(form);
101
+ doc.body.append(dialog);
102
+ const parts = { dialog, form, title, label, input, error, cancel, confirm };
103
+ made.set(doc, parts);
104
+ return parts;
105
+ }
106
+
107
+ /** The open dialog's settle function, per page. @type {WeakMap<Document, (text: string | null) => void>} */
108
+ const pending = new WeakMap();
109
+
110
+ /**
111
+ * Ask for a piece of text in the page's one-field dialog.
112
+ *
113
+ * @param {TextRequest} request - What to ask.
114
+ * @returns {Promise<string | null>} The text, trimmed; null when cancelled.
115
+ * @example
116
+ * const name = await askText({title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200});
117
+ * if (name) rename(workspace, name);
118
+ *
119
+ * const site = await askText({
120
+ * title: 'Add site', label: 'Site', action: 'Add',
121
+ * validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
122
+ * });
123
+ */
124
+ export function askText({
125
+ title, label, value = '', action = 'Save', cancel = 'Cancel', validate, maxLength, returnTo, doc = document,
126
+ }) {
127
+ const parts = partsFor(doc);
128
+ pending.get(doc)?.(null);
129
+ const active = doc.activeElement;
130
+ const back = returnTo === undefined ? (active instanceof /** @type {Window & typeof globalThis} */ (doc.defaultView).HTMLElement ? active : null) : returnTo;
131
+
132
+ parts.title.textContent = title;
133
+ parts.label.textContent = label;
134
+ parts.confirm.textContent = action;
135
+ parts.cancel.textContent = cancel;
136
+ parts.input.value = value;
137
+ if (maxLength === undefined) parts.input.removeAttribute('maxlength');
138
+ else parts.input.maxLength = maxLength;
139
+
140
+ const refresh = () => {
141
+ const { ok, reason } = checkText(parts.input.value, validate);
142
+ parts.error.textContent = reason;
143
+ parts.error.hidden = !reason;
144
+ parts.confirm.disabled = !ok;
145
+ };
146
+ refresh();
147
+
148
+ return new Promise((resolve) => {
149
+ const controller = new AbortController();
150
+ const { signal } = controller;
151
+ /** @param {string | null} text */
152
+ const settle = (text) => {
153
+ controller.abort();
154
+ pending.delete(doc);
155
+ if (parts.dialog.open) parts.dialog.close();
156
+ resolve(text);
157
+ // After whatever the answer re-renders, unless another dialog opened meanwhile.
158
+ queueMicrotask(() => {
159
+ if (doc.querySelector('dialog[open]')) return;
160
+ const target = typeof back === 'function' ? back() : back;
161
+ if (target?.isConnected) target.focus();
162
+ });
163
+ };
164
+ pending.set(doc, settle);
165
+ parts.input.addEventListener('input', refresh, { signal });
166
+ // Enter submits through the action button (the form's only submit button), never while it's disabled.
167
+ parts.form.addEventListener('submit', (event) => {
168
+ event.preventDefault();
169
+ if (checkText(parts.input.value, validate).ok) settle(parts.input.value.trim());
170
+ }, { signal });
171
+ parts.cancel.addEventListener('click', () => settle(null), { signal });
172
+ parts.dialog.addEventListener('keydown', (event) => {
173
+ if (event.key !== 'Escape') return;
174
+ event.preventDefault();
175
+ settle(null);
176
+ }, { signal });
177
+ parts.dialog.addEventListener('cancel', (event) => {
178
+ event.preventDefault();
179
+ settle(null);
180
+ }, { signal });
181
+
182
+ parts.dialog.showModal();
183
+ parts.input.select();
184
+ });
185
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Chrome's toast (cr-toast), bottom left, as chrome://bookmarks shows one
3
+ * after a delete: "Deleted “Research” Undo". One per page, made by
4
+ * {@link createToast}; a new message replaces the last and restarts the wait.
5
+ *
6
+ * @module ui/toast
7
+ */
8
+ /** How long chrome://bookmarks shows a toast (its cr-toast-manager's `duration`), in ms. */
9
+ export declare const TOAST_MS = 10000;
10
+ export type ToastOptions = {
11
+ /**
12
+ * The undo button's text. Default "Undo".
13
+ */
14
+ undoLabel?: string;
15
+ /**
16
+ * How long a toast stays, in ms. Default {@link TOAST_MS}.
17
+ */
18
+ duration?: number;
19
+ /**
20
+ * How long one with Undo stays. Default `duration`.
21
+ */
22
+ undoDuration?: number;
23
+ /**
24
+ * The page. Default `document`.
25
+ */
26
+ doc?: Document;
27
+ };
28
+ export type Toast = {
29
+ /**
30
+ * The `#toast` element (`role="status"`), in the page's body.
31
+ */
32
+ element: HTMLElement;
33
+ /**
34
+ * Show `text`, with an Undo
35
+ * button that hides the toast and runs `undo` when one is given.
36
+ */
37
+ show: (text: string, undo?: (() => unknown) | null) => void;
38
+ /**
39
+ * Take it down (and its Undo with it).
40
+ */
41
+ hide: () => void;
42
+ };
43
+ /**
44
+ * What {@link createToast} takes.
45
+ *
46
+ * @typedef {object} ToastOptions
47
+ * @property {string} [undoLabel] The undo button's text. Default "Undo".
48
+ * @property {number} [duration] How long a toast stays, in ms. Default {@link TOAST_MS}.
49
+ * @property {number} [undoDuration] How long one with Undo stays. Default `duration`.
50
+ * @property {Document} [doc] The page. Default `document`.
51
+ */
52
+ /**
53
+ * A page's toast.
54
+ *
55
+ * @typedef {object} Toast
56
+ * @property {HTMLElement} element The `#toast` element (`role="status"`), in the page's body.
57
+ * @property {(text: string, undo?: (() => unknown) | null) => void} show Show `text`, with an Undo
58
+ * button that hides the toast and runs `undo` when one is given.
59
+ * @property {() => void} hide Take it down (and its Undo with it).
60
+ */
61
+ /**
62
+ * Make the page's toast: `<div id="toast" class="toast" role="status">` with
63
+ * `#toast-text` and an Undo button `#toast-undo`, hidden until shown. Make it
64
+ * when the page loads, so screen readers know the live region before it speaks.
65
+ *
66
+ * @param {ToastOptions} [options] - The Undo text, how long it stays, and the page.
67
+ * @returns {Toast} The toast.
68
+ * @example
69
+ * const toast = createToast();
70
+ * await deleteWorkspace(workspace);
71
+ * toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
72
+ */
73
+ export declare function createToast({ undoLabel, duration, undoDuration, doc }?: ToastOptions): Toast;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Chrome's toast (cr-toast), bottom left, as chrome://bookmarks shows one
3
+ * after a delete: "Deleted “Research” Undo". One per page, made by
4
+ * {@link createToast}; a new message replaces the last and restarts the wait.
5
+ *
6
+ * @module ui/toast
7
+ */
8
+
9
+ /** How long chrome://bookmarks shows a toast (its cr-toast-manager's `duration`), in ms. */
10
+ export const TOAST_MS = 10_000;
11
+
12
+ /**
13
+ * What {@link createToast} takes.
14
+ *
15
+ * @typedef {object} ToastOptions
16
+ * @property {string} [undoLabel] The undo button's text. Default "Undo".
17
+ * @property {number} [duration] How long a toast stays, in ms. Default {@link TOAST_MS}.
18
+ * @property {number} [undoDuration] How long one with Undo stays. Default `duration`.
19
+ * @property {Document} [doc] The page. Default `document`.
20
+ */
21
+
22
+ /**
23
+ * A page's toast.
24
+ *
25
+ * @typedef {object} Toast
26
+ * @property {HTMLElement} element The `#toast` element (`role="status"`), in the page's body.
27
+ * @property {(text: string, undo?: (() => unknown) | null) => void} show Show `text`, with an Undo
28
+ * button that hides the toast and runs `undo` when one is given.
29
+ * @property {() => void} hide Take it down (and its Undo with it).
30
+ */
31
+
32
+ /**
33
+ * Make the page's toast: `<div id="toast" class="toast" role="status">` with
34
+ * `#toast-text` and an Undo button `#toast-undo`, hidden until shown. Make it
35
+ * when the page loads, so screen readers know the live region before it speaks.
36
+ *
37
+ * @param {ToastOptions} [options] - The Undo text, how long it stays, and the page.
38
+ * @returns {Toast} The toast.
39
+ * @example
40
+ * const toast = createToast();
41
+ * await deleteWorkspace(workspace);
42
+ * toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
43
+ */
44
+ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDuration = duration, doc = document } = {}) {
45
+ const toast = doc.createElement('div');
46
+ toast.id = 'toast';
47
+ toast.className = 'toast';
48
+ toast.setAttribute('role', 'status');
49
+ toast.setAttribute('aria-live', 'polite');
50
+ toast.hidden = true;
51
+ const text = doc.createElement('span');
52
+ text.id = 'toast-text';
53
+ const undoButton = doc.createElement('button');
54
+ undoButton.id = 'toast-undo';
55
+ undoButton.type = 'button';
56
+ undoButton.textContent = undoLabel;
57
+ undoButton.hidden = true;
58
+ toast.append(text, undoButton);
59
+ doc.body.append(toast);
60
+
61
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
62
+ let timer;
63
+ /** @type {(() => unknown) | null} */
64
+ let undoLast = null;
65
+
66
+ const hide = () => {
67
+ clearTimeout(timer);
68
+ toast.hidden = true;
69
+ undoLast = null;
70
+ };
71
+ undoButton.addEventListener('click', () => {
72
+ const undo = undoLast;
73
+ hide();
74
+ undo?.();
75
+ });
76
+
77
+ return {
78
+ element: toast,
79
+ show(message, undo = null) {
80
+ clearTimeout(timer);
81
+ text.textContent = message;
82
+ undoButton.hidden = !undo;
83
+ undoLast = undo;
84
+ toast.hidden = false;
85
+ timer = setTimeout(hide, undo ? undoDuration : duration);
86
+ },
87
+ hide,
88
+ };
89
+ }