@optionfactory/fml 9.0.0-rc1 → 9.0.0-rc11
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 +332 -19
- package/dist/fml.css +10 -9
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +763 -152
- 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 +10 -9
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +205 -7
- package/dist/ful.iife.js +758 -150
- 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 +756 -151
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +157 -15
- package/dist/web-types.json +347 -20
- 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,38 @@ 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
|
+
|
|
1410
|
+
/** Rendering a text input is what costs these the native min/max/step enforcement. */
|
|
1411
|
+
const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
|
|
1412
|
+
|
|
1413
|
+
const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
|
|
1414
|
+
|
|
1415
|
+
const NUMERIC_FILTERS = {
|
|
1416
|
+
numeric: (v) => signed(v, (t) => t.replace(/\D/g, '')),
|
|
1417
|
+
//one separator, the first: the rest are a slip while typing, not a value
|
|
1418
|
+
decimal: (v) =>
|
|
1419
|
+
signed(v, (t) => {
|
|
1420
|
+
const kept = t.replace(/[^\d.,]/g, '');
|
|
1421
|
+
const at = kept.search(/[.,]/);
|
|
1422
|
+
return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
|
|
1423
|
+
}),
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1242
1426
|
const warnedBoth = new WeakSet();
|
|
1243
1427
|
const filterOf = (el) => {
|
|
1244
1428
|
const keep = el.declared('keep');
|
|
@@ -1258,7 +1442,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1258
1442
|
const re = compiled('reject', reject);
|
|
1259
1443
|
return re && ((v) => v.replace(re, ''));
|
|
1260
1444
|
}
|
|
1261
|
-
return null;
|
|
1445
|
+
return NUMERIC_FILTERS[el._type()] ?? null;
|
|
1262
1446
|
};
|
|
1263
1447
|
|
|
1264
1448
|
/** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
|
|
@@ -1266,7 +1450,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1266
1450
|
static observed = ['placeholder'];
|
|
1267
1451
|
//configuration: the control is built from them and the value getter reads them,
|
|
1268
1452
|
//but none of them is meant to change once the element is up
|
|
1269
|
-
static attributes = [
|
|
1453
|
+
static attributes = [
|
|
1454
|
+
'type',
|
|
1455
|
+
'v-type',
|
|
1456
|
+
'keep',
|
|
1457
|
+
'reject',
|
|
1458
|
+
'uppercase:presence',
|
|
1459
|
+
'trim:presence',
|
|
1460
|
+
'autocomplete',
|
|
1461
|
+
];
|
|
1270
1462
|
static slots = true;
|
|
1271
1463
|
static template = `
|
|
1272
1464
|
<label>{{{{ slots.default }}}}</label>
|
|
@@ -1286,10 +1478,26 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1286
1478
|
return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
|
|
1287
1479
|
}
|
|
1288
1480
|
_build({ slots }) {
|
|
1289
|
-
const
|
|
1481
|
+
const declared = this._type();
|
|
1482
|
+
const mode = INPUT_MODES[declared];
|
|
1483
|
+
//a numeric widget is a text input: the type names the keyboard, not the
|
|
1484
|
+
//control, so the rendered markup stays valid html
|
|
1485
|
+
const type = mode ? 'text' : declared;
|
|
1290
1486
|
const fragment = this.template().withOverlay({ type, slots }).render();
|
|
1291
1487
|
this._input = fragment.querySelector('input,textarea');
|
|
1488
|
+
if (mode) {
|
|
1489
|
+
//before the passthrough, which stays the last word
|
|
1490
|
+
index_mjs.Attributes.set(this._input, 'inputmode', mode);
|
|
1491
|
+
}
|
|
1292
1492
|
|
|
1493
|
+
//the browser reads autocomplete off the control it is classifying, so the
|
|
1494
|
+
//field's own token, or the form's where it declares none, is put there.
|
|
1495
|
+
//Set before the passthrough, which stays the last word
|
|
1496
|
+
index_mjs.Attributes.set(
|
|
1497
|
+
this._input,
|
|
1498
|
+
'autocomplete',
|
|
1499
|
+
this.declared('autocomplete') ?? inheritedAutocomplete(this),
|
|
1500
|
+
);
|
|
1293
1501
|
index_mjs.Attributes.forward('input-', this, this._input);
|
|
1294
1502
|
this._input.addEventListener('input', (evt) => {
|
|
1295
1503
|
const strip = filterOf(this);
|
|
@@ -1332,13 +1540,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1332
1540
|
if (trimmed === '') {
|
|
1333
1541
|
return null;
|
|
1334
1542
|
}
|
|
1543
|
+
//the wire carries a dot wherever the comma is what the keyboard shows
|
|
1544
|
+
const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
|
|
1335
1545
|
if (this.declared('v-type') === 'number') {
|
|
1336
1546
|
//typed values are an explicit opt in, as the select's k-type: blank
|
|
1337
1547
|
//stays null, and a value that does not decode is kept as it is
|
|
1338
|
-
const n = Number(
|
|
1339
|
-
return Number.isNaN(n) ?
|
|
1548
|
+
const n = Number(normalized);
|
|
1549
|
+
return Number.isNaN(n) ? normalized : n;
|
|
1340
1550
|
}
|
|
1341
|
-
return
|
|
1551
|
+
return normalized;
|
|
1342
1552
|
}
|
|
1343
1553
|
set value(value) {
|
|
1344
1554
|
this._input.value = value === '' || value === undefined ? null : value;
|
|
@@ -1927,8 +2137,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
1927
2137
|
const box = invoker.getBoundingClientRect();
|
|
1928
2138
|
const here = popover.getBoundingClientRect();
|
|
1929
2139
|
//against the padding box, which is what a percentage inset resolves against
|
|
1930
|
-
popover.style.setProperty(
|
|
1931
|
-
|
|
2140
|
+
popover.style.setProperty(
|
|
2141
|
+
'--ful-note-callout-inline',
|
|
2142
|
+
`${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
|
|
2143
|
+
);
|
|
2144
|
+
popover.style.setProperty(
|
|
2145
|
+
'--ful-note-callout-block',
|
|
2146
|
+
`${box.top + box.height / 2 - here.top - popover.clientTop}px`,
|
|
2147
|
+
);
|
|
1932
2148
|
};
|
|
1933
2149
|
|
|
1934
2150
|
const place = (popover, anchored) => {
|
|
@@ -2032,67 +2248,93 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2032
2248
|
};
|
|
2033
2249
|
|
|
2034
2250
|
/**
|
|
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.
|
|
2251
|
+
* CSS anchor positioning for a popover and the invoker it belongs to, with the
|
|
2252
|
+
* hand-placed fallback for the platforms that do not have it.
|
|
2046
2253
|
*/
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2254
|
+
class Anchors {
|
|
2255
|
+
/**
|
|
2256
|
+
* Anchors a popover to its invoker.
|
|
2257
|
+
*
|
|
2258
|
+
* The invoker is given an `anchor-name` and the popover a `position-anchor`
|
|
2259
|
+
* pointing at it, which is what a stylesheet needs to place the popover
|
|
2260
|
+
* itself: the library's own menus say `top: anchor(bottom); left:
|
|
2261
|
+
* anchor(left)`. **Writing that css is the caller's half of this.** Without
|
|
2262
|
+
* it the popover lands wherever the user agent puts a popover, which is not
|
|
2263
|
+
* beside the invoker.
|
|
2264
|
+
*
|
|
2265
|
+
* Where the platform has no anchor positioning the popover is placed here
|
|
2266
|
+
* instead, beside the invoker whenever it opens, clamped into the viewport,
|
|
2267
|
+
* following it on scroll and resize, and cleaned up on close. That placement
|
|
2268
|
+
* draws the geometry the css above describes, so the two agree.
|
|
2269
|
+
*
|
|
2270
|
+
* @param {HTMLElement} invoker the element the popover belongs to
|
|
2271
|
+
* @param {HTMLElement} popover the `[popover]` element to place
|
|
2272
|
+
* @param {object} [options]
|
|
2273
|
+
* @param {string} [options.prefix] prefixes the generated anchor name and id,
|
|
2274
|
+
* so the dom says which component a name belongs to
|
|
2275
|
+
* @param {boolean} [options.invoke] points the invoker's `popovertarget` at
|
|
2276
|
+
* the popover, giving toggle and light dismiss with no script of your own
|
|
2277
|
+
* @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
|
|
2278
|
+
* step with the popover
|
|
2279
|
+
* @param {boolean} [options.stretch] widens the popover to its invoker, which
|
|
2280
|
+
* is what a combobox dropdown wants
|
|
2281
|
+
* @param {boolean} [options.handPlace] places here on every platform rather
|
|
2282
|
+
* than only as a fallback, which a popover asks for when it needs to know
|
|
2283
|
+
* where its invoker ended up: the tooltip's note points a callout at it, and
|
|
2284
|
+
* a pseudo-element cannot read an anchor outside its own containing block.
|
|
2285
|
+
* Such a popover declares no anchor placement in css, there being none to
|
|
2286
|
+
* agree with
|
|
2287
|
+
*/
|
|
2288
|
+
static wire(
|
|
2289
|
+
invoker,
|
|
2290
|
+
popover,
|
|
2291
|
+
{ prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
|
|
2292
|
+
) {
|
|
2293
|
+
const uid = index_mjs.Attributes.uid(prefix);
|
|
2294
|
+
if (invoke) {
|
|
2295
|
+
//popovertarget needs a target that can be named
|
|
2296
|
+
popover.id = popover.id || uid;
|
|
2297
|
+
invoker.setAttribute('popovertarget', popover.id);
|
|
2298
|
+
}
|
|
2299
|
+
const anchor = `--${uid}`;
|
|
2300
|
+
invoker.style.anchorName = anchor;
|
|
2301
|
+
popover.style.positionAnchor = anchor;
|
|
2302
|
+
if (expanded) {
|
|
2303
|
+
invoker.setAttribute('aria-expanded', 'false');
|
|
2304
|
+
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
2305
|
+
invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
//the naming above is what the stylesheet reads, so it happens either way:
|
|
2309
|
+
//only the hand placement below is the fallback, and only for a popover that
|
|
2310
|
+
//did not ask to be placed here whatever the platform offers
|
|
2311
|
+
if (!handPlace && platformAnchors()) {
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
const anchored = { invoker, stretch };
|
|
2315
|
+
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
2316
|
+
//placed before the showing, refined once laid out: the platform's
|
|
2317
|
+
//centered or corner spot never paints
|
|
2318
|
+
if (evt.newState === 'open') {
|
|
2319
|
+
place(popover, anchored);
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2063
2322
|
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
2064
|
-
|
|
2323
|
+
if (evt.newState === 'open') {
|
|
2324
|
+
open.set(popover, anchored);
|
|
2325
|
+
place(popover, anchored);
|
|
2326
|
+
} else {
|
|
2327
|
+
open.delete(popover);
|
|
2328
|
+
unplace(popover);
|
|
2329
|
+
}
|
|
2065
2330
|
});
|
|
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);
|
|
2079
|
-
}
|
|
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);
|
|
2331
|
+
if (!reflowWired) {
|
|
2332
|
+
reflowWired = true;
|
|
2333
|
+
document.addEventListener('scroll', schedule, true);
|
|
2334
|
+
window.addEventListener('resize', schedule);
|
|
2088
2335
|
}
|
|
2089
|
-
});
|
|
2090
|
-
if (!reflowWired) {
|
|
2091
|
-
reflowWired = true;
|
|
2092
|
-
document.addEventListener('scroll', schedule, true);
|
|
2093
|
-
window.addEventListener('resize', schedule);
|
|
2094
2336
|
}
|
|
2095
|
-
}
|
|
2337
|
+
}
|
|
2096
2338
|
|
|
2097
2339
|
/**
|
|
2098
2340
|
* Fetches a select's whole vocabulary from a url and serves every later read
|
|
@@ -2298,10 +2540,27 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2298
2540
|
method: el.declared('method') ?? 'POST',
|
|
2299
2541
|
mode: el.declared('mode'),
|
|
2300
2542
|
prefetch: el.declared('preload'),
|
|
2301
|
-
revision: el
|
|
2543
|
+
revision: SelectLoader.#revisionFrom(el),
|
|
2302
2544
|
responseMapper: SelectLoader.#responseMapperFrom(el),
|
|
2303
2545
|
});
|
|
2304
2546
|
}
|
|
2547
|
+
static #revisionFrom(el) {
|
|
2548
|
+
const declared = el.declared('revision');
|
|
2549
|
+
if (declared !== '') {
|
|
2550
|
+
return declared;
|
|
2551
|
+
}
|
|
2552
|
+
const configured = el.component('revision');
|
|
2553
|
+
if (configured === undefined || configured === null) {
|
|
2554
|
+
//not caching while the markup says to cache is the quiet failure
|
|
2555
|
+
//this branch exists to make loud
|
|
2556
|
+
console.warn(
|
|
2557
|
+
"a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
|
|
2558
|
+
el,
|
|
2559
|
+
);
|
|
2560
|
+
return null;
|
|
2561
|
+
}
|
|
2562
|
+
return typeof configured === 'function' ? configured() : configured;
|
|
2563
|
+
}
|
|
2305
2564
|
static #responseMapperFrom(el) {
|
|
2306
2565
|
if (el.declared('k-expr') && el.declared('l-expr')) {
|
|
2307
2566
|
return (response) => {
|
|
@@ -2334,7 +2593,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2334
2593
|
static slots = true;
|
|
2335
2594
|
static template = `
|
|
2336
2595
|
<ful-spinner class="centered" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
|
|
2337
|
-
<
|
|
2596
|
+
<ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
|
|
2338
2597
|
<menu tabindex="-1" role="listbox" hidden></menu>
|
|
2339
2598
|
`;
|
|
2340
2599
|
static templates = {
|
|
@@ -2356,7 +2615,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2356
2615
|
? this.template('options')
|
|
2357
2616
|
: index_mjs.Templates.fromFragment(slots.default);
|
|
2358
2617
|
this.#spinner = fragment.querySelector('ful-spinner');
|
|
2359
|
-
this.#empty = fragment.querySelector('
|
|
2618
|
+
this.#empty = fragment.querySelector('[data-ref=empty]');
|
|
2360
2619
|
this.#menu = fragment.querySelector('menu');
|
|
2361
2620
|
//the listbox is named so a combobox can point aria-controls and
|
|
2362
2621
|
//aria-activedescendant at it: a reference to an unnamed element resolves
|
|
@@ -2621,7 +2880,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2621
2880
|
});
|
|
2622
2881
|
//each pair carries its own anchor: two selects on a page must not share one
|
|
2623
2882
|
const group = fragment.querySelector('ful-control-group');
|
|
2624
|
-
|
|
2883
|
+
Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
|
|
2625
2884
|
[this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
|
|
2626
2885
|
this.#wireChrome();
|
|
2627
2886
|
this.#wireChips();
|
|
@@ -2643,6 +2902,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2643
2902
|
if (!this._interactive()) {
|
|
2644
2903
|
return;
|
|
2645
2904
|
}
|
|
2905
|
+
//a click on another control inside the select is that control's, not
|
|
2906
|
+
//the select's: a tooltip marker slotted into `info`, a button a page
|
|
2907
|
+
//put in an affix. Without this, reading the note beside a select
|
|
2908
|
+
//also stole the focus and dropped the dropdown over the note
|
|
2909
|
+
const elsewhere = e.target.closest('button, a[href], input, select, textarea');
|
|
2910
|
+
if (elsewhere && elsewhere !== this.#input) {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2646
2913
|
if (this.#ddmenu.shown) {
|
|
2647
2914
|
this.#close();
|
|
2648
2915
|
return;
|
|
@@ -2708,7 +2975,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2708
2975
|
return;
|
|
2709
2976
|
}
|
|
2710
2977
|
this.#abortdload();
|
|
2711
|
-
this.#close();
|
|
2978
|
+
this.#close(true);
|
|
2712
2979
|
});
|
|
2713
2980
|
this.#input.addEventListener('keydown', (e) => {
|
|
2714
2981
|
if (!this._interactive()) {
|
|
@@ -2741,7 +3008,6 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2741
3008
|
this.#editing = false;
|
|
2742
3009
|
this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
|
|
2743
3010
|
this.#changed();
|
|
2744
|
-
this.#syncBadges();
|
|
2745
3011
|
this.#input.focus();
|
|
2746
3012
|
this.#ddmenu.hide();
|
|
2747
3013
|
if (!this.#multiple) {
|
|
@@ -2798,7 +3064,6 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2798
3064
|
}
|
|
2799
3065
|
this.#values.delete(key);
|
|
2800
3066
|
this.#changed();
|
|
2801
|
-
this.#syncBadges();
|
|
2802
3067
|
}
|
|
2803
3068
|
#chipKeydown(e, badge) {
|
|
2804
3069
|
switch (e.code) {
|
|
@@ -2894,7 +3159,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2894
3159
|
}
|
|
2895
3160
|
case 'Tab': {
|
|
2896
3161
|
this.#abortdload();
|
|
2897
|
-
this.#close();
|
|
3162
|
+
this.#close(true);
|
|
2898
3163
|
break;
|
|
2899
3164
|
}
|
|
2900
3165
|
}
|
|
@@ -2910,12 +3175,25 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2910
3175
|
}
|
|
2911
3176
|
return;
|
|
2912
3177
|
}
|
|
2913
|
-
this.#
|
|
2914
|
-
this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
|
|
3178
|
+
this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
|
|
2915
3179
|
}
|
|
2916
|
-
|
|
3180
|
+
/**
|
|
3181
|
+
* @param commit whether the user is leaving the field, which only a blur or
|
|
3182
|
+
* a Tab is: Escape cancels, and closing the list or a claim landing leaves
|
|
3183
|
+
* the edit in progress
|
|
3184
|
+
*/
|
|
3185
|
+
#close(commit = false) {
|
|
2917
3186
|
this.#ddmenu.hide();
|
|
3187
|
+
//emptying the box and leaving is a clear: redisplaying the label would
|
|
3188
|
+
//discard the edit without a word. A multiple select keeps its box empty
|
|
3189
|
+
//by design, so only a single one can mean it
|
|
3190
|
+
const cleared =
|
|
3191
|
+
commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
|
|
2918
3192
|
this.#editing = false;
|
|
3193
|
+
if (cleared) {
|
|
3194
|
+
this.#values.clear();
|
|
3195
|
+
this.#changed();
|
|
3196
|
+
}
|
|
2919
3197
|
this.#display();
|
|
2920
3198
|
}
|
|
2921
3199
|
/**
|
|
@@ -2924,14 +3202,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2924
3202
|
* highlighted.
|
|
2925
3203
|
*/
|
|
2926
3204
|
#open() {
|
|
2927
|
-
this.#
|
|
2928
|
-
return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
|
|
3205
|
+
return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
|
|
2929
3206
|
}
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
3207
|
+
/**
|
|
3208
|
+
* Browsing is the whole vocabulary, not the label already chosen, and the
|
|
3209
|
+
* label stays on screen while it happens: a select showing nothing reads as
|
|
3210
|
+
* an empty one.
|
|
3211
|
+
*/
|
|
3212
|
+
#query() {
|
|
3213
|
+
return this.#editing ? this.#input.value : '';
|
|
2935
3214
|
}
|
|
2936
3215
|
#display() {
|
|
2937
3216
|
const entry = this.#values.values().next().value;
|
|
@@ -2942,6 +3221,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
2942
3221
|
return [...this.#values.values()];
|
|
2943
3222
|
}
|
|
2944
3223
|
#changed() {
|
|
3224
|
+
//settled before the event: a listener reading the control during it has to
|
|
3225
|
+
//see the selection that caused it
|
|
3226
|
+
this.#syncBadges();
|
|
2945
3227
|
//the detail carries the keys the value property answers with, as every
|
|
2946
3228
|
//other field's does, and the labeled selection beside them
|
|
2947
3229
|
this._notifyChange({ entry: this.entry });
|
|
@@ -3211,16 +3493,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3211
3493
|
evt.stopPropagation();
|
|
3212
3494
|
this._notifyChange();
|
|
3213
3495
|
});
|
|
3496
|
+
//the base points the label at the input with for/id, so the click toggles
|
|
3497
|
+
//the way it does in a plain form: the input's own change listener above
|
|
3498
|
+
//carries the notification, and readonly is refused by the freeze below
|
|
3214
3499
|
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
3500
|
//a checkbox has no editable text to preserve, so readonly freezes the
|
|
3225
3501
|
//whole choice, label click included: the container is the frozen piece
|
|
3226
3502
|
return {
|
|
@@ -3594,6 +3870,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3594
3870
|
static slots = true;
|
|
3595
3871
|
static config = {
|
|
3596
3872
|
searchIcon: 'search',
|
|
3873
|
+
emptyIcon: 'inbox',
|
|
3597
3874
|
};
|
|
3598
3875
|
static template = `
|
|
3599
3876
|
<ful-form data-tpl-if="slots.filters">
|
|
@@ -3607,10 +3884,21 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3607
3884
|
<tbody data-ref="initial">
|
|
3608
3885
|
<tr>
|
|
3609
3886
|
<td data-tpl-colspan="schema.length">
|
|
3610
|
-
<
|
|
3611
|
-
<
|
|
3887
|
+
<ful-empty>
|
|
3888
|
+
<ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
|
|
3612
3889
|
{{ #l10n:t('table.initial') }}
|
|
3613
|
-
</
|
|
3890
|
+
</ful-empty>
|
|
3891
|
+
</td>
|
|
3892
|
+
</tr>
|
|
3893
|
+
</tbody>
|
|
3894
|
+
<tbody data-ref="empty" hidden>
|
|
3895
|
+
<tr>
|
|
3896
|
+
<td data-tpl-colspan="schema.length">
|
|
3897
|
+
<ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
|
|
3898
|
+
<ful-empty data-tpl-if="!slots.empty">
|
|
3899
|
+
<ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
|
|
3900
|
+
{{ #l10n:t('table.no-data') }}
|
|
3901
|
+
</ful-empty>
|
|
3614
3902
|
</td>
|
|
3615
3903
|
</tr>
|
|
3616
3904
|
</tbody>
|
|
@@ -3640,11 +3928,6 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3640
3928
|
`;
|
|
3641
3929
|
static templates = {
|
|
3642
3930
|
row: `
|
|
3643
|
-
<tr data-tpl-if="pageResponse.data.length == 0">
|
|
3644
|
-
<td data-tpl-colspan="schema.length">
|
|
3645
|
-
{{ #l10n:t('table.no-data') }}
|
|
3646
|
-
</td>
|
|
3647
|
-
</tr>
|
|
3648
3931
|
{{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
|
|
3649
3932
|
`,
|
|
3650
3933
|
};
|
|
@@ -3653,6 +3936,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3653
3936
|
#body;
|
|
3654
3937
|
#loading;
|
|
3655
3938
|
#noAutoload;
|
|
3939
|
+
#empty;
|
|
3656
3940
|
#feedback;
|
|
3657
3941
|
#paginator;
|
|
3658
3942
|
#sorters;
|
|
@@ -3703,6 +3987,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3703
3987
|
this.#body = table.querySelector(':scope > tbody');
|
|
3704
3988
|
this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
|
|
3705
3989
|
this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
|
|
3990
|
+
this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
|
|
3706
3991
|
this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
|
|
3707
3992
|
this.#paginator = index_mjs.Nodes.queryChildren(fragment, 'ful-pagination');
|
|
3708
3993
|
this.replaceChildren(fragment);
|
|
@@ -3784,10 +4069,16 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3784
4069
|
//started is stale, and neither renders nor updates the request a later
|
|
3785
4070
|
//reload replays, whichever order the responses arrive in
|
|
3786
4071
|
const claim = this.#loads.take();
|
|
3787
|
-
|
|
3788
|
-
|
|
4072
|
+
//the rows stay while the table revalidates. Emptying the body and raising
|
|
4073
|
+
//the spinner row in its place collapsed the table to one tall row and
|
|
4074
|
+
//expanded it again on every sort, page and reload: two layout jumps for
|
|
4075
|
+
//what is the same table with newer rows in it. The spinner is for the load
|
|
4076
|
+
//with nothing to show yet, the first one and the one after a failure; the
|
|
4077
|
+
//rest announce themselves through aria-busy, which the stylesheet reads
|
|
4078
|
+
this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
|
|
3789
4079
|
this.#feedback.setAttribute('hidden', '');
|
|
3790
4080
|
this.#noAutoload.setAttribute('hidden', '');
|
|
4081
|
+
this.#empty.setAttribute('hidden', '');
|
|
3791
4082
|
this.setAttribute('aria-busy', 'true');
|
|
3792
4083
|
try {
|
|
3793
4084
|
const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
|
|
@@ -3796,6 +4087,16 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3796
4087
|
}
|
|
3797
4088
|
this.#latestRequest = { pageRequest, sortRequest, filterRequest };
|
|
3798
4089
|
this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
|
|
4090
|
+
//settled before the event: a listener counting what loaded has to
|
|
4091
|
+
//see the table as it now is
|
|
4092
|
+
this.removeAttribute('aria-busy');
|
|
4093
|
+
this.dispatchEvent(
|
|
4094
|
+
new CustomEvent('load:success', {
|
|
4095
|
+
bubbles: true,
|
|
4096
|
+
cancelable: false,
|
|
4097
|
+
detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
|
|
4098
|
+
}),
|
|
4099
|
+
);
|
|
3799
4100
|
} catch (/** @type any */ error) {
|
|
3800
4101
|
if (claim.stale) {
|
|
3801
4102
|
//the newer load owns the table and its outcome: a superseded
|
|
@@ -3803,17 +4104,24 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3803
4104
|
return;
|
|
3804
4105
|
}
|
|
3805
4106
|
this.#loading.setAttribute('hidden', '');
|
|
4107
|
+
//the rows the failed load was replacing go with it: what the table
|
|
4108
|
+
//holds is no longer what the request asked for, and leaving them
|
|
4109
|
+
//under the error would say the opposite
|
|
4110
|
+
this.#body.replaceChildren();
|
|
3806
4111
|
this.#feedback.removeAttribute('hidden');
|
|
3807
4112
|
this.#feedback.querySelector('[data-ref=feedback-error]').textContent = index_mjs$1.Failure.problemsText(
|
|
3808
4113
|
error,
|
|
3809
4114
|
`${error}`,
|
|
3810
4115
|
);
|
|
4116
|
+
this.removeAttribute('aria-busy');
|
|
4117
|
+
this.dispatchEvent(
|
|
4118
|
+
new CustomEvent('load:failure', {
|
|
4119
|
+
bubbles: true,
|
|
4120
|
+
cancelable: false,
|
|
4121
|
+
detail: { pageRequest, sortRequest, filterRequest, exception: error },
|
|
4122
|
+
}),
|
|
4123
|
+
);
|
|
3811
4124
|
throw error;
|
|
3812
|
-
} finally {
|
|
3813
|
-
//a superseded load owns nothing, the newer one's busy state included
|
|
3814
|
-
if (!claim.stale) {
|
|
3815
|
-
this.removeAttribute('aria-busy');
|
|
3816
|
-
}
|
|
3817
4125
|
}
|
|
3818
4126
|
}
|
|
3819
4127
|
/** Hands the loader to the callback, for runtime reconfigurations. */
|
|
@@ -3850,6 +4158,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3850
4158
|
})
|
|
3851
4159
|
.render(),
|
|
3852
4160
|
);
|
|
4161
|
+
this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
|
|
3853
4162
|
//one move, one repaint: the page and the count are the same state
|
|
3854
4163
|
this.#paginator.update({ current: pageRequest.page, total: pages });
|
|
3855
4164
|
}
|
|
@@ -3949,6 +4258,20 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3949
4258
|
//menu is where the localized words live
|
|
3950
4259
|
this.#button.textContent = this.#display(choice);
|
|
3951
4260
|
index_mjs.Attributes.set(this.#button, 'aria-label', this.#labelFor(choice));
|
|
4261
|
+
this.#mark();
|
|
4262
|
+
}
|
|
4263
|
+
/**
|
|
4264
|
+
* Marks the item the button currently holds, which is what a menu of one
|
|
4265
|
+
* choice among several owes the reader: the glyph on the button says which
|
|
4266
|
+
* one it is only to somebody who already knows the glyphs. The items are
|
|
4267
|
+
* `menuitemradio`, so the state is `aria-checked` rather than the
|
|
4268
|
+
* `aria-selected` a listbox would use, and the stylesheet draws it off that.
|
|
4269
|
+
*/
|
|
4270
|
+
#mark() {
|
|
4271
|
+
const current = this.value;
|
|
4272
|
+
for (const item of this.#items()) {
|
|
4273
|
+
item.setAttribute('aria-checked', String(item.getAttribute('value') === current));
|
|
4274
|
+
}
|
|
3952
4275
|
}
|
|
3953
4276
|
/** The host's disabled claim, composed with the pin: lifting one cannot lift the other. */
|
|
3954
4277
|
set claimed(claimed) {
|
|
@@ -3961,7 +4284,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3961
4284
|
const li = document.createElement('li');
|
|
3962
4285
|
li.setAttribute('role', 'none');
|
|
3963
4286
|
const a = document.createElement('a');
|
|
3964
|
-
|
|
4287
|
+
//one choice among several, which is what a radio item is: the
|
|
4288
|
+
//state belongs on the item, not on the button alone
|
|
4289
|
+
a.setAttribute('role', 'menuitemradio');
|
|
3965
4290
|
a.setAttribute('tabindex', '-1');
|
|
3966
4291
|
a.setAttribute('value', choice);
|
|
3967
4292
|
const word = this.#labelFor(choice);
|
|
@@ -3979,6 +4304,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3979
4304
|
return li;
|
|
3980
4305
|
}),
|
|
3981
4306
|
);
|
|
4307
|
+
this.#mark();
|
|
3982
4308
|
}
|
|
3983
4309
|
#sync() {
|
|
3984
4310
|
const pinned = this.pinned;
|
|
@@ -3999,7 +4325,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
3999
4325
|
#wire() {
|
|
4000
4326
|
const button = this.#button;
|
|
4001
4327
|
const menu = this.#menu;
|
|
4002
|
-
|
|
4328
|
+
Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
|
|
4003
4329
|
menu.addEventListener('toggle', (/** @type any */ evt) => {
|
|
4004
4330
|
if (evt.newState !== 'open') {
|
|
4005
4331
|
//give the invoker back the focus the menu had borrowed, without
|
|
@@ -4086,6 +4412,19 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4086
4412
|
const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
|
|
4087
4413
|
const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
|
|
4088
4414
|
|
|
4415
|
+
/**
|
|
4416
|
+
* The operands are the words the reader chose, not the keys the wire carries.
|
|
4417
|
+
* @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
|
|
4418
|
+
*/
|
|
4419
|
+
|
|
4420
|
+
const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
|
|
4421
|
+
|
|
4422
|
+
/** @returns {FilterCriterion|null} */
|
|
4423
|
+
const asCriterion = (label, operator, operands) => {
|
|
4424
|
+
const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
|
|
4425
|
+
return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
|
|
4426
|
+
};
|
|
4427
|
+
|
|
4089
4428
|
/**
|
|
4090
4429
|
* The shared shape of every operator-and-operands filter: an operator menu, one
|
|
4091
4430
|
* or two operands of the type the subclass declares, and a tuple that mirrors
|
|
@@ -4225,6 +4564,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4225
4564
|
_syncBetween() {
|
|
4226
4565
|
this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
|
|
4227
4566
|
}
|
|
4567
|
+
/**
|
|
4568
|
+
* The operands as shown rather than as serialized: a date filter describes
|
|
4569
|
+
* what is in its control, not the iso string it sends.
|
|
4570
|
+
* @returns {FilterCriterion|null}
|
|
4571
|
+
*/
|
|
4572
|
+
get criterion() {
|
|
4573
|
+
const operands =
|
|
4574
|
+
this._operator.value === 'BETWEEN'
|
|
4575
|
+
? [this._value1.value, this._value2.value]
|
|
4576
|
+
: [this._value1.value];
|
|
4577
|
+
return asCriterion(labelTextOf(this), this._operator.value, operands);
|
|
4578
|
+
}
|
|
4228
4579
|
get disabled() {
|
|
4229
4580
|
return super.disabled;
|
|
4230
4581
|
}
|
|
@@ -4460,6 +4811,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4460
4811
|
this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
|
|
4461
4812
|
this._value.value = v[1] ?? '';
|
|
4462
4813
|
}
|
|
4814
|
+
/** @returns {FilterCriterion|null} */
|
|
4815
|
+
get criterion() {
|
|
4816
|
+
const token = this._value.value;
|
|
4817
|
+
return asCriterion(labelTextOf(this), this._operator.value, [
|
|
4818
|
+
token === '' ? '' : booleanValueLabel(token),
|
|
4819
|
+
]);
|
|
4820
|
+
}
|
|
4463
4821
|
get disabled() {
|
|
4464
4822
|
return super.disabled;
|
|
4465
4823
|
}
|
|
@@ -4472,6 +4830,37 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4472
4830
|
}
|
|
4473
4831
|
}
|
|
4474
4832
|
|
|
4833
|
+
/**
|
|
4834
|
+
* Set membership over a select's vocabulary, answering for both of data-jpa's
|
|
4835
|
+
* set annotations: InEnum and InList take the same bare array of values and no
|
|
4836
|
+
* operator, differing only in what the server converts them to.
|
|
4837
|
+
*/
|
|
4838
|
+
class InFilter extends Select {
|
|
4839
|
+
async upgrade() {
|
|
4840
|
+
//the snapshot is taken inside super.upgrade(), so an attribute this
|
|
4841
|
+
//element does not let an author turn off has to be written before it
|
|
4842
|
+
this.toggleAttribute('multiple', true);
|
|
4843
|
+
await super.upgrade();
|
|
4844
|
+
}
|
|
4845
|
+
get value() {
|
|
4846
|
+
const keys = super.value;
|
|
4847
|
+
//an empty set is no filter: the table drops a falsy value, where an
|
|
4848
|
+
//empty array would travel and match nothing
|
|
4849
|
+
return keys.length === 0 ? null : keys;
|
|
4850
|
+
}
|
|
4851
|
+
set value(v) {
|
|
4852
|
+
super.value = v ?? [];
|
|
4853
|
+
}
|
|
4854
|
+
/** @returns {FilterCriterion|null} */
|
|
4855
|
+
get criterion() {
|
|
4856
|
+
return asCriterion(
|
|
4857
|
+
labelTextOf(this),
|
|
4858
|
+
null,
|
|
4859
|
+
(super.entry ?? []).map((e) => e.label),
|
|
4860
|
+
);
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4475
4864
|
/**
|
|
4476
4865
|
* The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
|
|
4477
4866
|
* ful-drawer: every activation of a section fires the section:requested family
|
|
@@ -4580,64 +4969,137 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4580
4969
|
});
|
|
4581
4970
|
};
|
|
4582
4971
|
|
|
4583
|
-
/**
|
|
4972
|
+
/**
|
|
4973
|
+
* An info icon button toggling a popover with a short explanation.
|
|
4974
|
+
*
|
|
4975
|
+
* The marker is the page's `config.icon`, and the `icon` attribute names a
|
|
4976
|
+
* `ful-icon` for the tooltip that means something other than plain information:
|
|
4977
|
+
* a caveat, a warning, a setting. A name the library does not paint is the
|
|
4978
|
+
* page's own, declared as `ful-icon[name='...'] { mask-image: ... }`.
|
|
4979
|
+
*
|
|
4980
|
+
* `describes` is for the tooltip standing in a field: the note becomes part of
|
|
4981
|
+
* the accessible description of that field's control, so it is announced on
|
|
4982
|
+
* reaching the field rather than only on opening the marker, and the marker
|
|
4983
|
+
* leaves the tab order, so a form of hinted fields costs no extra keystrokes to
|
|
4984
|
+
* walk. The marker stays clickable, and stays a tab stop wherever the note was
|
|
4985
|
+
* not taken, a tooltip claiming `describes` outside a field among them: the
|
|
4986
|
+
* stop only goes where something else delivers the content.
|
|
4987
|
+
*/
|
|
4584
4988
|
class Tooltip extends index_mjs.ParsedElement {
|
|
4585
4989
|
static slots = true;
|
|
4586
|
-
static attributes = ['placement'];
|
|
4990
|
+
static attributes = ['placement', 'icon', 'describes:presence'];
|
|
4587
4991
|
static config = {
|
|
4588
4992
|
icon: 'info-circle-fill',
|
|
4589
4993
|
};
|
|
4590
4994
|
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>
|
|
4995
|
+
<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
4996
|
<ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
|
|
4593
4997
|
`;
|
|
4594
4998
|
render({ slots }) {
|
|
4595
|
-
const fragment = this.template().withOverlay({ slots }).render();
|
|
4999
|
+
const fragment = this.template().withOverlay({ slots, icon: this.declared('icon') }).render();
|
|
4596
5000
|
const trigger = fragment.querySelector('[data-ref=trigger]');
|
|
4597
5001
|
const content = fragment.querySelector('[data-ref=content]');
|
|
4598
5002
|
//placed here rather than by the anchor css: the note draws a callout that
|
|
4599
5003
|
//has to point at the trigger wherever the viewport left room for the note,
|
|
4600
5004
|
//which is a measurement the stylesheet cannot make for a pseudo-element
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
}
|
|
5005
|
+
Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
|
|
5006
|
+
//above the marker by default: a note opening downwards covers the control
|
|
5007
|
+
//the marker explains, the marker riding the field's label
|
|
5008
|
+
content.setAttribute('placement', this.declared('placement') ?? 'top');
|
|
4606
5009
|
this.replaceChildren(fragment);
|
|
5010
|
+
if (this.declared('describes')) {
|
|
5011
|
+
Tooltip.#describe(this, trigger, content);
|
|
5012
|
+
}
|
|
5013
|
+
}
|
|
5014
|
+
/**
|
|
5015
|
+
* Offers the note to the field the tooltip stands in, and takes the trigger
|
|
5016
|
+
* out of the tab order only where the offer was accepted: a note nothing
|
|
5017
|
+
* carries is reachable by the keyboard through the marker alone, so
|
|
5018
|
+
* dropping the stop there would leave it reachable by nothing at all.
|
|
5019
|
+
*
|
|
5020
|
+
* The offer goes through the description protocol rather than naming a
|
|
5021
|
+
* field, the library's own arrow running from the forms to the disclosures.
|
|
5022
|
+
*/
|
|
5023
|
+
static #describe(tooltip, trigger, content) {
|
|
5024
|
+
if (!describable(tooltip)?.describedBy(content)) {
|
|
5025
|
+
console.warn('a ful-tooltip declares describes but stands in nothing that takes a description', tooltip);
|
|
5026
|
+
return;
|
|
5027
|
+
}
|
|
5028
|
+
trigger.tabIndex = -1;
|
|
4607
5029
|
}
|
|
4608
5030
|
}
|
|
4609
5031
|
|
|
4610
|
-
/**
|
|
5032
|
+
/**
|
|
5033
|
+
* A modal dialog on the native platform, open()/ask() resolving with the
|
|
5034
|
+
* closer's data-result.
|
|
5035
|
+
*
|
|
5036
|
+
* The header carries a close button, as the drawer's does: Escape dismisses a
|
|
5037
|
+
* modal on its own, but nothing says so, and a dialog whose only exit is a key
|
|
5038
|
+
* you have to know about leaves a pointer with nowhere to go. It answers the way
|
|
5039
|
+
* Escape does, with null.
|
|
5040
|
+
*
|
|
5041
|
+
* `requires-answer` is for the dialog that must be answered: the close button is not
|
|
5042
|
+
* rendered and Escape is refused, so the only way out is a button that carries a
|
|
5043
|
+
* result. It has to be both, a close button withheld while Escape still worked
|
|
5044
|
+
* being decoration rather than a rule.
|
|
5045
|
+
*
|
|
5046
|
+
* The chrome is reachable by class as well as by tag, so a plain `<dialog
|
|
5047
|
+
* class="ful-dialog">` written by a page gets the same look whatever its
|
|
5048
|
+
* structure: the tag form matches a direct child, and `ful-dialog-header`,
|
|
5049
|
+
* `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
|
|
5050
|
+
* dialog whose content is wrapped in a form needs.
|
|
5051
|
+
*/
|
|
5052
|
+
/**
|
|
5053
|
+
* How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
|
|
5054
|
+
* the `data-result` of the button that closed it and `response` what a submit
|
|
5055
|
+
* answered with, the one that did not happen being null.
|
|
5056
|
+
* @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
|
|
5057
|
+
*/
|
|
5058
|
+
|
|
4611
5059
|
class Dialog extends index_mjs.ParsedElement {
|
|
4612
|
-
static attributes = ['header'];
|
|
5060
|
+
static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
|
|
4613
5061
|
static slots = true;
|
|
4614
5062
|
static template = `
|
|
4615
5063
|
<dialog data-ref="dialog" class="ful-dialog">
|
|
4616
|
-
<header data-tpl-if="header
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
<button
|
|
5064
|
+
<header data-tpl-if="header || slots.header || !requiresAnswer" class="ful-dialog-header">
|
|
5065
|
+
{{{{ slots.header }}}}
|
|
5066
|
+
<h2 data-tpl-if="header">{{ header }}</h2>
|
|
5067
|
+
<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>
|
|
5068
|
+
</header>
|
|
5069
|
+
<section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
|
|
5070
|
+
<section data-ref="error" role="alert" hidden></section>
|
|
5071
|
+
<div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
|
|
5072
|
+
<footer class="ful-dialog-footer">
|
|
5073
|
+
<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
5074
|
{{{{ slots.buttons }}}}
|
|
4621
5075
|
</footer>
|
|
4622
5076
|
</dialog>
|
|
4623
5077
|
`;
|
|
4624
5078
|
#dialog;
|
|
4625
5079
|
#body;
|
|
5080
|
+
#loading;
|
|
5081
|
+
#error;
|
|
4626
5082
|
#requests = new SectionRequests();
|
|
5083
|
+
#updates = new Claims();
|
|
4627
5084
|
#resolvers = [];
|
|
5085
|
+
//the answer a submit closed the dialog with, which the return value cannot
|
|
5086
|
+
//carry: it is a string, and a response is whatever the server sent
|
|
5087
|
+
/** @type {DialogOutcome|null} */
|
|
5088
|
+
#answer = null;
|
|
4628
5089
|
render({ slots }) {
|
|
5090
|
+
const requiresAnswer = this.declared('requires-answer');
|
|
5091
|
+
const closeOnSubmit = this.declared('close-on-submit');
|
|
4629
5092
|
const fragment = this.template()
|
|
4630
|
-
.withOverlay({ slots, header: this.declared('header') ?? '' })
|
|
5093
|
+
.withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
|
|
4631
5094
|
.render();
|
|
4632
5095
|
this.#dialog = fragment.querySelector('[data-ref=dialog]');
|
|
4633
5096
|
this.#body = fragment.querySelector('[data-ref=body]');
|
|
5097
|
+
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
5098
|
+
this.#error = fragment.querySelector('[data-ref=error]');
|
|
4634
5099
|
this.#dialog.addEventListener('close', () => {
|
|
4635
|
-
this
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
}),
|
|
4639
|
-
);
|
|
4640
|
-
this.#settle();
|
|
5100
|
+
const outcome = this.#outcome();
|
|
5101
|
+
this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
|
|
5102
|
+
this.#settle(outcome);
|
|
4641
5103
|
});
|
|
4642
5104
|
this.#dialog.addEventListener('click', (/** @type any */ e) => {
|
|
4643
5105
|
const result = e.target.closest('button[data-result]')?.dataset.result;
|
|
@@ -4645,42 +5107,128 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4645
5107
|
this.#dialog.close(result);
|
|
4646
5108
|
}
|
|
4647
5109
|
});
|
|
5110
|
+
//dismissal, not an answer: the waiters are settled with a dismissal, as
|
|
5111
|
+
//Escape does. Optional because a subclass overriding the template owns
|
|
5112
|
+
//what it renders
|
|
5113
|
+
fragment
|
|
5114
|
+
.querySelector('[data-ref=close]')
|
|
5115
|
+
?.addEventListener('click', () => this.#dialog.close(''));
|
|
5116
|
+
if (closeOnSubmit) {
|
|
5117
|
+
//delegated on the body rather than bound to the form, so a body
|
|
5118
|
+
//delivered later by update() is covered by the same listener. The
|
|
5119
|
+
//form must be the body's own: a ful-table wraps its filters in a
|
|
5120
|
+
//ful-form of its own, and a search in a table the dialog holds is
|
|
5121
|
+
//not the dialog being answered
|
|
5122
|
+
this.#body.addEventListener('submit:success', (/** @type any */ e) => {
|
|
5123
|
+
if (e.target !== index_mjs.Nodes.queryChildren(this.#body, 'ful-form')) {
|
|
5124
|
+
return;
|
|
5125
|
+
}
|
|
5126
|
+
this.#answer = { dismissed: false, result: null, response: e.detail.response };
|
|
5127
|
+
this.#dialog.close('submitted');
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
5130
|
+
if (requiresAnswer) {
|
|
5131
|
+
//the platform's own dismissal, refused where the dialog must be
|
|
5132
|
+
//answered: cancel fires for Escape and for a close request the
|
|
5133
|
+
//browser makes on its own, and preventing it leaves the dialog open
|
|
5134
|
+
this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
|
|
5135
|
+
}
|
|
4648
5136
|
this.replaceChildren(fragment);
|
|
4649
5137
|
wireTargets();
|
|
4650
5138
|
}
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
5139
|
+
/**
|
|
5140
|
+
* How the dialog ended, in one shape for every way it can end: `dismissed`
|
|
5141
|
+
* alone tells a cancel from an answer, so a submit answering with no body at
|
|
5142
|
+
* all (a 204) is still an answer, where a bare `null` could not say which it
|
|
5143
|
+
* was. `result` carries the `data-result` of the button that closed it and
|
|
5144
|
+
* `response` what a submit answered with; the one that did not happen is null.
|
|
5145
|
+
*/
|
|
5146
|
+
#outcome() {
|
|
5147
|
+
if (this.#answer) {
|
|
5148
|
+
return this.#answer;
|
|
5149
|
+
}
|
|
5150
|
+
//a render that threw adopted no dialog, and a removal still owes its
|
|
5151
|
+
//waiters an answer: reading through it would raise a second, unrelated
|
|
5152
|
+
//failure over the one already reported
|
|
5153
|
+
const result = this.#dialog?.returnValue ?? '';
|
|
5154
|
+
return result === ''
|
|
5155
|
+
? { dismissed: true, result: null, response: null }
|
|
5156
|
+
: { dismissed: false, result, response: null };
|
|
5157
|
+
}
|
|
5158
|
+
//answers every waiter with the dialog's own answer: a dismissal while still
|
|
5159
|
+
//open or closed without a result, which is also the unanswered answer a
|
|
5160
|
+
//dialog leaving the document owes its waiters instead of hanging them
|
|
5161
|
+
#settle(outcome) {
|
|
4655
5162
|
const resolvers = this.#resolvers;
|
|
4656
5163
|
this.#resolvers = [];
|
|
4657
5164
|
for (const resolve of resolvers) {
|
|
4658
|
-
resolve(
|
|
5165
|
+
resolve(outcome);
|
|
4659
5166
|
}
|
|
4660
5167
|
}
|
|
4661
5168
|
disconnectedCallback() {
|
|
4662
|
-
this.#settle();
|
|
5169
|
+
this.#settle(this.#outcome());
|
|
4663
5170
|
}
|
|
4664
5171
|
open() {
|
|
4665
5172
|
return this.ask();
|
|
4666
5173
|
}
|
|
4667
5174
|
ask() {
|
|
4668
|
-
if (
|
|
4669
|
-
this.#
|
|
4670
|
-
this.#dialog.showModal();
|
|
5175
|
+
if (this.#show()) {
|
|
5176
|
+
this.#restChrome();
|
|
4671
5177
|
this.#request();
|
|
4672
5178
|
}
|
|
4673
5179
|
return new Promise((resolve) => {
|
|
4674
5180
|
this.#resolvers.push(resolve);
|
|
4675
5181
|
});
|
|
4676
5182
|
}
|
|
5183
|
+
/**
|
|
5184
|
+
* Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
|
|
5185
|
+
* resolved value paints the body (which is returned), a rejection paints the
|
|
5186
|
+
* problems and travels to the caller, and an update superseded by a newer one
|
|
5187
|
+
* paints nothing. The title is the `header` attribute, configuration like the
|
|
5188
|
+
* rest of the dialog's chrome, so what update() owns is the body alone.
|
|
5189
|
+
*/
|
|
5190
|
+
async update(cb) {
|
|
5191
|
+
//the claim detaches any update still in flight: its outcome belongs to
|
|
5192
|
+
//an abandoned opening and must neither be painted nor own the dialog
|
|
5193
|
+
const claim = this.#updates.take();
|
|
5194
|
+
this.#body.replaceChildren();
|
|
5195
|
+
this.#restChrome();
|
|
5196
|
+
this.#loading?.removeAttribute('hidden');
|
|
5197
|
+
this.#body.setAttribute('hidden', '');
|
|
5198
|
+
//update owns its own open-answer-deliver cycle, so it shows the dialog
|
|
5199
|
+
//without going through ask(): a user reopen during the wait is a real
|
|
5200
|
+
//open and goes through ask()
|
|
5201
|
+
this.#show();
|
|
5202
|
+
try {
|
|
5203
|
+
const delivered = await cb();
|
|
5204
|
+
if (claim.stale) {
|
|
5205
|
+
return this.#body;
|
|
5206
|
+
}
|
|
5207
|
+
this.#body.replaceChildren(delivered);
|
|
5208
|
+
this.#loading?.setAttribute('hidden', '');
|
|
5209
|
+
this.#body.removeAttribute('hidden');
|
|
5210
|
+
return this.#body;
|
|
5211
|
+
} catch (/** @type any */ e) {
|
|
5212
|
+
if (!claim.stale) {
|
|
5213
|
+
//revealed before it is filled, so the live region announces the
|
|
5214
|
+
//change rather than being revealed already holding it
|
|
5215
|
+
this.#error?.removeAttribute('hidden');
|
|
5216
|
+
if (this.#error) {
|
|
5217
|
+
this.#error.textContent = index_mjs$1.Failure.problemsText(e);
|
|
5218
|
+
}
|
|
5219
|
+
this.#loading?.setAttribute('hidden', '');
|
|
5220
|
+
this.#body.setAttribute('hidden', '');
|
|
5221
|
+
}
|
|
5222
|
+
throw e;
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
4677
5225
|
#request() {
|
|
4678
5226
|
this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
|
|
4679
5227
|
}
|
|
4680
5228
|
/**
|
|
4681
5229
|
* Re-fires section:requested on the body, open or closed: the explicit
|
|
4682
5230
|
* request for a body that wants refreshing. A failed refresh paints its
|
|
4683
|
-
* problems, nothing rejects:
|
|
5231
|
+
* problems, nothing rejects: update() stays the rejecting call.
|
|
4684
5232
|
*/
|
|
4685
5233
|
refresh() {
|
|
4686
5234
|
return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
|
|
@@ -4688,15 +5236,43 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4688
5236
|
close(result) {
|
|
4689
5237
|
this.#dialog.close(result ?? '');
|
|
4690
5238
|
}
|
|
5239
|
+
/** Shows the modal, answering whether this call is the one that opened it. */
|
|
5240
|
+
#show() {
|
|
5241
|
+
if (this.#dialog.open) {
|
|
5242
|
+
return false;
|
|
5243
|
+
}
|
|
5244
|
+
//an opening owes nothing to the one before it: the platform keeps
|
|
5245
|
+
//returnValue across a close with no result, and the answer a submit
|
|
5246
|
+
//left is just as stale
|
|
5247
|
+
this.#dialog.returnValue = '';
|
|
5248
|
+
this.#answer = null;
|
|
5249
|
+
this.#dialog.showModal();
|
|
5250
|
+
return true;
|
|
5251
|
+
}
|
|
5252
|
+
#restChrome() {
|
|
5253
|
+
this.#error?.replaceChildren();
|
|
5254
|
+
this.#error?.setAttribute('hidden', '');
|
|
5255
|
+
this.#loading?.setAttribute('hidden', '');
|
|
5256
|
+
this.#body?.removeAttribute('hidden');
|
|
5257
|
+
}
|
|
4691
5258
|
}
|
|
4692
5259
|
|
|
4693
|
-
/**
|
|
5260
|
+
/**
|
|
5261
|
+
* A side panel drawer on the native dialog platform, update() owning its
|
|
5262
|
+
* open-deliver cycle.
|
|
5263
|
+
*
|
|
5264
|
+
* The `header` slot is content beside the title, before it: an icon, a badge, a
|
|
5265
|
+
* status. It sits outside the heading rather than in it because `update()` sets
|
|
5266
|
+
* the title through `textContent`, which would take anything nested there with
|
|
5267
|
+
* it.
|
|
5268
|
+
*/
|
|
4694
5269
|
class Drawer extends index_mjs.ParsedElement {
|
|
4695
|
-
static attributes = ['title', 'placement'];
|
|
5270
|
+
static attributes = ['title', 'placement', 'close-on-submit:presence'];
|
|
4696
5271
|
static slots = true;
|
|
4697
5272
|
static template = `
|
|
4698
5273
|
<dialog data-ref="dialog" class="ful-drawer">
|
|
4699
5274
|
<header>
|
|
5275
|
+
{{{{ slots.header }}}}
|
|
4700
5276
|
<h2 data-ref="title">{{ title }}</h2>
|
|
4701
5277
|
<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
5278
|
</header>
|
|
@@ -4712,6 +5288,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4712
5288
|
#content;
|
|
4713
5289
|
#requests = new SectionRequests();
|
|
4714
5290
|
#updates = new Claims();
|
|
5291
|
+
//what a submit closed the drawer with, told from a close of any other kind:
|
|
5292
|
+
//a save answering with no body at all is still a save
|
|
5293
|
+
/** @type {{ dismissed: boolean, response: any }|null} */
|
|
5294
|
+
#answer = null;
|
|
4715
5295
|
render({ slots }) {
|
|
4716
5296
|
const fragment = this.template()
|
|
4717
5297
|
.withOverlay({ slots, title: this.declared('title') ?? '' })
|
|
@@ -4727,8 +5307,25 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4727
5307
|
}
|
|
4728
5308
|
fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
|
|
4729
5309
|
this.#dialog.addEventListener('close', () => {
|
|
4730
|
-
this.dispatchEvent(
|
|
5310
|
+
this.dispatchEvent(
|
|
5311
|
+
new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
|
|
5312
|
+
);
|
|
4731
5313
|
});
|
|
5314
|
+
if (this.declared('close-on-submit')) {
|
|
5315
|
+
//delegated on the content section rather than bound to the form: a
|
|
5316
|
+
//drawer's form usually arrives with an update() rather than with the
|
|
5317
|
+
//page, and the section outlives every delivery. The form must be the
|
|
5318
|
+
//content's own, a ful-table wrapping its filters in a ful-form of its
|
|
5319
|
+
//own and a search in a table the drawer holds not being the drawer
|
|
5320
|
+
//finishing
|
|
5321
|
+
this.#content.addEventListener('submit:success', (/** @type any */ e) => {
|
|
5322
|
+
if (e.target !== index_mjs.Nodes.queryChildren(this.#content, 'ful-form')) {
|
|
5323
|
+
return;
|
|
5324
|
+
}
|
|
5325
|
+
this.#answer = { dismissed: false, response: e.detail.response };
|
|
5326
|
+
this.close();
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
4732
5329
|
this.replaceChildren(fragment);
|
|
4733
5330
|
wireTargets();
|
|
4734
5331
|
}
|
|
@@ -4801,6 +5398,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
4801
5398
|
if (this.#dialog.open) {
|
|
4802
5399
|
return false;
|
|
4803
5400
|
}
|
|
5401
|
+
//an opening owes nothing to the one before it: the answer a submit left
|
|
5402
|
+
//belongs to the drawer that closed on it
|
|
5403
|
+
this.#answer = null;
|
|
4804
5404
|
this.#dialog.showModal();
|
|
4805
5405
|
return true;
|
|
4806
5406
|
}
|
|
@@ -5224,6 +5824,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5224
5824
|
'filters.boolean.false': 'No',
|
|
5225
5825
|
'info.tooltip': 'More information',
|
|
5226
5826
|
'dialog.acknowledge': 'Got it',
|
|
5827
|
+
'dialog.close': 'Close',
|
|
5227
5828
|
'drawer.close': 'Close',
|
|
5228
5829
|
'spinner.loading': 'Loading…',
|
|
5229
5830
|
'toast.region': 'Notifications',
|
|
@@ -5265,6 +5866,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5265
5866
|
'filters.boolean.false': 'No',
|
|
5266
5867
|
'info.tooltip': 'Maggiori informazioni',
|
|
5267
5868
|
'dialog.acknowledge': 'Ho capito',
|
|
5869
|
+
'dialog.close': 'Chiudi',
|
|
5268
5870
|
'drawer.close': 'Chiudi',
|
|
5269
5871
|
'spinner.loading': 'Caricamento…',
|
|
5270
5872
|
'toast.region': 'Notifiche',
|
|
@@ -5306,6 +5908,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5306
5908
|
'filters.boolean.false': 'No',
|
|
5307
5909
|
'info.tooltip': 'Más información',
|
|
5308
5910
|
'dialog.acknowledge': 'Entendido',
|
|
5911
|
+
'dialog.close': 'Cerrar',
|
|
5309
5912
|
'drawer.close': 'Cerrar',
|
|
5310
5913
|
'spinner.loading': 'Cargando…',
|
|
5311
5914
|
'toast.region': 'Notificaciones',
|
|
@@ -5350,6 +5953,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5350
5953
|
'filters.boolean.false': 'Non',
|
|
5351
5954
|
'info.tooltip': 'Plus d’informations',
|
|
5352
5955
|
'dialog.acknowledge': 'J’ai compris',
|
|
5956
|
+
'dialog.close': 'Fermer',
|
|
5353
5957
|
'drawer.close': 'Fermer',
|
|
5354
5958
|
'spinner.loading': 'Chargement…',
|
|
5355
5959
|
'toast.region': 'Notifications',
|
|
@@ -5420,6 +6024,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5420
6024
|
.defineElement('ful-filter-number', NumberFilter)
|
|
5421
6025
|
.defineElement('ful-filter-boolean', BooleanFilter)
|
|
5422
6026
|
.defineElement('ful-filter-text', TextFilter)
|
|
6027
|
+
.defineElement('ful-filter-in', InFilter)
|
|
5423
6028
|
.defineElement('ful-select', Select)
|
|
5424
6029
|
.defineElement('ful-dropdown', Dropdown)
|
|
5425
6030
|
.defineComponent('loaders:select', SelectLoader)
|
|
@@ -5437,6 +6042,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5437
6042
|
}
|
|
5438
6043
|
|
|
5439
6044
|
exports.Accordion = Accordion;
|
|
6045
|
+
exports.Anchors = Anchors;
|
|
5440
6046
|
exports.AsyncEvents = AsyncEvents;
|
|
5441
6047
|
exports.Bindings = Bindings;
|
|
5442
6048
|
exports.BooleanFilter = BooleanFilter;
|
|
@@ -5449,6 +6055,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5449
6055
|
exports.Field = Field;
|
|
5450
6056
|
exports.Form = Form;
|
|
5451
6057
|
exports.FormLoader = FormLoader;
|
|
6058
|
+
exports.InFilter = InFilter;
|
|
5452
6059
|
exports.Input = Input;
|
|
5453
6060
|
exports.InputFile = InputFile;
|
|
5454
6061
|
exports.InputInstant = InputInstant;
|
|
@@ -5478,6 +6085,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
|
|
|
5478
6085
|
exports.VersionedLocalStorage = VersionedLocalStorage;
|
|
5479
6086
|
exports.VersionedSessionStorage = VersionedSessionStorage;
|
|
5480
6087
|
exports.Wizard = Wizard;
|
|
6088
|
+
exports.describable = describable;
|
|
5481
6089
|
|
|
5482
6090
|
return exports;
|
|
5483
6091
|
|