@optionfactory/fml 9.0.0-rc6 → 9.0.0-rc8

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.d.mts CHANGED
@@ -1097,6 +1097,11 @@ declare class Tooltip extends ParsedElement {
1097
1097
  slots: any;
1098
1098
  }): void;
1099
1099
  }
1100
+ export type DialogOutcome = {
1101
+ dismissed: boolean;
1102
+ result: string | null;
1103
+ response: any;
1104
+ };
1100
1105
  /**
1101
1106
  * A modal dialog on the native platform, open()/ask() resolving with the
1102
1107
  * closer's data-result.
@@ -1117,6 +1122,12 @@ declare class Tooltip extends ParsedElement {
1117
1122
  * `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
1118
1123
  * dialog whose content is wrapped in a form needs.
1119
1124
  */
1125
+ /**
1126
+ * How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
1127
+ * the `data-result` of the button that closed it and `response` what a submit
1128
+ * answered with, the one that did not happen being null.
1129
+ * @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
1130
+ */
1120
1131
  declare class Dialog extends ParsedElement {
1121
1132
  #private;
1122
1133
  static attributes: string[];
@@ -1128,10 +1139,18 @@ declare class Dialog extends ParsedElement {
1128
1139
  disconnectedCallback(): void;
1129
1140
  open(): Promise<any>;
1130
1141
  ask(): Promise<any>;
1142
+ /**
1143
+ * Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
1144
+ * resolved value paints the body (which is returned), a rejection paints the
1145
+ * problems and travels to the caller, and an update superseded by a newer one
1146
+ * paints nothing. The title is the `header` attribute, configuration like the
1147
+ * rest of the dialog's chrome, so what update() owns is the body alone.
1148
+ */
1149
+ update(cb: any): Promise<any>;
1131
1150
  /**
1132
1151
  * Re-fires section:requested on the body, open or closed: the explicit
1133
1152
  * request for a body that wants refreshing. A failed refresh paints its
1134
- * problems, nothing rejects: there is no caller to reject towards.
1153
+ * problems, nothing rejects: update() stays the rejecting call.
1135
1154
  */
1136
1155
  refresh(): Promise<any[] | undefined>;
1137
1156
  close(result: any): void;
package/dist/ful.iife.js CHANGED
@@ -2859,6 +2859,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2859
2859
  if (!this._interactive()) {
2860
2860
  return;
2861
2861
  }
2862
+ //a click on another control inside the select is that control's, not
2863
+ //the select's: a tooltip marker slotted into `info`, a button a page
2864
+ //put in an affix. Without this, reading the note beside a select
2865
+ //also stole the focus and dropped the dropdown over the note
2866
+ const elsewhere = e.target.closest('button, a[href], input, select, textarea');
2867
+ if (elsewhere && elsewhere !== this.#input) {
2868
+ return;
2869
+ }
2862
2870
  if (this.#ddmenu.shown) {
2863
2871
  this.#close();
2864
2872
  return;
@@ -3994,8 +4002,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3994
4002
  //started is stale, and neither renders nor updates the request a later
3995
4003
  //reload replays, whichever order the responses arrive in
3996
4004
  const claim = this.#loads.take();
3997
- this.#body.replaceChildren();
3998
- this.#loading.removeAttribute('hidden');
4005
+ //the rows stay while the table revalidates. Emptying the body and raising
4006
+ //the spinner row in its place collapsed the table to one tall row and
4007
+ //expanded it again on every sort, page and reload: two layout jumps for
4008
+ //what is the same table with newer rows in it. The spinner is for the load
4009
+ //with nothing to show yet, the first one and the one after a failure; the
4010
+ //rest announce themselves through aria-busy, which the stylesheet reads
4011
+ this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
3999
4012
  this.#feedback.setAttribute('hidden', '');
4000
4013
  this.#noAutoload.setAttribute('hidden', '');
4001
4014
  this.setAttribute('aria-busy', 'true');
@@ -4013,6 +4026,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4013
4026
  return;
4014
4027
  }
4015
4028
  this.#loading.setAttribute('hidden', '');
4029
+ //the rows the failed load was replacing go with it: what the table
4030
+ //holds is no longer what the request asked for, and leaving them
4031
+ //under the error would say the opposite
4032
+ this.#body.replaceChildren();
4016
4033
  this.#feedback.removeAttribute('hidden');
4017
4034
  this.#feedback.querySelector('[data-ref=feedback-error]').textContent = index_mjs$1.Failure.problemsText(
4018
4035
  error,
@@ -4159,6 +4176,20 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4159
4176
  //menu is where the localized words live
4160
4177
  this.#button.textContent = this.#display(choice);
4161
4178
  index_mjs.Attributes.set(this.#button, 'aria-label', this.#labelFor(choice));
4179
+ this.#mark();
4180
+ }
4181
+ /**
4182
+ * Marks the item the button currently holds, which is what a menu of one
4183
+ * choice among several owes the reader: the glyph on the button says which
4184
+ * one it is only to somebody who already knows the glyphs. The items are
4185
+ * `menuitemradio`, so the state is `aria-checked` rather than the
4186
+ * `aria-selected` a listbox would use, and the stylesheet draws it off that.
4187
+ */
4188
+ #mark() {
4189
+ const current = this.value;
4190
+ for (const item of this.#items()) {
4191
+ item.setAttribute('aria-checked', String(item.getAttribute('value') === current));
4192
+ }
4162
4193
  }
4163
4194
  /** The host's disabled claim, composed with the pin: lifting one cannot lift the other. */
4164
4195
  set claimed(claimed) {
@@ -4171,7 +4202,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4171
4202
  const li = document.createElement('li');
4172
4203
  li.setAttribute('role', 'none');
4173
4204
  const a = document.createElement('a');
4174
- a.setAttribute('role', 'menuitem');
4205
+ //one choice among several, which is what a radio item is: the
4206
+ //state belongs on the item, not on the button alone
4207
+ a.setAttribute('role', 'menuitemradio');
4175
4208
  a.setAttribute('tabindex', '-1');
4176
4209
  a.setAttribute('value', choice);
4177
4210
  const word = this.#labelFor(choice);
@@ -4189,6 +4222,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4189
4222
  return li;
4190
4223
  }),
