@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/fml.iife.js CHANGED
@@ -11133,8 +11133,15 @@ var fml = (function (exports) {
11133
11133
  * `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
11134
11134
  * dialog whose content is wrapped in a form needs.
11135
11135
  */
11136
+ /**
11137
+ * How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
11138
+ * the `data-result` of the button that closed it and `response` what a submit
11139
+ * answered with, the one that did not happen being null.
11140
+ * @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
11141
+ */
11142
+
11136
11143
  class Dialog extends ParsedElement {
11137
- static attributes = ['header', 'requires-answer:presence'];
11144
+ static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
11138
11145
  static slots = true;
11139
11146
  static template = `
11140
11147
  <dialog data-ref="dialog" class="ful-dialog">
@@ -11142,31 +11149,40 @@ var fml = (function (exports) {
11142
11149
  <h2 data-tpl-if="header">{{ header }}</h2>
11143
11150
  <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>
11144
11151
  </header>
11152
+ <section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
11153
+ <section data-ref="error" role="alert" hidden></section>
11145
11154
  <div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
11146
11155
  <footer class="ful-dialog-footer">
11147
- <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>
11156
+ <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>
11148
11157
  {{{{ slots.buttons }}}}
11149
11158
  </footer>
11150
11159
  </dialog>
11151
11160
  `;
11152
11161
  #dialog;
11153
11162
  #body;
11163
+ #loading;
11164
+ #error;
11154
11165
  #requests = new SectionRequests();
11166
+ #updates = new Claims();
11155
11167
  #resolvers = [];
11168
+ //the answer a submit closed the dialog with, which the return value cannot
11169
+ //carry: it is a string, and a response is whatever the server sent
11170
+ /** @type {DialogOutcome|null} */
11171
+ #answer = null;
11156
11172
  render({ slots }) {
11157
11173
  const requiresAnswer = this.declared('requires-answer');
11174
+ const closeOnSubmit = this.declared('close-on-submit');
11158
11175
  const fragment = this.template()
11159
- .withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer })
11176
+ .withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
11160
11177
  .render();
11161
11178
  this.#dialog = fragment.querySelector('[data-ref=dialog]');
11162
11179
  this.#body = fragment.querySelector('[data-ref=body]');
11180
+ this.#loading = fragment.querySelector('[data-ref=loading]');
11181
+ this.#error = fragment.querySelector('[data-ref=error]');
11163
11182
  this.#dialog.addEventListener('close', () => {
11164
- this.dispatchEvent(
11165
- new CustomEvent('close', {
11166
- detail: { result: this.#dialog.returnValue === '' ? null : this.#dialog.returnValue },
11167
- }),
11168
- );
11169
- this.#settle();
11183
+ const outcome = this.#outcome();
11184
+ this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
11185
+ this.#settle(outcome);
11170
11186
  });
11171
11187
  this.#dialog.addEventListener('click', (/** @type any */ e) => {
11172
11188
  const result = e.target.closest('button[data-result]')?.dataset.result;
@@ -11174,11 +11190,26 @@ var fml = (function (exports) {
11174
11190
  this.#dialog.close(result);
11175
11191
  }
11176
11192
  });
11177
- //dismissal, not an answer: the waiters are settled with null, as Escape does.
11178
- //Optional because a subclass overriding the template owns what it renders
11193
+ //dismissal, not an answer: the waiters are settled with a dismissal, as
11194
+ //Escape does. Optional because a subclass overriding the template owns
11195
+ //what it renders
11179
11196
  fragment
11180
11197
  .querySelector('[data-ref=close]')
11181
11198
  ?.addEventListener('click', () => this.#dialog.close(''));
11199
+ if (closeOnSubmit) {
11200
+ //delegated on the body rather than bound to the form, so a body
11201
+ //delivered later by update() is covered by the same listener. The
11202
+ //form must be the body's own: a ful-table wraps its filters in a
11203
+ //ful-form of its own, and a search in a table the dialog holds is
11204
+ //not the dialog being answered
11205
+ this.#body.addEventListener('submit:success', (/** @type any */ e) => {
11206
+ if (e.target !== Nodes.queryChildren(this.#body, 'ful-form')) {
11207
+ return;
11208
+ }
11209
+ this.#answer = { dismissed: false, result: null, response: e.detail.response };
11210
+ this.#dialog.close('submitted');
11211
+ });
11212
+ }
11182
11213
  if (requiresAnswer) {
11183
11214
  //the platform's own dismissal, refused where the dialog must be
11184
11215
  //answered: cancel fires for Escape and for a close request the
@@ -11188,39 +11219,96 @@ var fml = (function (exports) {
11188
11219
  this.replaceChildren(fragment);
11189
11220
  wireTargets();
11190
11221
  }
11191
- //answers every waiter with the dialog's own answer: null while still open
11192
- //or closed without a result, which is also the unanswered answer a dialog
11193
- //leaving the document owes its waiters instead of hanging them
11194
- #settle() {
11222
+ /**
11223
+ * How the dialog ended, in one shape for every way it can end: `dismissed`
11224
+ * alone tells a cancel from an answer, so a submit answering with no body at
11225
+ * all (a 204) is still an answer, where a bare `null` could not say which it
11226
+ * was. `result` carries the `data-result` of the button that closed it and
11227
+ * `response` what a submit answered with; the one that did not happen is null.
11228
+ */
11229
+ #outcome() {
11230
+ if (this.#answer) {
11231
+ return this.#answer;
11232
+ }
11233
+ const result = this.#dialog.returnValue;
11234
+ return result === ''
11235
+ ? { dismissed: true, result: null, response: null }
11236
+ : { dismissed: false, result, response: null };
11237
+ }
11238
+ //answers every waiter with the dialog's own answer: a dismissal while still
11239
+ //open or closed without a result, which is also the unanswered answer a
11240
+ //dialog leaving the document owes its waiters instead of hanging them
11241
+ #settle(outcome) {
11195
11242
  const resolvers = this.#resolvers;
11196
11243
  this.#resolvers = [];
11197
11244
  for (const resolve of resolvers) {
11198
- resolve(this.#dialog.returnValue === '' ? null : this.#dialog.returnValue);
11245
+ resolve(outcome);
11199
11246
  }
11200
11247
  }
11201
11248
  disconnectedCallback() {
11202
- this.#settle();
11249
+ this.#settle(this.#outcome());
11203
11250
  }
11204
11251
  open() {
11205
11252
  return this.ask();
11206
11253
  }
11207
11254
  ask() {
11208
- if (!this.#dialog.open) {
11209
- this.#dialog.returnValue = '';
11210
- this.#dialog.showModal();
11255
+ if (this.#show()) {
11256
+ this.#restChrome();
11211
11257
  this.#request();
11212
11258
  }
11213
11259
  return new Promise((resolve) => {
11214
11260
  this.#resolvers.push(resolve);
11215
11261
  });
11216
11262
  }
11263
+ /**
11264
+ * Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
11265
+ * resolved value paints the body (which is returned), a rejection paints the
11266
+ * problems and travels to the caller, and an update superseded by a newer one
11267
+ * paints nothing. The title is the `header` attribute, configuration like the
11268
+ * rest of the dialog's chrome, so what update() owns is the body alone.
11269
+ */
11270
+ async update(cb) {
11271
+ //the claim detaches any update still in flight: its outcome belongs to
11272
+ //an abandoned opening and must neither be painted nor own the dialog
11273
+ const claim = this.#updates.take();
11274
+ this.#body.replaceChildren();
11275
+ this.#restChrome();
11276
+ this.#loading?.removeAttribute('hidden');
11277
+ this.#body.setAttribute('hidden', '');
11278
+ //update owns its own open-answer-deliver cycle, so it shows the dialog
11279
+ //without going through ask(): a user reopen during the wait is a real
11280
+ //open and goes through ask()
11281
+ this.#show();
11282
+ try {
11283
+ const delivered = await cb();
11284
+ if (claim.stale) {
11285
+ return this.#body;
11286
+ }
11287
+ this.#body.replaceChildren(delivered);
11288
+ this.#loading?.setAttribute('hidden', '');
11289
+ this.#body.removeAttribute('hidden');
11290
+ return this.#body;
11291
+ } catch (/** @type any */ e) {
11292
+ if (!claim.stale) {
11293
+ //revealed before it is filled, so the live region announces the
11294
+ //change rather than being revealed already holding it
11295
+ this.#error?.removeAttribute('hidden');
11296
+ if (this.#error) {
11297
+ this.#error.textContent = Failure.problemsText(e);
11298
+ }
11299
+ this.#loading?.setAttribute('hidden', '');
11300
+ this.#body.setAttribute('hidden', '');
11301
+ }
11302
+ throw e;
11303
+ }
11304
+ }
11217
11305
  #request() {
11218
11306
  this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
11219
11307
  }
11220
11308
  /**
11221
11309
  * Re-fires section:requested on the body, open or closed: the explicit
11222
11310
  * request for a body that wants refreshing. A failed refresh paints its
11223
- * problems, nothing rejects: there is no caller to reject towards.
11311
+ * problems, nothing rejects: update() stays the rejecting call.
11224
11312
  */
11225
11313
  refresh() {
11226
11314
  return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
@@ -11228,6 +11316,25 @@ var fml = (function (exports) {
11228
11316
  close(result) {
11229
11317
  this.#dialog.close(result ?? '');
11230
11318
  }
11319
+ /** Shows the modal, answering whether this call is the one that opened it. */
11320
+ #show() {
11321
+ if (this.#dialog.open) {
11322
+ return false;
11323
+ }
11324
+ //an opening owes nothing to the one before it: the platform keeps
11325
+ //returnValue across a close with no result, and the answer a submit
11326
+ //left is just as stale
11327
+ this.#dialog.returnValue = '';
11328
+ this.#answer = null;
11329
+ this.#dialog.showModal();
11330
+ return true;
11331
+ }
11332
+ #restChrome() {
11333
+ this.#error?.replaceChildren();
11334
+ this.#error?.setAttribute('hidden', '');
11335
+ this.#loading?.setAttribute('hidden', '');
11336
+ this.#body?.removeAttribute('hidden');
11337
+ }
11231
11338
  }
11232
11339
 
11233
11340
  /**
@@ -11240,7 +11347,7 @@ var fml = (function (exports) {
11240
11347
  * it.
11241
11348
  */
11242
11349
  class Drawer extends ParsedElement {
11243
- static attributes = ['title', 'placement'];
11350
+ static attributes = ['title', 'placement', 'close-on-submit:presence'];
11244
11351
  static slots = true;
11245
11352
  static template = `
11246
11353
  <dialog data-ref="dialog" class="ful-drawer">
@@ -11261,6 +11368,10 @@ var fml = (function (exports) {
11261
11368
  #content;
11262
11369
  #requests = new SectionRequests();
11263
11370
  #updates = new Claims();
11371
+ //what a submit closed the drawer with, told from a close of any other kind:
11372
+ //a save answering with no body at all is still a save
11373
+ /** @type {{ dismissed: boolean, response: any }|null} */
11374
+ #answer = null;
11264
11375
  render({ slots }) {
11265
11376
  const fragment = this.template()
11266
11377
  .withOverlay({ slots, title: this.declared('title') ?? '' })
@@ -11276,8 +11387,25 @@ var fml = (function (exports) {
11276
11387
  }
11277
11388
  fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
11278
11389
  this.#dialog.addEventListener('close', () => {
11279
- this.dispatchEvent(new CustomEvent('close'));
11390
+ this.dispatchEvent(
11391
+ new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
11392
+ );
11280
11393
  });
11394
+ if (this.declared('close-on-submit')) {
11395
+ //delegated on the content section rather than bound to the form: a
11396
+ //drawer's form usually arrives with an update() rather than with the
11397
+ //page, and the section outlives every delivery. The form must be the
11398
+ //content's own, a ful-table wrapping its filters in a ful-form of its
11399
+ //own and a search in a table the drawer holds not being the drawer
11400
+ //finishing
11401
+ this.#content.addEventListener('submit:success', (/** @type any */ e) => {
11402
+ if (e.target !== Nodes.queryChildren(this.#content, 'ful-form')) {
11403
+ return;
11404
+ }
11405
+ this.#answer = { dismissed: false, response: e.detail.response };
11406
+ this.close();
11407
+ });
11408
+ }
11281
11409
  this.replaceChildren(fragment);
11282
11410
  wireTargets();
11283
11411
  }
@@ -11350,6 +11478,9 @@ var fml = (function (exports) {
11350
11478
  if (this.#dialog.open) {
11351
11479
  return false;
11352
11480
  }
11481
+ //an opening owes nothing to the one before it: the answer a submit left
11482
+ //belongs to the drawer that closed on it
11483
+ this.#answer = null;
11353
11484
  this.#dialog.showModal();
11354
11485
  return true;
11355
11486
  }