@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.
- package/README.md +78 -6
- package/package.json +1 -1
- package/src/build/vendor.js +1 -1
- package/src/index.d.ts +22 -2
- package/src/index.js +17 -2
- package/src/styles/chrome-ui.css +5 -1
- package/src/testing/e2e.d.ts +64 -7
- package/src/testing/e2e.js +75 -20
- package/src/testing/index.d.ts +29 -3
- package/src/testing/index.js +21 -3
- package/src/testing/package.d.ts +62 -4
- package/src/testing/package.js +39 -8
- package/src/testing/saved-groups.d.ts +33 -0
- package/src/testing/saved-groups.js +222 -0
- package/src/testing/server.d.ts +47 -0
- package/src/testing/server.js +90 -0
- package/src/testing/warnings.d.ts +57 -0
- package/src/testing/warnings.js +69 -0
- package/src/ui/menu.d.ts +250 -0
- package/src/ui/menu.js +279 -0
- package/src/ui/text-dialog.d.ts +132 -0
- package/src/ui/text-dialog.js +185 -0
- package/src/ui/toast.d.ts +73 -0
- package/src/ui/toast.js +89 -0
package/src/ui/menu.d.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's action menu (cr-action-menu), the ⋮ menu of chrome://bookmarks and
|
|
3
|
+
* chrome://settings: a `<dialog class="menu">` of `role="menuitem"` buttons,
|
|
4
|
+
* shown modally over the button that opened it (or at the pointer, for a
|
|
5
|
+
* right-click) and kept on screen. A click outside it, Esc, Tab or the window
|
|
6
|
+
* losing focus closes it; ↑/↓ (wrapping), Home and End move between its
|
|
7
|
+
* items; and focus goes back to where it came from. Also the rows' ⋮ button,
|
|
8
|
+
* labelled "More actions for …" as Chrome labels its own.
|
|
9
|
+
*
|
|
10
|
+
* Sources: ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.ts
|
|
11
|
+
* (a modal `<dialog>`; a click on its backdrop closes it; the anchor gets the
|
|
12
|
+
* focus back; ↑/↓ wrap) and cr_icon_button with cr:more-vert.
|
|
13
|
+
*
|
|
14
|
+
* @module ui/menu
|
|
15
|
+
*/
|
|
16
|
+
/** The gap kept between a menu and the window's edges, in px. */
|
|
17
|
+
export declare const MENU_EDGE = 8;
|
|
18
|
+
export type MenuItem = {
|
|
19
|
+
/**
|
|
20
|
+
* Its text ("Rename").
|
|
21
|
+
*/
|
|
22
|
+
label: string;
|
|
23
|
+
/**
|
|
24
|
+
* Runs once the menu has closed.
|
|
25
|
+
*/
|
|
26
|
+
run: () => unknown;
|
|
27
|
+
/**
|
|
28
|
+
* Greyed out, and skipped by the arrow keys.
|
|
29
|
+
*/
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
};
|
|
32
|
+
export type MenuEntry = MenuItem | 'separator';
|
|
33
|
+
export type MenuPlace = {
|
|
34
|
+
/**
|
|
35
|
+
* The button that opened it.
|
|
36
|
+
*/
|
|
37
|
+
anchor?: HTMLElement;
|
|
38
|
+
/**
|
|
39
|
+
* Without an anchor: the pointer's x (a right-click's `clientX`).
|
|
40
|
+
*/
|
|
41
|
+
x?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Without an anchor: the pointer's y.
|
|
44
|
+
*/
|
|
45
|
+
y?: number;
|
|
46
|
+
/**
|
|
47
|
+
* The menu's name for screen readers ("Actions for Research").
|
|
48
|
+
*/
|
|
49
|
+
label?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Where focus goes when it closes. Default the anchor, else what had focus.
|
|
52
|
+
*/
|
|
53
|
+
focusTo?: HTMLElement;
|
|
54
|
+
};
|
|
55
|
+
export type Box = {
|
|
56
|
+
left: number;
|
|
57
|
+
top: number;
|
|
58
|
+
right: number;
|
|
59
|
+
bottom: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* One item: its text, what it does, and whether it's greyed out.
|
|
63
|
+
*
|
|
64
|
+
* @typedef {object} MenuItem
|
|
65
|
+
* @property {string} label Its text ("Rename").
|
|
66
|
+
* @property {() => unknown} run Runs once the menu has closed.
|
|
67
|
+
* @property {boolean} [disabled] Greyed out, and skipped by the arrow keys.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* An item, or `'separator'` for a line between groups of items.
|
|
71
|
+
*
|
|
72
|
+
* @typedef {MenuItem | 'separator'} MenuEntry
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* Where a menu opens: over (or below) the button that opened it, or at a
|
|
76
|
+
* pointer position.
|
|
77
|
+
*
|
|
78
|
+
* @typedef {object} MenuPlace
|
|
79
|
+
* @property {HTMLElement} [anchor] The button that opened it.
|
|
80
|
+
* @property {number} [x] Without an anchor: the pointer's x (a right-click's `clientX`).
|
|
81
|
+
* @property {number} [y] Without an anchor: the pointer's y.
|
|
82
|
+
* @property {string} [label] The menu's name for screen readers ("Actions for Research").
|
|
83
|
+
* @property {HTMLElement} [focusTo] Where focus goes when it closes. Default the anchor, else what had focus.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* A rectangle in the window, as `getBoundingClientRect()` gives it.
|
|
87
|
+
*
|
|
88
|
+
* @typedef {{left: number, top: number, right: number, bottom: number}} Box
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Where to put a menu, so it stays on screen. Pure.
|
|
92
|
+
*
|
|
93
|
+
* Over an anchor (Chrome's way): the menu's top at the anchor's top and its
|
|
94
|
+
* end at the anchor's end; moved up to end at the anchor's bottom when it
|
|
95
|
+
* would leave the window below. Below an anchor: 4px under it, or above it
|
|
96
|
+
* when there's no room. At a point: its top-left there, or its bottom-left
|
|
97
|
+
* when there's no room below. Always at least {@link MENU_EDGE} from the
|
|
98
|
+
* window's edges.
|
|
99
|
+
*
|
|
100
|
+
* @param {{width: number, height: number}} size - The menu's size.
|
|
101
|
+
* @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
|
|
102
|
+
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
|
|
103
|
+
* box, or a point; `placement` says where it goes from an anchor (default `'over'`).
|
|
104
|
+
* @returns {{left: number, top: number}} The menu's top-left, in px.
|
|
105
|
+
* @example
|
|
106
|
+
* menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
|
|
107
|
+
* // → {left: anchor.right - 160, top: anchor.top}
|
|
108
|
+
*/
|
|
109
|
+
export declare function menuPosition(size: {
|
|
110
|
+
width: number;
|
|
111
|
+
height: number;
|
|
112
|
+
}, viewport: {
|
|
113
|
+
width: number;
|
|
114
|
+
height: number;
|
|
115
|
+
}, { anchor, x, y, placement }: {
|
|
116
|
+
anchor?: Box;
|
|
117
|
+
x?: number;
|
|
118
|
+
y?: number;
|
|
119
|
+
placement?: 'over' | 'below';
|
|
120
|
+
}): {
|
|
121
|
+
left: number;
|
|
122
|
+
top: number;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Whether a point is inside a box (edges included). Pure.
|
|
126
|
+
*
|
|
127
|
+
* @param {Box} box - The box.
|
|
128
|
+
* @param {number} x - The point's x.
|
|
129
|
+
* @param {number} y - The point's y.
|
|
130
|
+
* @returns {boolean} True when inside.
|
|
131
|
+
*/
|
|
132
|
+
export declare function isInside(box: Box, x: number, y: number): boolean;
|
|
133
|
+
export type MenuOptions = {
|
|
134
|
+
/**
|
|
135
|
+
* The menu element's id.
|
|
136
|
+
*/
|
|
137
|
+
id?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Where it opens from a button. Default `'over'`, as Chrome's.
|
|
140
|
+
*/
|
|
141
|
+
placement?: 'over' | 'below';
|
|
142
|
+
/**
|
|
143
|
+
* The page. Default `document`.
|
|
144
|
+
*/
|
|
145
|
+
doc?: Document;
|
|
146
|
+
};
|
|
147
|
+
export type ActionMenu = {
|
|
148
|
+
/**
|
|
149
|
+
* The `<dialog class="menu" role="menu">`, in the page's body.
|
|
150
|
+
*/
|
|
151
|
+
element: HTMLDialogElement;
|
|
152
|
+
/**
|
|
153
|
+
* Fill it with `entries`
|
|
154
|
+
* and open it (closing it first if it's open).
|
|
155
|
+
*/
|
|
156
|
+
open: (entries: readonly MenuEntry[], where: MenuPlace) => void;
|
|
157
|
+
/**
|
|
158
|
+
* Close it, if open, and return the focus.
|
|
159
|
+
*/
|
|
160
|
+
close: () => void;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* What {@link createMenu} takes.
|
|
164
|
+
*
|
|
165
|
+
* @typedef {object} MenuOptions
|
|
166
|
+
* @property {string} [id] The menu element's id.
|
|
167
|
+
* @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
|
|
168
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
169
|
+
*/
|
|
170
|
+
/**
|
|
171
|
+
* A page's action menu.
|
|
172
|
+
*
|
|
173
|
+
* @typedef {object} ActionMenu
|
|
174
|
+
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in the page's body.
|
|
175
|
+
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
176
|
+
* and open it (closing it first if it's open).
|
|
177
|
+
* @property {() => void} close Close it, if open, and return the focus.
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* Make a page's action menu: one `<dialog class="menu">` that any number of
|
|
181
|
+
* ⋮ buttons (or right-clicks) open with their own items.
|
|
182
|
+
*
|
|
183
|
+
* @param {MenuOptions} [options] - Its id, placement and page.
|
|
184
|
+
* @returns {ActionMenu} The menu.
|
|
185
|
+
* @example
|
|
186
|
+
* const menu = createMenu();
|
|
187
|
+
* row.append(moreActionsButton(workspace.name, {
|
|
188
|
+
* onClick: (button) => menu.open([
|
|
189
|
+
* {label: 'Rename', run: () => rename(workspace)},
|
|
190
|
+
* 'separator',
|
|
191
|
+
* {label: 'Delete', run: () => remove(workspace)},
|
|
192
|
+
* ], {anchor: button}),
|
|
193
|
+
* }));
|
|
194
|
+
*/
|
|
195
|
+
export declare function createMenu({ id, placement, doc }?: MenuOptions): ActionMenu;
|
|
196
|
+
export type MoreActionsOptions = {
|
|
197
|
+
/**
|
|
198
|
+
* The whole label, for a page that translates it. Default
|
|
199
|
+
* `More actions for “<name>”`, Chrome's words.
|
|
200
|
+
*/
|
|
201
|
+
label?: string;
|
|
202
|
+
/**
|
|
203
|
+
* More classes, after `icon-button`.
|
|
204
|
+
*/
|
|
205
|
+
className?: string;
|
|
206
|
+
/**
|
|
207
|
+
* In the tab order. Default true; false for a row that takes the
|
|
208
|
+
* focus itself and opens the menu from the keyboard.
|
|
209
|
+
*/
|
|
210
|
+
focusable?: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* Show the label on hover too (`title`). Default false.
|
|
213
|
+
*/
|
|
214
|
+
tooltip?: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Opens its menu.
|
|
217
|
+
*/
|
|
218
|
+
onClick?: (button: HTMLButtonElement, event: MouseEvent) => void;
|
|
219
|
+
/**
|
|
220
|
+
* The page. Default `document`.
|
|
221
|
+
*/
|
|
222
|
+
doc?: Document;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* What {@link moreActionsButton} takes.
|
|
226
|
+
*
|
|
227
|
+
* @typedef {object} MoreActionsOptions
|
|
228
|
+
* @property {string} [label] The whole label, for a page that translates it. Default
|
|
229
|
+
* `More actions for “<name>”`, Chrome's words.
|
|
230
|
+
* @property {string} [className] More classes, after `icon-button`.
|
|
231
|
+
* @property {boolean} [focusable] In the tab order. Default true; false for a row that takes the
|
|
232
|
+
* focus itself and opens the menu from the keyboard.
|
|
233
|
+
* @property {boolean} [tooltip] Show the label on hover too (`title`). Default false.
|
|
234
|
+
* @property {(button: HTMLButtonElement, event: MouseEvent) => void} [onClick] Opens its menu.
|
|
235
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
236
|
+
*/
|
|
237
|
+
/**
|
|
238
|
+
* A row's ⋮ button: a 32px cr-icon-button with cr:more-vert (the
|
|
239
|
+
* stylesheet's `--icon-more-vert`, so the page needs no sprite), labelled for
|
|
240
|
+
* screen readers as Chrome labels its own.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} name - What the row is ("Research"), for "More actions for “Research”".
|
|
243
|
+
* @param {MoreActionsOptions} [options] - Its label, classes, tab stop, tooltip and click.
|
|
244
|
+
* @returns {HTMLButtonElement} The button.
|
|
245
|
+
* @example
|
|
246
|
+
* row.append(moreActionsButton(site, {onClick: (button) => menu.open(siteItems(site), {anchor: button})}));
|
|
247
|
+
* // A translated label:
|
|
248
|
+
* moreActionsButton(name, {label: t('rowActions', name), className: 'row-menu', focusable: false});
|
|
249
|
+
*/
|
|
250
|
+
export declare function moreActionsButton(name: string, { label, className, focusable, tooltip, onClick, doc }?: MoreActionsOptions): HTMLButtonElement;
|
package/src/ui/menu.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's action menu (cr-action-menu), the ⋮ menu of chrome://bookmarks and
|
|
3
|
+
* chrome://settings: a `<dialog class="menu">` of `role="menuitem"` buttons,
|
|
4
|
+
* shown modally over the button that opened it (or at the pointer, for a
|
|
5
|
+
* right-click) and kept on screen. A click outside it, Esc, Tab or the window
|
|
6
|
+
* losing focus closes it; ↑/↓ (wrapping), Home and End move between its
|
|
7
|
+
* items; and focus goes back to where it came from. Also the rows' ⋮ button,
|
|
8
|
+
* labelled "More actions for …" as Chrome labels its own.
|
|
9
|
+
*
|
|
10
|
+
* Sources: ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.ts
|
|
11
|
+
* (a modal `<dialog>`; a click on its backdrop closes it; the anchor gets the
|
|
12
|
+
* focus back; ↑/↓ wrap) and cr_icon_button with cr:more-vert.
|
|
13
|
+
*
|
|
14
|
+
* @module ui/menu
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The gap kept between a menu and the window's edges, in px. */
|
|
18
|
+
export const MENU_EDGE = 8;
|
|
19
|
+
|
|
20
|
+
/** The gap between a button and a menu placed below it, in px. */
|
|
21
|
+
const BELOW_GAP = 4;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One item: its text, what it does, and whether it's greyed out.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {object} MenuItem
|
|
27
|
+
* @property {string} label Its text ("Rename").
|
|
28
|
+
* @property {() => unknown} run Runs once the menu has closed.
|
|
29
|
+
* @property {boolean} [disabled] Greyed out, and skipped by the arrow keys.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An item, or `'separator'` for a line between groups of items.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {MenuItem | 'separator'} MenuEntry
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where a menu opens: over (or below) the button that opened it, or at a
|
|
40
|
+
* pointer position.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} MenuPlace
|
|
43
|
+
* @property {HTMLElement} [anchor] The button that opened it.
|
|
44
|
+
* @property {number} [x] Without an anchor: the pointer's x (a right-click's `clientX`).
|
|
45
|
+
* @property {number} [y] Without an anchor: the pointer's y.
|
|
46
|
+
* @property {string} [label] The menu's name for screen readers ("Actions for Research").
|
|
47
|
+
* @property {HTMLElement} [focusTo] Where focus goes when it closes. Default the anchor, else what had focus.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A rectangle in the window, as `getBoundingClientRect()` gives it.
|
|
52
|
+
*
|
|
53
|
+
* @typedef {{left: number, top: number, right: number, bottom: number}} Box
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Where to put a menu, so it stays on screen. Pure.
|
|
58
|
+
*
|
|
59
|
+
* Over an anchor (Chrome's way): the menu's top at the anchor's top and its
|
|
60
|
+
* end at the anchor's end; moved up to end at the anchor's bottom when it
|
|
61
|
+
* would leave the window below. Below an anchor: 4px under it, or above it
|
|
62
|
+
* when there's no room. At a point: its top-left there, or its bottom-left
|
|
63
|
+
* when there's no room below. Always at least {@link MENU_EDGE} from the
|
|
64
|
+
* window's edges.
|
|
65
|
+
*
|
|
66
|
+
* @param {{width: number, height: number}} size - The menu's size.
|
|
67
|
+
* @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
|
|
68
|
+
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
|
|
69
|
+
* box, or a point; `placement` says where it goes from an anchor (default `'over'`).
|
|
70
|
+
* @returns {{left: number, top: number}} The menu's top-left, in px.
|
|
71
|
+
* @example
|
|
72
|
+
* menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
|
|
73
|
+
* // → {left: anchor.right - 160, top: anchor.top}
|
|
74
|
+
*/
|
|
75
|
+
export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over' }) {
|
|
76
|
+
const { width, height } = size;
|
|
77
|
+
const left = (/** @type {number} */ start) => Math.max(MENU_EDGE, Math.min(start, viewport.width - width - MENU_EDGE));
|
|
78
|
+
const room = (/** @type {number} */ top) => top + height <= viewport.height - MENU_EDGE;
|
|
79
|
+
if (!anchor) return { left: left(x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
|
|
80
|
+
const start = left(anchor.right - width);
|
|
81
|
+
if (placement === 'below') {
|
|
82
|
+
const below = anchor.bottom + BELOW_GAP;
|
|
83
|
+
return { left: start, top: Math.max(MENU_EDGE, room(below) ? below : anchor.top - height - BELOW_GAP) };
|
|
84
|
+
}
|
|
85
|
+
return { left: start, top: Math.max(MENU_EDGE, room(anchor.top) ? anchor.top : anchor.bottom - height) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether a point is inside a box (edges included). Pure.
|
|
90
|
+
*
|
|
91
|
+
* @param {Box} box - The box.
|
|
92
|
+
* @param {number} x - The point's x.
|
|
93
|
+
* @param {number} y - The point's y.
|
|
94
|
+
* @returns {boolean} True when inside.
|
|
95
|
+
*/
|
|
96
|
+
export function isInside(box, x, y) {
|
|
97
|
+
return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What {@link createMenu} takes.
|
|
102
|
+
*
|
|
103
|
+
* @typedef {object} MenuOptions
|
|
104
|
+
* @property {string} [id] The menu element's id.
|
|
105
|
+
* @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
|
|
106
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A page's action menu.
|
|
111
|
+
*
|
|
112
|
+
* @typedef {object} ActionMenu
|
|
113
|
+
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in the page's body.
|
|
114
|
+
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
115
|
+
* and open it (closing it first if it's open).
|
|
116
|
+
* @property {() => void} close Close it, if open, and return the focus.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Make a page's action menu: one `<dialog class="menu">` that any number of
|
|
121
|
+
* ⋮ buttons (or right-clicks) open with their own items.
|
|
122
|
+
*
|
|
123
|
+
* @param {MenuOptions} [options] - Its id, placement and page.
|
|
124
|
+
* @returns {ActionMenu} The menu.
|
|
125
|
+
* @example
|
|
126
|
+
* const menu = createMenu();
|
|
127
|
+
* row.append(moreActionsButton(workspace.name, {
|
|
128
|
+
* onClick: (button) => menu.open([
|
|
129
|
+
* {label: 'Rename', run: () => rename(workspace)},
|
|
130
|
+
* 'separator',
|
|
131
|
+
* {label: 'Delete', run: () => remove(workspace)},
|
|
132
|
+
* ], {anchor: button}),
|
|
133
|
+
* }));
|
|
134
|
+
*/
|
|
135
|
+
export function createMenu({ id, placement = 'over', doc = document } = {}) {
|
|
136
|
+
const view = /** @type {Window & typeof globalThis} */ (doc.defaultView);
|
|
137
|
+
const menu = doc.createElement('dialog');
|
|
138
|
+
menu.className = 'menu';
|
|
139
|
+
menu.setAttribute('role', 'menu');
|
|
140
|
+
if (id) menu.id = id;
|
|
141
|
+
doc.body.append(menu);
|
|
142
|
+
|
|
143
|
+
/** @type {HTMLElement | null} */
|
|
144
|
+
let returnFocus = null;
|
|
145
|
+
/** @type {HTMLElement | null} */
|
|
146
|
+
let expanded = null;
|
|
147
|
+
|
|
148
|
+
const items = () => /** @type {HTMLButtonElement[]} */ ([...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')]);
|
|
149
|
+
|
|
150
|
+
/** Close without returning the focus (another open follows at once). */
|
|
151
|
+
const shut = () => {
|
|
152
|
+
if (menu.open) menu.close();
|
|
153
|
+
expanded?.setAttribute('aria-expanded', 'false');
|
|
154
|
+
expanded = null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const close = () => {
|
|
158
|
+
if (!menu.open) return;
|
|
159
|
+
const target = returnFocus;
|
|
160
|
+
returnFocus = null;
|
|
161
|
+
shut();
|
|
162
|
+
if (target?.isConnected) target.focus();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
menu.addEventListener('keydown', (event) => {
|
|
166
|
+
const list = items();
|
|
167
|
+
const at = list.indexOf(/** @type {HTMLButtonElement} */ (doc.activeElement));
|
|
168
|
+
/** @param {number} index */
|
|
169
|
+
const go = (index) => {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
list[(index + list.length) % list.length]?.focus();
|
|
172
|
+
};
|
|
173
|
+
if (event.key === 'ArrowDown') go(at + 1);
|
|
174
|
+
else if (event.key === 'ArrowUp') go(at < 0 ? -1 : at - 1);
|
|
175
|
+
else if (event.key === 'Home') go(0);
|
|
176
|
+
else if (event.key === 'End') go(-1);
|
|
177
|
+
else if (event.key === 'Escape' || event.key === 'Tab') {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
// Esc the browser handles itself (when the keydown above didn't see it).
|
|
183
|
+
menu.addEventListener('cancel', (event) => {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
close();
|
|
186
|
+
});
|
|
187
|
+
// A modal dialog's backdrop is part of it: a click there targets the dialog, outside its box.
|
|
188
|
+
menu.addEventListener('click', (event) => {
|
|
189
|
+
if (event.target === menu && !isInside(menu.getBoundingClientRect(), event.clientX, event.clientY)) close();
|
|
190
|
+
});
|
|
191
|
+
view.addEventListener('blur', close);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
element: menu,
|
|
195
|
+
open(entries, { anchor, x, y, label, focusTo }) {
|
|
196
|
+
shut();
|
|
197
|
+
menu.replaceChildren(...entries.map((entry) => {
|
|
198
|
+
if (entry === 'separator') {
|
|
199
|
+
const line = doc.createElement('hr');
|
|
200
|
+
line.setAttribute('role', 'separator');
|
|
201
|
+
return line;
|
|
202
|
+
}
|
|
203
|
+
const button = doc.createElement('button');
|
|
204
|
+
button.type = 'button';
|
|
205
|
+
button.setAttribute('role', 'menuitem');
|
|
206
|
+
button.tabIndex = -1;
|
|
207
|
+
button.textContent = entry.label;
|
|
208
|
+
button.disabled = Boolean(entry.disabled);
|
|
209
|
+
button.addEventListener('click', () => {
|
|
210
|
+
close();
|
|
211
|
+
entry.run();
|
|
212
|
+
});
|
|
213
|
+
return button;
|
|
214
|
+
}));
|
|
215
|
+
if (label) menu.setAttribute('aria-label', label);
|
|
216
|
+
else menu.removeAttribute('aria-label');
|
|
217
|
+
|
|
218
|
+
const active = doc.activeElement;
|
|
219
|
+
returnFocus = focusTo ?? anchor ?? (active instanceof view.HTMLElement ? active : null);
|
|
220
|
+
if (anchor?.hasAttribute('aria-haspopup')) {
|
|
221
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
222
|
+
expanded = anchor;
|
|
223
|
+
}
|
|
224
|
+
menu.showModal();
|
|
225
|
+
const { width, height } = menu.getBoundingClientRect();
|
|
226
|
+
const { left, top } = menuPosition({ width, height }, { width: view.innerWidth, height: view.innerHeight }, {
|
|
227
|
+
anchor: anchor?.getBoundingClientRect(), x, y, placement,
|
|
228
|
+
});
|
|
229
|
+
menu.style.left = `${left}px`;
|
|
230
|
+
menu.style.top = `${top}px`;
|
|
231
|
+
items()[0]?.focus();
|
|
232
|
+
},
|
|
233
|
+
close,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What {@link moreActionsButton} takes.
|
|
239
|
+
*
|
|
240
|
+
* @typedef {object} MoreActionsOptions
|
|
241
|
+
* @property {string} [label] The whole label, for a page that translates it. Default
|
|
242
|
+
* `More actions for “<name>”`, Chrome's words.
|
|
243
|
+
* @property {string} [className] More classes, after `icon-button`.
|
|
244
|
+
* @property {boolean} [focusable] In the tab order. Default true; false for a row that takes the
|
|
245
|
+
* focus itself and opens the menu from the keyboard.
|
|
246
|
+
* @property {boolean} [tooltip] Show the label on hover too (`title`). Default false.
|
|
247
|
+
* @property {(button: HTMLButtonElement, event: MouseEvent) => void} [onClick] Opens its menu.
|
|
248
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
249
|
+
*/
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A row's ⋮ button: a 32px cr-icon-button with cr:more-vert (the
|
|
253
|
+
* stylesheet's `--icon-more-vert`, so the page needs no sprite), labelled for
|
|
254
|
+
* screen readers as Chrome labels its own.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} name - What the row is ("Research"), for "More actions for “Research”".
|
|
257
|
+
* @param {MoreActionsOptions} [options] - Its label, classes, tab stop, tooltip and click.
|
|
258
|
+
* @returns {HTMLButtonElement} The button.
|
|
259
|
+
* @example
|
|
260
|
+
* row.append(moreActionsButton(site, {onClick: (button) => menu.open(siteItems(site), {anchor: button})}));
|
|
261
|
+
* // A translated label:
|
|
262
|
+
* moreActionsButton(name, {label: t('rowActions', name), className: 'row-menu', focusable: false});
|
|
263
|
+
*/
|
|
264
|
+
export function moreActionsButton(name, { label, className, focusable = true, tooltip = false, onClick, doc = document } = {}) {
|
|
265
|
+
const text = label ?? `More actions for “${name}”`;
|
|
266
|
+
const button = doc.createElement('button');
|
|
267
|
+
button.type = 'button';
|
|
268
|
+
button.className = className ? `icon-button ${className}` : 'icon-button';
|
|
269
|
+
button.setAttribute('aria-label', text);
|
|
270
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
271
|
+
if (tooltip) button.title = text;
|
|
272
|
+
if (!focusable) button.tabIndex = -1;
|
|
273
|
+
const icon = doc.createElement('span');
|
|
274
|
+
icon.className = 'icon icon-more-vert';
|
|
275
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
276
|
+
button.append(icon);
|
|
277
|
+
if (onClick) button.addEventListener('click', (event) => onClick(button, event));
|
|
278
|
+
return button;
|
|
279
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
export type TextRequest = {
|
|
12
|
+
/**
|
|
13
|
+
* The dialog's title ("Rename folder").
|
|
14
|
+
*/
|
|
15
|
+
title: string;
|
|
16
|
+
/**
|
|
17
|
+
* The field's label ("Name").
|
|
18
|
+
*/
|
|
19
|
+
label: string;
|
|
20
|
+
/**
|
|
21
|
+
* The text to start with (selected). Default ''.
|
|
22
|
+
*/
|
|
23
|
+
value?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The action button's text. Default "Save".
|
|
26
|
+
*/
|
|
27
|
+
action?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The cancel button's text. Default "Cancel".
|
|
30
|
+
*/
|
|
31
|
+
cancel?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Why the (non-blank) text can't be used,
|
|
34
|
+
* shown under the field; null when it can. Default: anything but blank.
|
|
35
|
+
*/
|
|
36
|
+
validate?: (text: string) => string | null;
|
|
37
|
+
/**
|
|
38
|
+
* The field's `maxlength`. Default none.
|
|
39
|
+
*/
|
|
40
|
+
maxLength?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Where the focus
|
|
43
|
+
* goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
|
|
44
|
+
* Default what had the focus when it opened.
|
|
45
|
+
*/
|
|
46
|
+
returnTo?: HTMLElement | (() => HTMLElement | null | undefined) | null;
|
|
47
|
+
/**
|
|
48
|
+
* The page. Default `document`.
|
|
49
|
+
*/
|
|
50
|
+
doc?: Document;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* What the dialog asks.
|
|
54
|
+
*
|
|
55
|
+
* @typedef {object} TextRequest
|
|
56
|
+
* @property {string} title The dialog's title ("Rename folder").
|
|
57
|
+
* @property {string} label The field's label ("Name").
|
|
58
|
+
* @property {string} [value] The text to start with (selected). Default ''.
|
|
59
|
+
* @property {string} [action] The action button's text. Default "Save".
|
|
60
|
+
* @property {string} [cancel] The cancel button's text. Default "Cancel".
|
|
61
|
+
* @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
|
|
62
|
+
* shown under the field; null when it can. Default: anything but blank.
|
|
63
|
+
* @property {number} [maxLength] The field's `maxlength`. Default none.
|
|
64
|
+
* @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
|
|
65
|
+
* goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
|
|
66
|
+
* Default what had the focus when it opened.
|
|
67
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* Whether a text can be used, and the reason to show when it can't. Pure.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text - What's typed.
|
|
73
|
+
* @param {(text: string) => string | null} [validate] - Why a non-blank text can't be used, or null.
|
|
74
|
+
* @returns {{ok: boolean, reason: string}} `ok` when it can be used; `reason` is '' for blank text
|
|
75
|
+
* (nothing to say yet) and for a usable one.
|
|
76
|
+
* @example
|
|
77
|
+
* checkText(' '); // → {ok: false, reason: ''}
|
|
78
|
+
* checkText('x', () => 'Enter a site'); // → {ok: false, reason: 'Enter a site'}
|
|
79
|
+
*/
|
|
80
|
+
export declare function checkText(text: string, validate?: (text: string) => string | null): {
|
|
81
|
+
ok: boolean;
|
|
82
|
+
reason: string;
|
|
83
|
+
};
|
|
84
|
+
export type Parts = {
|
|
85
|
+
/**
|
|
86
|
+
* The `<dialog>`.
|
|
87
|
+
*/
|
|
88
|
+
dialog: HTMLDialogElement;
|
|
89
|
+
/**
|
|
90
|
+
* Its form.
|
|
91
|
+
*/
|
|
92
|
+
form: HTMLFormElement;
|
|
93
|
+
/**
|
|
94
|
+
* The title.
|
|
95
|
+
*/
|
|
96
|
+
title: HTMLElement;
|
|
97
|
+
/**
|
|
98
|
+
* The field's label.
|
|
99
|
+
*/
|
|
100
|
+
label: HTMLLabelElement;
|
|
101
|
+
/**
|
|
102
|
+
* The field.
|
|
103
|
+
*/
|
|
104
|
+
input: HTMLInputElement;
|
|
105
|
+
/**
|
|
106
|
+
* The reason under the field.
|
|
107
|
+
*/
|
|
108
|
+
error: HTMLElement;
|
|
109
|
+
/**
|
|
110
|
+
* The cancel button.
|
|
111
|
+
*/
|
|
112
|
+
cancel: HTMLButtonElement;
|
|
113
|
+
/**
|
|
114
|
+
* The action button.
|
|
115
|
+
*/
|
|
116
|
+
confirm: HTMLButtonElement;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Ask for a piece of text in the page's one-field dialog.
|
|
120
|
+
*
|
|
121
|
+
* @param {TextRequest} request - What to ask.
|
|
122
|
+
* @returns {Promise<string | null>} The text, trimmed; null when cancelled.
|
|
123
|
+
* @example
|
|
124
|
+
* const name = await askText({title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200});
|
|
125
|
+
* if (name) rename(workspace, name);
|
|
126
|
+
*
|
|
127
|
+
* const site = await askText({
|
|
128
|
+
* title: 'Add site', label: 'Site', action: 'Add',
|
|
129
|
+
* validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
|
|
130
|
+
* });
|
|
131
|
+
*/
|
|
132
|
+
export declare function askText({ title, label, value, action, cancel, validate, maxLength, returnTo, doc, }: TextRequest): Promise<string | null>;
|