@arraypress/extension-kit 1.3.0 → 1.5.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.
@@ -90,6 +90,14 @@ export type SiteListWords = {
90
90
  */
91
91
  /** @type {Readonly<SiteListWords>} */
92
92
  export declare const SITE_LIST_WORDS: Readonly<SiteListWords>;
93
+ /**
94
+ * The message key each word is read from when a page passes its `t`, so
95
+ * every extension's `_locales` names them alike. `removeLabel` and
96
+ * `moreActions` take the site as `$1` (`"More actions for $SITE$"`).
97
+ *
98
+ * @type {Readonly<Record<keyof SiteListWords, string>>}
99
+ */
100
+ export declare const SITE_LIST_MESSAGES: Readonly<Record<keyof SiteListWords, string>>;
93
101
  export type SiteListOptions = {
94
102
  /**
95
103
  * The `<ul class="list">` to fill.
@@ -140,7 +148,13 @@ export type SiteListOptions = {
140
148
  */
141
149
  maxLength?: number;
142
150
  /**
143
- * Translations of {@link SITE_LIST_WORDS}.
151
+ * The page's message lookup
152
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
153
+ * Chrome's English ({@link SITE_LIST_WORDS}).
154
+ */
155
+ t?: (key: string, substitutions?: string | string[]) => string;
156
+ /**
157
+ * Words that differ from those, by name.
144
158
  */
145
159
  words?: Partial<SiteListWords>;
146
160
  };
@@ -170,7 +184,10 @@ export type SiteList = {
170
184
  * @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
171
185
  * @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
172
186
  * @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
173
- * @property {Partial<SiteListWords>} [words] Translations of {@link SITE_LIST_WORDS}.
187
+ * @property {(key: string, substitutions?: string | string[]) => string} [t] The page's message lookup
188
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
189
+ * Chrome's English ({@link SITE_LIST_WORDS}).
190
+ * @property {Partial<SiteListWords>} [words] Words that differ from those, by name.
174
191
  */
175
192
  /**
176
193
  * A site list.
@@ -190,10 +207,10 @@ export type SiteList = {
190
207
  * const never = createSiteList({
191
208
  * list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
192
209
  * parse: siteHost,
193
- * add: (host) => save({ neverSites: [...settings.neverSites, host] }),
194
- * remove: (host) => save({ neverSites: settings.neverSites.filter((h) => h !== host) }),
195
- * words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
210
+ * add: (host) => save({ neverSites: withSite(settings.neverSites, host) }),
211
+ * remove: (host) => save({ neverSites: withoutSite(settings.neverSites, host) }),
212
+ * t, // its words from optionsAdd, optionsRemove, …
196
213
  * });
197
214
  * never.render(settings.neverSites);
198
215
  */
199
- export declare function createSiteList({ list, empty, form, addButton, parse, add, remove, replace, invalid, label, maxLength, words, }: SiteListOptions): SiteList;
216
+ export declare function createSiteList({ list, empty, form, addButton, parse, add, remove, replace, invalid, label, maxLength, t, words, }: SiteListOptions): SiteList;
@@ -63,6 +63,43 @@ export const SITE_LIST_WORDS = Object.freeze({
63
63
  moreActions: (/** @type {string} */ label) => `More actions for “${label}”`,
64
64
  });
65
65
 
66
+ /**
67
+ * The message key each word is read from when a page passes its `t`, so
68
+ * every extension's `_locales` names them alike. `removeLabel` and
69
+ * `moreActions` take the site as `$1` (`"More actions for $SITE$"`).
70
+ *
71
+ * @type {Readonly<Record<keyof SiteListWords, string>>}
72
+ */
73
+ export const SITE_LIST_MESSAGES = Object.freeze({
74
+ add: 'optionsAdd',
75
+ addSite: 'optionsAddSite',
76
+ editSite: 'optionsEditSite',
77
+ edit: 'optionsEdit',
78
+ remove: 'optionsRemove',
79
+ save: 'optionsSave',
80
+ cancel: 'optionsCancel',
81
+ site: 'optionsSiteLabel',
82
+ invalid: 'optionsSiteInvalid',
83
+ removeLabel: 'optionsRemoveLabel',
84
+ moreActions: 'optionsMoreActions',
85
+ });
86
+
87
+ /**
88
+ * The list's words from a page's messages, under {@link SITE_LIST_MESSAGES}.
89
+ *
90
+ * @param {(key: string, substitutions?: string | string[]) => string} t - The page's message lookup.
91
+ * @returns {SiteListWords} The words.
92
+ */
93
+ function translatedWords(t) {
94
+ const m = SITE_LIST_MESSAGES;
95
+ return {
96
+ add: t(m.add), addSite: t(m.addSite), editSite: t(m.editSite), edit: t(m.edit), remove: t(m.remove),
97
+ save: t(m.save), cancel: t(m.cancel), site: t(m.site), invalid: t(m.invalid),
98
+ removeLabel: (label) => t(m.removeLabel, [label]),
99
+ moreActions: (label) => t(m.moreActions, [label]),
100
+ };
101
+ }
102
+
66
103
  /**
67
104
  * What {@link createSiteList} takes: the list, one way to add to it, and
68
105
  * what adding, removing and editing do.
@@ -83,7 +120,10 @@ export const SITE_LIST_WORDS = Object.freeze({
83
120
  * @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
84
121
  * @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
85
122
  * @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
86
- * @property {Partial<SiteListWords>} [words] Translations of {@link SITE_LIST_WORDS}.
123
+ * @property {(key: string, substitutions?: string | string[]) => string} [t] The page's message lookup
124
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
125
+ * Chrome's English ({@link SITE_LIST_WORDS}).
126
+ * @property {Partial<SiteListWords>} [words] Words that differ from those, by name.
87
127
  */
88
128
 
89
129
  /**
@@ -105,17 +145,17 @@ export const SITE_LIST_WORDS = Object.freeze({
105
145
  * const never = createSiteList({
106
146
  * list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
107
147
  * parse: siteHost,
108
- * add: (host) => save({ neverSites: [...settings.neverSites, host] }),
109
- * remove: (host) => save({ neverSites: settings.neverSites.filter((h) => h !== host) }),
110
- * words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
148
+ * add: (host) => save({ neverSites: withSite(settings.neverSites, host) }),
149
+ * remove: (host) => save({ neverSites: withoutSite(settings.neverSites, host) }),
150
+ * t, // its words from optionsAdd, optionsRemove, …
111
151
  * });
112
152
  * never.render(settings.neverSites);
113
153
  */
114
154
  export function createSiteList({
115
- list, empty, form, addButton, parse, add, remove, replace, invalid, label = siteLabel, maxLength = MAX_HOST_LENGTH, words,
155
+ list, empty, form, addButton, parse, add, remove, replace, invalid, label = siteLabel, maxLength = MAX_HOST_LENGTH, t, words,
116
156
  }) {
117
157
  const doc = list.ownerDocument;
118
- const w = { ...SITE_LIST_WORDS, ...words };
158
+ const w = { ...(t ? translatedWords(t) : SITE_LIST_WORDS), ...words };
119
159
  const menu = addButton ? createMenu({ doc }) : null;
120
160
 
121
161
  /**
@@ -1,43 +1,55 @@
1
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.
2
+ * Chrome's text dialog (cr-dialog with cr-inputs): a title, labelled fields,
3
+ * Cancel and an action button. One field, as chrome://bookmarks asks for a
4
+ * folder's name (`askText`); several, as chrome://settings/searchEngines asks
5
+ * for a site search (`askFields`). The action button stays disabled while a
6
+ * field is blank or isn't valid, with the reason under that field. Enter
7
+ * means the action button, Esc and Cancel cancel, and the focus goes back to
8
+ * what opened it. The page needs no markup: the dialog (`#text-dialog`) is
9
+ * made the first time, its fields each time it's asked.
8
10
  *
9
11
  * @module ui/text-dialog
10
12
  */
11
- export type TextRequest = {
13
+ export type FieldRequest = {
12
14
  /**
13
- * The dialog's title ("Rename folder").
15
+ * Its key in the answer ("folder"), and part of its id.
14
16
  */
15
- title: string;
17
+ name: string;
16
18
  /**
17
- * The field's label ("Name").
19
+ * Its label ("Folder").
18
20
  */
19
21
  label: string;
20
22
  /**
21
- * The text to start with (selected). Default ''.
23
+ * The text to start with. Default ''.
22
24
  */
23
25
  value?: string;
24
26
  /**
25
- * The action button's text. Default "Save".
26
- */
27
- action?: string;
28
- /**
29
- * The cancel button's text. Default "Cancel".
27
+ * Shown while it's empty. Default none.
30
28
  */
31
- cancel?: string;
29
+ placeholder?: string;
32
30
  /**
33
31
  * Why the (non-blank) text can't be used,
34
32
  * shown under the field; null when it can. Default: anything but blank.
35
33
  */
36
34
  validate?: (text: string) => string | null;
37
35
  /**
38
- * The field's `maxlength`. Default none.
36
+ * Its `maxlength`. Default none.
39
37
  */
40
38
  maxLength?: number;
39
+ };
40
+ export type DialogRequest = {
41
+ /**
42
+ * The dialog's title ("Rename folder").
43
+ */
44
+ title: string;
45
+ /**
46
+ * The action button's text. Default "Save".
47
+ */
48
+ action?: string;
49
+ /**
50
+ * The cancel button's text. Default "Cancel".
51
+ */
52
+ cancel?: string;
41
53
  /**
42
54
  * Where the focus
43
55
  * goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
@@ -49,23 +61,44 @@ export type TextRequest = {
49
61
  */
50
62
  doc?: Document;
51
63
  };
64
+ export type TextRequest = DialogRequest & Omit<FieldRequest, 'name'>;
65
+ export type FieldsRequest = DialogRequest & {
66
+ fields: FieldRequest[];
67
+ };
52
68
  /**
53
- * What the dialog asks.
69
+ * One field of the dialog.
54
70
  *
55
- * @typedef {object} TextRequest
71
+ * @typedef {object} FieldRequest
72
+ * @property {string} name Its key in the answer ("folder"), and part of its id.
73
+ * @property {string} label Its label ("Folder").
74
+ * @property {string} [value] The text to start with. Default ''.
75
+ * @property {string} [placeholder] Shown while it's empty. Default none.
76
+ * @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
77
+ * shown under the field; null when it can. Default: anything but blank.
78
+ * @property {number} [maxLength] Its `maxlength`. Default none.
79
+ */
80
+ /**
81
+ * What the dialog asks, whatever its fields.
82
+ *
83
+ * @typedef {object} DialogRequest
56
84
  * @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
85
  * @property {string} [action] The action button's text. Default "Save".
60
86
  * @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
87
  * @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
65
88
  * goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
66
89
  * Default what had the focus when it opened.
67
90
  * @property {Document} [doc] The page. Default `document`.
68
91
  */
92
+ /**
93
+ * What the one-field dialog asks.
94
+ *
95
+ * @typedef {DialogRequest & Omit<FieldRequest, 'name'>} TextRequest
96
+ */
97
+ /**
98
+ * What the dialog with several fields asks.
99
+ *
100
+ * @typedef {DialogRequest & {fields: FieldRequest[]}} FieldsRequest
101
+ */
69
102
  /**
70
103
  * Whether a text can be used, and the reason to show when it can't. Pure.
71
104
  *
@@ -81,6 +114,20 @@ export declare function checkText(text: string, validate?: (text: string) => str
81
114
  ok: boolean;
82
115
  reason: string;
83
116
  };
117
+ /**
118
+ * A field's element ids. A one-field dialog's are `text-dialog-input` and `text-dialog-error`;
119
+ * with several, each is named: `text-dialog-<name>` and `text-dialog-<name>-error`. Pure.
120
+ *
121
+ * @param {string} name - The field's name.
122
+ * @param {boolean} only - Whether it's the dialog's only field.
123
+ * @returns {{input: string, error: string}} The ids.
124
+ * @example
125
+ * fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
126
+ */
127
+ export declare function fieldIds(name: string, only: boolean): {
128
+ input: string;
129
+ error: string;
130
+ };
84
131
  export type Parts = {
85
132
  /**
86
133
  * The `<dialog>`.
@@ -95,17 +142,9 @@ export type Parts = {
95
142
  */
96
143
  title: HTMLElement;
97
144
  /**
98
- * The field's label.
99
- */
100
- label: HTMLLabelElement;
101
- /**
102
- * The field.
103
- */
104
- input: HTMLInputElement;
105
- /**
106
- * The reason under the field.
145
+ * Where the fields go.
107
146
  */
108
- error: HTMLElement;
147
+ body: HTMLElement;
109
148
  /**
110
149
  * The cancel button.
111
150
  */
@@ -115,8 +154,39 @@ export type Parts = {
115
154
  */
116
155
  confirm: HTMLButtonElement;
117
156
  };
157
+ export type Field = {
158
+ /**
159
+ * What was asked.
160
+ */
161
+ request: FieldRequest;
162
+ /**
163
+ * Its input.
164
+ */
165
+ input: HTMLInputElement;
166
+ /**
167
+ * The reason under it.
168
+ */
169
+ error: HTMLElement;
170
+ };
171
+ /**
172
+ * Ask for several pieces of text in the page's dialog, one field each.
173
+ *
174
+ * @param {FieldsRequest} request - What to ask.
175
+ * @returns {Promise<Record<string, string> | null>} Each field's text by its name, trimmed; null
176
+ * when cancelled.
177
+ * @example
178
+ * const rule = await askFields({
179
+ * title: 'Add folder rule', action: 'Add',
180
+ * fields: [
181
+ * {name: 'from', label: 'Downloads from', placeholder: 'example.com or .pdf', validate: checkFrom},
182
+ * {name: 'folder', label: 'Folder', placeholder: 'Receipts'},
183
+ * ],
184
+ * });
185
+ * if (rule) addRule(rule.from, rule.folder);
186
+ */
187
+ export declare function askFields({ title, fields: requests, action, cancel, returnTo, doc, }: FieldsRequest): Promise<Record<string, string> | null>;
118
188
  /**
119
- * Ask for a piece of text in the page's one-field dialog.
189
+ * Ask for a piece of text in the page's dialog, with one field.
120
190
  *
121
191
  * @param {TextRequest} request - What to ask.
122
192
  * @returns {Promise<string | null>} The text, trimmed; null when cancelled.
@@ -129,4 +199,4 @@ export type Parts = {
129
199
  * validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
130
200
  * });
131
201
  */
132
- export declare function askText({ title, label, value, action, cancel, validate, maxLength, returnTo, doc, }: TextRequest): Promise<string | null>;
202
+ export declare function askText({ label, value, placeholder, validate, maxLength, ...dialog }: TextRequest): Promise<string | null>;