@optionfactory/fml 9.0.0-rc13 → 9.0.0-rc15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/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 +71 -10
- package/dist/fml.css +6 -5
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +658 -117
- 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 +6 -5
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +110 -8
- package/dist/ful.iife.js +655 -115
- package/dist/ful.iife.js.map +1 -1
- package/dist/ful.iife.min.js +1 -1
- package/dist/ful.iife.min.js.map +1 -1
- package/dist/ful.min.mjs +1 -1
- package/dist/ful.min.mjs.map +1 -1
- package/dist/ful.mjs +655 -116
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +37 -7
- package/dist/web-types.json +67 -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
|
+
queueMicrotask(() => {
|
|
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
|
|
@@ -2332,10 +2412,8 @@ class Anchors {
|
|
|
2332
2412
|
}
|
|
2333
2413
|
const anchored = { invoker, stretch };
|
|
2334
2414
|
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
2335
|
-
//placed before the showing, refined once laid out: the platform's
|
|
2336
|
-
//centered or corner spot never paints
|
|
2337
2415
|
if (evt.newState === 'open') {
|
|
2338
|
-
|
|
2416
|
+
showOncePlaced(popover, anchored);
|
|
2339
2417
|
}
|
|
2340
2418
|
});
|
|
2341
2419
|
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
@@ -2353,6 +2431,52 @@ class Anchors {
|
|
|
2353
2431
|
window.addEventListener('resize', schedule);
|
|
2354
2432
|
}
|
|
2355
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 and placed before the call returns, so the first
|
|
2447
|
+
* position anybody sees is the right one and the caller can move the focus
|
|
2448
|
+
* into it on the next line.
|
|
2449
|
+
*
|
|
2450
|
+
* @param {HTMLElement} popover the `[popover]` element to show, or an
|
|
2451
|
+
* already-shown element to place
|
|
2452
|
+
* @param {Element|DOMRect} anchor the element, or the rectangle, to place
|
|
2453
|
+
* beside
|
|
2454
|
+
* @param {object} [options]
|
|
2455
|
+
* @param {boolean} [options.flip] place above the anchor where no room is
|
|
2456
|
+
* left below
|
|
2457
|
+
* @param {number} [options.gap] the distance from the anchor, in pixels
|
|
2458
|
+
* @returns {HTMLElement} the popover
|
|
2459
|
+
*/
|
|
2460
|
+
static show(popover, anchor, { flip = false, gap = 0 } = {}) {
|
|
2461
|
+
const placeShown = () => {
|
|
2462
|
+
const box = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
|
|
2463
|
+
const viewport = document.documentElement;
|
|
2464
|
+
const here = popover.getBoundingClientRect();
|
|
2465
|
+
const below = box.bottom + gap;
|
|
2466
|
+
const above = box.top - gap - here.height;
|
|
2467
|
+
const top = flip && below + here.height > viewport.clientHeight - PAD && above >= PAD ? above : below;
|
|
2468
|
+
popover.style.right = 'auto';
|
|
2469
|
+
popover.style.bottom = 'auto';
|
|
2470
|
+
popover.style.margin = '0';
|
|
2471
|
+
popover.style.left = `${clamp(box.left, PAD, Math.max(PAD, viewport.clientWidth - here.width - PAD))}px`;
|
|
2472
|
+
popover.style.top = `${clamp(top, PAD, Math.max(PAD, viewport.clientHeight - here.height - PAD))}px`;
|
|
2473
|
+
};
|
|
2474
|
+
if (typeof popover.showPopover === 'function' && !popover.matches(':popover-open')) {
|
|
2475
|
+
popover.showPopover();
|
|
2476
|
+
}
|
|
2477
|
+
placeShown();
|
|
2478
|
+
return popover;
|
|
2479
|
+
}
|
|
2356
2480
|
}
|
|
2357
2481
|
|
|
2358
2482
|
/**
|
|
@@ -2642,6 +2766,11 @@ class Dropdown extends ParsedElement {
|
|
|
2642
2766
|
//from the host when it gave one, since it has to set aria-controls before
|
|
2643
2767
|
//this element upgrades
|
|
2644
2768
|
this.#menu.id = this.declared('listbox') || Attributes.uid('ful-listbox');
|
|
2769
|
+
this.#menu.addEventListener('mousedown', (evt) => {
|
|
2770
|
+
if (evt.target.closest('li')) {
|
|
2771
|
+
evt.preventDefault();
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2645
2774
|
this.#menu.addEventListener('click', (evt) => {
|
|
2646
2775
|
evt.stopPropagation();
|
|
2647
2776
|
const li = evt.target.closest('li');
|
|
@@ -2649,12 +2778,19 @@ class Dropdown extends ParsedElement {
|
|
|
2649
2778
|
this.hide();
|
|
2650
2779
|
return;
|
|
2651
2780
|
}
|
|
2781
|
+
if (li.matches('[aria-disabled="true"]')) {
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2652
2784
|
this.#change(li);
|
|
2653
2785
|
});
|
|
2654
2786
|
this.replaceChildren(fragment);
|
|
2655
2787
|
}
|
|
2656
2788
|
#selected() {
|
|
2657
|
-
return
|
|
2789
|
+
return (
|
|
2790
|
+
this.#menu?.querySelector('[selected]:not([aria-disabled="true"])') ??
|
|
2791
|
+
this.#menu?.querySelector('li:not([aria-disabled="true"])') ??
|
|
2792
|
+
null
|
|
2793
|
+
);
|
|
2658
2794
|
}
|
|
2659
2795
|
#highlight(li) {
|
|
2660
2796
|
if (!li) {
|
|
@@ -2686,11 +2822,24 @@ class Dropdown extends ParsedElement {
|
|
|
2686
2822
|
const data = values.map((entry, index) => ({ index, ...entry }));
|
|
2687
2823
|
this.#optionstemplate.withOverlay(data).renderTo(this.#menu);
|
|
2688
2824
|
for (const [index, li] of [...this.#menu.children].entries()) {
|
|
2689
|
-
const
|
|
2825
|
+
const entry = values[index];
|
|
2826
|
+
const picked = keys.some((r) => r == entry?.key);
|
|
2690
2827
|
li.toggleAttribute('picked', picked);
|
|
2691
2828
|
//what is picked is what aria-selected means for a listbox: a tint alone
|
|
2692
2829
|
//says it to whoever can see it and to no one else
|
|
2693
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
|
+
if (disabled) {
|
|
2834
|
+
li.setAttribute('aria-disabled', 'true');
|
|
2835
|
+
} else {
|
|
2836
|
+
li.removeAttribute('aria-disabled');
|
|
2837
|
+
}
|
|
2838
|
+
if (disabled && entry.metadata.reason) {
|
|
2839
|
+
li.setAttribute('title', String(entry.metadata.reason));
|
|
2840
|
+
} else {
|
|
2841
|
+
li.removeAttribute('title');
|
|
2842
|
+
}
|
|
2694
2843
|
}
|
|
2695
2844
|
this.#empty.toggleAttribute('hidden', values.length !== 0);
|
|
2696
2845
|
this.#menu.toggleAttribute('hidden', values.length === 0);
|
|
@@ -2764,15 +2913,25 @@ class Dropdown extends ParsedElement {
|
|
|
2764
2913
|
if (this.shown) {
|
|
2765
2914
|
const selected = this.#selected();
|
|
2766
2915
|
const candidate = selected?.[`${forward ? 'next' : 'previous'}ElementSibling`];
|
|
2767
|
-
|
|
2768
|
-
|
|
2916
|
+
const target = candidate ? this.#walk(candidate, forward) : null;
|
|
2917
|
+
if (selected && target) {
|
|
2918
|
+
this.#highlight(target);
|
|
2769
2919
|
}
|
|
2770
2920
|
return;
|
|
2771
2921
|
}
|
|
2772
2922
|
await this.show(loader, keys);
|
|
2773
2923
|
}
|
|
2924
|
+
#walk(from, forward) {
|
|
2925
|
+
for (let li = from; li; li = li[`${forward ? 'next' : 'previous'}ElementSibling`]) {
|
|
2926
|
+
if (!li.matches('[aria-disabled="true"]')) {
|
|
2927
|
+
return li;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
return null;
|
|
2931
|
+
}
|
|
2774
2932
|
jump(first) {
|
|
2775
|
-
const
|
|
2933
|
+
const edge = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
|
|
2934
|
+
const target = this.#walk(edge, first);
|
|
2776
2935
|
if (target) {
|
|
2777
2936
|
this.#highlight(target);
|
|
2778
2937
|
}
|
|
@@ -2782,9 +2941,14 @@ class Dropdown extends ParsedElement {
|
|
|
2782
2941
|
if (!selected) {
|
|
2783
2942
|
return;
|
|
2784
2943
|
}
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2944
|
+
let target = selected;
|
|
2945
|
+
for (let i = 0; i !== this.#page(); ++i) {
|
|
2946
|
+
const next = this.#walk(target[`${forward ? 'next' : 'previous'}ElementSibling`], forward);
|
|
2947
|
+
if (!next) {
|
|
2948
|
+
break;
|
|
2949
|
+
}
|
|
2950
|
+
target = next;
|
|
2951
|
+
}
|
|
2788
2952
|
this.#highlight(target);
|
|
2789
2953
|
}
|
|
2790
2954
|
#page() {
|
|
@@ -2820,6 +2984,12 @@ class Select extends Field {
|
|
|
2820
2984
|
//`set value` normalizes a list of one to a single key, and the getter answers a
|
|
2821
2985
|
//scalar for a single select, so nothing downstream has to know which it was
|
|
2822
2986
|
static observed = ['multiple:presence', 'item-list:presence', 'value:csv'];
|
|
2987
|
+
unmarshal(attr, str) {
|
|
2988
|
+
if (attr === 'value' && str === '' && !this.hasAttribute('multiple')) {
|
|
2989
|
+
return [''];
|
|
2990
|
+
}
|
|
2991
|
+
return super.unmarshal(attr, str);
|
|
2992
|
+
}
|
|
2823
2993
|
static slots = true;
|
|
2824
2994
|
//a manual popover: the combobox keeps the focus on its input and owns
|
|
2825
2995
|
//the whole lifecycle (typing, arrows, blur, Escape, Tab), so no light
|
|
@@ -3913,7 +4083,7 @@ class Table extends ParsedElement {
|
|
|
3913
4083
|
<tbody data-ref="empty" hidden>
|
|
3914
4084
|
<tr>
|
|
3915
4085
|
<td data-tpl-colspan="schema.length">
|
|
3916
|
-
<ful-empty data-tpl-if="slots.empty"
|
|
4086
|
+
<ful-empty data-tpl-if="slots.empty" data-ref="empty-authors"></ful-empty>
|
|
3917
4087
|
<ful-empty data-tpl-if="!slots.empty">
|
|
3918
4088
|
<ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
|
|
3919
4089
|
{{ #l10n:t('table.no-data') }}
|
|
@@ -3956,6 +4126,8 @@ class Table extends ParsedElement {
|
|
|
3956
4126
|
#loading;
|
|
3957
4127
|
#noAutoload;
|
|
3958
4128
|
#empty;
|
|
4129
|
+
#emptyAuthors;
|
|
4130
|
+
#emptyTemplate;
|
|
3959
4131
|
#feedback;
|
|
3960
4132
|
#paginator;
|
|
3961
4133
|
#sorters;
|
|
@@ -4007,6 +4179,9 @@ class Table extends ParsedElement {
|
|
|
4007
4179
|
this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
|
|
4008
4180
|
this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
|
|
4009
4181
|
this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
|
|
4182
|
+
this.#emptyAuthors = this.#empty.querySelector('ful-empty[data-ref=empty-authors]');
|
|
4183
|
+
this.#emptyTemplate =
|
|
4184
|
+
slots.empty && !Fragments.isBlank(slots.empty) ? Templates.fromFragment(slots.empty) : null;
|
|
4010
4185
|
this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
|
|
4011
4186
|
this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
|
|
4012
4187
|
this.replaceChildren(fragment);
|
|
@@ -4178,20 +4353,124 @@ class Table extends ParsedElement {
|
|
|
4178
4353
|
.render(),
|
|
4179
4354
|
);
|
|
4180
4355
|
this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
|
|
4356
|
+
if (this.#emptyTemplate && pageResponse.data.length === 0) {
|
|
4357
|
+
//rendered per load, the rows' own scope: the empty state can say
|
|
4358
|
+
//whether nothing was ever there or the filters matched nothing
|
|
4359
|
+
this.#emptyAuthors.replaceChildren(
|
|
4360
|
+
this.#emptyTemplate
|
|
4361
|
+
.withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
|
|
4362
|
+
.render(),
|
|
4363
|
+
);
|
|
4364
|
+
}
|
|
4181
4365
|
//one move, one repaint: the page and the count are the same state
|
|
4182
4366
|
this.#paginator.update({ current: pageRequest.page, total: pages });
|
|
4183
4367
|
}
|
|
4184
4368
|
}
|
|
4185
4369
|
|
|
4370
|
+
/** how long a type-ahead buffer stands before the next character starts a new search */
|
|
4371
|
+
const TYPEAHEAD_WINDOW = 500;
|
|
4372
|
+
|
|
4373
|
+
/**
|
|
4374
|
+
* The keyboard protocol of a menu popover: the focus moving into the menu as it
|
|
4375
|
+
* opens and back to the invoker as it closes, roving focus over the items with
|
|
4376
|
+
* the arrows, Home and End, type-ahead over their text, and Enter and Space
|
|
4377
|
+
* activating the focused one.
|
|
4378
|
+
*
|
|
4379
|
+
* The menu owns what an item is: `items` answers them in the order they are
|
|
4380
|
+
* walked, read on every gesture so a menu that fills itself later needs no
|
|
4381
|
+
* rewiring, and `current` names the one an opening focuses, the first otherwise.
|
|
4382
|
+
*
|
|
4383
|
+
* @param {HTMLElement} invoker the element the menu belongs to, and where the focus goes back
|
|
4384
|
+
* @param {HTMLElement} menu the `[popover]` holding the items
|
|
4385
|
+
* @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
|
|
4386
|
+
*/
|
|
4387
|
+
const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
|
|
4388
|
+
let typed = '';
|
|
4389
|
+
let typedAt = 0;
|
|
4390
|
+
menu.addEventListener('toggle', (/** @type any */ evt) => {
|
|
4391
|
+
if (evt.newState !== 'open') {
|
|
4392
|
+
//give the invoker back the focus the menu had borrowed, without
|
|
4393
|
+
//stealing it from wherever else the close came from
|
|
4394
|
+
if (menu.contains(document.activeElement)) {
|
|
4395
|
+
invoker.focus();
|
|
4396
|
+
}
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
const all = items();
|
|
4400
|
+
(current() ?? all[0])?.focus();
|
|
4401
|
+
});
|
|
4402
|
+
menu.addEventListener('keydown', (evt) => {
|
|
4403
|
+
const target = /** @type HTMLElement */ (evt.target);
|
|
4404
|
+
const all = items();
|
|
4405
|
+
const item = all.find((candidate) => candidate === target || candidate.contains(target));
|
|
4406
|
+
if (!item) {
|
|
4407
|
+
return;
|
|
4408
|
+
}
|
|
4409
|
+
const at = all.indexOf(item);
|
|
4410
|
+
switch (evt.code) {
|
|
4411
|
+
case 'ArrowDown': {
|
|
4412
|
+
evt.preventDefault();
|
|
4413
|
+
all[(at + 1) % all.length]?.focus();
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
case 'ArrowUp': {
|
|
4417
|
+
evt.preventDefault();
|
|
4418
|
+
all[(at - 1 + all.length) % all.length]?.focus();
|
|
4419
|
+
return;
|
|
4420
|
+
}
|
|
4421
|
+
case 'Home': {
|
|
4422
|
+
evt.preventDefault();
|
|
4423
|
+
all[0]?.focus();
|
|
4424
|
+
return;
|
|
4425
|
+
}
|
|
4426
|
+
case 'End': {
|
|
4427
|
+
evt.preventDefault();
|
|
4428
|
+
all[all.length - 1]?.focus();
|
|
4429
|
+
return;
|
|
4430
|
+
}
|
|
4431
|
+
case 'Enter':
|
|
4432
|
+
case 'Space': {
|
|
4433
|
+
evt.preventDefault();
|
|
4434
|
+
item.click();
|
|
4435
|
+
invoker.focus();
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
4438
|
+
case 'Escape': {
|
|
4439
|
+
//the platform's close request hides the menu, the focus is placed
|
|
4440
|
+
//on the invoker before the focused item is detached from it
|
|
4441
|
+
invoker.focus();
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
if (evt.key.length !== 1 || evt.ctrlKey || evt.altKey || evt.metaKey) {
|
|
4446
|
+
return;
|
|
4447
|
+
}
|
|
4448
|
+
const now = Date.now();
|
|
4449
|
+
typed = now - typedAt > TYPEAHEAD_WINDOW ? evt.key : typed + evt.key;
|
|
4450
|
+
typedAt = now;
|
|
4451
|
+
const wanted = typed.toLowerCase();
|
|
4452
|
+
const repeated = [...wanted].every((character) => character === wanted[0]);
|
|
4453
|
+
const search = repeated ? wanted[0] : wanted;
|
|
4454
|
+
const from = repeated ? at + 1 : at;
|
|
4455
|
+
const found = [...all.slice(from), ...all.slice(0, from)].find((candidate) =>
|
|
4456
|
+
(candidate.textContent ?? '').trim().toLowerCase().startsWith(search),
|
|
4457
|
+
);
|
|
4458
|
+
if (found) {
|
|
4459
|
+
evt.preventDefault();
|
|
4460
|
+
found.focus();
|
|
4461
|
+
}
|
|
4462
|
+
});
|
|
4463
|
+
};
|
|
4464
|
+
|
|
4186
4465
|
/**
|
|
4187
4466
|
* An invoker button paired with the `ul[popover][role=menu]` that follows it:
|
|
4188
4467
|
* the chrome behind every filter's operator, sensitivity and boolean value.
|
|
4189
4468
|
*
|
|
4190
4469
|
* It fills the menu from a vocabulary, wires it the first time more than one
|
|
4191
4470
|
* choice survives the whitelist, pins the button to a static glyph when a
|
|
4192
|
-
* single one does,
|
|
4193
|
-
*
|
|
4194
|
-
*
|
|
4471
|
+
* single one does, takes the shared menu keyboard protocol, and keeps the
|
|
4472
|
+
* button's value, glyph and aria-label in step. A pick that changes the value
|
|
4473
|
+
* calls back; the host decides what that means.
|
|
4195
4474
|
*
|
|
4196
4475
|
* The button's `value` attribute is the store, as it is for a native control:
|
|
4197
4476
|
* the menu protocol finds the current item by it, and nothing mirrors it.
|
|
@@ -4268,6 +4547,13 @@ class ChoiceButton {
|
|
|
4268
4547
|
get pinned() {
|
|
4269
4548
|
return this.#allowed.length < 2;
|
|
4270
4549
|
}
|
|
4550
|
+
/**
|
|
4551
|
+
* The host's fixed claim: the button and its chrome leave while the value
|
|
4552
|
+
* keeps answering, which is what a fixed operator asks of its glyph.
|
|
4553
|
+
*/
|
|
4554
|
+
set fixed(fixed) {
|
|
4555
|
+
this.#button.toggleAttribute('hidden', !!fixed);
|
|
4556
|
+
}
|
|
4271
4557
|
get value() {
|
|
4272
4558
|
return this.#button.getAttribute('value');
|
|
4273
4559
|
}
|
|
@@ -4342,64 +4628,10 @@ class ChoiceButton {
|
|
|
4342
4628
|
return Array.from(this.#menu.querySelectorAll('li > a'), (a) => /** @type HTMLAnchorElement */ (a));
|
|
4343
4629
|
}
|
|
4344
4630
|
#wire() {
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
if (evt.newState !== 'open') {
|
|
4350
|
-
//give the invoker back the focus the menu had borrowed, without
|
|
4351
|
-
//stealing it from wherever else the close came from
|
|
4352
|
-
if (menu.contains(document.activeElement)) {
|
|
4353
|
-
button.focus();
|
|
4354
|
-
}
|
|
4355
|
-
return;
|
|
4356
|
-
}
|
|
4357
|
-
const items = this.#items();
|
|
4358
|
-
(items.find((a) => a.getAttribute('value') === this.value) ?? items[0])?.focus();
|
|
4359
|
-
});
|
|
4360
|
-
menu.addEventListener('keydown', (evt) => {
|
|
4361
|
-
const target = /** @type HTMLElement */ (evt.target);
|
|
4362
|
-
const item = /** @type HTMLAnchorElement | null */ (target.closest('li > a'));
|
|
4363
|
-
if (!item) {
|
|
4364
|
-
return;
|
|
4365
|
-
}
|
|
4366
|
-
const items = this.#items();
|
|
4367
|
-
const at = items.indexOf(item);
|
|
4368
|
-
switch (evt.code) {
|
|
4369
|
-
case 'ArrowDown': {
|
|
4370
|
-
evt.preventDefault();
|
|
4371
|
-
items[(at + 1) % items.length]?.focus();
|
|
4372
|
-
break;
|
|
4373
|
-
}
|
|
4374
|
-
case 'ArrowUp': {
|
|
4375
|
-
evt.preventDefault();
|
|
4376
|
-
items[(at - 1 + items.length) % items.length]?.focus();
|
|
4377
|
-
break;
|
|
4378
|
-
}
|
|
4379
|
-
case 'Home': {
|
|
4380
|
-
evt.preventDefault();
|
|
4381
|
-
items[0]?.focus();
|
|
4382
|
-
break;
|
|
4383
|
-
}
|
|
4384
|
-
case 'End': {
|
|
4385
|
-
evt.preventDefault();
|
|
4386
|
-
items[items.length - 1]?.focus();
|
|
4387
|
-
break;
|
|
4388
|
-
}
|
|
4389
|
-
case 'Enter':
|
|
4390
|
-
case 'Space': {
|
|
4391
|
-
evt.preventDefault();
|
|
4392
|
-
item.click();
|
|
4393
|
-
button.focus();
|
|
4394
|
-
break;
|
|
4395
|
-
}
|
|
4396
|
-
case 'Escape': {
|
|
4397
|
-
//the platform's close request hides the menu, the focus is placed
|
|
4398
|
-
//on the invoker before the focused item is detached from it
|
|
4399
|
-
button.focus();
|
|
4400
|
-
break;
|
|
4401
|
-
}
|
|
4402
|
-
}
|
|
4631
|
+
Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
|
|
4632
|
+
wireMenuKeys(this.#button, this.#menu, {
|
|
4633
|
+
items: () => this.#items(),
|
|
4634
|
+
current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
|
|
4403
4635
|
});
|
|
4404
4636
|
}
|
|
4405
4637
|
}
|
|
@@ -4451,6 +4683,7 @@ const asCriterion = (label, operator, operands) => {
|
|
|
4451
4683
|
*/
|
|
4452
4684
|
class CompareFilter extends Input {
|
|
4453
4685
|
static observed = ['value:json', 'operators:csv'];
|
|
4686
|
+
static attributes = ['operator'];
|
|
4454
4687
|
static OPERATORS = COMPARE_OPERATORS;
|
|
4455
4688
|
static DEFAULT_OPERATOR = 'EQ';
|
|
4456
4689
|
static template = `
|
|
@@ -4501,11 +4734,38 @@ class CompareFilter extends Input {
|
|
|
4501
4734
|
if (this._operator.value === null) {
|
|
4502
4735
|
this._showDefaultOperator();
|
|
4503
4736
|
}
|
|
4737
|
+
this.#fixOperator();
|
|
4504
4738
|
//the second operand mirrors the claims like the first one does, and the
|
|
4505
4739
|
//freeze reaches the operator and sensitivity buttons, whose popovers an
|
|
4506
4740
|
//input's readOnly cannot touch
|
|
4507
4741
|
return { ...pieces, freeze: this._container, also: [this._value2] };
|
|
4508
4742
|
}
|
|
4743
|
+
/**
|
|
4744
|
+
* The singular `operator` fixes the operator for the element's life: not a
|
|
4745
|
+
* choice, so no menu and no button, every tuple carrying it. The plural is
|
|
4746
|
+
* the menu's whitelist; declaring both is a confused page and says so,
|
|
4747
|
+
* the singular winning, and an unknown name warns and pins the default.
|
|
4748
|
+
*/
|
|
4749
|
+
#fixOperator() {
|
|
4750
|
+
const fixed = this.declared('operator');
|
|
4751
|
+
if (fixed === null) {
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
if (this.hasAttribute('operators')) {
|
|
4755
|
+
console.warn(`${this.localName}: operator fixes what operators offers a menu for, the plural is ignored`, this);
|
|
4756
|
+
}
|
|
4757
|
+
const vocabulary = this._vocabulary();
|
|
4758
|
+
/** @type {string|null} */
|
|
4759
|
+
let preferred = null;
|
|
4760
|
+
if (vocabulary.includes(fixed)) {
|
|
4761
|
+
preferred = fixed;
|
|
4762
|
+
} else {
|
|
4763
|
+
console.warn(`${this.localName}: '${fixed}' is not one of its operators, the default is pinned instead`, this);
|
|
4764
|
+
preferred = this._defaultOperator();
|
|
4765
|
+
}
|
|
4766
|
+
this._operator.allowed = [preferred];
|
|
4767
|
+
this._operator.fixed = true;
|
|
4768
|
+
}
|
|
4509
4769
|
_showDefaultOperator() {
|
|
4510
4770
|
const preferred = this._defaultOperator();
|
|
4511
4771
|
const allowed = this._operator.allowed;
|
|
@@ -4542,6 +4802,9 @@ class CompareFilter extends Input {
|
|
|
4542
4802
|
return this._operator ? this._operator.allowed : this._declaredOperators;
|
|
4543
4803
|
}
|
|
4544
4804
|
set operators(declared) {
|
|
4805
|
+
if (this.declared('operator') !== null) {
|
|
4806
|
+
return;
|
|
4807
|
+
}
|
|
4545
4808
|
if (!this._operator) {
|
|
4546
4809
|
this._declaredOperators = ChoiceButton.narrow(declared, this._vocabulary());
|
|
4547
4810
|
return;
|
|
@@ -4645,6 +4908,7 @@ class NumberFilter extends CompareFilter {
|
|
|
4645
4908
|
/** The compare filter over text, carrying a case sensitivity beside the operator. */
|
|
4646
4909
|
class TextFilter extends CompareFilter {
|
|
4647
4910
|
static observed = ['sensitivities:csv'];
|
|
4911
|
+
static attributes = ['sensitivity'];
|
|
4648
4912
|
static template = `
|
|
4649
4913
|
<label>{{{{ slots.default }}}}</label>
|
|
4650
4914
|
{{{{ slots.info }}}}
|
|
@@ -4688,8 +4952,28 @@ class TextFilter extends CompareFilter {
|
|
|
4688
4952
|
);
|
|
4689
4953
|
this._sensitivityButton.allowed = null;
|
|
4690
4954
|
this._sensitivityButton.value = SENSITIVITIES[0];
|
|
4955
|
+
this.#fixSensitivity();
|
|
4691
4956
|
return pieces;
|
|
4692
4957
|
}
|
|
4958
|
+
#fixSensitivity() {
|
|
4959
|
+
const fixed = this.declared('sensitivity');
|
|
4960
|
+
if (fixed === null) {
|
|
4961
|
+
return;
|
|
4962
|
+
}
|
|
4963
|
+
if (this.hasAttribute('sensitivities')) {
|
|
4964
|
+
console.warn('ful-filter-text: sensitivity fixes what sensitivities offers a menu for, the plural is ignored', this);
|
|
4965
|
+
}
|
|
4966
|
+
/** @type {string|null} */
|
|
4967
|
+
let preferred = null;
|
|
4968
|
+
if (SENSITIVITIES.includes(fixed)) {
|
|
4969
|
+
preferred = fixed;
|
|
4970
|
+
} else {
|
|
4971
|
+
console.warn(`ful-filter-text: '${fixed}' is not one of its sensitivities, the default is pinned instead`, this);
|
|
4972
|
+
preferred = 'IGNORE_CASE';
|
|
4973
|
+
}
|
|
4974
|
+
this._sensitivityButton.allowed = [preferred];
|
|
4975
|
+
this._sensitivityButton.fixed = true;
|
|
4976
|
+
}
|
|
4693
4977
|
_choices() {
|
|
4694
4978
|
return [...super._choices(), this._sensitivityButton].filter((c) => c);
|
|
4695
4979
|
}
|
|
@@ -4701,6 +4985,9 @@ class TextFilter extends CompareFilter {
|
|
|
4701
4985
|
return this._sensitivityButton ? this._sensitivityButton.allowed : this._declaredSensitivities;
|
|
4702
4986
|
}
|
|
4703
4987
|
set sensitivities(declared) {
|
|
4988
|
+
if (this.declared('sensitivity') !== null) {
|
|
4989
|
+
return;
|
|
4990
|
+
}
|
|
4704
4991
|
if (!this._sensitivityButton) {
|
|
4705
4992
|
this._declaredSensitivities = ChoiceButton.narrow(declared, SENSITIVITIES);
|
|
4706
4993
|
return;
|
|
@@ -4830,6 +5117,15 @@ class BooleanFilter extends Field {
|
|
|
4830
5117
|
this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
|
|
4831
5118
|
this._value.value = v[1] ?? '';
|
|
4832
5119
|
}
|
|
5120
|
+
formResetCallback() {
|
|
5121
|
+
super.formResetCallback();
|
|
5122
|
+
if (!this.hasAttribute('value')) {
|
|
5123
|
+
const allowed = this._operator.allowed;
|
|
5124
|
+
this._operator.value = allowed.includes(BooleanFilter.DEFAULT_OPERATOR)
|
|
5125
|
+
? BooleanFilter.DEFAULT_OPERATOR
|
|
5126
|
+
: allowed[0];
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
4833
5129
|
/** @returns {FilterCriterion|null} */
|
|
4834
5130
|
get criterion() {
|
|
4835
5131
|
const token = this._value.value;
|
|
@@ -5126,6 +5422,15 @@ class Dialog extends ParsedElement {
|
|
|
5126
5422
|
this.#body = fragment.querySelector('[data-ref=body]');
|
|
5127
5423
|
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
5128
5424
|
this.#error = fragment.querySelector('[data-ref=error]');
|
|
5425
|
+
//a dialog is a named surface: a reader announces the heading on opening,
|
|
5426
|
+
//which it cannot do unless the heading names the dialog. The name travels
|
|
5427
|
+
//as a reference rather than as aria-label text, so it stays the heading's
|
|
5428
|
+
//own however it is reworded
|
|
5429
|
+
const heading = fragment.querySelector('h2');
|
|
5430
|
+
if (heading) {
|
|
5431
|
+
heading.id ||= Attributes.uid('ful-dialog-title');
|
|
5432
|
+
this.#dialog.setAttribute('aria-labelledby', heading.id);
|
|
5433
|
+
}
|
|
5129
5434
|
this.#dialog.addEventListener('close', () => {
|
|
5130
5435
|
const outcome = this.#outcome();
|
|
5131
5436
|
this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
|
|
@@ -5293,6 +5598,87 @@ class Dialog extends ParsedElement {
|
|
|
5293
5598
|
this.#loading?.setAttribute('hidden', '');
|
|
5294
5599
|
this.#body?.removeAttribute('hidden');
|
|
5295
5600
|
}
|
|
5601
|
+
/**
|
|
5602
|
+
* Asks a question in a dialog of the page's own, resolving with its outcome
|
|
5603
|
+
* and removing the element afterwards: the element stays the author's when
|
|
5604
|
+
* the question lives in the page, while a question asked from a button had
|
|
5605
|
+
* to build, render, show, answer and clean up by hand.
|
|
5606
|
+
*
|
|
5607
|
+
* The body is text or a node: a string is never read as markup, an
|
|
5608
|
+
* `innerHTML` sink being a thing the library does not carry. Each button is
|
|
5609
|
+
* a `[result, label, className?]` tuple rendered as its answer, or a node
|
|
5610
|
+
* the caller has built itself, which is how a button carries anything a
|
|
5611
|
+
* tuple cannot say and how a footer gets a spacer.
|
|
5612
|
+
* @param {string} header
|
|
5613
|
+
* @param {string|Node} body
|
|
5614
|
+
* @param {([string, string, string?]|Node)[]} [buttons]
|
|
5615
|
+
* @param {{ className?: string }} [options] `className` dresses the dialog,
|
|
5616
|
+
* the way a declared `ful-dialog` is given one to size it
|
|
5617
|
+
* @returns {Promise<DialogOutcome>}
|
|
5618
|
+
*/
|
|
5619
|
+
static async ask(header, body, buttons = [], { className } = {}) {
|
|
5620
|
+
const dialog = document.createElement('ful-dialog');
|
|
5621
|
+
dialog.setAttribute('header', header);
|
|
5622
|
+
if (className) {
|
|
5623
|
+
dialog.className = className;
|
|
5624
|
+
}
|
|
5625
|
+
if (typeof body === 'string') {
|
|
5626
|
+
dialog.textContent = body;
|
|
5627
|
+
} else if (body) {
|
|
5628
|
+
dialog.append(body);
|
|
5629
|
+
}
|
|
5630
|
+
//buttons are slotted only when there are any: the acknowledge button
|
|
5631
|
+
//is the answer of a question asked without choices of its own
|
|
5632
|
+
if (buttons.length > 0) {
|
|
5633
|
+
const choices = document.createElement('template');
|
|
5634
|
+
choices.setAttribute('slot', 'buttons');
|
|
5635
|
+
for (const choice of buttons) {
|
|
5636
|
+
if (choice instanceof Node) {
|
|
5637
|
+
choices.content.append(choice);
|
|
5638
|
+
continue;
|
|
5639
|
+
}
|
|
5640
|
+
const [result, label, buttonClass] = choice;
|
|
5641
|
+
const button = document.createElement('button');
|
|
5642
|
+
button.type = 'button';
|
|
5643
|
+
button.dataset.result = result;
|
|
5644
|
+
if (buttonClass) {
|
|
5645
|
+
button.className = buttonClass;
|
|
5646
|
+
}
|
|
5647
|
+
button.textContent = label;
|
|
5648
|
+
choices.content.append(button);
|
|
5649
|
+
}
|
|
5650
|
+
dialog.append(choices);
|
|
5651
|
+
}
|
|
5652
|
+
document.body.append(dialog);
|
|
5653
|
+
try {
|
|
5654
|
+
await Rendering.waitFor(dialog);
|
|
5655
|
+
return await /** @type {Dialog} */ (dialog).ask();
|
|
5656
|
+
} finally {
|
|
5657
|
+
dialog.remove();
|
|
5658
|
+
}
|
|
5659
|
+
}
|
|
5660
|
+
/**
|
|
5661
|
+
* Asks a yes/no question, resolving with the confirm button's answer:
|
|
5662
|
+
* the cancel button, the close button and Escape all answer false, a
|
|
5663
|
+
* dismissal being the negative.
|
|
5664
|
+
* @param {string} header
|
|
5665
|
+
* @param {string|Node} body
|
|
5666
|
+
* @param {{ confirm?: string, cancel?: string }} [labels] overriding the localized defaults
|
|
5667
|
+
* @param {{ className?: string }} [options] as `ask` takes them
|
|
5668
|
+
* @returns {Promise<boolean>}
|
|
5669
|
+
*/
|
|
5670
|
+
static async confirm(header, body, labels = {}, options = {}) {
|
|
5671
|
+
const outcome = await Dialog.ask(
|
|
5672
|
+
header,
|
|
5673
|
+
body,
|
|
5674
|
+
[
|
|
5675
|
+
['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
|
|
5676
|
+
['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
|
|
5677
|
+
],
|
|
5678
|
+
options,
|
|
5679
|
+
);
|
|
5680
|
+
return !outcome.dismissed && outcome.result === 'confirm';
|
|
5681
|
+
}
|
|
5296
5682
|
}
|
|
5297
5683
|
|
|
5298
5684
|
/**
|
|
@@ -5305,7 +5691,7 @@ class Dialog extends ParsedElement {
|
|
|
5305
5691
|
* it.
|
|
5306
5692
|
*/
|
|
5307
5693
|
class Drawer extends ParsedElement {
|
|
5308
|
-
static attributes = ['
|
|
5694
|
+
static attributes = ['header', 'placement', 'close-on-submit:presence'];
|
|
5309
5695
|
static slots = true;
|
|
5310
5696
|
static template = `
|
|
5311
5697
|
<dialog data-ref="dialog" class="ful-drawer">
|
|
@@ -5330,12 +5716,23 @@ class Drawer extends ParsedElement {
|
|
|
5330
5716
|
//a save answering with no body at all is still a save
|
|
5331
5717
|
/** @type {{ dismissed: boolean, response: any }|null} */
|
|
5332
5718
|
#answer = null;
|
|
5719
|
+
#closing = false;
|
|
5720
|
+
#slideOutEnded = (/** @type AnimationEvent */ e) => {
|
|
5721
|
+
if (e.target === this.#dialog) {
|
|
5722
|
+
this.#closeNow();
|
|
5723
|
+
}
|
|
5724
|
+
};
|
|
5333
5725
|
render({ slots }) {
|
|
5334
5726
|
const fragment = this.template()
|
|
5335
|
-
.withOverlay({ slots, title: this.declared('
|
|
5727
|
+
.withOverlay({ slots, title: this.declared('header') ?? '' })
|
|
5336
5728
|
.render();
|
|
5337
5729
|
this.#dialog = fragment.querySelector('[data-ref=dialog]');
|
|
5338
5730
|
this.#title = fragment.querySelector('[data-ref=title]');
|
|
5731
|
+
//a named surface, like a dialog: the heading announces the drawer on
|
|
5732
|
+
//opening only where it names it, and the reference survives every
|
|
5733
|
+
//textContent update() writes into the heading
|
|
5734
|
+
this.#title.id ||= Attributes.uid('ful-drawer-title');
|
|
5735
|
+
this.#dialog.setAttribute('aria-labelledby', this.#title.id);
|
|
5339
5736
|
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
5340
5737
|
this.#error = fragment.querySelector('[data-ref=error]');
|
|
5341
5738
|
this.#content = fragment.querySelector('[data-ref=content]');
|
|
@@ -5353,6 +5750,10 @@ class Drawer extends ParsedElement {
|
|
|
5353
5750
|
this.close();
|
|
5354
5751
|
}
|
|
5355
5752
|
});
|
|
5753
|
+
this.#dialog.addEventListener('cancel', (e) => {
|
|
5754
|
+
e.preventDefault();
|
|
5755
|
+
this.close();
|
|
5756
|
+
});
|
|
5356
5757
|
this.#dialog.addEventListener('close', () => {
|
|
5357
5758
|
this.dispatchEvent(
|
|
5358
5759
|
new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
|
|
@@ -5376,23 +5777,23 @@ class Drawer extends ParsedElement {
|
|
|
5376
5777
|
this.replaceChildren(fragment);
|
|
5377
5778
|
wireTargets();
|
|
5378
5779
|
}
|
|
5379
|
-
get
|
|
5780
|
+
get header() {
|
|
5380
5781
|
return this.#title.textContent;
|
|
5381
5782
|
}
|
|
5382
|
-
set
|
|
5783
|
+
set header(v) {
|
|
5383
5784
|
this.#title.textContent = v ?? '';
|
|
5384
5785
|
}
|
|
5385
5786
|
/**
|
|
5386
|
-
* Opens the drawer under the given
|
|
5787
|
+
* Opens the drawer under the given header and waits for the callback: a
|
|
5387
5788
|
* resolved value paints the content section (which is returned), a
|
|
5388
5789
|
* rejection paints the problems and travels to the caller, and an update
|
|
5389
5790
|
* superseded by a newer one paints nothing.
|
|
5390
5791
|
*/
|
|
5391
|
-
async update(
|
|
5792
|
+
async update(header, cb) {
|
|
5392
5793
|
//the claim detaches any update still in flight: its outcome belongs to
|
|
5393
5794
|
//an abandoned opening and must neither be painted nor own the drawer
|
|
5394
5795
|
const claim = this.#updates.take();
|
|
5395
|
-
this.
|
|
5796
|
+
this.header = header;
|
|
5396
5797
|
this.#content.replaceChildren();
|
|
5397
5798
|
this.#restChrome();
|
|
5398
5799
|
this.#loading.removeAttribute('hidden');
|
|
@@ -5438,10 +5839,33 @@ class Drawer extends ParsedElement {
|
|
|
5438
5839
|
this.#requests.request(this, this.#content, null, null)?.catch(() => undefined);
|
|
5439
5840
|
}
|
|
5440
5841
|
close() {
|
|
5842
|
+
if (this.#closing || !this.#dialog.open) {
|
|
5843
|
+
return;
|
|
5844
|
+
}
|
|
5845
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
5846
|
+
this.#dialog.close();
|
|
5847
|
+
return;
|
|
5848
|
+
}
|
|
5849
|
+
this.#dialog.setAttribute('closing', '');
|
|
5850
|
+
if (this.#dialog.getAnimations().length === 0) {
|
|
5851
|
+
this.#closeNow();
|
|
5852
|
+
return;
|
|
5853
|
+
}
|
|
5854
|
+
this.#closing = true;
|
|
5855
|
+
this.#dialog.addEventListener('animationend', this.#slideOutEnded);
|
|
5856
|
+
}
|
|
5857
|
+
#closeNow() {
|
|
5858
|
+
this.#stopSlidingOut();
|
|
5441
5859
|
this.#dialog.close();
|
|
5442
5860
|
}
|
|
5861
|
+
#stopSlidingOut() {
|
|
5862
|
+
this.#closing = false;
|
|
5863
|
+
this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
|
|
5864
|
+
this.#dialog.removeAttribute('closing');
|
|
5865
|
+
}
|
|
5443
5866
|
/** Shows the modal, answering whether this call is the one that opened it. */
|
|
5444
5867
|
#show() {
|
|
5868
|
+
this.#stopSlidingOut();
|
|
5445
5869
|
if (this.#dialog.open) {
|
|
5446
5870
|
return false;
|
|
5447
5871
|
}
|
|
@@ -5459,6 +5883,65 @@ class Drawer extends ParsedElement {
|
|
|
5459
5883
|
}
|
|
5460
5884
|
}
|
|
5461
5885
|
|
|
5886
|
+
/**
|
|
5887
|
+
* A menu of commands on the native popover platform: the invoker named by
|
|
5888
|
+
* `for` toggles it and light dismiss and Escape come from the browser, while
|
|
5889
|
+
* the element adds the menu semantics and the keyboard protocol.
|
|
5890
|
+
*
|
|
5891
|
+
* The items are the buttons and links written inside it, kept as they are and
|
|
5892
|
+
* given their menu role and their place in the roving focus. Activation is the
|
|
5893
|
+
* item's own click, which the element only follows by closing.
|
|
5894
|
+
*/
|
|
5895
|
+
class Menu extends ParsedElement {
|
|
5896
|
+
static attributes = ['for'];
|
|
5897
|
+
render() {
|
|
5898
|
+
const named = this.declared('for');
|
|
5899
|
+
const invoker = named ? document.getElementById(named) : null;
|
|
5900
|
+
if (!invoker) {
|
|
5901
|
+
throw new Error(
|
|
5902
|
+
`ful-menu names no invoker: for="${named ?? ''}" must name an element already in the page when the menu upgrades`,
|
|
5903
|
+
);
|
|
5904
|
+
}
|
|
5905
|
+
this.setAttribute('role', 'menu');
|
|
5906
|
+
if (!this.hasAttribute('popover')) {
|
|
5907
|
+
this.setAttribute('popover', '');
|
|
5908
|
+
}
|
|
5909
|
+
invoker.setAttribute('aria-haspopup', 'menu');
|
|
5910
|
+
Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
|
|
5911
|
+
wireMenuKeys(invoker, this, { items: () => this.items });
|
|
5912
|
+
this.#mark();
|
|
5913
|
+
this.addEventListener('click', (evt) => {
|
|
5914
|
+
const target = /** @type HTMLElement */ (evt.target);
|
|
5915
|
+
if (this.items.some((item) => item === target || item.contains(target))) {
|
|
5916
|
+
this.hidePopover();
|
|
5917
|
+
}
|
|
5918
|
+
});
|
|
5919
|
+
}
|
|
5920
|
+
/**
|
|
5921
|
+
* The commands, in the order the arrows walk them: the menu's own buttons
|
|
5922
|
+
* and links, minus the disabled and hidden ones, which keep their role and
|
|
5923
|
+
* leave the walk.
|
|
5924
|
+
*/
|
|
5925
|
+
get items() {
|
|
5926
|
+
return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
|
|
5927
|
+
}
|
|
5928
|
+
/**
|
|
5929
|
+
* Gives every button and link of the menu its item role and takes it out of
|
|
5930
|
+
* the tab order, and answers them. Read on every gesture as well as at the
|
|
5931
|
+
* render, so a menu a page fills or empties later needs no rewiring.
|
|
5932
|
+
*/
|
|
5933
|
+
#mark() {
|
|
5934
|
+
const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);
|
|
5935
|
+
for (const item of all) {
|
|
5936
|
+
if (!item.hasAttribute('role')) {
|
|
5937
|
+
item.setAttribute('role', 'menuitem');
|
|
5938
|
+
}
|
|
5939
|
+
item.tabIndex = -1;
|
|
5940
|
+
}
|
|
5941
|
+
return all;
|
|
5942
|
+
}
|
|
5943
|
+
}
|
|
5944
|
+
|
|
5462
5945
|
const SEVERITIES = ['info', 'success', 'warning', 'error'];
|
|
5463
5946
|
|
|
5464
5947
|
//the regions alive in the document: the show-toast listener is wired once and
|
|
@@ -5488,6 +5971,9 @@ class Toasts extends ParsedElement {
|
|
|
5488
5971
|
if (!listenerWired) {
|
|
5489
5972
|
listenerWired = true;
|
|
5490
5973
|
document.addEventListener('show-toast', (/** @type any */ e) => {
|
|
5974
|
+
if (!e.detail) {
|
|
5975
|
+
return;
|
|
5976
|
+
}
|
|
5491
5977
|
for (const region of REGIONS) {
|
|
5492
5978
|
region.show(e.detail.message, e.detail);
|
|
5493
5979
|
}
|
|
@@ -5499,8 +5985,13 @@ class Toasts extends ParsedElement {
|
|
|
5499
5985
|
* Appends a toast carrying the message (a Failure shows its problems'
|
|
5500
5986
|
* reasons, one per line), severity picking the theme and the announcement,
|
|
5501
5987
|
* the toast retiring through its own timer or its dismiss button.
|
|
5988
|
+
*
|
|
5989
|
+
* The timer holds while the toast is hovered or holds the focus, so an
|
|
5990
|
+
* actionable toast waits for its reader: `action` is `{ label, onClick }`,
|
|
5991
|
+
* a button beside the message whose click answers and retires. The same
|
|
5992
|
+
* options travel in the `show-toast` event's detail.
|
|
5502
5993
|
* @param {any} message
|
|
5503
|
-
* @param {any} [options] severity and
|
|
5994
|
+
* @param {any} [options] severity, timeout and action
|
|
5504
5995
|
* @returns {HTMLElement}
|
|
5505
5996
|
*/
|
|
5506
5997
|
show(message, options = {}) {
|
|
@@ -5517,14 +6008,13 @@ class Toasts extends ParsedElement {
|
|
|
5517
6008
|
icon.setAttribute('name', 'x-lg');
|
|
5518
6009
|
icon.setAttribute('aria-hidden', 'true');
|
|
5519
6010
|
dismiss.append(icon);
|
|
5520
|
-
item.append(body
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
}
|
|
5525
|
-
});
|
|
6011
|
+
item.append(body);
|
|
6012
|
+
let remaining = options.timeout ?? this.#timeout;
|
|
6013
|
+
let startedAt = performance.now();
|
|
6014
|
+
let timer = 0;
|
|
5526
6015
|
const retire = () => {
|
|
5527
|
-
|
|
6016
|
+
clearTimeout(timer);
|
|
6017
|
+
//the toast may hold the focus, on its own buttons: handing it
|
|
5528
6018
|
//back to the region keeps the reader somewhere rather than on <body>
|
|
5529
6019
|
if (item.contains(document.activeElement)) {
|
|
5530
6020
|
/** @type HTMLElement */ (this).focus();
|
|
@@ -5538,9 +6028,49 @@ class Toasts extends ParsedElement {
|
|
|
5538
6028
|
item.remove();
|
|
5539
6029
|
}
|
|
5540
6030
|
};
|
|
6031
|
+
if (options.action?.label) {
|
|
6032
|
+
const action = document.createElement('button');
|
|
6033
|
+
action.type = 'button';
|
|
6034
|
+
action.className = 'ful-toast-action';
|
|
6035
|
+
action.textContent = String(options.action.label);
|
|
6036
|
+
action.addEventListener('click', () => {
|
|
6037
|
+
options.action.onClick?.();
|
|
6038
|
+
retire();
|
|
6039
|
+
});
|
|
6040
|
+
item.append(action);
|
|
6041
|
+
}
|
|
6042
|
+
item.append(dismiss);
|
|
6043
|
+
item.addEventListener('animationend', () => {
|
|
6044
|
+
if (item.classList.contains('ful-toast-out')) {
|
|
6045
|
+
item.remove();
|
|
6046
|
+
}
|
|
6047
|
+
});
|
|
6048
|
+
//the pointer and the focus hold independently: a count rather than a
|
|
6049
|
+
//flag, so the pointer leaving while the focus stays keeps the hold
|
|
6050
|
+
let holds = 0;
|
|
6051
|
+
const hold = () => {
|
|
6052
|
+
if (holds === 0) {
|
|
6053
|
+
clearTimeout(timer);
|
|
6054
|
+
remaining = Math.max(0, remaining - (performance.now() - startedAt));
|
|
6055
|
+
}
|
|
6056
|
+
++holds;
|
|
6057
|
+
};
|
|
6058
|
+
const release = () => {
|
|
6059
|
+
if (holds === 0) {
|
|
6060
|
+
return;
|
|
6061
|
+
}
|
|
6062
|
+
if (--holds === 0) {
|
|
6063
|
+
startedAt = performance.now();
|
|
6064
|
+
timer = setTimeout(retire, remaining);
|
|
6065
|
+
}
|
|
6066
|
+
};
|
|
6067
|
+
item.addEventListener('mouseenter', hold);
|
|
6068
|
+
item.addEventListener('mouseleave', release);
|
|
6069
|
+
item.addEventListener('focusin', hold);
|
|
6070
|
+
item.addEventListener('focusout', release);
|
|
5541
6071
|
dismiss.addEventListener('click', retire);
|
|
5542
6072
|
this.append(item);
|
|
5543
|
-
setTimeout(retire,
|
|
6073
|
+
timer = setTimeout(retire, remaining);
|
|
5544
6074
|
return item;
|
|
5545
6075
|
}
|
|
5546
6076
|
}
|
|
@@ -5651,7 +6181,7 @@ class Tabs extends ParsedElement {
|
|
|
5651
6181
|
this.#active = index;
|
|
5652
6182
|
this.reflectTo('active', index);
|
|
5653
6183
|
if (this.rendered && index !== previous) {
|
|
5654
|
-
this.dispatchEvent(new CustomEvent('change', { detail: { active: index, previous } }));
|
|
6184
|
+
this.dispatchEvent(new CustomEvent('tabs:change', { detail: { active: index, previous } }));
|
|
5655
6185
|
}
|
|
5656
6186
|
if (this.#panels.length > 0 && (index !== previous || !this.rendered)) {
|
|
5657
6187
|
//the activation is the reader's own gesture: the chrome reports a
|
|
@@ -5872,6 +6402,8 @@ var en = {
|
|
|
5872
6402
|
'info.tooltip': 'More information',
|
|
5873
6403
|
'dialog.acknowledge': 'Got it',
|
|
5874
6404
|
'dialog.close': 'Close',
|
|
6405
|
+
'dialog.confirm': 'Confirm',
|
|
6406
|
+
'dialog.cancel': 'Cancel',
|
|
5875
6407
|
'drawer.close': 'Close',
|
|
5876
6408
|
'spinner.loading': 'Loading…',
|
|
5877
6409
|
'toast.region': 'Notifications',
|
|
@@ -5914,6 +6446,8 @@ var it = {
|
|
|
5914
6446
|
'info.tooltip': 'Maggiori informazioni',
|
|
5915
6447
|
'dialog.acknowledge': 'Ho capito',
|
|
5916
6448
|
'dialog.close': 'Chiudi',
|
|
6449
|
+
'dialog.confirm': 'Conferma',
|
|
6450
|
+
'dialog.cancel': 'Annulla',
|
|
5917
6451
|
'drawer.close': 'Chiudi',
|
|
5918
6452
|
'spinner.loading': 'Caricamento…',
|
|
5919
6453
|
'toast.region': 'Notifiche',
|
|
@@ -5956,6 +6490,8 @@ var es = {
|
|
|
5956
6490
|
'info.tooltip': 'Más información',
|
|
5957
6491
|
'dialog.acknowledge': 'Entendido',
|
|
5958
6492
|
'dialog.close': 'Cerrar',
|
|
6493
|
+
'dialog.confirm': 'Confirmar',
|
|
6494
|
+
'dialog.cancel': 'Cancelar',
|
|
5959
6495
|
'drawer.close': 'Cerrar',
|
|
5960
6496
|
'spinner.loading': 'Cargando…',
|
|
5961
6497
|
'toast.region': 'Notificaciones',
|
|
@@ -6001,6 +6537,8 @@ var fr = {
|
|
|
6001
6537
|
'info.tooltip': 'Plus d’informations',
|
|
6002
6538
|
'dialog.acknowledge': 'J’ai compris',
|
|
6003
6539
|
'dialog.close': 'Fermer',
|
|
6540
|
+
'dialog.confirm': 'Confirmer',
|
|
6541
|
+
'dialog.cancel': 'Annuler',
|
|
6004
6542
|
'drawer.close': 'Fermer',
|
|
6005
6543
|
'spinner.loading': 'Chargement…',
|
|
6006
6544
|
'toast.region': 'Notifications',
|
|
@@ -6041,7 +6579,7 @@ class Plugin {
|
|
|
6041
6579
|
const httpClient =
|
|
6042
6580
|
this.#httpClient ?? HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
|
|
6043
6581
|
//the fallback chain is baked here: en, the active language, the consumer's own strings
|
|
6044
|
-
const language = this.#language.split('-')[0];
|
|
6582
|
+
const language = this.#language.split('-')[0].toLowerCase();
|
|
6045
6583
|
const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
|
|
6046
6584
|
registry
|
|
6047
6585
|
.defineModule('l10n', Localization)
|
|
@@ -6049,6 +6587,7 @@ class Plugin {
|
|
|
6049
6587
|
.defineElement('ful-tooltip', Tooltip)
|
|
6050
6588
|
.defineElement('ful-dialog', Dialog)
|
|
6051
6589
|
.defineElement('ful-drawer', Drawer)
|
|
6590
|
+
.defineElement('ful-menu', Menu)
|
|
6052
6591
|
.defineElement('ful-toasts', Toasts)
|
|
6053
6592
|
.defineElement('ful-tabs', Tabs)
|
|
6054
6593
|
.defineElement('ful-accordion', Accordion)
|
|
@@ -6088,5 +6627,5 @@ class Plugin {
|
|
|
6088
6627
|
}
|
|
6089
6628
|
}
|
|
6090
6629
|
|
|
6091
|
-
export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
|
|
6630
|
+
export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, Menu, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
|
|
6092
6631
|
//# sourceMappingURL=ful.mjs.map
|