4191
4224
  );
4225
+ this.#mark();
4192
4226
  }
4193
4227
  #sync() {
4194
4228
  const pinned = this.pinned;
@@ -4870,8 +4904,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4870
4904
  * `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
4871
4905
  * dialog whose content is wrapped in a form needs.
4872
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
+
4873
4914
  class Dialog extends index_mjs.ParsedElement {
4874
- static attributes = ['header', 'requires-answer:presence'];
4915
+ static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
4875
4916
  static slots = true;
4876
4917
  static template = `
4877
4918
  <dialog data-ref="dialog" class="ful-dialog">
@@ -4879,6 +4920,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4879
4920
  <h2 data-tpl-if="header">{{ header }}</h2>
4880
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>
4881
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>
4882
4925
  <div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
4883
4926
  <footer class="ful-dialog-footer">
4884
4927
  <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>
@@ -4888,8 +4931,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4888
4931
  `;
4889
4932
  #dialog;
4890
4933
  #body;
4934
+ #loading;
4935
+ #error;
4891
4936
  #requests = new SectionRequests();
4937
+ #updates = new Claims();
4892
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;
4893
4943
  render({ slots }) {
4894
4944
  const requiresAnswer = this.declared('requires-answer');
4895
4945
  const fragment = this.template()
@@ -4897,13 +4947,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4897
4947
  .render();
4898
4948
  this.#dialog = fragment.querySelector('[data-ref=dialog]');
4899
4949
  this.#body = fragment.querySelector('[data-ref=body]');
4950
+ this.#loading = fragment.querySelector('[data-ref=loading]');
4951
+ this.#error = fragment.querySelector('[data-ref=error]');
4900
4952
  this.#dialog.addEventListener('close', () => {
4901
- this.dispatchEvent(
4902
- new CustomEvent('close', {
4903
- detail: { result: this.#dialog.returnValue === '' ? null : this.#dialog.returnValue },
4904
- }),
4905
- );
4906
- this.#settle();
4953
+ const outcome = this.#outcome();
4954
+ this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
4955
+ this.#settle(outcome);
4907
4956
  });
4908
4957
  this.#dialog.addEventListener('click', (/** @type any */ e) => {
4909
4958
  const result = e.target.closest('button[data-result]')?.dataset.result;
@@ -4911,11 +4960,26 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4911
4960
  this.#dialog.close(result);
4912
4961
  }
4913
4962
  });
4914
- //dismissal, not an answer: the waiters are settled with null, as Escape does.
4915
- //Optional because a subclass overriding the template owns what it renders
4963
+ //dismissal, not an answer: the waiters are settled with a dismissal, as
4964
+ //Escape does. Optional because a subclass overriding the template owns
4965
+ //what it renders
4916
4966
  fragment
4917
4967
  .querySelector('[data-ref=close]')
4918
4968
  ?.addEventListener('click', () => this.#dialog.close(''));
4969
+ if (this.declared('close-on-submit')) {
4970
+ //delegated on the body rather than bound to the form, so a body
4971
+ //delivered later by update() is covered by the same listener. The
4972
+ //form must be the body's own: a ful-table wraps its filters in a
4973
+ //ful-form of its own, and a search in a table the dialog holds is
4974
+ //not the dialog being answered
4975
+ this.#body.addEventListener('submit:success', (/** @type any */ e) => {
4976
+ if (e.target !== index_mjs.Nodes.queryChildren(this.#body, 'ful-form')) {
4977
+ return;
4978
+ }
4979
+ this.#answer = { dismissed: false, result: null, response: e.detail.response };
4980
+ this.#dialog.close('submitted');
4981
+ });
4982
+ }
4919
4983
  if (requiresAnswer) {
4920
4984
  //the platform's own dismissal, refused where the dialog must be
4921
4985
  //answered: cancel fires for Escape and for a close request the
@@ -4925,39 +4989,96 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4925
4989
  this.replaceChildren(fragment);
4926
4990
  wireTargets();
4927
4991
  }
4928
- //answers every waiter with the dialog's own answer: null while still open
4929
- //or closed without a result, which is also the unanswered answer a dialog
4930
- //leaving the document owes its waiters instead of hanging them
4931
- #settle() {
4992
+ /**
4993
+ * How the dialog ended, in one shape for every way it can end: `dismissed`
4994
+ * alone tells a cancel from an answer, so a submit answering with no body at
4995
+ * all (a 204) is still an answer, where a bare `null` could not say which it
4996
+ * was. `result` carries the `data-result` of the button that closed it and
4997
+ * `response` what a submit answered with; the one that did not happen is null.
4998
+ */
4999
+ #outcome() {
5000
+ if (this.#answer) {
5001
+ return this.#answer;
5002
+ }
5003
+ const result = this.#dialog.returnValue;
5004
+ return result === ''
5005
+ ? { dismissed: true, result: null, response: null }
5006
+ : { dismissed: false, result, response: null };
5007
+ }
5008
+ //answers every waiter with the dialog's own answer: a dismissal while still
5009
+ //open or closed without a result, which is also the unanswered answer a
5010
+ //dialog leaving the document owes its waiters instead of hanging them
5011
+ #settle(outcome) {
4932
5012
  const resolvers = this.#resolvers;
4933
5013
  this.#resolvers = [];
4934
5014
  for (const resolve of resolvers) {
4935
- resolve(this.#dialog.returnValue === '' ? null : this.#dialog.returnValue);
5015
+ resolve(outcome);
4936
5016
  }
4937
5017
  }
4938
5018
  disconnectedCallback() {
4939
- this.#settle();
5019
+ this.#settle(this.#outcome());
4940
5020
  }
4941
5021
  open() {
4942
5022
  return this.ask();
4943
5023
  }
4944
5024
  ask() {
4945
- if (!this.#dialog.open) {
4946
- this.#dialog.returnValue = '';
4947
- this.#dialog.showModal();
5025
+ if (this.#show()) {
5026
+ this.#restChrome();
4948
5027
  this.#request();
4949
5028
  }
4950
5029
  return new Promise((resolve) => {
4951
5030
  this.#resolvers.push(resolve);
4952
5031
  });
4953
5032
  }
5033
+ /**
5034
+ * Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
5035
+ * resolved value paints the body (which is returned), a rejection paints the
5036
+ * problems and travels to the caller, and an update superseded by a newer one
5037
+ * paints nothing. The title is the `header` attribute, configuration like the
5038
+ * rest of the dialog's chrome, so what update() owns is the body alone.
5039
+ */
5040
+ async update(cb) {
5041
+ //the claim detaches any update still in flight: its outcome belongs to
5042
+ //an abandoned opening and must neither be painted nor own the dialog
5043
+ const claim = this.#updates.take();
5044
+ this.#body.replaceChildren();
5045
+ this.#restChrome();
5046
+ this.#loading?.removeAttribute('hidden');
5047
+ this.#body.setAttribute('hidden', '');
5048
+ //update owns its own open-answer-deliver cycle, so it shows the dialog
5049
+ //without going through ask(): a user reopen during the wait is a real
5050
+ //open and goes through ask()
5051
+ this.#show();
5052
+ try {
5053
+ const delivered = await cb();
5054
+ if (claim.stale) {
5055
+ return this.#body;
5056
+ }
5057
+ this.#body.replaceChildren(delivered);
5058
+ this.#loading?.setAttribute('hidden', '');
5059
+ this.#body.removeAttribute('hidden');
5060
+ return this.#body;
5061
+ } catch (/** @type any */ e) {
5062
+ if (!claim.stale) {
5063
+ //revealed before it is filled, so the live region announces the
5064
+ //change rather than being revealed already holding it
5065
+ this.#error?.removeAttribute('hidden');
5066
+ if (this.#error) {
5067
+ this.#error.textContent = index_mjs$1.Failure.problemsText(e);
5068
+ }
5069
+ this.#loading?.setAttribute('hidden', '');
5070
+ this.#body.setAttribute('hidden', '');
5071
+ }
5072
+ throw e;
5073
+ }
5074
+ }
4954
5075
  #request() {
4955
5076
  this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
4956
5077
  }
4957
5078
  /**
4958
5079
  * Re-fires section:requested on the body, open or closed: the explicit
4959
5080
  * request for a body that wants refreshing. A failed refresh paints its
4960
- * problems, nothing rejects: there is no caller to reject towards.
5081
+ * problems, nothing rejects: update() stays the rejecting call.
4961
5082
  */
4962
5083
  refresh() {
4963
5084
  return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
@@ -4965,6 +5086,25 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4965
5086
  close(result) {
4966
5087
  this.#dialog.close(result ?? '');
4967
5088
  }
5089
+ /** Shows the modal, answering whether this call is the one that opened it. */
5090
+ #show() {
5091
+ if (this.#dialog.open) {
5092
+ return false;
5093
+ }
5094
+ //an opening owes nothing to the one before it: the platform keeps
5095
+ //returnValue across a close with no result, and the answer a submit
5096
+ //left is just as stale
5097
+ this.#dialog.returnValue = '';
5098
+ this.#answer = null;
5099
+ this.#dialog.showModal();
5100
+ return true;
5101
+ }
5102
+ #restChrome() {
5103
+ this.#error?.replaceChildren();
5104
+ this.#error?.setAttribute('hidden', '');
5105
+ this.#loading?.setAttribute('hidden', '');
5106
+ this.#body?.removeAttribute('hidden');
5107
+ }
4968
5108
  }
4969
5109
 
4970
5110
  /**
@@ -4977,7 +5117,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4977
5117
  * it.
4978
5118
  */
4979
5119
  class Drawer extends index_mjs.ParsedElement {
4980
- static attributes = ['title', 'placement'];
5120
+ static attributes = ['title', 'placement', 'close-on-submit:presence'];
4981
5121
  static slots = true;
4982
5122
  static template = `
