@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/fml.iife.js CHANGED
@@ -6963,6 +6963,11 @@ var fml = (function (exports) {
6963
6963
  * `rest` being the remainder of the path (`''` on an exact match) that a
6964
6964
  * composite can use to route the problem to an inner control.
6965
6965
  *
6966
+ * A control counts only when it can show a problem, through a
6967
+ * `setCustomValidity` of its own: a form-associated element that has none is
6968
+ * skipped, so its problems fall to a shorter prefix or to the banner instead
6969
+ * of being dropped.
6970
+ *
6966
6971
  * Every other problem, and a field problem naming no control, is shown in
6967
6972
  * each `ful-errors` banner of the form: the banner gets `role="alert"`, is
6968
6973
  * revealed and holds the reasons one per line. With no such problem the
@@ -7001,7 +7006,7 @@ var fml = (function (exports) {
7001
7006
  const parts = name.split('.');
7002
7007
  for (let i = parts.length; i !== 0; --i) {
7003
7008
  const prefix = parts.slice(0, i).join('.');
7004
- const targets = named.get(prefix) ?? [];
7009
+ const targets = (named.get(prefix) ?? []).filter((el) => typeof el.setCustomValidity === 'function');
7005
7010
  if (targets.length === 0) {
7006
7011
  continue;
7007
7012
  }
@@ -7459,6 +7464,14 @@ var fml = (function (exports) {
7459
7464
  * accessibility tree.
7460
7465
  * - `also` are further controls that mirror `disabled` and `readOnly`
7461
7466
  * beside the claim target.
7467
+ *
7468
+ * The pieces are elements the field rendered itself, native controls or
7469
+ * wrappers of its own. A ful field is not one: it has no `readOnly`, its
7470
+ * host's role takes no aria, and a label's `for` names its host rather than
7471
+ * its control. A composite built from ful fields answers the inner field as
7472
+ * `control`, which forwards `focus()`, lets it render its own label with
7473
+ * `label` and `announces` null, and forwards the claims in its setters,
7474
+ * calling super first.
7462
7475
  * @param {{ slots: Record<string, DocumentFragment> | undefined }} conf
7463
7476
  * @returns {any} the pieces, or a promise of them
7464
7477
  * @throws {Error} when the subclass does not implement it
@@ -7733,11 +7746,13 @@ var fml = (function (exports) {
7733
7746
  * A call while a submit is in flight returns at once, doing nothing.
7734
7747
  * @param {HTMLElement} [submitter] the button that submitted, if any; it is
7735
7748
  * the only button whose name and value are submitted
7736
- * @returns {Promise<void>} settling once the submit has finished, whatever its outcome
7749
+ * @returns {Promise<boolean>} settling once the submit has finished: true
7750
+ * after `submit:success`, false when it failed, was cancelled or was dropped
7751
+ * for one in flight
7737
7752
  */
7738
7753
  async submit(submitter) {
7739
7754
  if (this.#submitting) {
7740
- return;
7755
+ return false;
7741
7756
  }
7742
7757
  this.#submitting = true;
7743
7758
  this.spinner(true);
@@ -7754,7 +7769,7 @@ var fml = (function (exports) {
7754
7769
  detail: { submitter, values, request },
7755
7770
  });
7756
7771
  if (!this.dispatchEvent(se)) {
7757
- return;
7772
+ return false;
7758
7773
  }
7759
7774
  this.errors = [];
7760
7775
  const sre = new CustomEvent('submit:requested', {
@@ -7777,6 +7792,7 @@ var fml = (function (exports) {
7777
7792
  detail: { submitter, values, request, response: mapped },
7778
7793
  }),
7779
7794
  );
7795
+ return true;
7780
7796
  } catch (e) {
7781
7797
  this.dispatchEvent(
7782
7798
  new CustomEvent('submit:failure', {
@@ -7790,11 +7806,27 @@ var fml = (function (exports) {
7790
7806
  } else {
7791
7807
  console.warn('failed to submit form', this, 'reason:', e);
7792
7808
  }
7809
+ return false;
7793
7810
  } finally {
7794
7811
  this.#submitting = false;
7795
7812
  this.spinner(false);
7796
7813
  }
7797
7814
  }
7815
+ /**
7816
+ * Saves the unsaved edits, for a page action that would lose them, such as
7817
+ * one reloading the page: under `track-changes` it submits only while the
7818
+ * form is dirty, and without it always submits.
7819
+ * @param {HTMLElement} [submitter] as `submit` takes it
7820
+ * @returns {Promise<boolean>} whether no unsaved edit is left: false when
7821
+ * the submit did not succeed, or when the user edited the form while it
7822
+ * was in flight
7823
+ */
7824
+ async save(submitter) {
7825
+ if (this.#tracking && !this.dirty) {
7826
+ return true;
7827
+ }
7828
+ return (await this.submit(submitter)) && !this.dirty;
7829
+ }
7798
7830
  /**
7799
7831
  * Resets the native form, each field returning to its default value. Under
7800
7832
  * `track-changes` every control counts as edited: the form is dirty if the
@@ -7867,12 +7899,14 @@ var fml = (function (exports) {
7867
7899
  }
7868
7900
  }
7869
7901
  /**
7870
- * Counts a control as edited by the user, for a value the page writes on
7902
+ * Counts controls as edited by the user, for values the page writes on
7871
7903
  * the user's behalf: assigning `value` reports nothing, as 0019 wants.
7872
- * @param {Element} el the control, or an element inside it
7904
+ * @param {...Element} els each a control, or an element inside one
7873
7905
  */
7874
- edited(el) {
7875
- this.#touch(el);
7906
+ edited(...els) {
7907
+ for (const el of els) {
7908
+ this.#touch(el);
7909
+ }
7876
7910
  }
7877
7911
  /**
7878
7912
  * @param {Map<string, string>} saved
@@ -10825,7 +10859,7 @@ var fml = (function (exports) {
10825
10859
  return;
10826
10860
  }
10827
10861
  /** @type {HTMLInputElement|null} */
10828
- const el = this.querySelector(`input[type=radio][value=${CSS.escape(String(value))}]`);
10862
+ const el = this.querySelector(`input[type=radio][value="${CSS.escape(String(value))}"]`);
10829
10863
  if (el === null) {
10830
10864
  clear();
10831
10865
  return;
@@ -12974,6 +13008,8 @@ var fml = (function (exports) {
12974
13008
  #error;
12975
13009
  #requests = new SectionRequests();
12976
13010
  #updates = new Claims();
13011
+ /** @type {Promise<boolean>|null} */
13012
+ #confirming = null;
12977
13013
  /**
12978
13014
  * @param {HTMLElement} host the element the section requests are dispatched on
12979
13015
  * @param {DocumentFragment} fragment the rendered template, holding a
@@ -12997,17 +13033,53 @@ var fml = (function (exports) {
12997
13033
  get section() {
12998
13034
  return this.#section;
12999
13035
  }
13036
+ /**
13037
+ * Whether a `ful-form` of this dialog, not one of a dialog nested in it,
13038
+ * holds unsaved edits (see `Form.dirty`).
13039
+ * @returns {boolean}
13040
+ */
13041
+ get dirty() {
13042
+ return Array.from(this.#dialog.querySelectorAll('ful-form')).some(
13043
+ (form) => form.closest('dialog') === this.#dialog && /** @type {any} */ (form).dirty,
13044
+ );
13045
+ }
13000
13046
  /**
13001
13047
  * Dispatches the dismissal on the host and calls `close` unless a listener
13002
- * prevented it. A dismissal that is not cancelable announces a close the
13003
- * platform makes whatever the page does, so `close` is not called for it.
13048
+ * prevented it or, with `confirm` given and the dialog `dirty`, `confirm`
13049
+ * answers false. A dismissal that is not cancelable announces a close the
13050
+ * platform makes whatever the page does, so nothing is asked and `close` is
13051
+ * not called for it. While `confirm` is pending a further dismissal waits
13052
+ * for the same answer, and a dialog closed meanwhile is not closed again.
13004
13053
  * @param {CustomEvent} asked
13005
13054
  * @param {() => void} close
13055
+ * @param {(() => Promise<boolean>)|null} confirm asks whether to drop the unsaved edits
13056
+ * @returns {Promise<boolean>} whether the dismissal closes the dialog
13006
13057
  */
13007
- dismiss(asked, close) {
13008
- if (this.#host.dispatchEvent(asked) && asked.cancelable) {
13058
+ dismiss(asked, close, confirm) {
13059
+ if (this.#confirming) {
13060
+ return this.#confirming;
13061
+ }
13062
+ if (!this.#host.dispatchEvent(asked)) {
13063
+ return Promise.resolve(false);
13064
+ }
13065
+ if (!asked.cancelable) {
13066
+ return Promise.resolve(true);
13067
+ }
13068
+ if (!confirm || !this.dirty) {
13009
13069
  close();
13070
+ return Promise.resolve(true);
13010
13071
  }
13072
+ this.#confirming = confirm()
13073
+ .then((drop) => {
13074
+ if (drop && this.#dialog.open) {
13075
+ close();
13076
+ }
13077
+ return drop;
13078
+ })
13079
+ .finally(() => {
13080
+ this.#confirming = null;
13081
+ });
13082
+ return this.#confirming;
13011
13083
  }
13012
13084
  /**
13013
13085
  * Calls `dismiss` when a press and its release both land on the backdrop,
@@ -13061,11 +13133,12 @@ var fml = (function (exports) {
13061
13133
  * `show`, and fills the content with what the callback resolves to. A
13062
13134
  * rejection is shown, as the problems' reasons, in the error section, and
13063
13135
  * the content is hidden. When a newer `update()` starts before the callback
13064
- * settles, this call paints nothing: it resolves with the section regardless,
13065
- * and still rejects with the callback's error.
13136
+ * settles, this call paints nothing and resolves `null`, the section being
13137
+ * the newer call's; it still rejects with the callback's error.
13066
13138
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
13067
13139
  * @param {() => void} show opens the dialog when it is closed
13068
- * @returns {Promise<HTMLElement>} the content section
13140
+ * @returns {Promise<HTMLElement|null>} the content section, or `null` for a
13141
+ * call a newer one overtook
13069
13142
  * @throws {any} what the callback threw or rejected with
13070
13143
  */
13071
13144
  async update(cb, show) {
@@ -13078,7 +13151,7 @@ var fml = (function (exports) {
13078
13151
  try {
13079
13152
  const delivered = await cb();
13080
13153
  if (claim.stale) {
13081
- return this.#section;
13154
+ return null;
13082
13155
  }
13083
13156
  this.#section.replaceChildren(delivered);
13084
13157
  this.#loading?.setAttribute('hidden', '');
@@ -13147,21 +13220,24 @@ var fml = (function (exports) {
13147
13220
  * and a press and release both on the backdrop close the dialog, each as a
13148
13221
  * dismissal, as Escape does.
13149
13222
  *
13150
- * Before any of those three gestures closes it, the element dispatches a
13151
- * cancelable, non-bubbling `dialog:dismiss` with `detail: { reason }`, the
13152
- * reason being `button`, `backdrop` or `escape`; a listener calling
13153
- * `preventDefault()` keeps it open, to guard unsaved edits. A `data-result`
13154
- * button, `close-on-submit` and `close()` answer rather than dismiss, and
13155
- * dispatch none.
13223
+ * Those three gestures go through `dismiss(reason)`, the reason being
13224
+ * `button`, `backdrop` or `escape`: before it closes the dialog, the element
13225
+ * dispatches a cancelable, non-bubbling `dialog:dismiss` with
13226
+ * `detail: { reason }`, and a listener calling `preventDefault()` keeps it
13227
+ * open. Under `guard-dismiss`, a dismissal nobody refused, while a `ful-form`
13228
+ * of the dialog holds unsaved edits (see `Form.dirty`), first asks the user,
13229
+ * through `Dialog.confirm`, whether to close and drop them. A `data-result`
13230
+ * button, `close-on-submit`, `close()` and `update()` are answers or the page
13231
+ * deciding: they dispatch nothing and ask nothing.
13156
13232
  *
13157
13233
  * The platform lets a page refuse Escape once per user interaction: a second
13158
13234
  * Escape with no click or keystroke in between closes the dialog whatever the
13159
- * page does, `requires-answer` included. That close is still announced by a
13160
- * `dialog:dismiss`, which is then not cancelable.
13235
+ * page does, `requires-answer` and `guard-dismiss` included. That close is
13236
+ * still announced by a `dialog:dismiss`, which is then not cancelable.
13161
13237
  *
13162
13238
  * `requires-answer` withholds the close button and refuses Escape and the
13163
13239
  * backdrop, so the dialog is closed only by a button carrying `data-result`
13164
- * or from code through `close()`.
13240
+ * or from code, through `close()` or `dismiss()`.
13165
13241
  *
13166
13242
  * `close-on-submit` closes the dialog when a `ful-form` that is a direct child
13167
13243
  * of the body dispatches `submit:success`, answering with its
@@ -13181,7 +13257,7 @@ var fml = (function (exports) {
13181
13257
  * in a form needs.
13182
13258
  */
13183
13259
  class Dialog extends ParsedElement {
13184
- static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
13260
+ static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence', 'guard-dismiss:presence'];
13185
13261
  static slots = true;
13186
13262
  static template = `
13187
13263
  <dialog data-ref="dialog" class="ful-dialog">
@@ -13231,15 +13307,10 @@ var fml = (function (exports) {
13231
13307
  this.#dialog.close(result);
13232
13308
  }
13233
13309
  });
13234
- const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
13235
- this.#sections.dismiss(
13236
- new CustomEvent('dialog:dismiss', { cancelable: !platformCloses, detail: { reason } }),
13237
- () => this.#dialog.close(''),
13238
- );
13239
13310
  if (!requiresAnswer) {
13240
- this.#sections.onBackdrop(() => dismiss('backdrop'));
13311
+ this.#sections.onBackdrop(() => this.dismiss('backdrop'));
13241
13312
  }
13242
- fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => dismiss('button'));
13313
+ fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => this.dismiss('button'));
13243
13314
  if (closeOnSubmit) {
13244
13315
  this.#sections.onSubmitted((response) => {
13245
13316
  this.#answer = { dismissed: false, result: null, response };
@@ -13249,12 +13320,12 @@ var fml = (function (exports) {
13249
13320
  this.#dialog.addEventListener('cancel', (/** @type any */ e) => {
13250
13321
  if (!e.cancelable) {
13251
13322
  this.#dialog.returnValue = '';
13252
- dismiss('escape', true);
13323
+ this.#dismiss('escape', false);
13253
13324
  return;
13254
13325
  }
13255
13326
  e.preventDefault();
13256
13327
  if (!requiresAnswer) {
13257
- dismiss('escape');
13328
+ this.dismiss('escape');
13258
13329
  }
13259
13330
  });
13260
13331
  this.replaceChildren(fragment);
@@ -13326,11 +13397,12 @@ var fml = (function (exports) {
13326
13397
  *
13327
13398
  * No `section:requested` is dispatched, and no `ask()` waiter is added:
13328
13399
  * listen for the `close` event to learn how the dialog ended. When a newer
13329
- * `update()` starts before the callback settles, this call paints nothing:
13330
- * it resolves with the body regardless, and still rejects with the
13331
- * callback's error.
13400
+ * `update()` starts before the callback settles, this call paints nothing
13401
+ * and resolves `null`, the body being the newer call's; it still rejects
13402
+ * with the callback's error.
13332
13403
  * @param {() => Node|string|Promise<Node|string>} cb producing the body content
13333
- * @returns {Promise<Element>} the body section, in the document
13404
+ * @returns {Promise<Element|null>} the body section, in the document, or
13405
+ * `null` for a call a newer one overtook
13334
13406
  * @throws {any} what the callback threw or rejected with
13335
13407
  */
13336
13408
  async update(cb) {
@@ -13346,6 +13418,34 @@ var fml = (function (exports) {
13346
13418
  refresh() {
13347
13419
  return this.#sections.request();
13348
13420
  }
13421
+ /**
13422
+ * Dismisses the dialog as the user would: dispatches the cancelable
13423
+ * `dialog:dismiss` with `detail: { reason }` and, under `guard-dismiss`,
13424
+ * asks before dropping unsaved edits, closing the dialog as a dismissal
13425
+ * unless either refused. `requires-answer` does not refuse it, as it does
13426
+ * not refuse `close()`. Does nothing on a closed dialog.
13427
+ * @param {string} reason why it is dismissed, such as `navigation`
13428
+ * @returns {Promise<boolean>} whether the dialog is closed, false when a
13429
+ * listener or the user kept it open
13430
+ */
13431
+ dismiss(reason) {
13432
+ if (!this.#dialog.open) {
13433
+ return Promise.resolve(true);
13434
+ }
13435
+ return this.#dismiss(reason, true);
13436
+ }
13437
+ /**
13438
+ * @param {string} reason
13439
+ * @param {boolean} cancelable false for a close the platform makes anyway
13440
+ * @returns {Promise<boolean>}
13441
+ */
13442
+ #dismiss(reason, cancelable) {
13443
+ return this.#sections.dismiss(
13444
+ new CustomEvent('dialog:dismiss', { cancelable, detail: { reason } }),
13445
+ () => this.#dialog.close(''),
13446
+ this.declared('guard-dismiss') ? discardEdits : null,
13447
+ );
13448
+ }
13349
13449
  /**
13350
13450
  * Closes an open dialog, doing nothing on a closed one. A non-empty result
13351
13451
  * answers as the button carrying that `data-result` would; no result, or
@@ -13445,6 +13545,19 @@ var fml = (function (exports) {
13445
13545
  }
13446
13546
  }
13447
13547
 
13548
+ /**
13549
+ * Asks, through `Dialog.confirm`, whether to close and drop the unsaved edits,
13550
+ * with the localized `dialog.discard.*` texts.
13551
+ * @returns {Promise<boolean>} true to drop them
13552
+ */
13553
+ const discardEdits = () => {
13554
+ const l10n = Localization.of();
13555
+ return Dialog.confirm(l10n.t('dialog.discard.header'), l10n.t('dialog.discard.body'), {
13556
+ confirm: l10n.t('dialog.discard.confirm'),
13557
+ cancel: l10n.t('dialog.discard.cancel'),
13558
+ });
13559
+ };
13560
+
13448
13561
  /**
13449
13562
  * A side panel on the native `<dialog>`, shown as a modal: a header holding
13450
13563
  * the title and a localized close button, then loading, error and content
@@ -13457,17 +13570,23 @@ var fml = (function (exports) {
13457
13570
  * default, when absent) or `start`, following the writing direction.
13458
13571
  *
13459
13572
  * The close button, Escape, and a press and release both on the backdrop all
13460
- * go through `close()`, once a cancelable, non-bubbling `drawer:dismiss` with
13461
- * `detail: { reason }` (`button`, `backdrop` or `escape`) has been dispatched
13462
- * on the element and no listener prevented it: `preventDefault()` keeps the
13463
- * drawer open, to guard unsaved edits. `close()` and `close-on-submit`
13464
- * dispatch none. The platform lets a page refuse Escape once per user
13573
+ * go through `dismiss(reason)`, the reason being `button`, `backdrop` or
13574
+ * `escape`: before it closes the drawer, the element dispatches a cancelable,
13575
+ * non-bubbling `drawer:dismiss` with `detail: { reason }`, and a listener
13576
+ * calling `preventDefault()` keeps it open. Under `guard-dismiss`, a dismissal
13577
+ * nobody refused, while a `ful-form` of the drawer holds unsaved edits (see
13578
+ * `Form.dirty`), first asks the user, through `Dialog.confirm`, whether to
13579
+ * close and drop them. A `data-result` button, `close-on-submit`, `close()`
13580
+ * and `update()` are answers or the page deciding: they dispatch nothing and
13581
+ * ask nothing. The platform lets a page refuse Escape once per user
13465
13582
  * interaction: a second Escape with no click or keystroke in between closes
13466
- * the drawer at once, without the slide, and its `drawer:dismiss` is then not
13467
- * cancelable. Every close dispatches a non-bubbling `close` event on
13468
- * the element with `detail: { dismissed, response }`: `{ dismissed: false,
13469
- * response }` when a form closed it under `close-on-submit`, and
13470
- * `{ dismissed: true, response: null }` for any other close.
13583
+ * the drawer at once, without the slide, `guard-dismiss` included, and its
13584
+ * `drawer:dismiss` is then not cancelable.
13585
+ *
13586
+ * Any button inside the drawer carrying `data-result` closes it with that
13587
+ * result, an empty one being a dismissal. Every close dispatches a
13588
+ * non-bubbling `close` event on the element carrying the `DialogOutcome` as
13589
+ * `detail`, before the pending `ask()` calls resolve.
13471
13590
  *
13472
13591
  * `close-on-submit` closes the drawer when a `ful-form` that is a direct child
13473
13592
  * of the content section dispatches `submit:success`, carrying its
@@ -13479,7 +13598,7 @@ var fml = (function (exports) {
13479
13598
  * element's id calls `open()` on click.
13480
13599
  */
13481
13600
  class Drawer extends ParsedElement {
13482
- static attributes = ['header', 'placement', 'close-on-submit:presence'];
13601
+ static attributes = ['header', 'placement', 'close-on-submit:presence', 'guard-dismiss:presence'];
13483
13602
  static slots = true;
13484
13603
  static template = `
13485
13604
  <dialog data-ref="dialog" class="ful-drawer">
@@ -13496,8 +13615,9 @@ var fml = (function (exports) {
13496
13615
  #dialog;
13497
13616
  #title;
13498
13617
  #sections;
13499
- /** @type {{ dismissed: boolean, response: any }|null} */
13618
+ /** @type {{ dismissed: boolean, result: string|null, response: any }|null} */
13500
13619
  #answer = null;
13620
+ #resolvers = [];
13501
13621
  #closing = false;
13502
13622
  #slideOutEnded = (/** @type AnimationEvent */ e) => {
13503
13623
  if (e.target === this.#dialog) {
@@ -13520,32 +13640,33 @@ var fml = (function (exports) {
13520
13640
  if (placement) {
13521
13641
  this.#dialog.setAttribute('placement', placement);
13522
13642
  }
13523
- const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
13524
- this.#sections.dismiss(
13525
- new CustomEvent('drawer:dismiss', { cancelable: !platformCloses, detail: { reason } }),
13526
- () => this.close(),
13527
- );
13528
13643
  /** @type {HTMLElement} */ (fragment.querySelector('[data-ref=close]')).addEventListener('click', () =>
13529
- dismiss('button'),
13644
+ this.dismiss('button'),
13530
13645
  );
13531
- this.#sections.onBackdrop(() => dismiss('backdrop'));
13646
+ this.#sections.onBackdrop(() => this.dismiss('backdrop'));
13532
13647
  this.#dialog.addEventListener('cancel', (e) => {
13533
13648
  if (!e.cancelable) {
13534
13649
  this.#stopSlidingOut();
13535
- dismiss('escape', true);
13650
+ this.#dismiss('escape', false);
13536
13651
  return;
13537
13652
  }
13538
13653
  e.preventDefault();
13539
- dismiss('escape');
13654
+ this.dismiss('escape');
13655
+ });
13656
+ this.#dialog.addEventListener('click', (/** @type any */ e) => {
13657
+ const result = e.target.closest('button[data-result]')?.dataset.result;
13658
+ if (result !== undefined) {
13659
+ this.close(result);
13660
+ }
13540
13661
  });
13541
13662
  this.#dialog.addEventListener('close', () => {
13542
- this.dispatchEvent(
13543
- new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
13544
- );
13663
+ const outcome = this.#outcome();
13664
+ this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
13665
+ this.#settle(outcome);
13545
13666
  });
13546
13667
  if (this.declared('close-on-submit')) {
13547
13668
  this.#sections.onSubmitted((response) => {
13548
- this.#answer = { dismissed: false, response };
13669
+ this.#answer = { dismissed: false, result: null, response };
13549
13670
  this.close();
13550
13671
  });
13551
13672
  }
@@ -13571,12 +13692,13 @@ var fml = (function (exports) {
13571
13692
  * which has `role="alert"`, and the content is hidden.
13572
13693
  *
13573
13694
  * No `section:requested` is dispatched. When a newer `update()` starts
13574
- * before the callback settles, this call paints nothing: it resolves with
13575
- * the content section regardless, and still rejects with the callback's
13576
- * error.
13695
+ * before the callback settles, this call paints nothing and resolves
13696
+ * `null`, the content section being the newer call's; it still rejects with
13697
+ * the callback's error.
13577
13698
  * @param {string} header the title, set as text
13578
13699
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
13579
- * @returns {Promise<Element>} the content section, in the document
13700
+ * @returns {Promise<Element|null>} the content section, in the document, or
13701
+ * `null` for a call a newer one overtook
13580
13702
  * @throws {any} what the callback threw or rejected with
13581
13703
  */
13582
13704
  async update(header, cb) {
@@ -13593,9 +13715,36 @@ var fml = (function (exports) {
13593
13715
  refresh() {
13594
13716
  return this.#sections.request();
13595
13717
  }
13718
+ /** @returns {{ dismissed: boolean, result: string|null, response: any }} */
13719
+ #outcome() {
13720
+ return this.#answer ?? { dismissed: true, result: null, response: null };
13721
+ }
13722
+ /** @param {{ dismissed: boolean, result: string|null, response: any }} outcome */
13723
+ #settle(outcome) {
13724
+ const resolvers = this.#resolvers;
13725
+ this.#resolvers = [];
13726
+ for (const resolve of resolvers) {
13727
+ resolve(outcome);
13728
+ }
13729
+ }
13596
13730
  /**
13597
- * Shows the drawer as a modal. On a drawer already open nothing happens,
13598
- * except that one sliding out stops and stays open.
13731
+ * Resolves every pending `ask()` and `open()` with the outcome so far,
13732
+ * a dismissal for a drawer still open, so that no caller waits on a drawer
13733
+ * that left the document. The native dialog is not closed and no `close`
13734
+ * event is dispatched.
13735
+ */
13736
+ disconnectedCallback() {
13737
+ this.#settle(this.#outcome());
13738
+ }
13739
+ /**
13740
+ * The same as `ask()`.
13741
+ * @returns {Promise<{ dismissed: boolean, result: string|null, response: any }>}
13742
+ */
13743
+ open() {
13744
+ return this.ask();
13745
+ }
13746
+ /**
13747
+ * Shows the drawer as a modal and resolves with how it ends.
13599
13748
  *
13600
13749
  * The call that opens the drawer resets the outcome of the previous
13601
13750
  * opening, hides the error and loading sections, and dispatches
@@ -13603,26 +13752,67 @@ var fml = (function (exports) {
13603
13752
  * `detail: { section, first, name: null, index: null }`, `section` being
13604
13753
  * the content section and `first` true only on the first answered request),
13605
13754
  * so a listener can fill the content. A failed request is painted into the
13606
- * content section.
13755
+ * content section and does not reject. On a drawer already open nothing is
13756
+ * shown or requested, except that one sliding out stops and stays open, and
13757
+ * the call waits for the same ending as the calls before it, which is how
13758
+ * a caller learns the outcome of the opening an `update()` made.
13759
+ * @returns {Promise<{ dismissed: boolean, result: string|null, response: any }>} resolving on
13760
+ * close, or on removal from the document with a dismissal
13607
13761
  */
13608
- open() {
13609
- if (!this.#show()) {
13610
- return;
13762
+ ask() {
13763
+ if (this.#show()) {
13764
+ this.#sections.restChrome();
13765
+ this.#sections.request();
13611
13766
  }
13612
- this.#sections.restChrome();
13613
- this.#sections.request();
13767
+ return new Promise((resolve) => {
13768
+ this.#resolvers.push(resolve);
13769
+ });
13770
+ }
13771
+ /**
13772
+ * Dismisses the drawer as the user would: dispatches the cancelable
13773
+ * `drawer:dismiss` with `detail: { reason }` and, under `guard-dismiss`,
13774
+ * asks before dropping unsaved edits, sliding the drawer out as a
13775
+ * dismissal unless either refused. Does nothing on a drawer that is closed
13776
+ * or already sliding out.
13777
+ * @param {string} reason why it is dismissed, such as `navigation`
13778
+ * @returns {Promise<boolean>} whether the drawer is closed or closing,
13779
+ * false when a listener or the user kept it open
13780
+ */
13781
+ dismiss(reason) {
13782
+ if (this.#closing || !this.#dialog.open) {
13783
+ return Promise.resolve(true);
13784
+ }
13785
+ return this.#dismiss(reason, true);
13786
+ }
13787
+ /**
13788
+ * @param {string} reason
13789
+ * @param {boolean} cancelable false for a close the platform makes anyway
13790
+ * @returns {Promise<boolean>}
13791
+ */
13792
+ #dismiss(reason, cancelable) {
13793
+ return this.#sections.dismiss(
13794
+ new CustomEvent('drawer:dismiss', { cancelable, detail: { reason } }),
13795
+ () => this.close(),
13796
+ this.declared('guard-dismiss') ? discardEdits : null,
13797
+ );
13614
13798
  }
13615
13799
  /**
13616
13800
  * Slides the drawer out and closes it when the animation ends, setting the
13617
13801
  * `closing` attribute on the native dialog meanwhile; the `close` event is
13618
13802
  * dispatched then, after this call has returned. Where the user prefers
13619
- * reduced motion, or no animation runs, it closes at once. Does nothing on
13620
- * a drawer that is closed or already sliding out.
13803
+ * reduced motion, or no animation runs, it closes at once. A non-empty
13804
+ * result answers as the button carrying that `data-result` would; no
13805
+ * result, or an empty one, is a dismissal. Does nothing on a drawer that is
13806
+ * closed or already sliding out.
13807
+ * @param {string} [result]
13621
13808
  */
13622
- close() {
13809
+ close(result) {
13623
13810
  if (this.#closing || !this.#dialog.open) {
13624
13811
  return;
13625
13812
  }
13813
+ if (result) {
13814
+ this.#answer = { dismissed: false, result, response: null };
13815
+ }
13626
13816
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
13627
13817
  this.#dialog.close();
13628
13818
  return;
@@ -14334,6 +14524,10 @@ var fml = (function (exports) {
14334
14524
  'dialog.close': 'Close',
14335
14525
  'dialog.confirm': 'Confirm',
14336
14526
  'dialog.cancel': 'Cancel',
14527
+ 'dialog.discard.header': 'Unsaved changes',
14528
+ 'dialog.discard.body': 'There are unsaved changes: close anyway?',
14529
+ 'dialog.discard.confirm': 'Close without saving',
14530
+ 'dialog.discard.cancel': 'Keep editing',
14337
14531
  'drawer.close': 'Close',
14338
14532
  'spinner.loading': 'Loading…',
14339
14533
  'toast.region': 'Notifications',
@@ -14379,6 +14573,10 @@ var fml = (function (exports) {
14379
14573
  'dialog.close': 'Chiudi',
14380
14574
  'dialog.confirm': 'Conferma',
14381
14575
  'dialog.cancel': 'Annulla',
14576
+ 'dialog.discard.header': 'Modifiche non salvate',
14577
+ 'dialog.discard.body': 'Ci sono modifiche non salvate: chiudere comunque?',
14578
+ 'dialog.discard.confirm': 'Chiudi senza salvare',
14579
+ 'dialog.discard.cancel': 'Continua a modificare',
14382
14580
  'drawer.close': 'Chiudi',
14383
14581
  'spinner.loading': 'Caricamento…',
14384
14582
  'toast.region': 'Notifiche',
@@ -14427,6 +14625,10 @@ var fml = (function (exports) {
14427
14625
  'dialog.close': 'Cerrar',
14428
14626
  'dialog.confirm': 'Confirmar',
14429
14627
  'dialog.cancel': 'Cancelar',
14628
+ 'dialog.discard.header': 'Cambios sin guardar',
14629
+ 'dialog.discard.body': 'Hay cambios sin guardar: ¿cerrar de todos modos?',
14630
+ 'dialog.discard.confirm': 'Cerrar sin guardar',
14631
+ 'dialog.discard.cancel': 'Seguir editando',
14430
14632
  'drawer.close': 'Cerrar',
14431
14633
  'spinner.loading': 'Cargando…',
14432
14634
  'toast.region': 'Notificaciones',
@@ -14475,6 +14677,10 @@ var fml = (function (exports) {
14475
14677
  'dialog.close': 'Fermer',
14476
14678
  'dialog.confirm': 'Confirmer',
14477
14679
  'dialog.cancel': 'Annuler',
14680
+ 'dialog.discard.header': 'Modifications non enregistrées',
14681
+ 'dialog.discard.body': 'Des modifications ne sont pas enregistrées : fermer quand même ?',
14682
+ 'dialog.discard.confirm': 'Fermer sans enregistrer',
14683
+ 'dialog.discard.cancel': 'Continuer à modifier',
14478
14684
  'drawer.close': 'Fermer',
14479
14685
  'spinner.loading': 'Chargement…',
14480
14686
  'toast.region': 'Notifications',