@arraypress/extension-kit 1.6.0 → 1.8.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.
@@ -19,8 +19,16 @@ import { focusedElement } from '../dom/focus.js';
19
19
  * @typedef {object} FieldRequest
20
20
  * @property {string} name Its key in the answer ("folder"), and part of its id.
21
21
  * @property {string} label Its label ("Folder").
22
- * @property {string} [value] The text to start with. Default ''.
22
+ * @property {string | Promise<string>} [value] The text to start with. Default ''. A promise is text
23
+ * still being worked out (an on-device model's suggestion): the field waits, disabled, with
24
+ * `waiting` under it, and takes the text when it comes ('' if it fails).
25
+ * @property {string} [waiting] What the field says while its `value` is worked out ("Finding words…").
23
26
  * @property {string} [placeholder] Shown while it's empty. Default none.
27
+ * @property {boolean} [multiline] A `<textarea>` (cr-textarea) rather than one line: Enter starts a new
28
+ * line, and only the action button saves. Default false.
29
+ * @property {number} [rows] A multi-line field's height in lines. Default 6.
30
+ * @property {string | ((text: string) => string)} [hint] Help under the field (cr-input's
31
+ * description), or a function of its text, kept up as it's typed ("12 words"). Default none.
24
32
  * @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
25
33
  * shown under the field; null when it can. Default: anything but blank.
26
34
  * @property {number} [maxLength] Its `maxlength`. Default none.
@@ -74,14 +82,14 @@ export function checkText(text, validate = () => null) {
74
82
  *
75
83
  * @param {string} name - The field's name.
76
84
  * @param {boolean} only - Whether it's the dialog's only field.
77
- * @returns {{input: string, error: string}} The ids.
85
+ * @returns {{input: string, error: string, hint: string}} The ids.
78
86
  * @example
79
- * fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
87
+ * fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error', hint: 'text-dialog-folder-hint'}
80
88
  */
81
89
  export function fieldIds(name, only) {
82
90
  return only
83
- ? { input: 'text-dialog-input', error: 'text-dialog-error' }
84
- : { input: `text-dialog-${name}`, error: `text-dialog-${name}-error` };
91
+ ? { input: 'text-dialog-input', error: 'text-dialog-error', hint: 'text-dialog-hint' }
92
+ : { input: `text-dialog-${name}`, error: `text-dialog-${name}-error`, hint: `text-dialog-${name}-hint` };
85
93
  }
86
94
 
87
95
  /**
@@ -101,8 +109,10 @@ export function fieldIds(name, only) {
101
109
  *
102
110
  * @typedef {object} Field
103
111
  * @property {FieldRequest} request What was asked.
104
- * @property {HTMLInputElement} input Its input.
112
+ * @property {HTMLInputElement | HTMLTextAreaElement} input Its input.
105
113
  * @property {HTMLElement} error The reason under it.
114
+ * @property {HTMLElement} hint The help under it.
115
+ * @property {boolean} waiting Whether its value is still being worked out.
106
116
  */
107
117
 
108
118
  /** Each page's dialog, made on first use. @type {WeakMap<Document, Parts>} */
@@ -161,14 +171,19 @@ function fieldFor(doc, request, only) {
161
171
  const row = element(doc, 'div', { class: 'field' });
162
172
  const label = element(doc, 'label', { for: ids.input });
163
173
  label.textContent = request.label;
164
- const input = element(doc, 'input', { id: ids.input, type: 'text', autocomplete: 'off', 'aria-describedby': ids.error });
165
- input.value = request.value ?? '';
174
+ const described = `${ids.error} ${ids.hint}`;
175
+ const input = request.multiline
176
+ ? element(doc, 'textarea', { id: ids.input, rows: String(request.rows ?? 6), 'aria-describedby': described })
177
+ : element(doc, 'input', { id: ids.input, type: 'text', autocomplete: 'off', 'aria-describedby': described });
178
+ input.value = typeof request.value === 'string' ? request.value : '';
166
179
  if (request.placeholder) input.placeholder = request.placeholder;
167
180
  if (request.maxLength !== undefined) input.maxLength = request.maxLength;
168
181
  const error = element(doc, 'span', { id: ids.error, class: 'hint error' });
169
182
  error.hidden = true;
170
- row.append(label, input, error);
171
- return { row, field: { request, input, error } };
183
+ const hint = element(doc, 'span', { id: ids.hint, class: 'hint secondary' });
184
+ hint.hidden = true;
185
+ row.append(label, input, error, hint);
186
+ return { row, field: { request, input, error, hint, waiting: false } };
172
187
  }
173
188
 
174
189
  /** The open dialog's settle function, per page. @type {WeakMap<Document, (answer: Record<string, string> | null) => void>} */
@@ -205,12 +220,14 @@ export function askFields({
205
220
  parts.confirm.textContent = action;
206
221
  parts.cancel.textContent = cancel;
207
222
 
208
- const allOk = () => fields.every(({ input, request }) => checkText(input.value, request.validate).ok);
223
+ const allOk = () => fields.every(({ input, request, waiting }) => !waiting && checkText(input.value, request.validate).ok);
209
224
  /** @param {Field} field */
210
- const refresh = ({ input, error, request }) => {
211
- const { reason } = checkText(input.value, request.validate);
225
+ const refresh = ({ input, error, hint, request, waiting }) => {
226
+ const { reason } = waiting ? { reason: '' } : checkText(input.value, request.validate);
212
227
  error.textContent = reason;
213
228
  error.hidden = !reason;
229
+ hint.textContent = waiting ? request.waiting ?? '' : typeof request.hint === 'function' ? request.hint(input.value) : request.hint ?? '';
230
+ hint.hidden = !hint.textContent;
214
231
  };
215
232
  for (const field of fields) refresh(field);
216
233
  parts.confirm.disabled = !allOk();
@@ -232,8 +249,29 @@ export function askFields({
232
249
  });
233
250
  };
234
251
  pending.set(doc, settle);
252
+ let typed = false;
253
+ // A value still being worked out: the field waits, then takes it, unless the dialog has moved on.
254
+ for (const field of fields) {
255
+ const { value } = field.request;
256
+ if (typeof value !== 'object' || !value) continue;
257
+ field.waiting = true;
258
+ field.input.disabled = true;
259
+ refresh(field);
260
+ value.catch(() => '').then((text) => {
261
+ if (signal.aborted) return;
262
+ field.waiting = false;
263
+ field.input.disabled = false;
264
+ field.input.value = text;
265
+ refresh(field);
266
+ parts.confirm.disabled = !allOk();
267
+ // The person was waiting for it: the focus moves in, unless they've been typing meanwhile.
268
+ if (!typed) field.input.focus();
269
+ });
270
+ }
271
+ parts.confirm.disabled = !allOk();
235
272
  for (const field of fields) {
236
273
  field.input.addEventListener('input', () => {
274
+ typed = true;
237
275
  refresh(field);
238
276
  parts.confirm.disabled = !allOk();
239
277
  }, { signal });
@@ -256,7 +294,7 @@ export function askFields({
256
294
 
257
295
  parts.dialog.showModal();
258
296
  // The first field still to fill in, or the first, its text selected to type over.
259
- (fields.find(({ input }) => !input.value) ?? fields[0])?.input.select();
297
+ (fields.find(({ input }) => !input.value && !input.disabled) ?? fields[0])?.input.select();
260
298
  });
261
299
  }
262
300
 
@@ -275,8 +313,8 @@ export function askFields({
275
313
  * });
276
314
  */
277
315
  export async function askText({
278
- label, value, placeholder, validate, maxLength, ...dialog
316
+ label, value, placeholder, validate, maxLength, multiline, rows, hint, waiting, ...dialog
279
317
  }) {
280
- const answer = await askFields({ ...dialog, fields: [{ name: 'input', label, value, placeholder, validate, maxLength }] });
318
+ const answer = await askFields({ ...dialog, fields: [{ name: 'input', label, value, placeholder, validate, maxLength, multiline, rows, hint, waiting }] });
281
319
  return answer?.input ?? null;
282
320
  }