@optionfactory/fml 9.0.0-rc12 → 9.0.0-rc14
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 +2 -2
- package/dist/client-errors.iife.js +6 -11
- package/dist/client-errors.iife.js.map +1 -1
- package/dist/client-errors.iife.min.js +1 -1
- package/dist/client-errors.iife.min.js.map +1 -1
- package/dist/custom-elements.json +45 -10
- package/dist/fml.css +7 -7
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +503 -62
- 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/ftl.iife.js +2 -2
- package/dist/ftl.iife.js.map +1 -1
- package/dist/ftl.iife.min.js +1 -1
- package/dist/ftl.iife.min.js.map +1 -1
- package/dist/ftl.min.mjs +1 -1
- package/dist/ftl.min.mjs.map +1 -1
- package/dist/ftl.mjs +2 -2
- package/dist/ftl.mjs.map +1 -1
- package/dist/ful.css +7 -7
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +79 -7
- package/dist/ful.iife.js +501 -60
- 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 +501 -60
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +27 -7
- package/dist/web-types.json +51 -11
- package/package.json +1 -1
package/dist/ful.mjs
CHANGED
|
@@ -560,30 +560,62 @@ class Bindings {
|
|
|
560
560
|
}
|
|
561
561
|
|
|
562
562
|
static mutateIn(form, values) {
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
.
|
|
566
|
-
for (const [flattenedKey, value] of Object.entries(Bindings.flatten(values, '', new Set(names)))) {
|
|
567
|
-
for (const el of form.querySelectorAll(`[name='${CSS.escape(flattenedKey)}']`)) {
|
|
563
|
+
const named = Bindings.#named(form);
|
|
564
|
+
for (const [flattenedKey, value] of Object.entries(Bindings.flatten(values, '', new Set(named.keys())))) {
|
|
565
|
+
for (const el of named.get(flattenedKey) ?? []) {
|
|
568
566
|
Bindings.mutate(el, value);
|
|
569
567
|
}
|
|
570
568
|
}
|
|
571
569
|
}
|
|
572
570
|
|
|
571
|
+
/**
|
|
572
|
+
* The form's own named controls, by name: `form.elements`, the platform's
|
|
573
|
+
* association list, so a field in the subtree that belongs to another form
|
|
574
|
+
* (a nested form built by script, a `form` attribute pointing elsewhere) is
|
|
575
|
+
* not the caller's to touch, where a subtree query would have reached it.
|
|
576
|
+
*/
|
|
577
|
+
static #named(form) {
|
|
578
|
+
const named = new Map();
|
|
579
|
+
for (const el of form.elements) {
|
|
580
|
+
const name = el.getAttribute('name');
|
|
581
|
+
if (!name) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const bucket = named.get(name);
|
|
585
|
+
if (bucket) {
|
|
586
|
+
bucket.push(el);
|
|
587
|
+
} else {
|
|
588
|
+
named.set(name, [el]);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return named;
|
|
592
|
+
}
|
|
593
|
+
|
|
573
594
|
static errors(form, es, scrollOnError) {
|
|
574
595
|
//focus management announces the error of the field it lands on through
|
|
575
596
|
//aria-describedby: a live region on top of that would read everything twice,
|
|
576
|
-
//so the polite announcement exists only when nothing takes the focus
|
|
577
|
-
form
|
|
578
|
-
|
|
579
|
-
|
|
597
|
+
//so the polite announcement exists only when nothing takes the focus.
|
|
598
|
+
//The regions and the banner belong to the form whose subtree holds them
|
|
599
|
+
//nearest: a nested form's are not this form's to touch
|
|
600
|
+
const ofForm = (el) => el.closest('form') === form;
|
|
601
|
+
Array.from(form.querySelectorAll('ful-field-error'))
|
|
602
|
+
.filter(ofForm)
|
|
603
|
+
.forEach((el) => {
|
|
604
|
+
el.setAttribute('aria-live', scrollOnError ? 'off' : 'polite');
|
|
605
|
+
});
|
|
580
606
|
const pinned = (e) => (e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT') && e.context;
|
|
581
607
|
const fieldErrors = es.filter(pinned);
|
|
582
608
|
const globalErrors = es.filter((e) => !pinned(e));
|
|
583
|
-
form
|
|
584
|
-
|
|
585
|
-
|
|
609
|
+
const named = Bindings.#named(form);
|
|
610
|
+
for (const targets of named.values()) {
|
|
611
|
+
targets.forEach((el) => {
|
|
612
|
+
el.setCustomValidity?.('');
|
|
613
|
+
});
|
|
614
|
+
}
|
|
586
615
|
form.querySelectorAll('ful-errors').forEach((el) => {
|
|
616
|
+
if (!ofForm(el)) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
587
619
|
el.setAttribute('role', 'alert');
|
|
588
620
|
el.replaceChildren();
|
|
589
621
|
el.setAttribute('hidden', '');
|
|
@@ -594,7 +626,7 @@ class Bindings {
|
|
|
594
626
|
const parts = name.split('.');
|
|
595
627
|
for (let i = parts.length; i !== 0; --i) {
|
|
596
628
|
const prefix = parts.slice(0, i).join('.');
|
|
597
|
-
const targets =
|
|
629
|
+
const targets = named.get(prefix) ?? [];
|
|
598
630
|
if (targets.length === 0) {
|
|
599
631
|
continue;
|
|
600
632
|
}
|
|
@@ -614,6 +646,9 @@ class Bindings {
|
|
|
614
646
|
});
|
|
615
647
|
const bannered = [...globalErrors, ...unmatched];
|
|
616
648
|
form.querySelectorAll('ful-errors').forEach((el) => {
|
|
649
|
+
if (!ofForm(el)) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
617
652
|
const hel = /** @type HTMLElement} */ (el);
|
|
618
653
|
if (bannered.length === 0) {
|
|
619
654
|
hel.innerText = '';
|
|
@@ -628,7 +663,8 @@ class Bindings {
|
|
|
628
663
|
if (es.length === 0 || !scrollOnError) {
|
|
629
664
|
return;
|
|
630
665
|
}
|
|
631
|
-
Array.from(form.
|
|
666
|
+
Array.from(form.elements)
|
|
667
|
+
.filter((el) => el.matches(':invalid'))
|
|
632
668
|
.sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y)[0]
|
|
633
669
|
?.focus();
|
|
634
670
|
}
|
|
@@ -669,6 +705,7 @@ class Field extends ParsedElement {
|
|
|
669
705
|
/** the role the element internals carry, 'presentation' unless the control is its own */
|
|
670
706
|
static ROLE = 'presentation';
|
|
671
707
|
#control;
|
|
708
|
+
#pendingFocus = null;
|
|
672
709
|
#described;
|
|
673
710
|
#descriptions = [];
|
|
674
711
|
#errorId = null;
|
|
@@ -750,12 +787,17 @@ class Field extends ParsedElement {
|
|
|
750
787
|
//only where the platform cannot: a control still associated with the
|
|
751
788
|
//form, an author's own input in a slot among them, submits on its own
|
|
752
789
|
//and would otherwise submit twice
|
|
753
|
-
if (target.form === this.
|
|
790
|
+
if (target.form === this.form || !Field.#submitsOnEnter(target)) {
|
|
754
791
|
return;
|
|
755
792
|
}
|
|
756
793
|
this._requestSubmit();
|
|
757
794
|
});
|
|
758
795
|
this.replaceChildren(fragment);
|
|
796
|
+
if (this.#pendingFocus !== null) {
|
|
797
|
+
const options = this.#pendingFocus;
|
|
798
|
+
this.#pendingFocus = null;
|
|
799
|
+
control.focus(options);
|
|
800
|
+
}
|
|
759
801
|
}
|
|
760
802
|
/**
|
|
761
803
|
* The platform's own rule for which control Enter submits from, measured on
|
|
@@ -823,7 +865,11 @@ class Field extends ParsedElement {
|
|
|
823
865
|
}
|
|
824
866
|
}
|
|
825
867
|
focus(options) {
|
|
826
|
-
this.#control
|
|
868
|
+
if (this.#control) {
|
|
869
|
+
this.#control.focus(options);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
this.#pendingFocus = options ?? {};
|
|
827
873
|
}
|
|
828
874
|
/**
|
|
829
875
|
* Clears or reports one validation problem: the text lands on the field's
|
|
@@ -850,9 +896,13 @@ class Field extends ParsedElement {
|
|
|
850
896
|
this.internals.setValidity({ customError: true }, ' ');
|
|
851
897
|
this.#fieldError.innerText = error;
|
|
852
898
|
}
|
|
899
|
+
/** The form the platform associated the field with, null outside one: a native control's own `form`. */
|
|
900
|
+
get form() {
|
|
901
|
+
return this.internals.form;
|
|
902
|
+
}
|
|
853
903
|
/** Submits the associated form through its first submitter, as Enter on a native control would. */
|
|
854
904
|
_requestSubmit() {
|
|
855
|
-
const form = this.
|
|
905
|
+
const form = this.form;
|
|
856
906
|
if (!form) {
|
|
857
907
|
return;
|
|
858
908
|
}
|
|
@@ -1210,6 +1260,11 @@ class Form extends ParsedElement {
|
|
|
1210
1260
|
);
|
|
1211
1261
|
if (this.declared('clear-invalid-on-change')) {
|
|
1212
1262
|
this.addEventListener('change', (/** @type any */ evt) => {
|
|
1263
|
+
//a form-associated custom element carries no `form` property of
|
|
1264
|
+
//its own: the owner reads through the internals it attached
|
|
1265
|
+
if ((evt.target.form ?? evt.target.internals?.form) !== this.form) {
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1213
1268
|
evt.target.setCustomValidity?.('');
|
|
1214
1269
|
});
|
|
1215
1270
|
}
|
|
@@ -1275,8 +1330,9 @@ class Form extends ParsedElement {
|
|
|
1275
1330
|
);
|
|
1276
1331
|
if (e instanceof Failure) {
|
|
1277
1332
|
this.errors = e.problems;
|
|
1333
|
+
} else {
|
|
1334
|
+
console.warn('failed to submit form', this, 'reason:', e);
|
|
1278
1335
|
}
|
|
1279
|
-
console.warn('failed to submit form', this, 'reason:', e);
|
|
1280
1336
|
} finally {
|
|
1281
1337
|
this.#submitting = false;
|
|
1282
1338
|
this.spinner(false);
|
|
@@ -1326,6 +1382,9 @@ class Form extends ParsedElement {
|
|
|
1326
1382
|
//same way, and a form that only dimmed its button said it to no one
|
|
1327
1383
|
Attributes.set(this, 'aria-busy', spin ? 'true' : null);
|
|
1328
1384
|
this.querySelectorAll('ful-spinner').forEach((el) => {
|
|
1385
|
+
if (el.closest('form') !== this.form) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1329
1388
|
const hel = /** @type HTMLElement */ (el);
|
|
1330
1389
|
if (spin) {
|
|
1331
1390
|
this.#announce(hel);
|
|
@@ -1336,7 +1395,7 @@ class Form extends ParsedElement {
|
|
|
1336
1395
|
});
|
|
1337
1396
|
this.querySelectorAll('input,button').forEach((el) => {
|
|
1338
1397
|
const hel = /** @type HTMLButtonElement|HTMLInputElement */ (el);
|
|
1339
|
-
if (hel.type !== 'submit' && hel.type !== 'reset') {
|
|
1398
|
+
if (hel.form !== this.form || (hel.type !== 'submit' && hel.type !== 'reset')) {
|
|
1340
1399
|
return;
|
|
1341
1400
|
}
|
|
1342
1401
|
if (spin) {
|
|
@@ -2177,11 +2236,18 @@ const place = (popover, anchored) => {
|
|
|
2177
2236
|
popover.style.bottom = 'auto';
|
|
2178
2237
|
popover.style.margin = '0';
|
|
2179
2238
|
if (stretch) {
|
|
2180
|
-
|
|
2181
|
-
popover.style.width
|
|
2239
|
+
popover.style.removeProperty('width');
|
|
2240
|
+
popover.style.removeProperty('min-width');
|
|
2241
|
+
popover.style.removeProperty('max-width');
|
|
2242
|
+
const cap = Math.min(parseFloat(computed.maxWidth) || vw, vw - 2 * PAD);
|
|
2243
|
+
popover.style.minWidth = `${Math.min(box.width, vw - 2 * PAD)}px`;
|
|
2244
|
+
popover.style.maxWidth = `${cap}px`;
|
|
2245
|
+
const width = popover.getBoundingClientRect().width;
|
|
2182
2246
|
popover.style.left = `${clamp(box.left, PAD, vw - width - PAD)}px`;
|
|
2183
2247
|
const height = popover.getBoundingClientRect().height;
|
|
2184
|
-
|
|
2248
|
+
const below = box.bottom + gap.top;
|
|
2249
|
+
const top = below + height <= vh - PAD ? below : box.top - gap.bottom - height;
|
|
2250
|
+
popover.style.top = `${clamp(top, PAD, vh - height - PAD)}px`;
|
|
2185
2251
|
return;
|
|
2186
2252
|
}
|
|
2187
2253
|
//a popover wraps against the spot it lands on: the width is measured
|
|
@@ -2226,7 +2292,9 @@ const unplace = (popover) => {
|
|
|
2226
2292
|
'bottom',
|
|
2227
2293
|
'margin',
|
|
2228
2294
|
'max-width',
|
|
2295
|
+
'min-width',
|
|
2229
2296
|
'width',
|
|
2297
|
+
'visibility',
|
|
2230
2298
|
'--ful-note-callout-inline',
|
|
2231
2299
|
'--ful-note-callout-block',
|
|
2232
2300
|
]) {
|
|
@@ -2234,6 +2302,16 @@ const unplace = (popover) => {
|
|
|
2234
2302
|
}
|
|
2235
2303
|
};
|
|
2236
2304
|
|
|
2305
|
+
const showOncePlaced = (popover, anchored) => {
|
|
2306
|
+
popover.style.visibility = 'hidden';
|
|
2307
|
+
requestAnimationFrame(() => {
|
|
2308
|
+
if (popover.matches(':popover-open')) {
|
|
2309
|
+
place(popover, anchored);
|
|
2310
|
+
}
|
|
2311
|
+
popover.style.removeProperty('visibility');
|
|
2312
|
+
});
|
|
2313
|
+
};
|
|
2314
|
+
|
|
2237
2315
|
const reflow = () => {
|
|
2238
2316
|
frame = 0;
|
|
2239
2317
|
for (const [popover, anchored] of open) {
|
|
@@ -2284,8 +2362,10 @@ class Anchors {
|
|
|
2284
2362
|
* the popover, giving toggle and light dismiss with no script of your own
|
|
2285
2363
|
* @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
|
|
2286
2364
|
* step with the popover
|
|
2287
|
-
* @param {boolean} [options.stretch] widens the popover to
|
|
2288
|
-
* is what a combobox
|
|
2365
|
+
* @param {boolean} [options.stretch] widens the popover to at least its
|
|
2366
|
+
* invoker's width, capped by its own `max-width`, which is what a combobox
|
|
2367
|
+
* dropdown wants; it flips above the invoker where the viewport leaves no
|
|
2368
|
+
* room below
|
|
2289
2369
|
* @param {boolean} [options.handPlace] places here on every platform rather
|
|
2290
2370
|
* than only as a fallback, which a popover asks for when it needs to know
|
|
2291
2371
|
* where its invoker ended up: the tooltip's note points a callout at it, and
|
|
@@ -2302,7 +2382,18 @@ class Anchors {
|
|
|
2302
2382
|
if (invoke) {
|
|
2303
2383
|
//popovertarget needs a target that can be named
|
|
2304
2384
|
popover.id = popover.id || uid;
|
|
2305
|
-
invoker.
|
|
2385
|
+
if (invoker.localName === 'button' || invoker.localName === 'input') {
|
|
2386
|
+
invoker.setAttribute('popovertarget', popover.id);
|
|
2387
|
+
} else {
|
|
2388
|
+
invoker.addEventListener('click', () => popover.togglePopover());
|
|
2389
|
+
invoker.addEventListener('keydown', (/** @type any */ evt) => {
|
|
2390
|
+
if (evt.key !== 'Enter' && evt.key !== ' ') {
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
evt.preventDefault();
|
|
2394
|
+
popover.togglePopover();
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2306
2397
|
}
|
|
2307
2398
|
const anchor = `--${uid}`;
|
|
2308
2399
|
invoker.style.anchorName = anchor;
|
|
@@ -2321,10 +2412,8 @@ class Anchors {
|
|
|
2321
2412
|
}
|
|
2322
2413
|
const anchored = { invoker, stretch };
|
|
2323
2414
|
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
2324
|
-
//placed before the showing, refined once laid out: the platform's
|
|
2325
|
-
//centered or corner spot never paints
|
|
2326
2415
|
if (evt.newState === 'open') {
|
|
2327
|
-
|
|
2416
|
+
showOncePlaced(popover, anchored);
|
|
2328
2417
|
}
|
|
2329
2418
|
});
|
|
2330
2419
|
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
@@ -2342,6 +2431,57 @@ class Anchors {
|
|
|
2342
2431
|
window.addEventListener('resize', schedule);
|
|
2343
2432
|
}
|
|
2344
2433
|
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Shows and places a popover beside an anchor, once: the popover shared by
|
|
2436
|
+
* many invokers, each open handing over its own anchor, and an anchor that
|
|
2437
|
+
* is a rectangle rather than an element, one read out of an iframe being
|
|
2438
|
+
* the case a paired invoker could not cover. No pairing, no reflow
|
|
2439
|
+
* following, no close wiring: closing is the caller's, or the platform's
|
|
2440
|
+
* for a light-dismissing popover.
|
|
2441
|
+
*
|
|
2442
|
+
* The placement is the menus' own geometry, below the anchor and
|
|
2443
|
+
* start-aligned, clamped into the viewport; `flip` moves it above the
|
|
2444
|
+
* anchor where the viewport leaves no room below, as the css
|
|
2445
|
+
* `position-try-fallbacks` the paired popovers declare. A popover not
|
|
2446
|
+
* already open is shown before it is placed, so the first position anybody
|
|
2447
|
+
* sees is the right one.
|
|
2448
|
+
*
|
|
2449
|
+
* @param {HTMLElement} popover the `[popover]` element to show, or an
|
|
2450
|
+
* already-shown element to place
|
|
2451
|
+
* @param {Element|DOMRect} anchor the element, or the rectangle, to place
|
|
2452
|
+
* beside
|
|
2453
|
+
* @param {object} [options]
|
|
2454
|
+
* @param {boolean} [options.flip] place above the anchor where no room is
|
|
2455
|
+
* left below
|
|
2456
|
+
* @param {number} [options.gap] the distance from the anchor, in pixels
|
|
2457
|
+
* @returns {HTMLElement} the popover
|
|
2458
|
+
*/
|
|
2459
|
+
static show(popover, anchor, { flip = false, gap = 0 } = {}) {
|
|
2460
|
+
const placeShown = () => {
|
|
2461
|
+
const box = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
|
|
2462
|
+
const viewport = document.documentElement;
|
|
2463
|
+
const here = popover.getBoundingClientRect();
|
|
2464
|
+
const below = box.bottom + gap;
|
|
2465
|
+
const above = box.top - gap - here.height;
|
|
2466
|
+
const top = flip && below + here.height > viewport.clientHeight - PAD && above >= PAD ? above : below;
|
|
2467
|
+
popover.style.right = 'auto';
|
|
2468
|
+
popover.style.bottom = 'auto';
|
|
2469
|
+
popover.style.margin = '0';
|
|
2470
|
+
popover.style.left = `${clamp(box.left, PAD, Math.max(PAD, viewport.clientWidth - here.width - PAD))}px`;
|
|
2471
|
+
popover.style.top = `${clamp(top, PAD, Math.max(PAD, viewport.clientHeight - here.height - PAD))}px`;
|
|
2472
|
+
};
|
|
2473
|
+
if (popover.matches(':popover-open') || typeof popover.showPopover !== 'function') {
|
|
2474
|
+
placeShown();
|
|
2475
|
+
return popover;
|
|
2476
|
+
}
|
|
2477
|
+
popover.style.visibility = 'hidden';
|
|
2478
|
+
popover.showPopover();
|
|
2479
|
+
requestAnimationFrame(() => {
|
|
2480
|
+
placeShown();
|
|
2481
|
+
popover.style.removeProperty('visibility');
|
|
2482
|
+
});
|
|
2483
|
+
return popover;
|
|
2484
|
+
}
|
|
2345
2485
|
}
|
|
2346
2486
|
|
|
2347
2487
|
/**
|
|
@@ -2638,12 +2778,19 @@ class Dropdown extends ParsedElement {
|
|
|
2638
2778
|
this.hide();
|
|
2639
2779
|
return;
|
|
2640
2780
|
}
|
|
2781
|
+
if (li.hasAttribute('aria-disabled')) {
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2641
2784
|
this.#change(li);
|
|
2642
2785
|
});
|
|
2643
2786
|
this.replaceChildren(fragment);
|
|
2644
2787
|
}
|
|
2645
2788
|
#selected() {
|
|
2646
|
-
return
|
|
2789
|
+
return (
|
|
2790
|
+
this.#menu?.querySelector('[selected]:not([aria-disabled])') ??
|
|
2791
|
+
this.#menu?.querySelector('li:not([aria-disabled])') ??
|
|
2792
|
+
null
|
|
2793
|
+
);
|
|
2647
2794
|
}
|
|
2648
2795
|
#highlight(li) {
|
|
2649
2796
|
if (!li) {
|
|
@@ -2675,11 +2822,20 @@ class Dropdown extends ParsedElement {
|
|
|
2675
2822
|
const data = values.map((entry, index) => ({ index, ...entry }));
|
|
2676
2823
|
this.#optionstemplate.withOverlay(data).renderTo(this.#menu);
|
|
2677
2824
|
for (const [index, li] of [...this.#menu.children].entries()) {
|
|
2678
|
-
const
|
|
2825
|
+
const entry = values[index];
|
|
2826
|
+
const picked = keys.some((r) => r == entry?.key);
|
|
2679
2827
|
li.toggleAttribute('picked', picked);
|
|
2680
2828
|
//what is picked is what aria-selected means for a listbox: a tint alone
|
|
2681
2829
|
//says it to whoever can see it and to no one else
|
|
2682
2830
|
li.setAttribute('aria-selected', picked ? 'true' : 'false');
|
|
2831
|
+
//metadata.disabled refuses the entry, metadata.reason saying why
|
|
2832
|
+
const disabled = !!entry?.metadata?.disabled;
|
|
2833
|
+
li.toggleAttribute('aria-disabled', disabled);
|
|
2834
|
+
if (disabled && entry.metadata.reason) {
|
|
2835
|
+
li.setAttribute('title', String(entry.metadata.reason));
|
|
2836
|
+
} else {
|
|
2837
|
+
li.removeAttribute('title');
|
|
2838
|
+
}
|
|
2683
2839
|
}
|
|
2684
2840
|
this.#empty.toggleAttribute('hidden', values.length !== 0);
|
|
2685
2841
|
this.#menu.toggleAttribute('hidden', values.length === 0);
|
|
@@ -2753,15 +2909,25 @@ class Dropdown extends ParsedElement {
|
|
|
2753
2909
|
if (this.shown) {
|
|
2754
2910
|
const selected = this.#selected();
|
|
2755
2911
|
const candidate = selected?.[`${forward ? 'next' : 'previous'}ElementSibling`];
|
|
2756
|
-
|
|
2757
|
-
|
|
2912
|
+
const target = candidate ? this.#walk(candidate, forward) : null;
|
|
2913
|
+
if (selected && target) {
|
|
2914
|
+
this.#highlight(target);
|
|
2758
2915
|
}
|
|
2759
2916
|
return;
|
|
2760
2917
|
}
|
|
2761
2918
|
await this.show(loader, keys);
|
|
2762
2919
|
}
|
|
2920
|
+
#walk(from, forward) {
|
|
2921
|
+
for (let li = from; li; li = li[`${forward ? 'next' : 'previous'}ElementSibling`]) {
|
|
2922
|
+
if (!li.hasAttribute('aria-disabled')) {
|
|
2923
|
+
return li;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
return null;
|
|
2927
|
+
}
|
|
2763
2928
|
jump(first) {
|
|
2764
|
-
const
|
|
2929
|
+
const edge = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
|
|
2930
|
+
const target = this.#walk(edge, first);
|
|
2765
2931
|
if (target) {
|
|
2766
2932
|
this.#highlight(target);
|
|
2767
2933
|
}
|
|
@@ -2771,9 +2937,14 @@ class Dropdown extends ParsedElement {
|
|
|
2771
2937
|
if (!selected) {
|
|
2772
2938
|
return;
|
|
2773
2939
|
}
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2940
|
+
let target = selected;
|
|
2941
|
+
for (let i = 0; i !== this.#page(); ++i) {
|
|
2942
|
+
const next = this.#walk(target[`${forward ? 'next' : 'previous'}ElementSibling`], forward);
|
|
2943
|
+
if (!next) {
|
|
2944
|
+
break;
|
|
2945
|
+
}
|
|
2946
|
+
target = next;
|
|
2947
|
+
}
|
|
2777
2948
|
this.#highlight(target);
|
|
2778
2949
|
}
|
|
2779
2950
|
#page() {
|
|
@@ -2809,6 +2980,12 @@ class Select extends Field {
|
|
|
2809
2980
|
//`set value` normalizes a list of one to a single key, and the getter answers a
|
|
2810
2981
|
//scalar for a single select, so nothing downstream has to know which it was
|
|
2811
2982
|
static observed = ['multiple:presence', 'item-list:presence', 'value:csv'];
|
|
2983
|
+
unmarshal(attr, str) {
|
|
2984
|
+
if (attr === 'value' && str === '' && !this.hasAttribute('multiple')) {
|
|
2985
|
+
return [''];
|
|
2986
|
+
}
|
|
2987
|
+
return super.unmarshal(attr, str);
|
|
2988
|
+
}
|
|
2812
2989
|
static slots = true;
|
|
2813
2990
|
//a manual popover: the combobox keeps the focus on its input and owns
|
|
2814
2991
|
//the whole lifecycle (typing, arrows, blur, Escape, Tab), so no light
|
|
@@ -2914,7 +3091,7 @@ class Select extends Field {
|
|
|
2914
3091
|
//the select's: a tooltip marker slotted into `info`, a button a page
|
|
2915
3092
|
//put in an affix. Without this, reading the note beside a select
|
|
2916
3093
|
//also stole the focus and dropped the dropdown over the note
|
|
2917
|
-
const elsewhere = e.target.closest('button, a[href], input, select, textarea');
|
|
3094
|
+
const elsewhere = e.target.closest('button, [role="button"], a[href], input, select, textarea');
|
|
2918
3095
|
if (elsewhere && elsewhere !== this.#input) {
|
|
2919
3096
|
return;
|
|
2920
3097
|
}
|
|
@@ -3902,7 +4079,7 @@ class Table extends ParsedElement {
|
|
|
3902
4079
|
<tbody data-ref="empty" hidden>
|
|
3903
4080
|
<tr>
|
|
3904
4081
|
<td data-tpl-colspan="schema.length">
|
|
3905
|
-
<ful-empty data-tpl-if="slots.empty"
|
|
4082
|
+
<ful-empty data-tpl-if="slots.empty" data-ref="empty-authors"></ful-empty>
|
|
3906
4083
|
<ful-empty data-tpl-if="!slots.empty">
|
|
3907
4084
|
<ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
|
|
3908
4085
|
{{ #l10n:t('table.no-data') }}
|
|
@@ -3945,6 +4122,8 @@ class Table extends ParsedElement {
|
|
|
3945
4122
|
#loading;
|
|
3946
4123
|
#noAutoload;
|
|
3947
4124
|
#empty;
|
|
4125
|
+
#emptyAuthors;
|
|
4126
|
+
#emptyTemplate;
|
|
3948
4127
|
#feedback;
|
|
3949
4128
|
#paginator;
|
|
3950
4129
|
#sorters;
|
|
@@ -3996,6 +4175,9 @@ class Table extends ParsedElement {
|
|
|
3996
4175
|
this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
|
|
3997
4176
|
this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
|
|
3998
4177
|
this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
|
|
4178
|
+
this.#emptyAuthors = this.#empty.querySelector('ful-empty[data-ref=empty-authors]');
|
|
4179
|
+
this.#emptyTemplate =
|
|
4180
|
+
slots.empty && !Fragments.isBlank(slots.empty) ? Templates.fromFragment(slots.empty) : null;
|
|
3999
4181
|
this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
|
|
4000
4182
|
this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
|
|
4001
4183
|
this.replaceChildren(fragment);
|
|
@@ -4167,6 +4349,15 @@ class Table extends ParsedElement {
|
|
|
4167
4349
|
.render(),
|
|
4168
4350
|
);
|
|
4169
4351
|
this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
|
|
4352
|
+
if (this.#emptyTemplate && pageResponse.data.length === 0) {
|
|
4353
|
+
//rendered per load, the rows' own scope: the empty state can say
|
|
4354
|
+
//whether nothing was ever there or the filters matched nothing
|
|
4355
|
+
this.#emptyAuthors.replaceChildren(
|
|
4356
|
+
this.#emptyTemplate
|
|
4357
|
+
.withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
|
|
4358
|
+
.render(),
|
|
4359
|
+
);
|
|
4360
|
+
}
|
|
4170
4361
|
//one move, one repaint: the page and the count are the same state
|
|
4171
4362
|
this.#paginator.update({ current: pageRequest.page, total: pages });
|
|
4172
4363
|
}
|
|
@@ -4257,6 +4448,13 @@ class ChoiceButton {
|
|
|
4257
4448
|
get pinned() {
|
|
4258
4449
|
return this.#allowed.length < 2;
|
|
4259
4450
|
}
|
|
4451
|
+
/**
|
|
4452
|
+
* The host's fixed claim: the button and its chrome leave while the value
|
|
4453
|
+
* keeps answering, which is what a fixed operator asks of its glyph.
|
|
4454
|
+
*/
|
|
4455
|
+
set fixed(fixed) {
|
|
4456
|
+
this.#button.toggleAttribute('hidden', !!fixed);
|
|
4457
|
+
}
|
|
4260
4458
|
get value() {
|
|
4261
4459
|
return this.#button.getAttribute('value');
|
|
4262
4460
|
}
|
|
@@ -4440,6 +4638,7 @@ const asCriterion = (label, operator, operands) => {
|
|
|
4440
4638
|
*/
|
|
4441
4639
|
class CompareFilter extends Input {
|
|
4442
4640
|
static observed = ['value:json', 'operators:csv'];
|
|
4641
|
+
static attributes = ['operator'];
|
|
4443
4642
|
static OPERATORS = COMPARE_OPERATORS;
|
|
4444
4643
|
static DEFAULT_OPERATOR = 'EQ';
|
|
4445
4644
|
static template = `
|
|
@@ -4490,11 +4689,38 @@ class CompareFilter extends Input {
|
|
|
4490
4689
|
if (this._operator.value === null) {
|
|
4491
4690
|
this._showDefaultOperator();
|
|
4492
4691
|
}
|
|
4692
|
+
this.#fixOperator();
|
|
4493
4693
|
//the second operand mirrors the claims like the first one does, and the
|
|
4494
4694
|
//freeze reaches the operator and sensitivity buttons, whose popovers an
|
|
4495
4695
|
//input's readOnly cannot touch
|
|
4496
4696
|
return { ...pieces, freeze: this._container, also: [this._value2] };
|
|
4497
4697
|
}
|
|
4698
|
+
/**
|
|
4699
|
+
* The singular `operator` fixes the operator for the element's life: not a
|
|
4700
|
+
* choice, so no menu and no button, every tuple carrying it. The plural is
|
|
4701
|
+
* the menu's whitelist; declaring both is a confused page and says so,
|
|
4702
|
+
* the singular winning, and an unknown name warns and pins the default.
|
|
4703
|
+
*/
|
|
4704
|
+
#fixOperator() {
|
|
4705
|
+
const fixed = this.declared('operator');
|
|
4706
|
+
if (fixed === null) {
|
|
4707
|
+
return;
|
|
4708
|
+
}
|
|
4709
|
+
if (this.hasAttribute('operators')) {
|
|
4710
|
+
console.warn(`${this.localName}: operator fixes what operators offers a menu for, the plural is ignored`, this);
|
|
4711
|
+
}
|
|
4712
|
+
const vocabulary = this._vocabulary();
|
|
4713
|
+
/** @type {string|null} */
|
|
4714
|
+
let preferred = null;
|
|
4715
|
+
if (vocabulary.includes(fixed)) {
|
|
4716
|
+
preferred = fixed;
|
|
4717
|
+
} else {
|
|
4718
|
+
console.warn(`${this.localName}: '${fixed}' is not one of its operators, the default is pinned instead`, this);
|
|
4719
|
+
preferred = this._defaultOperator();
|
|
4720
|
+
}
|
|
4721
|
+
this._operator.allowed = [preferred];
|
|
4722
|
+
this._operator.fixed = true;
|
|
4723
|
+
}
|
|
4498
4724
|
_showDefaultOperator() {
|
|
4499
4725
|
const preferred = this._defaultOperator();
|
|
4500
4726
|
const allowed = this._operator.allowed;
|
|
@@ -4531,6 +4757,9 @@ class CompareFilter extends Input {
|
|
|
4531
4757
|
return this._operator ? this._operator.allowed : this._declaredOperators;
|
|
4532
4758
|
}
|
|
4533
4759
|
set operators(declared) {
|
|
4760
|
+
if (this.declared('operator') !== null) {
|
|
4761
|
+
return;
|
|
4762
|
+
}
|
|
4534
4763
|
if (!this._operator) {
|
|
4535
4764
|
this._declaredOperators = ChoiceButton.narrow(declared, this._vocabulary());
|
|
4536
4765
|
return;
|
|
@@ -4634,6 +4863,7 @@ class NumberFilter extends CompareFilter {
|
|
|
4634
4863
|
/** The compare filter over text, carrying a case sensitivity beside the operator. */
|
|
4635
4864
|
class TextFilter extends CompareFilter {
|
|
4636
4865
|
static observed = ['sensitivities:csv'];
|
|
4866
|
+
static attributes = ['sensitivity'];
|
|
4637
4867
|
static template = `
|
|
4638
4868
|
<label>{{{{ slots.default }}}}</label>
|
|
4639
4869
|
{{{{ slots.info }}}}
|
|
@@ -4677,8 +4907,28 @@ class TextFilter extends CompareFilter {
|
|
|
4677
4907
|
);
|
|
4678
4908
|
this._sensitivityButton.allowed = null;
|
|
4679
4909
|
this._sensitivityButton.value = SENSITIVITIES[0];
|
|
4910
|
+
this.#fixSensitivity();
|
|
4680
4911
|
return pieces;
|
|
4681
4912
|
}
|
|
4913
|
+
#fixSensitivity() {
|
|
4914
|
+
const fixed = this.declared('sensitivity');
|
|
4915
|
+
if (fixed === null) {
|
|
4916
|
+
return;
|
|
4917
|
+
}
|
|
4918
|
+
if (this.hasAttribute('sensitivities')) {
|
|
4919
|
+
console.warn('ful-filter-text: sensitivity fixes what sensitivities offers a menu for, the plural is ignored', this);
|
|
4920
|
+
}
|
|
4921
|
+
/** @type {string|null} */
|
|
4922
|
+
let preferred = null;
|
|
4923
|
+
if (SENSITIVITIES.includes(fixed)) {
|
|
4924
|
+
preferred = fixed;
|
|
4925
|
+
} else {
|
|
4926
|
+
console.warn(`ful-filter-text: '${fixed}' is not one of its sensitivities, the default is pinned instead`, this);
|
|
4927
|
+
preferred = 'IGNORE_CASE';
|
|
4928
|
+
}
|
|
4929
|
+
this._sensitivityButton.allowed = [preferred];
|
|
4930
|
+
this._sensitivityButton.fixed = true;
|
|
4931
|
+
}
|
|
4682
4932
|
_choices() {
|
|
4683
4933
|
return [...super._choices(), this._sensitivityButton].filter((c) => c);
|
|
4684
4934
|
}
|
|
@@ -4690,6 +4940,9 @@ class TextFilter extends CompareFilter {
|
|
|
4690
4940
|
return this._sensitivityButton ? this._sensitivityButton.allowed : this._declaredSensitivities;
|
|
4691
4941
|
}
|
|
4692
4942
|
set sensitivities(declared) {
|
|
4943
|
+
if (this.declared('sensitivity') !== null) {
|
|
4944
|
+
return;
|
|
4945
|
+
}
|
|
4693
4946
|
if (!this._sensitivityButton) {
|
|
4694
4947
|
this._declaredSensitivities = ChoiceButton.narrow(declared, SENSITIVITIES);
|
|
4695
4948
|
return;
|
|
@@ -4819,6 +5072,15 @@ class BooleanFilter extends Field {
|
|
|
4819
5072
|
this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
|
|
4820
5073
|
this._value.value = v[1] ?? '';
|
|
4821
5074
|
}
|
|
5075
|
+
formResetCallback() {
|
|
5076
|
+
super.formResetCallback();
|
|
5077
|
+
if (!this.hasAttribute('value')) {
|
|
5078
|
+
const allowed = this._operator.allowed;
|
|
5079
|
+
this._operator.value = allowed.includes(BooleanFilter.DEFAULT_OPERATOR)
|
|
5080
|
+
? BooleanFilter.DEFAULT_OPERATOR
|
|
5081
|
+
: allowed[0];
|
|
5082
|
+
}
|
|
5083
|
+
}
|
|
4822
5084
|
/** @returns {FilterCriterion|null} */
|
|
4823
5085
|
get criterion() {
|
|
4824
5086
|
const token = this._value.value;
|
|
@@ -4845,7 +5107,6 @@ class BooleanFilter extends Field {
|
|
|
4845
5107
|
*/
|
|
4846
5108
|
class InFilter extends Select {
|
|
4847
5109
|
static attributes = ['operator'];
|
|
4848
|
-
/** the selection as the array the wire takes, whether one key was chosen or many */
|
|
4849
5110
|
#keys() {
|
|
4850
5111
|
const chosen = super.value;
|
|
4851
5112
|
if (this.multiple) {
|
|
@@ -4866,8 +5127,6 @@ class InFilter extends Select {
|
|
|
4866
5127
|
set value(v) {
|
|
4867
5128
|
const operator = this.declared('operator');
|
|
4868
5129
|
let keys = v === null || v === undefined ? [] : Array.isArray(v) ? v : [v];
|
|
4869
|
-
//what the getter answers has to be assignable back, and it carries the
|
|
4870
|
-
//operator in front of the keys
|
|
4871
5130
|
if (operator && keys[0] === operator) {
|
|
4872
5131
|
keys = keys.slice(1);
|
|
4873
5132
|
}
|
|
@@ -5014,7 +5273,7 @@ class Tooltip extends ParsedElement {
|
|
|
5014
5273
|
icon: 'info-circle-fill',
|
|
5015
5274
|
};
|
|
5016
5275
|
static template = `
|
|
5017
|
-
<
|
|
5276
|
+
<span role="button" tabindex="0" 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></span>
|
|
5018
5277
|
<ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
|
|
5019
5278
|
`;
|
|
5020
5279
|
render({ slots }) {
|
|
@@ -5118,15 +5377,32 @@ class Dialog extends ParsedElement {
|
|
|
5118
5377
|
this.#body = fragment.querySelector('[data-ref=body]');
|
|
5119
5378
|
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
5120
5379
|
this.#error = fragment.querySelector('[data-ref=error]');
|
|
5380
|
+
//a dialog is a named surface: a reader announces the heading on opening,
|
|
5381
|
+
//which it cannot do unless the heading names the dialog. The name travels
|
|
5382
|
+
//as a reference rather than as aria-label text, so it stays the heading's
|
|
5383
|
+
//own however it is reworded
|
|
5384
|
+
const heading = fragment.querySelector('h2');
|
|
5385
|
+
if (heading) {
|
|
5386
|
+
heading.id ||= Attributes.uid('ful-dialog-title');
|
|
5387
|
+
this.#dialog.setAttribute('aria-labelledby', heading.id);
|
|
5388
|
+
}
|
|
5121
5389
|
this.#dialog.addEventListener('close', () => {
|
|
5122
5390
|
const outcome = this.#outcome();
|
|
5123
5391
|
this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
|
|
5124
5392
|
this.#settle(outcome);
|
|
5125
5393
|
});
|
|
5394
|
+
let pressedOutside = false;
|
|
5395
|
+
this.#dialog.addEventListener('mousedown', (/** @type any */ e) => {
|
|
5396
|
+
pressedOutside = e.target === this.#dialog;
|
|
5397
|
+
});
|
|
5126
5398
|
this.#dialog.addEventListener('click', (/** @type any */ e) => {
|
|
5127
5399
|
const result = e.target.closest('button[data-result]')?.dataset.result;
|
|
5128
5400
|
if (result !== undefined) {
|
|
5129
5401
|
this.#dialog.close(result);
|
|
5402
|
+
return;
|
|
5403
|
+
}
|
|
5404
|
+
if (!requiresAnswer && pressedOutside && e.target === this.#dialog) {
|
|
5405
|
+
this.#dialog.close('');
|
|
5130
5406
|
}
|
|
5131
5407
|
});
|
|
5132
5408
|
//dismissal, not an answer: the waiters are settled with a dismissal, as
|
|
@@ -5277,6 +5553,69 @@ class Dialog extends ParsedElement {
|
|
|
5277
5553
|
this.#loading?.setAttribute('hidden', '');
|
|
5278
5554
|
this.#body?.removeAttribute('hidden');
|
|
5279
5555
|
}
|
|
5556
|
+
/**
|
|
5557
|
+
* Asks a question in a dialog of the page's own, resolving with its outcome
|
|
5558
|
+
* and removing the element afterwards: the element stays the author's when
|
|
5559
|
+
* the question lives in the page, while a question asked from a button had
|
|
5560
|
+
* to build, render, show, answer and clean up by hand.
|
|
5561
|
+
*
|
|
5562
|
+
* The body is text or a node: a string is never read as markup, an
|
|
5563
|
+
* `innerHTML` sink being a thing the library does not carry. Each button is
|
|
5564
|
+
* a `[result, label, className?]` tuple, rendered as its answer.
|
|
5565
|
+
* @param {string} header
|
|
5566
|
+
* @param {string|Node} body
|
|
5567
|
+
* @param {[string, string, string?][]} [buttons]
|
|
5568
|
+
* @returns {Promise<DialogOutcome>}
|
|
5569
|
+
*/
|
|
5570
|
+
static async ask(header, body, buttons = []) {
|
|
5571
|
+
const dialog = document.createElement('ful-dialog');
|
|
5572
|
+
dialog.setAttribute('header', header);
|
|
5573
|
+
if (typeof body === 'string') {
|
|
5574
|
+
dialog.textContent = body;
|
|
5575
|
+
} else if (body) {
|
|
5576
|
+
dialog.append(body);
|
|
5577
|
+
}
|
|
5578
|
+
//buttons are slotted only when there are any: the acknowledge button
|
|
5579
|
+
//is the answer of a question asked without choices of its own
|
|
5580
|
+
if (buttons.length > 0) {
|
|
5581
|
+
const choices = document.createElement('template');
|
|
5582
|
+
choices.setAttribute('slot', 'buttons');
|
|
5583
|
+
for (const [result, label, className] of buttons) {
|
|
5584
|
+
const button = document.createElement('button');
|
|
5585
|
+
button.type = 'button';
|
|
5586
|
+
button.dataset.result = result;
|
|
5587
|
+
if (className) {
|
|
5588
|
+
button.className = className;
|
|
5589
|
+
}
|
|
5590
|
+
button.textContent = label;
|
|
5591
|
+
choices.content.append(button);
|
|
5592
|
+
}
|
|
5593
|
+
dialog.append(choices);
|
|
5594
|
+
}
|
|
5595
|
+
document.body.append(dialog);
|
|
5596
|
+
try {
|
|
5597
|
+
await Rendering.waitFor(dialog);
|
|
5598
|
+
return await /** @type {Dialog} */ (dialog).ask();
|
|
5599
|
+
} finally {
|
|
5600
|
+
dialog.remove();
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
5603
|
+
/**
|
|
5604
|
+
* Asks a yes/no question, resolving with the confirm button's answer:
|
|
5605
|
+
* the cancel button, the close button and Escape all answer false, a
|
|
5606
|
+
* dismissal being the negative.
|
|
5607
|
+
* @param {string} header
|
|
5608
|
+
* @param {string|Node} body
|
|
5609
|
+
* @param {{ confirm?: string, cancel?: string }} [labels] overriding the localized defaults
|
|
5610
|
+
* @returns {Promise<boolean>}
|
|
5611
|
+
*/
|
|
5612
|
+
static async confirm(header, body, labels = {}) {
|
|
5613
|
+
const outcome = await Dialog.ask(header, body, [
|
|
5614
|
+
['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
|
|
5615
|
+
['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
|
|
5616
|
+
]);
|
|
5617
|
+
return !outcome.dismissed && outcome.result === 'confirm';
|
|
5618
|
+
}
|
|
5280
5619
|
}
|
|
5281
5620
|
|
|
5282
5621
|
/**
|
|
@@ -5289,7 +5628,7 @@ class Dialog extends ParsedElement {
|
|
|
5289
5628
|
* it.
|
|
5290
5629
|
*/
|
|
5291
5630
|
class Drawer extends ParsedElement {
|
|
5292
|
-
static attributes = ['
|
|
5631
|
+
static attributes = ['header', 'placement', 'close-on-submit:presence'];
|
|
5293
5632
|
static slots = true;
|
|
5294
5633
|
static template = `
|
|
5295
5634
|
<dialog data-ref="dialog" class="ful-drawer">
|
|
@@ -5314,12 +5653,23 @@ class Drawer extends ParsedElement {
|
|
|
5314
5653
|
//a save answering with no body at all is still a save
|
|
5315
5654
|
/** @type {{ dismissed: boolean, response: any }|null} */
|
|
5316
5655
|
#answer = null;
|
|
5656
|
+
#closing = false;
|
|
5657
|
+
#slideOutEnded = (/** @type AnimationEvent */ e) => {
|
|
5658
|
+
if (e.target === this.#dialog) {
|
|
5659
|
+
this.#closeNow();
|
|
5660
|
+
}
|
|
5661
|
+
};
|
|
5317
5662
|
render({ slots }) {
|
|
5318
5663
|
const fragment = this.template()
|
|
5319
|
-
.withOverlay({ slots, title: this.declared('
|
|
5664
|
+
.withOverlay({ slots, title: this.declared('header') ?? '' })
|
|
5320
5665
|
.render();
|
|
5321
5666
|
this.#dialog = fragment.querySelector('[data-ref=dialog]');
|
|
5322
5667
|
this.#title = fragment.querySelector('[data-ref=title]');
|
|
5668
|
+
//a named surface, like a dialog: the heading announces the drawer on
|
|
5669
|
+
//opening only where it names it, and the reference survives every
|
|
5670
|
+
//textContent update() writes into the heading
|
|
5671
|
+
this.#title.id ||= Attributes.uid('ful-drawer-title');
|
|
5672
|
+
this.#dialog.setAttribute('aria-labelledby', this.#title.id);
|
|
5323
5673
|
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
5324
5674
|
this.#error = fragment.querySelector('[data-ref=error]');
|
|
5325
5675
|
this.#content = fragment.querySelector('[data-ref=content]');
|
|
@@ -5328,6 +5678,19 @@ class Drawer extends ParsedElement {
|
|
|
5328
5678
|
this.#dialog.setAttribute('placement', placement);
|
|
5329
5679
|
}
|
|
5330
5680
|
fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
|
|
5681
|
+
let pressedOutside = false;
|
|
5682
|
+
this.#dialog.addEventListener('mousedown', (e) => {
|
|
5683
|
+
pressedOutside = e.target === this.#dialog;
|
|
5684
|
+
});
|
|
5685
|
+
this.#dialog.addEventListener('click', (e) => {
|
|
5686
|
+
if (pressedOutside && e.target === this.#dialog) {
|
|
5687
|
+
this.close();
|
|
5688
|
+
}
|
|
5689
|
+
});
|
|
5690
|
+
this.#dialog.addEventListener('cancel', (e) => {
|
|
5691
|
+
e.preventDefault();
|
|
5692
|
+
this.close();
|
|
5693
|
+
});
|
|
5331
5694
|
this.#dialog.addEventListener('close', () => {
|
|
5332
5695
|
this.dispatchEvent(
|
|
5333
5696
|
new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
|
|
@@ -5351,23 +5714,23 @@ class Drawer extends ParsedElement {
|
|
|
5351
5714
|
this.replaceChildren(fragment);
|
|
5352
5715
|
wireTargets();
|
|
5353
5716
|
}
|
|
5354
|
-
get
|
|
5717
|
+
get header() {
|
|
5355
5718
|
return this.#title.textContent;
|
|
5356
5719
|
}
|
|
5357
|
-
set
|
|
5720
|
+
set header(v) {
|
|
5358
5721
|
this.#title.textContent = v ?? '';
|
|
5359
5722
|
}
|
|
5360
5723
|
/**
|
|
5361
|
-
* Opens the drawer under the given
|
|
5724
|
+
* Opens the drawer under the given header and waits for the callback: a
|
|
5362
5725
|
* resolved value paints the content section (which is returned), a
|
|
5363
5726
|
* rejection paints the problems and travels to the caller, and an update
|
|
5364
5727
|
* superseded by a newer one paints nothing.
|
|
5365
5728
|
*/
|
|
5366
|
-
async update(
|
|
5729
|
+
async update(header, cb) {
|
|
5367
5730
|
//the claim detaches any update still in flight: its outcome belongs to
|
|
5368
5731
|
//an abandoned opening and must neither be painted nor own the drawer
|
|
5369
5732
|
const claim = this.#updates.take();
|
|
5370
|
-
this.
|
|
5733
|
+
this.header = header;
|
|
5371
5734
|
this.#content.replaceChildren();
|
|
5372
5735
|
this.#restChrome();
|
|
5373
5736
|
this.#loading.removeAttribute('hidden');
|
|
@@ -5413,10 +5776,33 @@ class Drawer extends ParsedElement {
|
|
|
5413
5776
|
this.#requests.request(this, this.#content, null, null)?.catch(() => undefined);
|
|
5414
5777
|
}
|
|
5415
5778
|
close() {
|
|
5779
|
+
if (this.#closing || !this.#dialog.open) {
|
|
5780
|
+
return;
|
|
5781
|
+
}
|
|
5782
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
5783
|
+
this.#dialog.close();
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
this.#dialog.setAttribute('closing', '');
|
|
5787
|
+
if (this.#dialog.getAnimations().length === 0) {
|
|
5788
|
+
this.#closeNow();
|
|
5789
|
+
return;
|
|
5790
|
+
}
|
|
5791
|
+
this.#closing = true;
|
|
5792
|
+
this.#dialog.addEventListener('animationend', this.#slideOutEnded);
|
|
5793
|
+
}
|
|
5794
|
+
#closeNow() {
|
|
5795
|
+
this.#stopSlidingOut();
|
|
5416
5796
|
this.#dialog.close();
|
|
5417
5797
|
}
|
|
5798
|
+
#stopSlidingOut() {
|
|
5799
|
+
this.#closing = false;
|
|
5800
|
+
this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
|
|
5801
|
+
this.#dialog.removeAttribute('closing');
|
|
5802
|
+
}
|
|
5418
5803
|
/** Shows the modal, answering whether this call is the one that opened it. */
|
|
5419
5804
|
#show() {
|
|
5805
|
+
this.#stopSlidingOut();
|
|
5420
5806
|
if (this.#dialog.open) {
|
|
5421
5807
|
return false;
|
|
5422
5808
|
}
|
|
@@ -5463,6 +5849,9 @@ class Toasts extends ParsedElement {
|
|
|
5463
5849
|
if (!listenerWired) {
|
|
5464
5850
|
listenerWired = true;
|
|
5465
5851
|
document.addEventListener('show-toast', (/** @type any */ e) => {
|
|
5852
|
+
if (!e.detail) {
|
|
5853
|
+
return;
|
|
5854
|
+
}
|
|
5466
5855
|
for (const region of REGIONS) {
|
|
5467
5856
|
region.show(e.detail.message, e.detail);
|
|
5468
5857
|
}
|
|
@@ -5474,8 +5863,13 @@ class Toasts extends ParsedElement {
|
|
|
5474
5863
|
* Appends a toast carrying the message (a Failure shows its problems'
|
|
5475
5864
|
* reasons, one per line), severity picking the theme and the announcement,
|
|
5476
5865
|
* the toast retiring through its own timer or its dismiss button.
|
|
5866
|
+
*
|
|
5867
|
+
* The timer holds while the toast is hovered or holds the focus, so an
|
|
5868
|
+
* actionable toast waits for its reader: `action` is `{ label, onClick }`,
|
|
5869
|
+
* a button beside the message whose click answers and retires. The same
|
|
5870
|
+
* options travel in the `show-toast` event's detail.
|
|
5477
5871
|
* @param {any} message
|
|
5478
|
-
* @param {any} [options] severity and
|
|
5872
|
+
* @param {any} [options] severity, timeout and action
|
|
5479
5873
|
* @returns {HTMLElement}
|
|
5480
5874
|
*/
|
|
5481
5875
|
show(message, options = {}) {
|
|
@@ -5492,14 +5886,13 @@ class Toasts extends ParsedElement {
|
|
|
5492
5886
|
icon.setAttribute('name', 'x-lg');
|
|
5493
5887
|
icon.setAttribute('aria-hidden', 'true');
|
|
5494
5888
|
dismiss.append(icon);
|
|
5495
|
-
item.append(body
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
}
|
|
5500
|
-
});
|
|
5889
|
+
item.append(body);
|
|
5890
|
+
let remaining = options.timeout ?? this.#timeout;
|
|
5891
|
+
let startedAt = performance.now();
|
|
5892
|
+
let timer = 0;
|
|
5501
5893
|
const retire = () => {
|
|
5502
|
-
|
|
5894
|
+
clearTimeout(timer);
|
|
5895
|
+
//the toast may hold the focus, on its own buttons: handing it
|
|
5503
5896
|
//back to the region keeps the reader somewhere rather than on <body>
|
|
5504
5897
|
if (item.contains(document.activeElement)) {
|
|
5505
5898
|
/** @type HTMLElement */ (this).focus();
|
|
@@ -5513,9 +5906,49 @@ class Toasts extends ParsedElement {
|
|
|
5513
5906
|
item.remove();
|
|
5514
5907
|
}
|
|
5515
5908
|
};
|
|
5909
|
+
if (options.action?.label) {
|
|
5910
|
+
const action = document.createElement('button');
|
|
5911
|
+
action.type = 'button';
|
|
5912
|
+
action.className = 'ful-toast-action';
|
|
5913
|
+
action.textContent = String(options.action.label);
|
|
5914
|
+
action.addEventListener('click', () => {
|
|
5915
|
+
options.action.onClick?.();
|
|
5916
|
+
retire();
|
|
5917
|
+
});
|
|
5918
|
+
item.append(action);
|
|
5919
|
+
}
|
|
5920
|
+
item.append(dismiss);
|
|
5921
|
+
item.addEventListener('animationend', () => {
|
|
5922
|
+
if (item.classList.contains('ful-toast-out')) {
|
|
5923
|
+
item.remove();
|
|
5924
|
+
}
|
|
5925
|
+
});
|
|
5926
|
+
//the pointer and the focus hold independently: a count rather than a
|
|
5927
|
+
//flag, so the pointer leaving while the focus stays keeps the hold
|
|
5928
|
+
let holds = 0;
|
|
5929
|
+
const hold = () => {
|
|
5930
|
+
if (holds === 0) {
|
|
5931
|
+
clearTimeout(timer);
|
|
5932
|
+
remaining = Math.max(0, remaining - (performance.now() - startedAt));
|
|
5933
|
+
}
|
|
5934
|
+
++holds;
|
|
5935
|
+
};
|
|
5936
|
+
const release = () => {
|
|
5937
|
+
if (holds === 0) {
|
|
5938
|
+
return;
|
|
5939
|
+
}
|
|
5940
|
+
if (--holds === 0) {
|
|
5941
|
+
startedAt = performance.now();
|
|
5942
|
+
timer = setTimeout(retire, remaining);
|
|
5943
|
+
}
|
|
5944
|
+
};
|
|
5945
|
+
item.addEventListener('mouseenter', hold);
|
|
5946
|
+
item.addEventListener('mouseleave', release);
|
|
5947
|
+
item.addEventListener('focusin', hold);
|
|
5948
|
+
item.addEventListener('focusout', release);
|
|
5516
5949
|
dismiss.addEventListener('click', retire);
|
|
5517
5950
|
this.append(item);
|
|
5518
|
-
setTimeout(retire,
|
|
5951
|
+
timer = setTimeout(retire, remaining);
|
|
5519
5952
|
return item;
|
|
5520
5953
|
}
|
|
5521
5954
|
}
|
|
@@ -5626,7 +6059,7 @@ class Tabs extends ParsedElement {
|
|
|
5626
6059
|
this.#active = index;
|
|
5627
6060
|
this.reflectTo('active', index);
|
|
5628
6061
|
if (this.rendered && index !== previous) {
|
|
5629
|
-
this.dispatchEvent(new CustomEvent('change', { detail: { active: index, previous } }));
|
|
6062
|
+
this.dispatchEvent(new CustomEvent('tabs:change', { detail: { active: index, previous } }));
|
|
5630
6063
|
}
|
|
5631
6064
|
if (this.#panels.length > 0 && (index !== previous || !this.rendered)) {
|
|
5632
6065
|
//the activation is the reader's own gesture: the chrome reports a
|
|
@@ -5847,6 +6280,8 @@ var en = {
|
|
|
5847
6280
|
'info.tooltip': 'More information',
|
|
5848
6281
|
'dialog.acknowledge': 'Got it',
|
|
5849
6282
|
'dialog.close': 'Close',
|
|
6283
|
+
'dialog.confirm': 'Confirm',
|
|
6284
|
+
'dialog.cancel': 'Cancel',
|
|
5850
6285
|
'drawer.close': 'Close',
|
|
5851
6286
|
'spinner.loading': 'Loading…',
|
|
5852
6287
|
'toast.region': 'Notifications',
|
|
@@ -5889,6 +6324,8 @@ var it = {
|
|
|
5889
6324
|
'info.tooltip': 'Maggiori informazioni',
|
|
5890
6325
|
'dialog.acknowledge': 'Ho capito',
|
|
5891
6326
|
'dialog.close': 'Chiudi',
|
|
6327
|
+
'dialog.confirm': 'Conferma',
|
|
6328
|
+
'dialog.cancel': 'Annulla',
|
|
5892
6329
|
'drawer.close': 'Chiudi',
|
|
5893
6330
|
'spinner.loading': 'Caricamento…',
|
|
5894
6331
|
'toast.region': 'Notifiche',
|
|
@@ -5931,6 +6368,8 @@ var es = {
|
|
|
5931
6368
|
'info.tooltip': 'Más información',
|
|
5932
6369
|
'dialog.acknowledge': 'Entendido',
|
|
5933
6370
|
'dialog.close': 'Cerrar',
|
|
6371
|
+
'dialog.confirm': 'Confirmar',
|
|
6372
|
+
'dialog.cancel': 'Cancelar',
|
|
5934
6373
|
'drawer.close': 'Cerrar',
|
|
5935
6374
|
'spinner.loading': 'Cargando…',
|
|
5936
6375
|
'toast.region': 'Notificaciones',
|
|
@@ -5976,6 +6415,8 @@ var fr = {
|
|
|
5976
6415
|
'info.tooltip': 'Plus d’informations',
|
|
5977
6416
|
'dialog.acknowledge': 'J’ai compris',
|
|
5978
6417
|
'dialog.close': 'Fermer',
|
|
6418
|
+
'dialog.confirm': 'Confirmer',
|
|
6419
|
+
'dialog.cancel': 'Annuler',
|
|
5979
6420
|
'drawer.close': 'Fermer',
|
|
5980
6421
|
'spinner.loading': 'Chargement…',
|
|
5981
6422
|
'toast.region': 'Notifications',
|
|
@@ -6016,7 +6457,7 @@ class Plugin {
|
|
|
6016
6457
|
const httpClient =
|
|
6017
6458
|
this.#httpClient ?? HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
|
|
6018
6459
|
//the fallback chain is baked here: en, the active language, the consumer's own strings
|
|
6019
|
-
const language = this.#language.split('-')[0];
|
|
6460
|
+
const language = this.#language.split('-')[0].toLowerCase();
|
|
6020
6461
|
const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
|
|
6021
6462
|
registry
|
|
6022
6463
|
.defineModule('l10n', Localization)
|