@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/README.md +1 -1
- package/dist/custom-elements.json +18 -4
- package/dist/fml.css +4 -4
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +188 -24
- package/dist/fml.iife.js.map +1 -1
- package/dist/fml.iife.min.js +1 -1
- package/dist/fml.iife.min.js.map +1 -1
- package/dist/ful.css +4 -4
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +20 -1
- package/dist/ful.iife.js +188 -24
- package/dist/ful.iife.js.map +1 -1
- package/dist/ful.iife.min.js +1 -1
- package/dist/ful.iife.min.js.map +1 -1
- package/dist/ful.min.mjs +1 -1
- package/dist/ful.min.mjs.map +1 -1
- package/dist/ful.mjs +188 -24
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +9 -1
- package/dist/web-types.json +21 -5
- package/package.json +1 -1
package/dist/ful.mjs
CHANGED
|
@@ -2859,6 +2859,14 @@ class Select extends Field {
|
|
|
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 @@ class Table extends ParsedElement {
|
|
|
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
|
-
|
|
3998
|
-
|
|
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 @@ class Table extends ParsedElement {
|
|
|
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 = Failure.problemsText(
|
|
4018
4035
|
error,
|
|
@@ -4159,6 +4176,20 @@ class ChoiceButton {
|
|
|
4159
4176
|
//menu is where the localized words live
|
|
4160
4177
|
this.#button.textContent = this.#display(choice);
|
|
4161
4178
|
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 @@ class ChoiceButton {
|
|
|
4171
4202
|
const li = document.createElement('li');
|
|
4172
4203
|
li.setAttribute('role', 'none');
|
|
4173
4204
|
const a = document.createElement('a');
|
|
4174
|
-
|
|
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 @@ class ChoiceButton {
|
|
|
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 @@ class Tooltip extends ParsedElement {
|
|
|
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 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 @@ class Dialog extends ParsedElement {
|
|
|
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 @@ class Dialog extends ParsedElement {
|
|
|
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 @@ class Dialog extends ParsedElement {
|
|
|
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
|
|
4902
|
-
|
|
4903
|
-
|
|
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 @@ class Dialog extends ParsedElement {
|
|
|
4911
4960
|
this.#dialog.close(result);
|
|
4912
4961
|
}
|
|
4913
4962
|
});
|
|
4914
|
-
//dismissal, not an answer: the waiters are settled with
|
|
4915
|
-
//Optional because a subclass overriding the template owns
|
|
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 !== 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 @@ class Dialog extends ParsedElement {
|
|
|
4925
4989
|
this.replaceChildren(fragment);
|
|
4926
4990
|
wireTargets();
|
|
4927
4991
|
}
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
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(
|
|
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 (
|
|
4946
|
-
this.#
|
|
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 = 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:
|
|
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 @@ class Dialog extends ParsedElement {
|
|
|
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 @@ class Dialog extends ParsedElement {
|
|
|
4977
5117
|
* it.
|
|
4978
5118
|
*/
|
|
4979
5119
|
class Drawer extends 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 @@ class Drawer extends ParsedElement {
|
|
|
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 @@ class Drawer extends ParsedElement {
|
|
|
5013
5157
|
}
|
|
5014
5158
|
fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
|
|
5015
5159
|
this.#dialog.addEventListener('close', () => {
|
|
5016
|
-
this.dispatchEvent(
|
|
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 !== 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 @@ class Drawer extends ParsedElement {
|
|
|
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
|
}
|