@optionfactory/fml 9.0.0-rc1 → 9.0.0-rc10
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 +3 -4
- package/dist/custom-elements.json +119 -6
- package/dist/fml.css +8 -8
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +579 -116
- 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 +8 -8
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +179 -7
- package/dist/ful.iife.js +576 -115
- 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 +575 -116
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +63 -3
- package/dist/web-types.json +135 -7
- package/package.json +1 -1
package/dist/ful.iife.js
CHANGED
|
@@ -233,6 +233,48 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The protocol by which content standing inside a field becomes part of the
|
|
238
|
+
* accessible description of that field's control.
|
|
239
|
+
*
|
|
240
|
+
* A field owns its control's `aria-describedby`: it is the only thing that
|
|
241
|
+
* knows which element the description belongs on, and it already writes the
|
|
242
|
+
* entry for its own error region. Content the author slotted into the field
|
|
243
|
+
* cannot write that attribute itself without becoming a second owner of it, and
|
|
244
|
+
* it cannot be wired by the field either, because a slotted custom element
|
|
245
|
+
* renders after the field has mounted and has nothing to point at when the
|
|
246
|
+
* field looks.
|
|
247
|
+
*
|
|
248
|
+
* So the content asks, once it has something to offer. `describable(el)`
|
|
249
|
+
* answers the nearest ancestor that accepts a description, and the caller hands
|
|
250
|
+
* its element to that ancestor's `describedBy`, which answers whether it was
|
|
251
|
+
* taken. Nothing here names a field or a tooltip: the relation is expressed as
|
|
252
|
+
* a capability, so the two ends need not import each other, which matters
|
|
253
|
+
* because the library's own arrow runs from the forms to the disclosures.
|
|
254
|
+
*
|
|
255
|
+
* The lookup lives here rather than at its one call site so the protocol has a
|
|
256
|
+
* name, a place to be documented and a single definition to change.
|
|
257
|
+
*/
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @typedef {{ describedBy(el: HTMLElement): boolean }} Describable
|
|
261
|
+
*/
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The nearest ancestor of `el` that accepts elements into the description of
|
|
265
|
+
* whatever it considers its control, or null when nothing in the ancestry does.
|
|
266
|
+
* @param {Element} el
|
|
267
|
+
* @returns {(Element & Describable) | null}
|
|
268
|
+
*/
|
|
269
|
+
const describable = (el) => {
|
|
270
|
+
for (let at = el.parentElement; at; at = at.parentElement) {
|
|
271
|
+
if (typeof (/** @type {any} */ (at).describedBy) === 'function') {
|
|
272
|
+
return /** @type {any} */ (at);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
};
|
|
277
|
+
|
|
236
278
|
/**
|
|
237
279
|
* Sleeping, debouncing and throttling. Debounce and throttle both return the
|
|
238
280
|
* wrapped function together with a cancel function.
|
|
@@ -627,6 +669,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
627
669
|
/** the role the element internals carry, 'presentation' unless the control is its own */
|
|
628
670
|
static ROLE = 'presentation';
|
|
629
671
|
#control;
|
|
672
|
+
#described;
|
|
673
|
+
#descriptions = [];
|
|
674
|
+
#errorId = null;
|
|
630
675
|
#fieldError;
|
|
631
676
|
#claims;
|
|
632
677
|
#announces;
|
|
@@ -678,15 +723,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
678
723
|
true,
|
|
679
724
|
);
|
|
680
725
|
}
|
|
681
|
-
//the
|
|
726
|
+
//the description lands on the control, or on the host where there is no
|
|
682
727
|
//single control to describe (a radio group's legend names its fieldset)
|
|
728
|
+
this.#described = described ?? control;
|
|
683
729
|
if (error) {
|
|
684
|
-
|
|
730
|
+
//named for what it is, the generic id being for whoever brings no name
|
|
731
|
+
error.id = error.id || index_mjs.Attributes.uid('ful-field-error');
|
|
732
|
+
this.#errorId = error.id;
|
|
685
733
|
}
|
|
734
|
+
//anything handed over before the field had a target lands here
|
|
735
|
+
this.#describe();
|
|
686
736
|
if (label) {
|
|
687
|
-
|
|
688
|
-
//a label that does not natively target the control still focuses it
|
|
689
|
-
label.addEventListener('click', () => this.focus());
|
|
737
|
+
Field.#name(this, label, control);
|
|
690
738
|
}
|
|
691
739
|
//the platform's implicit submission, stood in for where the field's own
|
|
692
740
|
//protocol took it away: the inner controls carry form="", so Enter in one
|
|
@@ -719,6 +767,61 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
719
767
|
static #submitsOnEnter(el) {
|
|
720
768
|
return el instanceof HTMLInputElement && !['file', 'button', 'submit', 'reset', 'image'].includes(el.type);
|
|
721
769
|
}
|
|
770
|
+
/**
|
|
771
|
+
* Adds an element to the accessible description of the field's control and
|
|
772
|
+
* answers whether the field took it.
|
|
773
|
+
*
|
|
774
|
+
* A field takes one whenever it is offered, before its own render as
|
|
775
|
+
* readily as after: content slotted into a field is a custom element of its
|
|
776
|
+
* own and may upgrade on either side of the field it stands in, which
|
|
777
|
+
* happens in both directions in practice, a tooltip beating an async select
|
|
778
|
+
* to its render while losing to a plain input. A description handed over
|
|
779
|
+
* early waits here and is written the moment the field has somewhere to
|
|
780
|
+
* write it, so the caller never has to know the order.
|
|
781
|
+
*
|
|
782
|
+
* The reference lands on the element handed over rather than on a wrapper
|
|
783
|
+
* around it: a hidden element is included in a description only where it is
|
|
784
|
+
* named directly, and content that reaches the description through a
|
|
785
|
+
* wrapper is skipped while it is hidden. A popover closed until someone
|
|
786
|
+
* opens it is exactly that, so the caller passes the popover itself.
|
|
787
|
+
*
|
|
788
|
+
* An attribute rather than `ariaDescribedByElements`: the property reflects
|
|
789
|
+
* to nothing, so the description would live in the accessibility tree alone
|
|
790
|
+
* and vanish entirely on a browser without aria element reflection.
|
|
791
|
+
*
|
|
792
|
+
* This is the field's half of the description protocol; `describable` in
|
|
793
|
+
* `ful/descriptions.mjs` is the half the content uses to find the field.
|
|
794
|
+
* @param {HTMLElement} el
|
|
795
|
+
* @returns {boolean}
|
|
796
|
+
*/
|
|
797
|
+
describedBy(el) {
|
|
798
|
+
if (!el) {
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
if (!el.id) {
|
|
802
|
+
el.id = index_mjs.Attributes.uid('ful-described');
|
|
803
|
+
}
|
|
804
|
+
if (!this.#descriptions.includes(el.id)) {
|
|
805
|
+
this.#descriptions.push(el.id);
|
|
806
|
+
}
|
|
807
|
+
this.#describe();
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Writes the description the field has collected, the error region last:
|
|
812
|
+
* the standing explanations are what the field always says, the problem is
|
|
813
|
+
* the news. The field owns the attribute outright rather than appending to
|
|
814
|
+
* whatever is there, so the order does not depend on who arrived when.
|
|
815
|
+
*/
|
|
816
|
+
#describe() {
|
|
817
|
+
if (!this.#described) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const ids = [...this.#descriptions, this.#errorId].filter((id) => id);
|
|
821
|
+
if (ids.length) {
|
|
822
|
+
this.#described.setAttribute('aria-describedby', ids.join(' '));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
722
825
|
focus(options) {
|
|
723
826
|
this.#control?.focus(options);
|
|
724
827
|
}
|
|
@@ -775,6 +878,49 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
775
878
|
}),
|
|
776
879
|
);
|
|
777
880
|
}
|
|
881
|
+
/** The html elements a label's `for` may point at, `input[type=hidden]` excepted. */
|
|
882
|
+
static #LABELABLE = new Set(['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA']);
|
|
883
|
+
/**
|
|
884
|
+
* Names the control from the field's label, natively wherever the platform
|
|
885
|
+
* allows it.
|
|
886
|
+
*
|
|
887
|
+
* `for` and `id` are the form the dom itself carries, so the association is
|
|
888
|
+
* there for anything reading the markup rather than the accessibility tree:
|
|
889
|
+
* an audit tool, the browser's autofill, a translation pass. It also makes
|
|
890
|
+
* the label's click reach the control the way it does in a plain form, which
|
|
891
|
+
* is focus for a text control and activation for a checkbox, so the field
|
|
892
|
+
* needs no handler of its own.
|
|
893
|
+
*
|
|
894
|
+
* A control the platform will not let a label target, a composite carrying
|
|
895
|
+
* `role="radiogroup"` among them, takes `aria-labelledby` instead. That is an
|
|
896
|
+
* attribute too, so the association is equally visible; what it does not carry
|
|
897
|
+
* is the label's click, which is why the handler stays on that path only.
|
|
898
|
+
*
|
|
899
|
+
* Neither branch uses `ariaLabelledByElements`. The property reflects to no
|
|
900
|
+
* attribute, so the name lived in the accessibility tree alone: nothing reading
|
|
901
|
+
* the dom saw it, and on a browser without aria element reflection the
|
|
902
|
+
* assignment is a silent expando and the field has no name at all.
|
|
903
|
+
* @param {any} field
|
|
904
|
+
* @param {HTMLElement} label
|
|
905
|
+
* @param {any} control
|
|
906
|
+
*/
|
|
907
|
+
static #name(field, label, control) {
|
|
908
|
+
const labelable =
|
|
909
|
+
Field.#LABELABLE.has(control.tagName) && control.getAttribute('type') !== 'hidden';
|
|
910
|
+
if (!labelable) {
|
|
911
|
+
if (!label.id) {
|
|
912
|
+
label.id = index_mjs.Attributes.uid('ful-label');
|
|
913
|
+
}
|
|
914
|
+
control.setAttribute('aria-labelledby', label.id);
|
|
915
|
+
//aria-labelledby carries the name but not the label's click
|
|
916
|
+
label.addEventListener('click', () => field.focus());
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (!control.id) {
|
|
920
|
+
control.id = index_mjs.Attributes.uid('ful-control');
|
|
921
|
+
}
|
|
922
|
+
label.setAttribute('for', control.id);
|
|
923
|
+
}
|
|
778
924
|
/**
|
|
779
925
|
* Whether the field's chrome should answer a gesture. Badges, dropzones,
|
|
780
926
|
* menus and labels are not form controls, so their handlers must ask the
|
|
@@ -912,8 +1058,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
912
1058
|
* three claims reach it
|
|
913
1059
|
* - `error` is the field's live region
|
|
914
1060
|
* - `label`, when given, names the control and focuses it on click
|
|
915
|
-
* - `described` moves the
|
|
916
|
-
*
|
|
1061
|
+
* - `described` moves the description off the control and onto another
|
|
1062
|
+
* element, the host where no single control can carry it: the error
|
|
1063
|
+
* region and anything `describedBy` is later handed both land there
|
|
917
1064
|
* - `claims` moves the three claims onto a wrapper the field disables as a
|
|
918
1065
|
* whole, leaving focus and aria on the control
|
|
919
1066
|
* - `announces` is the element whose role carries `aria-readonly` and
|
|
@@ -1025,6 +1172,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1025
1172
|
'response-mapper',
|
|
1026
1173
|
'clear-invalid-on-change:presence',
|
|
1027
1174
|
'scroll-on-error:presence',
|
|
1175
|
+
'autocomplete',
|
|
1028
1176
|
];
|
|
1029
1177
|
form;
|
|
1030
1178
|
render() {
|
|
@@ -1035,6 +1183,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1035
1183
|
//internals messages custom elements have no default UI for
|
|
1036
1184
|
form.setAttribute('novalidate', '');
|
|
1037
1185
|
index_mjs.Attributes.forward('form-', this, form);
|
|
1186
|
+
//the fields read it off whichever of the two they reach first, which depends
|
|
1187
|
+
//on whether they upgraded before or after this render: they cannot read it
|
|
1188
|
+
//off their own control, which carries form="" and so has no form owner
|
|
1189
|
+
index_mjs.Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
|
|
1038
1190
|
form.replaceChildren(...this.childNodes);
|
|
1039
1191
|
form.addEventListener('submit', async (e) => {
|
|
1040
1192
|
e.preventDefault();
|
|
@@ -1239,6 +1391,22 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1239
1391
|
* `reject="[^0-9]"` both leave the digits. Keeping is the one worth reaching for,
|
|
1240
1392
|
* the rejecting spelling of an allowed set being a double negative.
|
|
1241
1393
|
*/
|
|
1394
|
+
/**
|
|
1395
|
+
* The autofill token a field inherits from the form around it.
|
|
1396
|
+
*
|
|
1397
|
+
* A control is rendered with `form=""` so that the host is the only thing that
|
|
1398
|
+
* submits, which also leaves it without a form owner, and the platform resolves
|
|
1399
|
+
* `autocomplete` through the form owner. So a form declaring it reaches nothing
|
|
1400
|
+
* on its own and the field reads the setting off the form element instead.
|
|
1401
|
+
*
|
|
1402
|
+
* The `form` a `ful-form` renders answers here, the host copying its token onto
|
|
1403
|
+
* it, and a plain `form` around ful fields answers too: the platform meant the
|
|
1404
|
+
* same thing by it, and its inheritance is broken here for the same reason. An
|
|
1405
|
+
* ancestor always upgrades before its descendants, so the rendered form is in
|
|
1406
|
+
* place by the time a field of its own builds.
|
|
1407
|
+
*/
|
|
1408
|
+
const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
|
|
1409
|
+
|
|
1242
1410
|
const warnedBoth = new WeakSet();
|
|
1243
1411
|
const filterOf = (el) => {
|
|
1244
1412
|
const keep = el.declared('keep');
|
|
@@ -1266,7 +1434,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1266
1434
|
static observed = ['placeholder'];
|
|
1267
1435
|
//configuration: the control is built from them and the value getter reads them,
|
|
1268
1436
|
//but none of them is meant to change once the element is up
|
|
1269
|
-
static attributes = [
|
|
1437
|
+
static attributes = [
|
|
1438
|
+
'type',
|
|
1439
|
+
'v-type',
|
|
1440
|
+
'keep',
|
|
1441
|
+
'reject',
|
|
1442
|
+
'uppercase:presence',
|
|
1443
|
+
'trim:presence',
|
|
1444
|
+
'autocomplete',
|
|
1445
|
+
];
|
|
1270
1446
|
static slots = true;
|
|
1271
1447
|
static template = `
|
|
1272
1448
|
<label>{{{{ slots.default }}}}</label>
|
|
@@ -1290,6 +1466,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1290
1466
|
const fragment = this.template().withOverlay({ type, slots }).render();
|
|
1291
1467
|
this._input = fragment.querySelector('input,textarea');
|
|
1292
1468
|
|
|
1469
|
+
//the browser reads autocomplete off the control it is classifying, so the
|
|
1470
|
+
//field's own token, or the form's where it declares none, is put there.
|
|
1471
|
+
//Set before the passthrough, which stays the last word
|
|
1472
|
+
index_mjs.Attributes.set(
|
|
1473
|
+
this._input,
|
|
1474
|
+
'autocomplete',
|
|
1475
|
+
this.declared('autocomplete') ?? inheritedAutocomplete(this),
|
|
1476
|
+
);
|
|
1293
1477
|
index_mjs.Attributes.forward('input-', this, this._input);
|
|
1294
1478
|
this._input.addEventListener('input', (evt) => {
|
|
1295
1479
|
const strip = filterOf(this);
|
|
@@ -1927,8 +2111,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1927
2111
|
const box = invoker.getBoundingClientRect();
|
|
1928
2112
|
const here = popover.getBoundingClientRect();
|
|
1929
2113
|
//against the padding box, which is what a percentage inset resolves against
|
|
1930
|
-
popover.style.setProperty(
|
|
1931
|
-
|
|
2114
|
+
popover.style.setProperty(
|
|
2115
|
+
'--ful-note-callout-inline',
|
|
2116
|
+
`${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
|
|
2117
|
+
);
|
|
2118
|
+
popover.style.setProperty(
|
|
2119
|
+
'--ful-note-callout-block',
|
|
2120
|
+
`${box.top + box.height / 2 - here.top - popover.clientTop}px`,
|
|
2121
|
+
);
|
|
1932
2122
|
};
|
|
1933
2123
|
|
|
1934
2124
|
const place = (popover, anchored) => {
|
|
@@ -2032,67 +2222,93 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2032
2222
|
};
|
|
2033
2223
|
|
|
2034
2224
|
/**
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
* invoker whenever it opens, stretched to the invoker's width when
|
|
2038
|
-
* asked, and cleaned up when it closes. Where the css works the call
|
|
2039
|
-
* is a no-op.
|
|
2040
|
-
*
|
|
2041
|
-
* `handPlace` takes the placement here on every platform, which a popover
|
|
2042
|
-
* asks for when it needs to know where its invoker ended up: the note's
|
|
2043
|
-
* callout points at the invoker, and a pseudo-element cannot read an anchor
|
|
2044
|
-
* that is not inside its own containing block, so the note is measured rather
|
|
2045
|
-
* than placed by the css. Its stylesheet declares no position-area to match.
|
|
2225
|
+
* CSS anchor positioning for a popover and the invoker it belongs to, with the
|
|
2226
|
+
* hand-placed fallback for the platforms that do not have it.
|
|
2046
2227
|
*/
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2228
|
+
class Anchors {
|
|
2229
|
+
/**
|
|
2230
|
+
* Anchors a popover to its invoker.
|
|
2231
|
+
*
|
|
2232
|
+
* The invoker is given an `anchor-name` and the popover a `position-anchor`
|
|
2233
|
+
* pointing at it, which is what a stylesheet needs to place the popover
|
|
2234
|
+
* itself: the library's own menus say `top: anchor(bottom); left:
|
|
2235
|
+
* anchor(left)`. **Writing that css is the caller's half of this.** Without
|
|
2236
|
+
* it the popover lands wherever the user agent puts a popover, which is not
|
|
2237
|
+
* beside the invoker.
|
|
2238
|
+
*
|
|
2239
|
+
* Where the platform has no anchor positioning the popover is placed here
|
|
2240
|
+
* instead, beside the invoker whenever it opens, clamped into the viewport,
|
|
2241
|
+
* following it on scroll and resize, and cleaned up on close. That placement
|
|
2242
|
+
* draws the geometry the css above describes, so the two agree.
|
|
2243
|
+
*
|
|
2244
|
+
* @param {HTMLElement} invoker the element the popover belongs to
|
|
2245
|
+
* @param {HTMLElement} popover the `[popover]` element to place
|
|
2246
|
+
* @param {object} [options]
|
|
2247
|
+
* @param {string} [options.prefix] prefixes the generated anchor name and id,
|
|
2248
|
+
* so the dom says which component a name belongs to
|
|
2249
|
+
* @param {boolean} [options.invoke] points the invoker's `popovertarget` at
|
|
2250
|
+
* the popover, giving toggle and light dismiss with no script of your own
|
|
2251
|
+
* @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
|
|
2252
|
+
* step with the popover
|
|
2253
|
+
* @param {boolean} [options.stretch] widens the popover to its invoker, which
|
|
2254
|
+
* is what a combobox dropdown wants
|
|
2255
|
+
* @param {boolean} [options.handPlace] places here on every platform rather
|
|
2256
|
+
* than only as a fallback, which a popover asks for when it needs to know
|
|
2257
|
+
* where its invoker ended up: the tooltip's note points a callout at it, and
|
|
2258
|
+
* a pseudo-element cannot read an anchor outside its own containing block.
|
|
2259
|
+
* Such a popover declares no anchor placement in css, there being none to
|
|
2260
|
+
* agree with
|
|
2261
|
+
*/
|
|
2262
|
+
static wire(
|
|
2263
|
+
invoker,
|
|
2264
|
+
popover,
|
|
2265
|
+
{ prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
|
|
2266
|
+
) {
|
|
2267
|
+
const uid = index_mjs.Attributes.uid(prefix);
|
|
2268
|
+
if (invoke) {
|
|
2269
|
+
//popovertarget needs a target that can be named
|
|
2270
|
+
popover.id = popover.id || uid;
|
|
2271
|
+
invoker.setAttribute('popovertarget', popover.id);
|
|
2272
|
+
}
|
|
2273
|
+
const anchor = `--${uid}`;
|
|
2274
|
+
invoker.style.anchorName = anchor;
|
|
2275
|
+
popover.style.positionAnchor = anchor;
|
|
2276
|
+
if (expanded) {
|
|
2277
|
+
invoker.setAttribute('aria-expanded', 'false');
|
|
2278
|
+
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
2279
|
+
invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
//the naming above is what the stylesheet reads, so it happens either way:
|
|
2283
|
+
//only the hand placement below is the fallback, and only for a popover that
|
|
2284
|
+
//did not ask to be placed here whatever the platform offers
|
|
2285
|
+
if (!handPlace && platformAnchors()) {
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const anchored = { invoker, stretch };
|
|
2289
|
+
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
2290
|
+
//placed before the showing, refined once laid out: the platform's
|
|
2291
|
+
//centered or corner spot never paints
|
|
2292
|
+
if (evt.newState === 'open') {
|
|
2293
|
+
place(popover, anchored);
|
|
2294
|
+
}
|
|
2295
|
+
});
|
|
2063
2296
|
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
2064
|
-
|
|
2297
|
+
if (evt.newState === 'open') {
|
|
2298
|
+
open.set(popover, anchored);
|
|
2299
|
+
place(popover, anchored);
|
|
2300
|
+
} else {
|
|
2301
|
+
open.delete(popover);
|
|
2302
|
+
unplace(popover);
|
|
2303
|
+
}
|
|
2065
2304
|
});
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
if (!handPlace && platformAnchors()) {
|
|
2071
|
-
return;
|
|
2072
|
-
}
|
|
2073
|
-
const anchored = { invoker, stretch };
|
|
2074
|
-
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
2075
|
-
//placed before the showing, refined once laid out: the platform's
|
|
2076
|
-
//centered or corner spot never paints
|
|
2077
|
-
if (evt.newState === 'open') {
|
|
2078
|
-
place(popover, anchored);
|
|
2305
|
+
if (!reflowWired) {
|
|
2306
|
+
reflowWired = true;
|
|
2307
|
+
document.addEventListener('scroll', schedule, true);
|
|
2308
|
+
window.addEventListener('resize', schedule);
|
|
2079
2309
|
}
|
|
2080
|
-
});
|
|
2081
|
-
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
2082
|
-
if (evt.newState === 'open') {
|
|
2083
|
-
open.set(popover, anchored);
|
|
2084
|
-
place(popover, anchored);
|
|
2085
|
-
} else {
|
|
2086
|
-
open.delete(popover);
|
|
2087
|
-
unplace(popover);
|
|
2088
|
-
}
|
|
2089
|
-
});
|
|
2090
|
-
if (!reflowWired) {
|
|
2091
|
-
reflowWired = true;
|
|
2092
|
-
document.addEventListener('scroll', schedule, true);
|
|
2093
|
-
window.addEventListener('resize', schedule);
|
|
2094
2310
|
}
|
|
2095
|
-
}
|
|
2311
|
+
}
|
|
2096
2312
|
|
|
2097
2313
|
/**
|
|
2098
2314
|
* Fetches a select's whole vocabulary from a url and serves every later read
|
|
@@ -2621,7 +2837,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2621
2837
|
});
|
|
2622
2838
|
//each pair carries its own anchor: two selects on a page must not share one
|
|
2623
2839
|
const group = fragment.querySelector('ful-control-group');
|
|
2624
|
-
|
|
2840
|
+
Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
|
|
2625
2841
|
[this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
|
|
2626
2842
|
this.#wireChrome();
|
|
2627
2843
|
this.#wireChips();
|
|
@@ -2643,6 +2859,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2643
2859
|
if (!this._interactive()) {
|
|
2644
2860
|
return;
|
|
2645
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
|
+
}
|
|
2646
2870
|
if (this.#ddmenu.shown) {
|
|
2647
2871
|
this.#close();
|
|
2648
2872
|
return;
|
|
@@ -3211,16 +3435,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3211
3435
|
evt.stopPropagation();
|
|
3212
3436
|
this._notifyChange();
|
|
3213
3437
|
});
|
|
3438
|
+
//the base points the label at the input with for/id, so the click toggles
|
|
3439
|
+
//the way it does in a plain form: the input's own change listener above
|
|
3440
|
+
//carries the notification, and readonly is refused by the freeze below
|
|
3214
3441
|
const label = fragment.querySelector('label');
|
|
3215
|
-
//the label neither wraps the input nor targets it, so the toggle is the
|
|
3216
|
-
//field's; the base adds the focus
|
|
3217
|
-
label.addEventListener('click', () => {
|
|
3218
|
-
if (!this._interactive()) {
|
|
3219
|
-
return;
|
|
3220
|
-
}
|
|
3221
|
-
this.value = !this.value;
|
|
3222
|
-
this._notifyChange();
|
|
3223
|
-
});
|
|
3224
3442
|
//a checkbox has no editable text to preserve, so readonly freezes the
|
|
3225
3443
|
//whole choice, label click included: the container is the frozen piece
|
|
3226
3444
|
return {
|
|
@@ -3784,8 +4002,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3784
4002
|
//started is stale, and neither renders nor updates the request a later
|
|
3785
4003
|
//reload replays, whichever order the responses arrive in
|
|
3786
4004
|
const claim = this.#loads.take();
|
|
3787
|
-
|
|
3788
|
-
|
|
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);
|
|
3789
4012
|
this.#feedback.setAttribute('hidden', '');
|
|
3790
4013
|
this.#noAutoload.setAttribute('hidden', '');
|
|
3791
4014
|
this.setAttribute('aria-busy', 'true');
|
|
@@ -3803,6 +4026,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3803
4026
|
return;
|
|
3804
4027
|
}
|
|
3805
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();
|
|
3806
4033
|
this.#feedback.removeAttribute('hidden');
|
|
3807
4034
|
this.#feedback.querySelector('[data-ref=feedback-error]').textContent = index_mjs$1.Failure.problemsText(
|
|
3808
4035
|
error,
|
|
@@ -3949,6 +4176,20 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3949
4176
|
//menu is where the localized words live
|
|
3950
4177
|
this.#button.textContent = this.#display(choice);
|
|
3951
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
|
+
}
|
|
3952
4193
|
}
|
|
3953
4194
|
/** The host's disabled claim, composed with the pin: lifting one cannot lift the other. */
|
|
3954
4195
|
set claimed(claimed) {
|
|
@@ -3961,7 +4202,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3961
4202
|
const li = document.createElement('li');
|
|
3962
4203
|
li.setAttribute('role', 'none');
|
|
3963
4204
|
const a = document.createElement('a');
|
|
3964
|
-
|
|
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');
|
|
3965
4208
|
a.setAttribute('tabindex', '-1');
|
|
3966
4209
|
a.setAttribute('value', choice);
|
|
3967
4210
|
const word = this.#labelFor(choice);
|
|
@@ -3979,6 +4222,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3979
4222
|
return li;
|
|
3980
4223
|
}),
|
|
3981
4224
|
);
|
|
4225
|
+
this.#mark();
|
|
3982
4226
|
}
|
|
3983
4227
|
#sync() {
|
|
3984
4228
|
const pinned = this.pinned;
|
|
@@ -3999,7 +4243,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3999
4243
|
#wire() {
|
|
4000
4244
|
const button = this.#button;
|
|
4001
4245
|
const menu = this.#menu;
|
|
4002
|
-
|
|
4246
|
+
Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
|
|
4003
4247
|
menu.addEventListener('toggle', (/** @type any */ evt) => {
|
|
4004
4248
|
if (evt.newState !== 'open') {
|
|
4005
4249
|
//give the invoker back the focus the menu had borrowed, without
|
|
@@ -4580,64 +4824,137 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4580
4824
|
});
|
|
4581
4825
|
};
|
|
4582
4826
|
|
|
4583
|
-
/**
|
|
4827
|
+
/**
|
|
4828
|
+
* An info icon button toggling a popover with a short explanation.
|
|
4829
|
+
*
|
|
4830
|
+
* The marker is the page's `config.icon`, and the `icon` attribute names a
|
|
4831
|
+
* `ful-icon` for the tooltip that means something other than plain information:
|
|
4832
|
+
* a caveat, a warning, a setting. A name the library does not paint is the
|
|
4833
|
+
* page's own, declared as `ful-icon[name='...'] { mask-image: ... }`.
|
|
4834
|
+
*
|
|
4835
|
+
* `describes` is for the tooltip standing in a field: the note becomes part of
|
|
4836
|
+
* the accessible description of that field's control, so it is announced on
|
|
4837
|
+
* reaching the field rather than only on opening the marker, and the marker
|
|
4838
|
+
* leaves the tab order, so a form of hinted fields costs no extra keystrokes to
|
|
4839
|
+
* walk. The marker stays clickable, and stays a tab stop wherever the note was
|
|
4840
|
+
* not taken, a tooltip claiming `describes` outside a field among them: the
|
|
4841
|
+
* stop only goes where something else delivers the content.
|
|
4842
|
+
*/
|
|
4584
4843
|
class Tooltip extends index_mjs.ParsedElement {
|
|
4585
4844
|
static slots = true;
|
|
4586
|
-
static attributes = ['placement'];
|
|
4845
|
+
static attributes = ['placement', 'icon', 'describes:presence'];
|
|
4587
4846
|
static config = {
|
|
4588
4847
|
icon: 'info-circle-fill',
|
|
4589
4848
|
};
|
|
4590
4849
|
static template = `
|
|
4591
|
-
<button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="config.icon" aria-hidden="true"></ful-icon></button>
|
|
4850
|
+
<button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="icon ?? config.icon" aria-hidden="true"></ful-icon></button>
|
|
4592
4851
|
<ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
|
|
4593
4852
|
`;
|
|
4594
4853
|
render({ slots }) {
|
|
4595
|
-
const fragment = this.template().withOverlay({ slots }).render();
|
|
4854
|
+
const fragment = this.template().withOverlay({ slots, icon: this.declared('icon') }).render();
|
|
4596
4855
|
const trigger = fragment.querySelector('[data-ref=trigger]');
|
|
4597
4856
|
const content = fragment.querySelector('[data-ref=content]');
|
|
4598
4857
|
//placed here rather than by the anchor css: the note draws a callout that
|
|
4599
4858
|
//has to point at the trigger wherever the viewport left room for the note,
|
|
4600
4859
|
//which is a measurement the stylesheet cannot make for a pseudo-element
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
}
|
|
4860
|
+
Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
|
|
4861
|
+
//above the marker by default: a note opening downwards covers the control
|
|
4862
|
+
//the marker explains, the marker riding the field's label
|
|
4863
|
+
content.setAttribute('placement', this.declared('placement') ?? 'top');
|
|
4606
4864
|
this.replaceChildren(fragment);
|
|
4865
|
+
if (this.declared('describes')) {
|
|
4866
|
+
Tooltip.#describe(this, trigger, content);
|
|
4867
|
+
}
|
|
4868
|
+
}
|
|
4869
|
+
/**
|
|
4870
|
+
* Offers the note to the field the tooltip stands in, and takes the trigger
|
|
4871
|
+
* out of the tab order only where the offer was accepted: a note nothing
|
|
4872
|
+
* carries is reachable by the keyboard through the marker alone, so
|
|
4873
|
+
* dropping the stop there would leave it reachable by nothing at all.
|
|
4874
|
+
*
|
|
4875
|
+
* The offer goes through the description protocol rather than naming a
|
|
4876
|
+
* field, the library's own arrow running from the forms to the disclosures.
|
|
4877
|
+
*/
|
|
4878
|
+
static #describe(tooltip, trigger, content) {
|
|
4879
|
+
if (!describable(tooltip)?.describedBy(content)) {
|
|
4880
|
+
console.warn('a ful-tooltip declares describes but stands in nothing that takes a description', tooltip);
|
|
4881
|
+
return;
|
|
4882
|
+
}
|
|
4883
|
+
trigger.tabIndex = -1;
|
|
4607
4884
|
}
|
|
4608
4885
|
}
|
|
4609
4886
|
|
|
4610
|
-
/**
|
|
4887
|
+
/**
|
|
4888
|
+
* A modal dialog on the native platform, open()/ask() resolving with the
|
|
4889
|
+
* closer's data-result.
|
|
4890
|
+
*
|
|
4891
|
+
* The header carries a close button, as the drawer's does: Escape dismisses a
|
|
4892
|
+
* modal on its own, but nothing says so, and a dialog whose only exit is a key
|
|
4893
|
+
* you have to know about leaves a pointer with nowhere to go. It answers the way
|
|
4894
|
+
* Escape does, with null.
|
|
4895
|
+
*
|
|
4896
|
+
* `requires-answer` is for the dialog that must be answered: the close button is not
|
|
4897
|
+
* rendered and Escape is refused, so the only way out is a button that carries a
|
|
4898
|
+
* result. It has to be both, a close button withheld while Escape still worked
|
|
4899
|
+
* being decoration rather than a rule.
|
|
4900
|
+
*
|
|
4901
|
+
* The chrome is reachable by class as well as by tag, so a plain `<dialog
|
|
4902
|
+
* class="ful-dialog">` written by a page gets the same look whatever its
|
|
4903
|
+
* structure: the tag form matches a direct child, and `ful-dialog-header`,
|
|
4904
|
+
* `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
|
|
4905
|
+
* dialog whose content is wrapped in a form needs.
|
|
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
|
+
|
|
4611
4914
|
class Dialog extends index_mjs.ParsedElement {
|
|
4612
|
-
static attributes = ['header'];
|
|
4915
|
+
static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
|
|
4613
4916
|
static slots = true;
|
|
4614
4917
|
static template = `
|
|
4615
4918
|
<dialog data-ref="dialog" class="ful-dialog">
|
|
4616
|
-
<header data-tpl-if="header
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
<button
|
|
4919
|
+
<header data-tpl-if="header || slots.header || !requiresAnswer" class="ful-dialog-header">
|
|
4920
|
+
{{{{ slots.header }}}}
|
|
4921
|
+
<h2 data-tpl-if="header">{{ header }}</h2>
|
|
4922
|
+
<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>
|
|
4923
|
+
</header>
|
|
4924
|
+
<section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
|
|
4925
|
+
<section data-ref="error" role="alert" hidden></section>
|
|
4926
|
+
<div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
|
|
4927
|
+
<footer class="ful-dialog-footer">
|
|
4928
|
+
<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>
|
|
4620
4929
|
{{{{ slots.buttons }}}}
|
|
4621
4930
|
</footer>
|
|
4622
4931
|
</dialog>
|
|
4623
4932
|
`;
|
|
4624
4933
|
#dialog;
|
|
4625
4934
|
#body;
|
|
4935
|
+
#loading;
|
|
4936
|
+
#error;
|
|
4626
4937
|
#requests = new SectionRequests();
|
|
4938
|
+
#updates = new Claims();
|
|
4627
4939
|
#resolvers = [];
|
|
4940
|
+
//the answer a submit closed the dialog with, which the return value cannot
|
|
4941
|
+
//carry: it is a string, and a response is whatever the server sent
|
|
4942
|
+
/** @type {DialogOutcome|null} */
|
|
4943
|
+
#answer = null;
|
|
4628
4944
|
render({ slots }) {
|
|
4945
|
+
const requiresAnswer = this.declared('requires-answer');
|
|
4946
|
+
const closeOnSubmit = this.declared('close-on-submit');
|
|
4629
4947
|
const fragment = this.template()
|
|
4630
|
-
.withOverlay({ slots, header: this.declared('header') ?? '' })
|
|
4948
|
+
.withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
|
|
4631
4949
|
.render();
|
|
4632
4950
|
this.#dialog = fragment.querySelector('[data-ref=dialog]');
|
|
4633
4951
|
this.#body = fragment.querySelector('[data-ref=body]');
|
|
4952
|
+
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
4953
|
+
this.#error = fragment.querySelector('[data-ref=error]');
|
|
4634
4954
|
this.#dialog.addEventListener('close', () => {
|
|
4635
|
-
this
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
}),
|
|
4639
|
-
);
|
|
4640
|
-
this.#settle();
|
|
4955
|
+
const outcome = this.#outcome();
|
|
4956
|
+
this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
|
|
4957
|
+
this.#settle(outcome);
|
|
4641
4958
|
});
|
|
4642
4959
|
this.#dialog.addEventListener('click', (/** @type any */ e) => {
|
|
4643
4960
|
const result = e.target.closest('button[data-result]')?.dataset.result;
|
|
@@ -4645,42 +4962,128 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4645
4962
|
this.#dialog.close(result);
|
|
4646
4963
|
}
|
|
4647
4964
|
});
|
|
4965
|
+
//dismissal, not an answer: the waiters are settled with a dismissal, as
|
|
4966
|
+
//Escape does. Optional because a subclass overriding the template owns
|
|
4967
|
+
//what it renders
|
|
4968
|
+
fragment
|
|
4969
|
+
.querySelector('[data-ref=close]')
|
|
4970
|
+
?.addEventListener('click', () => this.#dialog.close(''));
|
|
4971
|
+
if (closeOnSubmit) {
|
|
4972
|
+
//delegated on the body rather than bound to the form, so a body
|
|
4973
|
+
//delivered later by update() is covered by the same listener. The
|
|
4974
|
+
//form must be the body's own: a ful-table wraps its filters in a
|
|
4975
|
+
//ful-form of its own, and a search in a table the dialog holds is
|
|
4976
|
+
//not the dialog being answered
|
|
4977
|
+
this.#body.addEventListener('submit:success', (/** @type any */ e) => {
|
|
4978
|
+
if (e.target !== index_mjs.Nodes.queryChildren(this.#body, 'ful-form')) {
|
|
4979
|
+
return;
|
|
4980
|
+
}
|
|
4981
|
+
this.#answer = { dismissed: false, result: null, response: e.detail.response };
|
|
4982
|
+
this.#dialog.close('submitted');
|
|
4983
|
+
});
|
|
4984
|
+
}
|
|
4985
|
+
if (requiresAnswer) {
|
|
4986
|
+
//the platform's own dismissal, refused where the dialog must be
|
|
4987
|
+
//answered: cancel fires for Escape and for a close request the
|
|
4988
|
+
//browser makes on its own, and preventing it leaves the dialog open
|
|
4989
|
+
this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
|
|
4990
|
+
}
|
|
4648
4991
|
this.replaceChildren(fragment);
|
|
4649
4992
|
wireTargets();
|
|
4650
4993
|
}
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4994
|
+
/**
|
|
4995
|
+
* How the dialog ended, in one shape for every way it can end: `dismissed`
|
|
4996
|
+
* alone tells a cancel from an answer, so a submit answering with no body at
|
|
4997
|
+
* all (a 204) is still an answer, where a bare `null` could not say which it
|
|
4998
|
+
* was. `result` carries the `data-result` of the button that closed it and
|
|
4999
|
+
* `response` what a submit answered with; the one that did not happen is null.
|
|
5000
|
+
*/
|
|
5001
|
+
#outcome() {
|
|
5002
|
+
if (this.#answer) {
|
|
5003
|
+
return this.#answer;
|
|
5004
|
+
}
|
|
5005
|
+
//a render that threw adopted no dialog, and a removal still owes its
|
|
5006
|
+
//waiters an answer: reading through it would raise a second, unrelated
|
|
5007
|
+
//failure over the one already reported
|
|
5008
|
+
const result = this.#dialog?.returnValue ?? '';
|
|
5009
|
+
return result === ''
|
|
5010
|
+
? { dismissed: true, result: null, response: null }
|
|
5011
|
+
: { dismissed: false, result, response: null };
|
|
5012
|
+
}
|
|
5013
|
+
//answers every waiter with the dialog's own answer: a dismissal while still
|
|
5014
|
+
//open or closed without a result, which is also the unanswered answer a
|
|
5015
|
+
//dialog leaving the document owes its waiters instead of hanging them
|
|
5016
|
+
#settle(outcome) {
|
|
4655
5017
|
const resolvers = this.#resolvers;
|
|
4656
5018
|
this.#resolvers = [];
|
|
4657
5019
|
for (const resolve of resolvers) {
|
|
4658
|
-
resolve(
|
|
5020
|
+
resolve(outcome);
|
|
4659
5021
|
}
|
|
4660
5022
|
}
|
|
4661
5023
|
disconnectedCallback() {
|
|
4662
|
-
this.#settle();
|
|
5024
|
+
this.#settle(this.#outcome());
|
|
4663
5025
|
}
|
|
4664
5026
|
open() {
|
|
4665
5027
|
return this.ask();
|
|
4666
5028
|
}
|
|
4667
5029
|
ask() {
|
|
4668
|
-
if (
|
|
4669
|
-
this.#
|
|
4670
|
-
this.#dialog.showModal();
|
|
5030
|
+
if (this.#show()) {
|
|
5031
|
+
this.#restChrome();
|
|
4671
5032
|
this.#request();
|
|
4672
5033
|
}
|
|
4673
5034
|
return new Promise((resolve) => {
|
|
4674
5035
|
this.#resolvers.push(resolve);
|
|
4675
5036
|
});
|
|
4676
5037
|
}
|
|
5038
|
+
/**
|
|
5039
|
+
* Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
|
|
5040
|
+
* resolved value paints the body (which is returned), a rejection paints the
|
|
5041
|
+
* problems and travels to the caller, and an update superseded by a newer one
|
|
5042
|
+
* paints nothing. The title is the `header` attribute, configuration like the
|
|
5043
|
+
* rest of the dialog's chrome, so what update() owns is the body alone.
|
|
5044
|
+
*/
|
|
5045
|
+
async update(cb) {
|
|
5046
|
+
//the claim detaches any update still in flight: its outcome belongs to
|
|
5047
|
+
//an abandoned opening and must neither be painted nor own the dialog
|
|
5048
|
+
const claim = this.#updates.take();
|
|
5049
|
+
this.#body.replaceChildren();
|
|
5050
|
+
this.#restChrome();
|
|
5051
|
+
this.#loading?.removeAttribute('hidden');
|
|
5052
|
+
this.#body.setAttribute('hidden', '');
|
|
5053
|
+
//update owns its own open-answer-deliver cycle, so it shows the dialog
|
|
5054
|
+
//without going through ask(): a user reopen during the wait is a real
|
|
5055
|
+
//open and goes through ask()
|
|
5056
|
+
this.#show();
|
|
5057
|
+
try {
|
|
5058
|
+
const delivered = await cb();
|
|
5059
|
+
if (claim.stale) {
|
|
5060
|
+
return this.#body;
|
|
5061
|
+
}
|
|
5062
|
+
this.#body.replaceChildren(delivered);
|
|
5063
|
+
this.#loading?.setAttribute('hidden', '');
|
|
5064
|
+
this.#body.removeAttribute('hidden');
|
|
5065
|
+
return this.#body;
|
|
5066
|
+
} catch (/** @type any */ e) {
|
|
5067
|
+
if (!claim.stale) {
|
|
5068
|
+
//revealed before it is filled, so the live region announces the
|
|
5069
|
+
//change rather than being revealed already holding it
|
|
5070
|
+
this.#error?.removeAttribute('hidden');
|
|
5071
|
+
if (this.#error) {
|
|
5072
|
+
this.#error.textContent = index_mjs$1.Failure.problemsText(e);
|
|
5073
|
+
}
|
|
5074
|
+
this.#loading?.setAttribute('hidden', '');
|
|
5075
|
+
this.#body.setAttribute('hidden', '');
|
|
5076
|
+
}
|
|
5077
|
+
throw e;
|
|
5078
|
+
}
|
|
5079
|
+
}
|
|
4677
5080
|
#request() {
|
|
4678
5081
|
this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
|
|
4679
5082
|
}
|
|
4680
5083
|
/**
|
|
4681
5084
|
* Re-fires section:requested on the body, open or closed: the explicit
|
|
4682
5085
|
* request for a body that wants refreshing. A failed refresh paints its
|
|
4683
|
-
* problems, nothing rejects:
|
|
5086
|
+
* problems, nothing rejects: update() stays the rejecting call.
|
|
4684
5087
|
*/
|
|
4685
5088
|
refresh() {
|
|
4686
5089
|
return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
|
|
@@ -4688,15 +5091,43 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4688
5091
|
close(result) {
|
|
4689
5092
|
this.#dialog.close(result ?? '');
|
|
4690
5093
|
}
|
|
5094
|
+
/** Shows the modal, answering whether this call is the one that opened it. */
|
|
5095
|
+
#show() {
|
|
5096
|
+
if (this.#dialog.open) {
|
|
5097
|
+
return false;
|
|
5098
|
+
}
|
|
5099
|
+
//an opening owes nothing to the one before it: the platform keeps
|
|
5100
|
+
//returnValue across a close with no result, and the answer a submit
|
|
5101
|
+
//left is just as stale
|
|
5102
|
+
this.#dialog.returnValue = '';
|
|
5103
|
+
this.#answer = null;
|
|
5104
|
+
this.#dialog.showModal();
|
|
5105
|
+
return true;
|
|
5106
|
+
}
|
|
5107
|
+
#restChrome() {
|
|
5108
|
+
this.#error?.replaceChildren();
|
|
5109
|
+
this.#error?.setAttribute('hidden', '');
|
|
5110
|
+
this.#loading?.setAttribute('hidden', '');
|
|
5111
|
+
this.#body?.removeAttribute('hidden');
|
|
5112
|
+
}
|
|
4691
5113
|
}
|
|
4692
5114
|
|
|
4693
|
-
/**
|
|
5115
|
+
/**
|
|
5116
|
+
* A side panel drawer on the native dialog platform, update() owning its
|
|
5117
|
+
* open-deliver cycle.
|
|
5118
|
+
*
|
|
5119
|
+
* The `header` slot is content beside the title, before it: an icon, a badge, a
|
|
5120
|
+
* status. It sits outside the heading rather than in it because `update()` sets
|
|
5121
|
+
* the title through `textContent`, which would take anything nested there with
|
|
5122
|
+
* it.
|
|
5123
|
+
*/
|
|
4694
5124
|
class Drawer extends index_mjs.ParsedElement {
|
|
4695
|
-
static attributes = ['title', 'placement'];
|
|
5125
|
+
static attributes = ['title', 'placement', 'close-on-submit:presence'];
|
|
4696
5126
|
static slots = true;
|
|
4697
5127
|
static template = `
|
|
4698
5128
|
<dialog data-ref="dialog" class="ful-drawer">
|
|
4699
5129
|
<header>
|
|
5130
|
+
{{{{ slots.header }}}}
|
|
4700
5131
|
<h2 data-ref="title">{{ title }}</h2>
|
|
4701
5132
|
<button type="button" data-ref="close" data-tpl-aria-label="#l10n:t('drawer.close')"><ful-icon name="x-lg" aria-hidden="true"></ful-icon></button>
|
|
4702
5133
|
</header>
|
|
@@ -4712,6 +5143,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4712
5143
|
#content;
|
|
4713
5144
|
#requests = new SectionRequests();
|
|
4714
5145
|
#updates = new Claims();
|
|
5146
|
+
//what a submit closed the drawer with, told from a close of any other kind:
|
|
5147
|
+
//a save answering with no body at all is still a save
|
|
5148
|
+
/** @type {{ dismissed: boolean, response: any }|null} */
|
|
5149
|
+
#answer = null;
|
|
4715
5150
|
render({ slots }) {
|
|
4716
5151
|
const fragment = this.template()
|
|
4717
5152
|
.withOverlay({ slots, title: this.declared('title') ?? '' })
|
|
@@ -4727,8 +5162,25 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4727
5162
|
}
|
|
4728
5163
|
fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
|
|
4729
5164
|
this.#dialog.addEventListener('close', () => {
|
|
4730
|
-
this.dispatchEvent(
|
|
5165
|
+
this.dispatchEvent(
|
|
5166
|
+
new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
|
|
5167
|
+
);
|
|
4731
5168
|
});
|
|
5169
|
+
if (this.declared('close-on-submit')) {
|
|
5170
|
+
//delegated on the content section rather than bound to the form: a
|
|
5171
|
+
//drawer's form usually arrives with an update() rather than with the
|
|
5172
|
+
//page, and the section outlives every delivery. The form must be the
|
|
5173
|
+
//content's own, a ful-table wrapping its filters in a ful-form of its
|
|
5174
|
+
//own and a search in a table the drawer holds not being the drawer
|
|
5175
|
+
//finishing
|
|
5176
|
+
this.#content.addEventListener('submit:success', (/** @type any */ e) => {
|
|
5177
|
+
if (e.target !== index_mjs.Nodes.queryChildren(this.#content, 'ful-form')) {
|
|
5178
|
+
return;
|
|
5179
|
+
}
|
|
5180
|
+
this.#answer = { dismissed: false, response: e.detail.response };
|
|
5181
|
+
this.close();
|
|
5182
|
+
});
|
|
5183
|
+
}
|
|
4732
5184
|
this.replaceChildren(fragment);
|
|
4733
5185
|
wireTargets();
|
|
4734
5186
|
}
|
|
@@ -4801,6 +5253,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4801
5253
|
if (this.#dialog.open) {
|
|
4802
5254
|
return false;
|
|
4803
5255
|
}
|
|
5256
|
+
//an opening owes nothing to the one before it: the answer a submit left
|
|
5257
|
+
//belongs to the drawer that closed on it
|
|
5258
|
+
this.#answer = null;
|
|
4804
5259
|
this.#dialog.showModal();
|
|
4805
5260
|
return true;
|
|
4806
5261
|
}
|
|
@@ -5224,6 +5679,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5224
5679
|
'filters.boolean.false': 'No',
|
|
5225
5680
|
'info.tooltip': 'More information',
|
|
5226
5681
|
'dialog.acknowledge': 'Got it',
|
|
5682
|
+
'dialog.close': 'Close',
|
|
5227
5683
|
'drawer.close': 'Close',
|
|
5228
5684
|
'spinner.loading': 'Loading…',
|
|
5229
5685
|
'toast.region': 'Notifications',
|
|
@@ -5265,6 +5721,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5265
5721
|
'filters.boolean.false': 'No',
|
|
5266
5722
|
'info.tooltip': 'Maggiori informazioni',
|
|
5267
5723
|
'dialog.acknowledge': 'Ho capito',
|
|
5724
|
+
'dialog.close': 'Chiudi',
|
|
5268
5725
|
'drawer.close': 'Chiudi',
|
|
5269
5726
|
'spinner.loading': 'Caricamento…',
|
|
5270
5727
|
'toast.region': 'Notifiche',
|
|
@@ -5306,6 +5763,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5306
5763
|
'filters.boolean.false': 'No',
|
|
5307
5764
|
'info.tooltip': 'Más información',
|
|
5308
5765
|
'dialog.acknowledge': 'Entendido',
|
|
5766
|
+
'dialog.close': 'Cerrar',
|
|
5309
5767
|
'drawer.close': 'Cerrar',
|
|
5310
5768
|
'spinner.loading': 'Cargando…',
|
|
5311
5769
|
'toast.region': 'Notificaciones',
|
|
@@ -5350,6 +5808,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5350
5808
|
'filters.boolean.false': 'Non',
|
|
5351
5809
|
'info.tooltip': 'Plus d’informations',
|
|
5352
5810
|
'dialog.acknowledge': 'J’ai compris',
|
|
5811
|
+
'dialog.close': 'Fermer',
|
|
5353
5812
|
'drawer.close': 'Fermer',
|
|
5354
5813
|
'spinner.loading': 'Chargement…',
|
|
5355
5814
|
'toast.region': 'Notifications',
|
|
@@ -5437,6 +5896,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5437
5896
|
}
|
|
5438
5897
|
|
|
5439
5898
|
exports.Accordion = Accordion;
|
|
5899
|
+
exports.Anchors = Anchors;
|
|
5440
5900
|
exports.AsyncEvents = AsyncEvents;
|
|
5441
5901
|
exports.Bindings = Bindings;
|
|
5442
5902
|
exports.BooleanFilter = BooleanFilter;
|
|
@@ -5478,6 +5938,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5478
5938
|
exports.VersionedLocalStorage = VersionedLocalStorage;
|
|
5479
5939
|
exports.VersionedSessionStorage = VersionedSessionStorage;
|
|
5480
5940
|
exports.Wizard = Wizard;
|
|
5941
|
+
exports.describable = describable;
|
|
5481
5942
|
|
|
5482
5943
|
return exports;
|
|
5483
5944
|
|