@optionfactory/fml 9.0.0-rc22 → 9.0.0-rc24

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/ful.mjs CHANGED
@@ -693,6 +693,11 @@ class Bindings {
693
693
  * `rest` being the remainder of the path (`''` on an exact match) that a
694
694
  * composite can use to route the problem to an inner control.
695
695
  *
696
+ * A control counts only when it can show a problem, through a
697
+ * `setCustomValidity` of its own: a form-associated element that has none is
698
+ * skipped, so its problems fall to a shorter prefix or to the banner instead
699
+ * of being dropped.
700
+ *
696
701
  * Every other problem, and a field problem naming no control, is shown in
697
702
  * each `ful-errors` banner of the form: the banner gets `role="alert"`, is
698
703
  * revealed and holds the reasons one per line. With no such problem the
@@ -731,7 +736,7 @@ class Bindings {
731
736
  const parts = name.split('.');
732
737
  for (let i = parts.length; i !== 0; --i) {
733
738
  const prefix = parts.slice(0, i).join('.');
734
- const targets = named.get(prefix) ?? [];
739
+ const targets = (named.get(prefix) ?? []).filter((el) => typeof el.setCustomValidity === 'function');
735
740
  if (targets.length === 0) {
736
741
  continue;
737
742
  }
@@ -1189,6 +1194,14 @@ class Field extends ParsedElement {
1189
1194
  * accessibility tree.
1190
1195
  * - `also` are further controls that mirror `disabled` and `readOnly`
1191
1196
  * beside the claim target.
1197
+ *
1198
+ * The pieces are elements the field rendered itself, native controls or
1199
+ * wrappers of its own. A ful field is not one: it has no `readOnly`, its
1200
+ * host's role takes no aria, and a label's `for` names its host rather than
1201
+ * its control. A composite built from ful fields answers the inner field as
1202
+ * `control`, which forwards `focus()`, lets it render its own label with
1203
+ * `label` and `announces` null, and forwards the claims in its setters,
1204
+ * calling super first.
1192
1205
  * @param {{ slots: Record<string, DocumentFragment> | undefined }} conf
1193
1206
  * @returns {any} the pieces, or a promise of them
1194
1207
  * @throws {Error} when the subclass does not implement it
@@ -1463,11 +1476,13 @@ class Form extends ParsedElement {
1463
1476
  * A call while a submit is in flight returns at once, doing nothing.
1464
1477
  * @param {HTMLElement} [submitter] the button that submitted, if any; it is
1465
1478
  * the only button whose name and value are submitted
1466
- * @returns {Promise<void>} settling once the submit has finished, whatever its outcome
1479
+ * @returns {Promise<boolean>} settling once the submit has finished: true
1480
+ * after `submit:success`, false when it failed, was cancelled or was dropped
1481
+ * for one in flight
1467
1482
  */
1468
1483
  async submit(submitter) {
1469
1484
  if (this.#submitting) {
1470
- return;
1485
+ return false;
1471
1486
  }
1472
1487
  this.#submitting = true;
1473
1488
  this.spinner(true);
@@ -1484,7 +1499,7 @@ class Form extends ParsedElement {
1484
1499
  detail: { submitter, values, request },
1485
1500
  });
1486
1501
  if (!this.dispatchEvent(se)) {
1487
- return;
1502
+ return false;
1488
1503
  }
1489
1504
  this.errors = [];
1490
1505
  const sre = new CustomEvent('submit:requested', {
@@ -1507,6 +1522,7 @@ class Form extends ParsedElement {
1507
1522
  detail: { submitter, values, request, response: mapped },
1508
1523
  }),
1509
1524
  );
1525
+ return true;
1510
1526
  } catch (e) {
1511
1527
  this.dispatchEvent(
1512
1528
  new CustomEvent('submit:failure', {
@@ -1520,11 +1536,27 @@ class Form extends ParsedElement {
1520
1536
  } else {
1521
1537
  console.warn('failed to submit form', this, 'reason:', e);
1522
1538
  }
1539
+ return false;
1523
1540
  } finally {
1524
1541
  this.#submitting = false;
1525
1542
  this.spinner(false);
1526
1543
  }
1527
1544
  }
1545
+ /**
1546
+ * Saves the unsaved edits, for a page action that would lose them, such as
1547
+ * one reloading the page: under `track-changes` it submits only while the
1548
+ * form is dirty, and without it always submits.
1549
+ * @param {HTMLElement} [submitter] as `submit` takes it
1550
+ * @returns {Promise<boolean>} whether no unsaved edit is left: false when
1551
+ * the submit did not succeed, or when the user edited the form while it
1552
+ * was in flight
1553
+ */
1554
+ async save(submitter) {
1555
+ if (this.#tracking && !this.dirty) {
1556
+ return true;
1557
+ }
1558
+ return (await this.submit(submitter)) && !this.dirty;
1559
+ }
1528
1560
  /**
1529
1561
  * Resets the native form, each field returning to its default value. Under
1530
1562
  * `track-changes` every control counts as edited: the form is dirty if the
@@ -1597,12 +1629,14 @@ class Form extends ParsedElement {
1597
1629
  }
1598
1630
  }
1599
1631
  /**
1600
- * Counts a control as edited by the user, for a value the page writes on
1632
+ * Counts controls as edited by the user, for values the page writes on
1601
1633
  * the user's behalf: assigning `value` reports nothing, as 0019 wants.
1602
- * @param {Element} el the control, or an element inside it
1634
+ * @param {...Element} els each a control, or an element inside one
1603
1635
  */
1604
- edited(el) {
1605
- this.#touch(el);
1636
+ edited(...els) {
1637
+ for (const el of els) {
1638
+ this.#touch(el);
1639
+ }
1606
1640
  }
1607
1641
  /**
1608
1642
  * @param {Map<string, string>} saved
@@ -4555,7 +4589,7 @@ class RadioGroup extends Field {
4555
4589
  return;
4556
4590
  }
4557
4591
  /** @type {HTMLInputElement|null} */
4558
- const el = this.querySelector(`input[type=radio][value=${CSS.escape(String(value))}]`);
4592
+ const el = this.querySelector(`input[type=radio][value="${CSS.escape(String(value))}"]`);
4559
4593
  if (el === null) {
4560
4594
  clear();
4561
4595
  return;
@@ -6704,6 +6738,8 @@ class DialogSections {
6704
6738
  #error;
6705
6739
  #requests = new SectionRequests();
6706
6740
  #updates = new Claims();
6741
+ /** @type {Promise<boolean>|null} */
6742
+ #confirming = null;
6707
6743
  /**
6708
6744
  * @param {HTMLElement} host the element the section requests are dispatched on
6709
6745
  * @param {DocumentFragment} fragment the rendered template, holding a
@@ -6727,17 +6763,53 @@ class DialogSections {
6727
6763
  get section() {
6728
6764
  return this.#section;
6729
6765
  }
6766
+ /**
6767
+ * Whether a `ful-form` of this dialog, not one of a dialog nested in it,
6768
+ * holds unsaved edits (see `Form.dirty`).
6769
+ * @returns {boolean}
6770
+ */
6771
+ get dirty() {
6772
+ return Array.from(this.#dialog.querySelectorAll('ful-form')).some(
6773
+ (form) => form.closest('dialog') === this.#dialog && /** @type {any} */ (form).dirty,
6774
+ );
6775
+ }
6730
6776
  /**
6731
6777
  * Dispatches the dismissal on the host and calls `close` unless a listener
6732
- * prevented it. A dismissal that is not cancelable announces a close the
6733
- * platform makes whatever the page does, so `close` is not called for it.
6778
+ * prevented it or, with `confirm` given and the dialog `dirty`, `confirm`
6779
+ * answers false. A dismissal that is not cancelable announces a close the
6780
+ * platform makes whatever the page does, so nothing is asked and `close` is
6781
+ * not called for it. While `confirm` is pending a further dismissal waits
6782
+ * for the same answer, and a dialog closed meanwhile is not closed again.
6734
6783
  * @param {CustomEvent} asked
6735
6784
  * @param {() => void} close
6785
+ * @param {(() => Promise<boolean>)|null} confirm asks whether to drop the unsaved edits
6786
+ * @returns {Promise<boolean>} whether the dismissal closes the dialog
6736
6787
  */
6737
- dismiss(asked, close) {
6738
- if (this.#host.dispatchEvent(asked) && asked.cancelable) {
6788
+ dismiss(asked, close, confirm) {
6789
+ if (this.#confirming) {
6790
+ return this.#confirming;
6791
+ }
6792
+ if (!this.#host.dispatchEvent(asked)) {
6793
+ return Promise.resolve(false);
6794
+ }
6795
+ if (!asked.cancelable) {
6796
+ return Promise.resolve(true);
6797
+ }
6798
+ if (!confirm || !this.dirty) {
6739
6799
  close();
6800
+ return Promise.resolve(true);
6740
6801
  }
6802
+ this.#confirming = confirm()
6803
+ .then((drop) => {
6804
+ if (drop && this.#dialog.open) {
6805
+ close();
6806
+ }
6807
+ return drop;
6808
+ })
6809
+ .finally(() => {
6810
+ this.#confirming = null;
6811
+ });
6812
+ return this.#confirming;
6741
6813
  }
6742
6814
  /**
6743
6815
  * Calls `dismiss` when a press and its release both land on the backdrop,
@@ -6791,11 +6863,12 @@ class DialogSections {
6791
6863
  * `show`, and fills the content with what the callback resolves to. A
6792
6864
  * rejection is shown, as the problems' reasons, in the error section, and
6793
6865
  * the content is hidden. When a newer `update()` starts before the callback
6794
- * settles, this call paints nothing: it resolves with the section regardless,
6795
- * and still rejects with the callback's error.
6866
+ * settles, this call paints nothing and resolves `null`, the section being
6867
+ * the newer call's; it still rejects with the callback's error.
6796
6868
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
6797
6869
  * @param {() => void} show opens the dialog when it is closed
6798
- * @returns {Promise<HTMLElement>} the content section
6870
+ * @returns {Promise<HTMLElement|null>} the content section, or `null` for a
6871
+ * call a newer one overtook
6799
6872
  * @throws {any} what the callback threw or rejected with
6800
6873
  */
6801
6874
  async update(cb, show) {
@@ -6808,7 +6881,7 @@ class DialogSections {
6808
6881
  try {
6809
6882
  const delivered = await cb();
6810
6883
  if (claim.stale) {
6811
- return this.#section;
6884
+ return null;
6812
6885
  }
6813
6886
  this.#section.replaceChildren(delivered);
6814
6887
  this.#loading?.setAttribute('hidden', '');
@@ -6877,21 +6950,24 @@ const wireTargets = () => {
6877
6950
  * and a press and release both on the backdrop close the dialog, each as a
6878
6951
  * dismissal, as Escape does.
6879
6952
  *
6880
- * Before any of those three gestures closes it, the element dispatches a
6881
- * cancelable, non-bubbling `dialog:dismiss` with `detail: { reason }`, the
6882
- * reason being `button`, `backdrop` or `escape`; a listener calling
6883
- * `preventDefault()` keeps it open, to guard unsaved edits. A `data-result`
6884
- * button, `close-on-submit` and `close()` answer rather than dismiss, and
6885
- * dispatch none.
6953
+ * Those three gestures go through `dismiss(reason)`, the reason being
6954
+ * `button`, `backdrop` or `escape`: before it closes the dialog, the element
6955
+ * dispatches a cancelable, non-bubbling `dialog:dismiss` with
6956
+ * `detail: { reason }`, and a listener calling `preventDefault()` keeps it
6957
+ * open. Under `guard-dismiss`, a dismissal nobody refused, while a `ful-form`
6958
+ * of the dialog holds unsaved edits (see `Form.dirty`), first asks the user,
6959
+ * through `Dialog.confirm`, whether to close and drop them. A `data-result`
6960
+ * button, `close-on-submit`, `close()` and `update()` are answers or the page
6961
+ * deciding: they dispatch nothing and ask nothing.
6886
6962
  *
6887
6963
  * The platform lets a page refuse Escape once per user interaction: a second
6888
6964
  * Escape with no click or keystroke in between closes the dialog whatever the
6889
- * page does, `requires-answer` included. That close is still announced by a
6890
- * `dialog:dismiss`, which is then not cancelable.
6965
+ * page does, `requires-answer` and `guard-dismiss` included. That close is
6966
+ * still announced by a `dialog:dismiss`, which is then not cancelable.
6891
6967
  *
6892
6968
  * `requires-answer` withholds the close button and refuses Escape and the
6893
6969
  * backdrop, so the dialog is closed only by a button carrying `data-result`
6894
- * or from code through `close()`.
6970
+ * or from code, through `close()` or `dismiss()`.
6895
6971
  *
6896
6972
  * `close-on-submit` closes the dialog when a `ful-form` that is a direct child
6897
6973
  * of the body dispatches `submit:success`, answering with its
@@ -6911,7 +6987,7 @@ const wireTargets = () => {
6911
6987
  * in a form needs.
6912
6988
  */
6913
6989
  class Dialog extends ParsedElement {
6914
- static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
6990
+ static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence', 'guard-dismiss:presence'];
6915
6991
  static slots = true;
6916
6992
  static template = `
6917
6993
  <dialog data-ref="dialog" class="ful-dialog">
@@ -6961,15 +7037,10 @@ class Dialog extends ParsedElement {
6961
7037
  this.#dialog.close(result);
6962
7038
  }
6963
7039
  });
6964
- const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
6965
- this.#sections.dismiss(
6966
- new CustomEvent('dialog:dismiss', { cancelable: !platformCloses, detail: { reason } }),
6967
- () => this.#dialog.close(''),
6968
- );
6969
7040
  if (!requiresAnswer) {
6970
- this.#sections.onBackdrop(() => dismiss('backdrop'));
7041
+ this.#sections.onBackdrop(() => this.dismiss('backdrop'));
6971
7042
  }
6972
- fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => dismiss('button'));
7043
+ fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => this.dismiss('button'));
6973
7044
  if (closeOnSubmit) {
6974
7045
  this.#sections.onSubmitted((response) => {
6975
7046
  this.#answer = { dismissed: false, result: null, response };
@@ -6979,12 +7050,12 @@ class Dialog extends ParsedElement {
6979
7050
  this.#dialog.addEventListener('cancel', (/** @type any */ e) => {
6980
7051
  if (!e.cancelable) {
6981
7052
  this.#dialog.returnValue = '';
6982
- dismiss('escape', true);
7053
+ this.#dismiss('escape', false);
6983
7054
  return;
6984
7055
  }
6985
7056
  e.preventDefault();
6986
7057
  if (!requiresAnswer) {
6987
- dismiss('escape');
7058
+ this.dismiss('escape');
6988
7059
  }
6989
7060
  });
6990
7061
  this.replaceChildren(fragment);
@@ -7056,11 +7127,12 @@ class Dialog extends ParsedElement {
7056
7127
  *
7057
7128
  * No `section:requested` is dispatched, and no `ask()` waiter is added:
7058
7129
  * listen for the `close` event to learn how the dialog ended. When a newer
7059
- * `update()` starts before the callback settles, this call paints nothing:
7060
- * it resolves with the body regardless, and still rejects with the
7061
- * callback's error.
7130
+ * `update()` starts before the callback settles, this call paints nothing
7131
+ * and resolves `null`, the body being the newer call's; it still rejects
7132
+ * with the callback's error.
7062
7133
  * @param {() => Node|string|Promise<Node|string>} cb producing the body content
7063
- * @returns {Promise<Element>} the body section, in the document
7134
+ * @returns {Promise<Element|null>} the body section, in the document, or
7135
+ * `null` for a call a newer one overtook
7064
7136
  * @throws {any} what the callback threw or rejected with
7065
7137
  */
7066
7138
  async update(cb) {
@@ -7076,6 +7148,34 @@ class Dialog extends ParsedElement {
7076
7148
  refresh() {
7077
7149
  return this.#sections.request();
7078
7150
  }
7151
+ /**
7152
+ * Dismisses the dialog as the user would: dispatches the cancelable
7153
+ * `dialog:dismiss` with `detail: { reason }` and, under `guard-dismiss`,
7154
+ * asks before dropping unsaved edits, closing the dialog as a dismissal
7155
+ * unless either refused. `requires-answer` does not refuse it, as it does
7156
+ * not refuse `close()`. Does nothing on a closed dialog.
7157
+ * @param {string} reason why it is dismissed, such as `navigation`
7158
+ * @returns {Promise<boolean>} whether the dialog is closed, false when a
7159
+ * listener or the user kept it open
7160
+ */
7161
+ dismiss(reason) {
7162
+ if (!this.#dialog.open) {
7163
+ return Promise.resolve(true);
7164
+ }
7165
+ return this.#dismiss(reason, true);
7166
+ }
7167
+ /**
7168
+ * @param {string} reason
7169
+ * @param {boolean} cancelable false for a close the platform makes anyway
7170
+ * @returns {Promise<boolean>}
7171
+ */
7172
+ #dismiss(reason, cancelable) {
7173
+ return this.#sections.dismiss(
7174
+ new CustomEvent('dialog:dismiss', { cancelable, detail: { reason } }),
7175
+ () => this.#dialog.close(''),
7176
+ this.declared('guard-dismiss') ? discardEdits : null,
7177
+ );
7178
+ }
7079
7179
  /**
7080
7180
  * Closes an open dialog, doing nothing on a closed one. A non-empty result
7081
7181
  * answers as the button carrying that `data-result` would; no result, or
@@ -7175,6 +7275,19 @@ class Dialog extends ParsedElement {
7175
7275
  }
7176
7276
  }
7177
7277
 
7278
+ /**
7279
+ * Asks, through `Dialog.confirm`, whether to close and drop the unsaved edits,
7280
+ * with the localized `dialog.discard.*` texts.
7281
+ * @returns {Promise<boolean>} true to drop them
7282
+ */
7283
+ const discardEdits = () => {
7284
+ const l10n = Localization.of();
7285
+ return Dialog.confirm(l10n.t('dialog.discard.header'), l10n.t('dialog.discard.body'), {
7286
+ confirm: l10n.t('dialog.discard.confirm'),
7287
+ cancel: l10n.t('dialog.discard.cancel'),
7288
+ });
7289
+ };
7290
+
7178
7291
  /**
7179
7292
  * A side panel on the native `<dialog>`, shown as a modal: a header holding
7180
7293
  * the title and a localized close button, then loading, error and content
@@ -7187,17 +7300,23 @@ class Dialog extends ParsedElement {
7187
7300
  * default, when absent) or `start`, following the writing direction.
7188
7301
  *
7189
7302
  * The close button, Escape, and a press and release both on the backdrop all
7190
- * go through `close()`, once a cancelable, non-bubbling `drawer:dismiss` with
7191
- * `detail: { reason }` (`button`, `backdrop` or `escape`) has been dispatched
7192
- * on the element and no listener prevented it: `preventDefault()` keeps the
7193
- * drawer open, to guard unsaved edits. `close()` and `close-on-submit`
7194
- * dispatch none. The platform lets a page refuse Escape once per user
7303
+ * go through `dismiss(reason)`, the reason being `button`, `backdrop` or
7304
+ * `escape`: before it closes the drawer, the element dispatches a cancelable,
7305
+ * non-bubbling `drawer:dismiss` with `detail: { reason }`, and a listener
7306
+ * calling `preventDefault()` keeps it open. Under `guard-dismiss`, a dismissal
7307
+ * nobody refused, while a `ful-form` of the drawer holds unsaved edits (see
7308
+ * `Form.dirty`), first asks the user, through `Dialog.confirm`, whether to
7309
+ * close and drop them. A `data-result` button, `close-on-submit`, `close()`
7310
+ * and `update()` are answers or the page deciding: they dispatch nothing and
7311
+ * ask nothing. The platform lets a page refuse Escape once per user
7195
7312
  * interaction: a second Escape with no click or keystroke in between closes
7196
- * the drawer at once, without the slide, and its `drawer:dismiss` is then not
7197
- * cancelable. Every close dispatches a non-bubbling `close` event on
7198
- * the element with `detail: { dismissed, response }`: `{ dismissed: false,
7199
- * response }` when a form closed it under `close-on-submit`, and
7200
- * `{ dismissed: true, response: null }` for any other close.
7313
+ * the drawer at once, without the slide, `guard-dismiss` included, and its
7314
+ * `drawer:dismiss` is then not cancelable.
7315
+ *
7316
+ * Any button inside the drawer carrying `data-result` closes it with that
7317
+ * result, an empty one being a dismissal. Every close dispatches a
7318
+ * non-bubbling `close` event on the element carrying the `DialogOutcome` as
7319
+ * `detail`, before the pending `ask()` calls resolve.
7201
7320
  *
7202
7321
  * `close-on-submit` closes the drawer when a `ful-form` that is a direct child
7203
7322
  * of the content section dispatches `submit:success`, carrying its
@@ -7209,7 +7328,7 @@ class Dialog extends ParsedElement {
7209
7328
  * element's id calls `open()` on click.
7210
7329
  */
7211
7330
  class Drawer extends ParsedElement {
7212
- static attributes = ['header', 'placement', 'close-on-submit:presence'];
7331
+ static attributes = ['header', 'placement', 'close-on-submit:presence', 'guard-dismiss:presence'];
7213
7332
  static slots = true;
7214
7333
  static template = `
7215
7334
  <dialog data-ref="dialog" class="ful-drawer">
@@ -7226,8 +7345,9 @@ class Drawer extends ParsedElement {
7226
7345
  #dialog;
7227
7346
  #title;
7228
7347
  #sections;
7229
- /** @type {{ dismissed: boolean, response: any }|null} */
7348
+ /** @type {{ dismissed: boolean, result: string|null, response: any }|null} */
7230
7349
  #answer = null;
7350
+ #resolvers = [];
7231
7351
  #closing = false;
7232
7352
  #slideOutEnded = (/** @type AnimationEvent */ e) => {
7233
7353
  if (e.target === this.#dialog) {
@@ -7250,32 +7370,33 @@ class Drawer extends ParsedElement {
7250
7370
  if (placement) {
7251
7371
  this.#dialog.setAttribute('placement', placement);
7252
7372
  }
7253
- const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
7254
- this.#sections.dismiss(
7255
- new CustomEvent('drawer:dismiss', { cancelable: !platformCloses, detail: { reason } }),
7256
- () => this.close(),
7257
- );
7258
7373
  /** @type {HTMLElement} */ (fragment.querySelector('[data-ref=close]')).addEventListener('click', () =>
7259
- dismiss('button'),
7374
+ this.dismiss('button'),
7260
7375
  );
7261
- this.#sections.onBackdrop(() => dismiss('backdrop'));
7376
+ this.#sections.onBackdrop(() => this.dismiss('backdrop'));
7262
7377
  this.#dialog.addEventListener('cancel', (e) => {
7263
7378
  if (!e.cancelable) {
7264
7379
  this.#stopSlidingOut();
7265
- dismiss('escape', true);
7380
+ this.#dismiss('escape', false);
7266
7381
  return;
7267
7382
  }
7268
7383
  e.preventDefault();
7269
- dismiss('escape');
7384
+ this.dismiss('escape');
7385
+ });
7386
+ this.#dialog.addEventListener('click', (/** @type any */ e) => {
7387
+ const result = e.target.closest('button[data-result]')?.dataset.result;
7388
+ if (result !== undefined) {
7389
+ this.close(result);
7390
+ }
7270
7391
  });
7271
7392
  this.#dialog.addEventListener('close', () => {
7272
- this.dispatchEvent(
7273
- new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
7274
- );
7393
+ const outcome = this.#outcome();
7394
+ this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
7395
+ this.#settle(outcome);
7275
7396
  });
7276
7397
  if (this.declared('close-on-submit')) {
7277
7398
  this.#sections.onSubmitted((response) => {
7278
- this.#answer = { dismissed: false, response };
7399
+ this.#answer = { dismissed: false, result: null, response };
7279
7400
  this.close();
7280
7401
  });
7281
7402
  }
@@ -7301,12 +7422,13 @@ class Drawer extends ParsedElement {
7301
7422
  * which has `role="alert"`, and the content is hidden.
7302
7423
  *
7303
7424
  * No `section:requested` is dispatched. When a newer `update()` starts
7304
- * before the callback settles, this call paints nothing: it resolves with
7305
- * the content section regardless, and still rejects with the callback's
7306
- * error.
7425
+ * before the callback settles, this call paints nothing and resolves
7426
+ * `null`, the content section being the newer call's; it still rejects with
7427
+ * the callback's error.
7307
7428
  * @param {string} header the title, set as text
7308
7429
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
7309
- * @returns {Promise<Element>} the content section, in the document
7430
+ * @returns {Promise<Element|null>} the content section, in the document, or
7431
+ * `null` for a call a newer one overtook
7310
7432
  * @throws {any} what the callback threw or rejected with
7311
7433
  */
7312
7434
  async update(header, cb) {
@@ -7323,9 +7445,36 @@ class Drawer extends ParsedElement {
7323
7445
  refresh() {
7324
7446
  return this.#sections.request();
7325
7447
  }
7448
+ /** @returns {{ dismissed: boolean, result: string|null, response: any }} */
7449
+ #outcome() {
7450
+ return this.#answer ?? { dismissed: true, result: null, response: null };
7451
+ }
7452
+ /** @param {{ dismissed: boolean, result: string|null, response: any }} outcome */
7453
+ #settle(outcome) {
7454
+ const resolvers = this.#resolvers;
7455
+ this.#resolvers = [];
7456
+ for (const resolve of resolvers) {
7457
+ resolve(outcome);
7458
+ }
7459
+ }
7326
7460
  /**
7327
- * Shows the drawer as a modal. On a drawer already open nothing happens,
7328
- * except that one sliding out stops and stays open.
7461
+ * Resolves every pending `ask()` and `open()` with the outcome so far,
7462
+ * a dismissal for a drawer still open, so that no caller waits on a drawer
7463
+ * that left the document. The native dialog is not closed and no `close`
7464
+ * event is dispatched.
7465
+ */
7466
+ disconnectedCallback() {
7467
+ this.#settle(this.#outcome());
7468
+ }
7469
+ /**
7470
+ * The same as `ask()`.
7471
+ * @returns {Promise<{ dismissed: boolean, result: string|null, response: any }>}
7472
+ */
7473
+ open() {
7474
+ return this.ask();
7475
+ }
7476
+ /**
7477
+ * Shows the drawer as a modal and resolves with how it ends.
7329
7478
  *
7330
7479
  * The call that opens the drawer resets the outcome of the previous
7331
7480
  * opening, hides the error and loading sections, and dispatches
@@ -7333,26 +7482,67 @@ class Drawer extends ParsedElement {
7333
7482
  * `detail: { section, first, name: null, index: null }`, `section` being
7334
7483
  * the content section and `first` true only on the first answered request),
7335
7484
  * so a listener can fill the content. A failed request is painted into the
7336
- * content section.
7485
+ * content section and does not reject. On a drawer already open nothing is
7486
+ * shown or requested, except that one sliding out stops and stays open, and
7487
+ * the call waits for the same ending as the calls before it, which is how
7488
+ * a caller learns the outcome of the opening an `update()` made.
7489
+ * @returns {Promise<{ dismissed: boolean, result: string|null, response: any }>} resolving on
7490
+ * close, or on removal from the document with a dismissal
7337
7491
  */
7338
- open() {
7339
- if (!this.#show()) {
7340
- return;
7492
+ ask() {
7493
+ if (this.#show()) {
7494
+ this.#sections.restChrome();
7495
+ this.#sections.request();
7341
7496
  }
7342
- this.#sections.restChrome();
7343
- this.#sections.request();
7497
+ return new Promise((resolve) => {
7498
+ this.#resolvers.push(resolve);
7499
+ });
7500
+ }
7501
+ /**
7502
+ * Dismisses the drawer as the user would: dispatches the cancelable
7503
+ * `drawer:dismiss` with `detail: { reason }` and, under `guard-dismiss`,
7504
+ * asks before dropping unsaved edits, sliding the drawer out as a
7505
+ * dismissal unless either refused. Does nothing on a drawer that is closed
7506
+ * or already sliding out.
7507
+ * @param {string} reason why it is dismissed, such as `navigation`
7508
+ * @returns {Promise<boolean>} whether the drawer is closed or closing,
7509
+ * false when a listener or the user kept it open
7510
+ */
7511
+ dismiss(reason) {
7512
+ if (this.#closing || !this.#dialog.open) {
7513
+ return Promise.resolve(true);
7514
+ }
7515
+ return this.#dismiss(reason, true);
7516
+ }
7517
+ /**
7518
+ * @param {string} reason
7519
+ * @param {boolean} cancelable false for a close the platform makes anyway
7520
+ * @returns {Promise<boolean>}
7521
+ */
7522
+ #dismiss(reason, cancelable) {
7523
+ return this.#sections.dismiss(
7524
+ new CustomEvent('drawer:dismiss', { cancelable, detail: { reason } }),
7525
+ () => this.close(),
7526
+ this.declared('guard-dismiss') ? discardEdits : null,
7527
+ );
7344
7528
  }
7345
7529
  /**
7346
7530
  * Slides the drawer out and closes it when the animation ends, setting the
7347
7531
  * `closing` attribute on the native dialog meanwhile; the `close` event is
7348
7532
  * dispatched then, after this call has returned. Where the user prefers
7349
- * reduced motion, or no animation runs, it closes at once. Does nothing on
7350
- * a drawer that is closed or already sliding out.
7533
+ * reduced motion, or no animation runs, it closes at once. A non-empty
7534
+ * result answers as the button carrying that `data-result` would; no
7535
+ * result, or an empty one, is a dismissal. Does nothing on a drawer that is
7536
+ * closed or already sliding out.
7537
+ * @param {string} [result]
7351
7538
  */
7352
- close() {
7539
+ close(result) {
7353
7540
  if (this.#closing || !this.#dialog.open) {
7354
7541
  return;
7355
7542
  }
7543
+ if (result) {
7544
+ this.#answer = { dismissed: false, result, response: null };
7545
+ }
7356
7546
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
7357
7547
  this.#dialog.close();
7358
7548
  return;
@@ -8064,6 +8254,10 @@ var en = {
8064
8254
  'dialog.close': 'Close',
8065
8255
  'dialog.confirm': 'Confirm',
8066
8256
  'dialog.cancel': 'Cancel',
8257
+ 'dialog.discard.header': 'Unsaved changes',
8258
+ 'dialog.discard.body': 'There are unsaved changes: close anyway?',
8259
+ 'dialog.discard.confirm': 'Close without saving',
8260
+ 'dialog.discard.cancel': 'Keep editing',
8067
8261
  'drawer.close': 'Close',
8068
8262
  'spinner.loading': 'Loading…',
8069
8263
  'toast.region': 'Notifications',
@@ -8109,6 +8303,10 @@ var it = {
8109
8303
  'dialog.close': 'Chiudi',
8110
8304
  'dialog.confirm': 'Conferma',
8111
8305
  'dialog.cancel': 'Annulla',
8306
+ 'dialog.discard.header': 'Modifiche non salvate',
8307
+ 'dialog.discard.body': 'Ci sono modifiche non salvate: chiudere comunque?',
8308
+ 'dialog.discard.confirm': 'Chiudi senza salvare',
8309
+ 'dialog.discard.cancel': 'Continua a modificare',
8112
8310
  'drawer.close': 'Chiudi',
8113
8311
  'spinner.loading': 'Caricamento…',
8114
8312
  'toast.region': 'Notifiche',
@@ -8157,6 +8355,10 @@ var es = {
8157
8355
  'dialog.close': 'Cerrar',
8158
8356
  'dialog.confirm': 'Confirmar',
8159
8357
  'dialog.cancel': 'Cancelar',
8358
+ 'dialog.discard.header': 'Cambios sin guardar',
8359
+ 'dialog.discard.body': 'Hay cambios sin guardar: ¿cerrar de todos modos?',
8360
+ 'dialog.discard.confirm': 'Cerrar sin guardar',
8361
+ 'dialog.discard.cancel': 'Seguir editando',
8160
8362
  'drawer.close': 'Cerrar',
8161
8363
  'spinner.loading': 'Cargando…',
8162
8364
  'toast.region': 'Notificaciones',
@@ -8205,6 +8407,10 @@ var fr = {
8205
8407
  'dialog.close': 'Fermer',
8206
8408
  'dialog.confirm': 'Confirmer',
8207
8409
  'dialog.cancel': 'Annuler',
8410
+ 'dialog.discard.header': 'Modifications non enregistrées',
8411
+ 'dialog.discard.body': 'Des modifications ne sont pas enregistrées : fermer quand même ?',
8412
+ 'dialog.discard.confirm': 'Fermer sans enregistrer',
8413
+ 'dialog.discard.cancel': 'Continuer à modifier',
8208
8414
  'drawer.close': 'Fermer',
8209
8415
  'spinner.loading': 'Chargement…',
8210
8416
  'toast.region': 'Notifications',