@optionfactory/fml 9.0.0-rc7 → 9.0.0-rc9

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
@@ -4904,8 +4904,15 @@ class Tooltip extends ParsedElement {
4904
4904
  * `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
4905
4905
  * dialog whose content is wrapped in a form needs.
4906
4906
  */
4907
+ /**
4908
+ * How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
4909
+ * the `data-result` of the button that closed it and `response` what a submit
4910
+ * answered with, the one that did not happen being null.
4911
+ * @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
4912
+ */
4913
+
4907
4914
  class Dialog extends ParsedElement {
4908
- static attributes = ['header', 'requires-answer:presence'];
4915
+ static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
4909
4916
  static slots = true;
4910
4917
  static template = `
4911
4918
  <dialog data-ref="dialog" class="ful-dialog">
@@ -4913,31 +4920,40 @@ class Dialog extends ParsedElement {
4913
4920
  <h2 data-tpl-if="header">{{ header }}</h2>
4914
4921
  <button data-tpl-if="!requiresAnswer" type="button" data-ref="close" data-tpl-aria-label="#l10n:t('dialog.close')"><ful-icon name="x-lg" aria-hidden="true"></ful-icon></button>
4915
4922
  </header>
4923
+ <section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
4924
+ <section data-ref="error" role="alert" hidden></section>
4916
4925
  <div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
4917
4926
  <footer class="ful-dialog-footer">
4918
- <button type="button" data-ref="acknowledge" data-result="acknowledged" data-tpl-if="!slots.buttons" data-tpl-aria-label="#l10n:t('dialog.acknowledge')">{{ #l10n:t('dialog.acknowledge') }}</button>
4927
+ <button type="button" data-ref="acknowledge" data-result="acknowledged" data-tpl-if="!slots.buttons && !closeOnSubmit" data-tpl-aria-label="#l10n:t('dialog.acknowledge')">{{ #l10n:t('dialog.acknowledge') }}</button>
4919
4928
  {{{{ slots.buttons }}}}
4920
4929
  </footer>
4921
4930
  </dialog>
4922
4931
  `;
4923
4932
  #dialog;
4924
4933
  #body;
4934
+ #loading;
4935
+ #error;
4925
4936
  #requests = new SectionRequests();
4937
+ #updates = new Claims();
4926
4938
  #resolvers = [];
4939
+ //the answer a submit closed the dialog with, which the return value cannot
4940
+ //carry: it is a string, and a response is whatever the server sent
4941
+ /** @type {DialogOutcome|null} */
4942
+ #answer = null;
4927
4943
  render({ slots }) {
4928
4944
  const requiresAnswer = this.declared('requires-answer');
4945
+ const closeOnSubmit = this.declared('close-on-submit');
4929
4946
  const fragment = this.template()
4930
- .withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer })
4947
+ .withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
4931
4948
  .render();
4932
4949
  this.#dialog = fragment.querySelector('[data-ref=dialog]');
4933
4950
  this.#body = fragment.querySelector('[data-ref=body]');
4951
+ this.#loading = fragment.querySelector('[data-ref=loading]');
4952
+ this.#error = fragment.querySelector('[data-ref=error]');
4934
4953
  this.#dialog.addEventListener('close', () => {
4935
- this.dispatchEvent(
4936
- new CustomEvent('close', {
4937
- detail: { result: this.#dialog.returnValue === '' ? null : this.#dialog.returnValue },
4938
- }),
4939
- );
4940
- this.#settle();
4954
+ const outcome = this.#outcome();
4955
+ this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
4956
+ this.#settle(outcome);
4941
4957
  });
4942
4958
  this.#dialog.addEventListener('click', (/** @type any */ e) => {
4943
4959
  const result = e.target.closest('button[data-result]')?.dataset.result;
@@ -4945,11 +4961,26 @@ class Dialog extends ParsedElement {
4945
4961
  this.#dialog.close(result);
4946
4962
  }
4947
4963
  });
4948
- //dismissal, not an answer: the waiters are settled with null, as Escape does.
4949
- //Optional because a subclass overriding the template owns what it renders
4964
+ //dismissal, not an answer: the waiters are settled with a dismissal, as
4965
+ //Escape does. Optional because a subclass overriding the template owns
4966
+ //what it renders
4950
4967
  fragment
4951
4968
  .querySelector('[data-ref=close]')
4952
4969
  ?.addEventListener('click', () => this.#dialog.close(''));
4970
+ if (closeOnSubmit) {
4971
+ //delegated on the body rather than bound to the form, so a body
4972
+ //delivered later by update() is covered by the same listener. The
4973
+ //form must be the body's own: a ful-table wraps its filters in a
4974
+ //ful-form of its own, and a search in a table the dialog holds is
4975
+ //not the dialog being answered
4976
+ this.#body.addEventListener('submit:success', (/** @type any */ e) => {
4977
+ if (e.target !== Nodes.queryChildren(this.#body, 'ful-form')) {
4978
+ return;
4979
+ }
4980
+ this.#answer = { dismissed: false, result: null, response: e.detail.response };
4981
+ this.#dialog.close('submitted');
4982
+ });
4983
+ }
4953
4984
  if (requiresAnswer) {
4954
4985
  //the platform's own dismissal, refused where the dialog must be
4955
4986
  //answered: cancel fires for Escape and for a close request the
@@ -4959,39 +4990,96 @@ class Dialog extends ParsedElement {
4959
4990
  this.replaceChildren(fragment);
4960
4991
  wireTargets();
4961
4992
  }
4962
- //answers every waiter with the dialog's own answer: null while still open
4963
- //or closed without a result, which is also the unanswered answer a dialog
4964
- //leaving the document owes its waiters instead of hanging them
4965
- #settle() {
4993
+ /**
4994
+ * How the dialog ended, in one shape for every way it can end: `dismissed`
4995
+ * alone tells a cancel from an answer, so a submit answering with no body at
4996
+ * all (a 204) is still an answer, where a bare `null` could not say which it
4997
+ * was. `result` carries the `data-result` of the button that closed it and
4998
+ * `response` what a submit answered with; the one that did not happen is null.
4999
+ */
5000
+ #outcome() {
5001
+ if (this.#answer) {
5002
+ return this.#answer;
5003
+ }
5004
+ const result = this.#dialog.returnValue;
5005
+ return result === ''
5006
+ ? { dismissed: true, result: null, response: null }
5007
+ : { dismissed: false, result, response: null };
5008
+ }
5009
+ //answers every waiter with the dialog's own answer: a dismissal while still
5010
+ //open or closed without a result, which is also the unanswered answer a
5011
+ //dialog leaving the document owes its waiters instead of hanging them
5012
+ #settle(outcome) {
4966
5013
  const resolvers = this.#resolvers;
4967
5014
  this.#resolvers = [];
4968
5015
  for (const resolve of resolvers) {
4969
- resolve(this.#dialog.returnValue === '' ? null : this.#dialog.returnValue);
5016
+ resolve(outcome);
4970
5017
  }
4971
5018
  }
4972
5019
  disconnectedCallback() {
4973
- this.#settle();
5020
+ this.#settle(this.#outcome());
4974
5021
  }
4975
5022
  open() {
4976
5023
  return this.ask();
4977
5024
  }
4978
5025
  ask() {
4979
- if (!this.#dialog.open) {
4980
- this.#dialog.returnValue = '';
4981
- this.#dialog.showModal();
5026
+ if (this.#show()) {
5027
+ this.#restChrome();
4982
5028
  this.#request();
4983
5029
  }
4984
5030
  return new Promise((resolve) => {
4985
5031
  this.#resolvers.push(resolve);
4986
5032
  });
4987
5033
  }
5034
+ /**
5035
+ * Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
5036
+ * resolved value paints the body (which is returned), a rejection paints the
5037
+ * problems and travels to the caller, and an update superseded by a newer one
5038
+ * paints nothing. The title is the `header` attribute, configuration like the
5039
+ * rest of the dialog's chrome, so what update() owns is the body alone.
5040
+ */
5041
+ async update(cb) {
5042
+ //the claim detaches any update still in flight: its outcome belongs to
5043
+ //an abandoned opening and must neither be painted nor own the dialog
5044
+ const claim = this.#updates.take();
5045
+ this.#body.replaceChildren();
5046
+ this.#restChrome();
5047
+ this.#loading?.removeAttribute('hidden');
5048
+ this.#body.setAttribute('hidden', '');
5049
+ //update owns its own open-answer-deliver cycle, so it shows the dialog
5050
+ //without going through ask(): a user reopen during the wait is a real
5051
+ //open and goes through ask()
5052
+ this.#show();
5053
+ try {
5054
+ const delivered = await cb();
5055
+ if (claim.stale) {
5056
+ return this.#body;
5057
+ }
5058
+ this.#body.replaceChildren(delivered);
5059
+ this.#loading?.setAttribute('hidden', '');
5060
+ this.#body.removeAttribute('hidden');
5061
+ return this.#body;
5062
+ } catch (/** @type any */ e) {
5063
+ if (!claim.stale) {
5064
+ //revealed before it is filled, so the live region announces the
5065
+ //change rather than being revealed already holding it
5066
+ this.#error?.removeAttribute('hidden');
5067
+ if (this.#error) {
5068
+ this.#error.textContent = Failure.problemsText(e);
5069
+ }
5070
+ this.#loading?.setAttribute('hidden', '');
5071
+ this.#body.setAttribute('hidden', '');
5072
+ }
5073
+ throw e;
5074
+ }
5075
+ }
4988
5076
  #request() {
4989
5077
  this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
4990
5078
  }
4991
5079
  /**
4992
5080
  * Re-fires section:requested on the body, open or closed: the explicit
4993
5081
  * request for a body that wants refreshing. A failed refresh paints its
4994
- * problems, nothing rejects: there is no caller to reject towards.
5082
+ * problems, nothing rejects: update() stays the rejecting call.
4995
5083
  */
4996
5084
  refresh() {
4997
5085
  return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
@@ -4999,6 +5087,25 @@ class Dialog extends ParsedElement {
4999
5087
  close(result) {
5000
5088
  this.#dialog.close(result ?? '');
5001
5089
  }
5090
+ /** Shows the modal, answering whether this call is the one that opened it. */
5091
+ #show() {
5092
+ if (this.#dialog.open) {
5093
+ return false;
5094
+ }
5095
+ //an opening owes nothing to the one before it: the platform keeps
5096
+ //returnValue across a close with no result, and the answer a submit
5097
+ //left is just as stale
5098
+ this.#dialog.returnValue = '';
5099
+ this.#answer = null;
5100
+ this.#dialog.showModal();
5101
+ return true;
5102
+ }
5103
+ #restChrome() {
5104
+ this.#error?.replaceChildren();
5105
+ this.#error?.setAttribute('hidden', '');
5106
+ this.#loading?.setAttribute('hidden', '');
5107
+ this.#body?.removeAttribute('hidden');
5108
+ }
5002
5109
  }
5003
5110
 
5004
5111
  /**
@@ -5011,7 +5118,7 @@ class Dialog extends ParsedElement {
5011
5118
  * it.
5012
5119
  */
5013
5120
  class Drawer extends ParsedElement {
5014
- static attributes = ['title', 'placement'];
5121
+ static attributes = ['title', 'placement', 'close-on-submit:presence'];
5015
5122
  static slots = true;
5016
5123
  static template = `
5017
5124
  <dialog data-ref="dialog" class="ful-drawer">
@@ -5032,6 +5139,10 @@ class Drawer extends ParsedElement {
5032
5139
  #content;
5033
5140
  #requests = new SectionRequests();
5034
5141
  #updates = new Claims();
5142
+ //what a submit closed the drawer with, told from a close of any other kind:
5143
+ //a save answering with no body at all is still a save
5144
+ /** @type {{ dismissed: boolean, response: any }|null} */
5145
+ #answer = null;
5035
5146
  render({ slots }) {
5036
5147
  const fragment = this.template()
5037
5148
  .withOverlay({ slots, title: this.declared('title') ?? '' })
@@ -5047,8 +5158,25 @@ class Drawer extends ParsedElement {
5047
5158
  }
5048
5159
  fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
5049
5160
  this.#dialog.addEventListener('close', () => {
5050
- this.dispatchEvent(new CustomEvent('close'));
5161
+ this.dispatchEvent(
5162
+ new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
5163
+ );
5051
5164
  });
5165
+ if (this.declared('close-on-submit')) {
5166
+ //delegated on the content section rather than bound to the form: a
5167
+ //drawer's form usually arrives with an update() rather than with the
5168
+ //page, and the section outlives every delivery. The form must be the
5169
+ //content's own, a ful-table wrapping its filters in a ful-form of its
5170
+ //own and a search in a table the drawer holds not being the drawer
5171
+ //finishing
5172
+ this.#content.addEventListener('submit:success', (/** @type any */ e) => {
5173
+ if (e.target !== Nodes.queryChildren(this.#content, 'ful-form')) {
5174
+ return;
5175
+ }
5176
+ this.#answer = { dismissed: false, response: e.detail.response };
5177
+ this.close();
5178
+ });
5179
+ }
5052
5180
  this.replaceChildren(fragment);
5053
5181
  wireTargets();
5054
5182
  }
@@ -5121,6 +5249,9 @@ class Drawer extends ParsedElement {
5121
5249
  if (this.#dialog.open) {
5122
5250
  return false;
5123
5251
  }
5252
+ //an opening owes nothing to the one before it: the answer a submit left
5253
+ //belongs to the drawer that closed on it
5254
+ this.#answer = null;
5124
5255
  this.#dialog.showModal();
5125
5256
  return true;
5126
5257
  }