4983
5123
  <dialog data-ref="dialog" class="ful-drawer">
@@ -4998,6 +5138,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4998
5138
  #content;
4999
5139
  #requests = new SectionRequests();
5000
5140
  #updates = new Claims();
5141
+ //what a submit closed the drawer with, told from a close of any other kind:
5142
+ //a save answering with no body at all is still a save
5143
+ /** @type {{ dismissed: boolean, response: any }|null} */
5144
+ #answer = null;
5001
5145
  render({ slots }) {
5002
5146
  const fragment = this.template()
5003
5147
  .withOverlay({ slots, title: this.declared('title') ?? '' })
@@ -5013,8 +5157,25 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5013
5157
  }
5014
5158
  fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
5015
5159
  this.#dialog.addEventListener('close', () => {
5016
- this.dispatchEvent(new CustomEvent('close'));
5160
+ this.dispatchEvent(
5161
+ new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
5162
+ );
5017
5163
  });
5164
+ if (this.declared('close-on-submit')) {
5165
+ //delegated on the content section rather than bound to the form: a
5166
+ //drawer's form usually arrives with an update() rather than with the
5167
+ //page, and the section outlives every delivery. The form must be the
5168
+ //content's own, a ful-table wrapping its filters in a ful-form of its
5169
+ //own and a search in a table the drawer holds not being the drawer
5170
+ //finishing
5171
+ this.#content.addEventListener('submit:success', (/** @type any */ e) => {
5172
+ if (e.target !== index_mjs.Nodes.queryChildren(this.#content, 'ful-form')) {
5173
+ return;
5174
+ }
5175
+ this.#answer = { dismissed: false, response: e.detail.response };
5176
+ this.close();
5177
+ });
5178
+ }
5018
5179
  this.replaceChildren(fragment);
5019
5180
  wireTargets();
5020
5181
  }
@@ -5087,6 +5248,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5087
5248
  if (this.#dialog.open) {
5088
5249
  return false;
5089
5250
  }
5251
+ //an opening owes nothing to the one before it: the answer a submit left
5252
+ //belongs to the drawer that closed on it
5253
+ this.#answer = null;
5090
5254
  this.#dialog.showModal();
5091
5255
  return true;
5092
5256
  }