@optionfactory/fml 9.0.0-rc13 → 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/dist/ful.mjs CHANGED
@@ -560,30 +560,62 @@ class Bindings {
560
560
  }
561
561
 
562
562
  static mutateIn(form, values) {
563
- const names = Array.from(form.elements)
564
- .map((el) => el.getAttribute('name'))
565
- .filter((n) => n);
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.querySelectorAll('ful-field-error').forEach((el) => {
578
- el.setAttribute('aria-live', scrollOnError ? 'off' : 'polite');
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.querySelectorAll(`[name]`).forEach((el) => {
584
- el.setCustomValidity?.('');
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 = form.querySelectorAll(`[name='${CSS.escape(prefix)}']`);
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.querySelectorAll(`:invalid`))
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.internals.form || !Field.#submitsOnEnter(target)) {
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?.focus(options);
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.internals.form;
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
- const width = Math.min(box.width, vw - 2 * PAD);
2181
- popover.style.width = `${width}px`;
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
- popover.style.top = `${clamp(box.bottom + gap.top, PAD, vh - height - PAD)}px`;
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 its invoker, which
2288
- * is what a combobox dropdown wants
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
- place(popover, anchored);
2416
+ showOncePlaced(popover, anchored);
2339
2417
  }
2340
2418
  });
2341
2419
  popover.addEventListener('toggle', (/** @type any */ evt) => {
@@ -2353,6 +2431,57 @@ 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 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
+ }
2356
2485
  }
2357
2486
 
2358
2487
  /**
@@ -2649,12 +2778,19 @@ class Dropdown extends ParsedElement {
2649
2778
  this.hide();
2650
2779
  return;
2651
2780
  }
2781
+ if (li.hasAttribute('aria-disabled')) {
2782
+ return;
2783
+ }
2652
2784
  this.#change(li);
2653
2785
  });
2654
2786
  this.replaceChildren(fragment);
2655
2787
  }
2656
2788
  #selected() {
2657
- return this.#menu?.querySelector('[selected]') ?? this.#menu?.firstElementChild ?? null;
2789
+ return (
2790
+ this.#menu?.querySelector('[selected]:not([aria-disabled])') ??
2791
+ this.#menu?.querySelector('li:not([aria-disabled])') ??
2792
+ null
2793
+ );
2658
2794
  }
2659
2795
  #highlight(li) {
2660
2796
  if (!li) {
@@ -2686,11 +2822,20 @@ 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 picked = keys.some((r) => r == values[index]?.key);
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
+ 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
+ }
2694
2839
  }
2695
2840
  this.#empty.toggleAttribute('hidden', values.length !== 0);
2696
2841
  this.#menu.toggleAttribute('hidden', values.length === 0);
@@ -2764,15 +2909,25 @@ class Dropdown extends ParsedElement {
2764
2909
  if (this.shown) {
2765
2910
  const selected = this.#selected();
2766
2911
  const candidate = selected?.[`${forward ? 'next' : 'previous'}ElementSibling`];
2767
- if (selected && candidate) {
2768
- this.#highlight(candidate);
2912
+ const target = candidate ? this.#walk(candidate, forward) : null;
2913
+ if (selected && target) {
2914
+ this.#highlight(target);
2769
2915
  }
2770
2916
  return;
2771
2917
  }
2772
2918
  await this.show(loader, keys);
2773
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
+ }
2774
2928
  jump(first) {
2775
- const target = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
2929
+ const edge = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
2930
+ const target = this.#walk(edge, first);
2776
2931
  if (target) {
2777
2932
  this.#highlight(target);
2778
2933
  }
@@ -2782,9 +2937,14 @@ class Dropdown extends ParsedElement {
2782
2937
  if (!selected) {
2783
2938
  return;
2784
2939
  }
2785
- const lis = Array.from(this.#menu.children);
2786
- const step = this.#page();
2787
- const target = lis[Math.max(0, Math.min(lis.length - 1, lis.indexOf(selected) + (forward ? step : -step)))];
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
+ }
2788
2948
  this.#highlight(target);
2789
2949
  }
2790
2950
  #page() {
@@ -2820,6 +2980,12 @@ class Select extends Field {
2820
2980
  //`set value` normalizes a list of one to a single key, and the getter answers a
2821
2981
  //scalar for a single select, so nothing downstream has to know which it was
2822
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
+ }
2823
2989
  static slots = true;
2824
2990
  //a manual popover: the combobox keeps the focus on its input and owns
2825
2991
  //the whole lifecycle (typing, arrows, blur, Escape, Tab), so no light
@@ -3913,7 +4079,7 @@ class Table extends ParsedElement {
3913
4079
  <tbody data-ref="empty" hidden>
3914
4080
  <tr>
3915
4081
  <td data-tpl-colspan="schema.length">
3916
- <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
4082
+ <ful-empty data-tpl-if="slots.empty" data-ref="empty-authors"></ful-empty>
3917
4083
  <ful-empty data-tpl-if="!slots.empty">
3918
4084
  <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
3919
4085
  {{ #l10n:t('table.no-data') }}
@@ -3956,6 +4122,8 @@ class Table extends ParsedElement {
3956
4122
  #loading;
3957
4123
  #noAutoload;
3958
4124
  #empty;
4125
+ #emptyAuthors;
4126
+ #emptyTemplate;
3959
4127
  #feedback;
3960
4128
  #paginator;
3961
4129
  #sorters;
@@ -4007,6 +4175,9 @@ class Table extends ParsedElement {
4007
4175
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
4008
4176
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
4009
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;
4010
4181
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
4011
4182
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
4012
4183
  this.replaceChildren(fragment);
@@ -4178,6 +4349,15 @@ class Table extends ParsedElement {
4178
4349
  .render(),
4179
4350
  );
4180
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
+ }
4181
4361
  //one move, one repaint: the page and the count are the same state
4182
4362
  this.#paginator.update({ current: pageRequest.page, total: pages });
4183
4363
  }
@@ -4268,6 +4448,13 @@ class ChoiceButton {
4268
4448
  get pinned() {
4269
4449
  return this.#allowed.length < 2;
4270
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
+ }
4271
4458
  get value() {
4272
4459
  return this.#button.getAttribute('value');
4273
4460
  }
@@ -4451,6 +4638,7 @@ const asCriterion = (label, operator, operands) => {
4451
4638
  */
4452
4639
  class CompareFilter extends Input {
4453
4640
  static observed = ['value:json', 'operators:csv'];
4641
+ static attributes = ['operator'];
4454
4642
  static OPERATORS = COMPARE_OPERATORS;
4455
4643
  static DEFAULT_OPERATOR = 'EQ';
4456
4644
  static template = `
@@ -4501,11 +4689,38 @@ class CompareFilter extends Input {
4501
4689
  if (this._operator.value === null) {
4502
4690
  this._showDefaultOperator();
4503
4691
  }
4692
+ this.#fixOperator();
4504
4693
  //the second operand mirrors the claims like the first one does, and the
4505
4694
  //freeze reaches the operator and sensitivity buttons, whose popovers an
4506
4695
  //input's readOnly cannot touch
4507
4696
  return { ...pieces, freeze: this._container, also: [this._value2] };
4508
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
+ }
4509
4724
  _showDefaultOperator() {
4510
4725
  const preferred = this._defaultOperator();
4511
4726
  const allowed = this._operator.allowed;
@@ -4542,6 +4757,9 @@ class CompareFilter extends Input {
4542
4757
  return this._operator ? this._operator.allowed : this._declaredOperators;
4543
4758
  }
4544
4759
  set operators(declared) {
4760
+ if (this.declared('operator') !== null) {
4761
+ return;
4762
+ }
4545
4763
  if (!this._operator) {
4546
4764
  this._declaredOperators = ChoiceButton.narrow(declared, this._vocabulary());
4547
4765
  return;
@@ -4645,6 +4863,7 @@ class NumberFilter extends CompareFilter {
4645
4863
  /** The compare filter over text, carrying a case sensitivity beside the operator. */
4646
4864
  class TextFilter extends CompareFilter {
4647
4865
  static observed = ['sensitivities:csv'];
4866
+ static attributes = ['sensitivity'];
4648
4867
  static template = `
4649
4868
  <label>{{{{ slots.default }}}}</label>
4650
4869
  {{{{ slots.info }}}}
@@ -4688,8 +4907,28 @@ class TextFilter extends CompareFilter {
4688
4907
  );
4689
4908
  this._sensitivityButton.allowed = null;
4690
4909
  this._sensitivityButton.value = SENSITIVITIES[0];
4910
+ this.#fixSensitivity();
4691
4911
  return pieces;
4692
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
+ }
4693
4932
  _choices() {
4694
4933
  return [...super._choices(), this._sensitivityButton].filter((c) => c);
4695
4934
  }
@@ -4701,6 +4940,9 @@ class TextFilter extends CompareFilter {
4701
4940
  return this._sensitivityButton ? this._sensitivityButton.allowed : this._declaredSensitivities;
4702
4941
  }
4703
4942
  set sensitivities(declared) {
4943
+ if (this.declared('sensitivity') !== null) {
4944
+ return;
4945
+ }
4704
4946
  if (!this._sensitivityButton) {
4705
4947
  this._declaredSensitivities = ChoiceButton.narrow(declared, SENSITIVITIES);
4706
4948
  return;
@@ -4830,6 +5072,15 @@ class BooleanFilter extends Field {
4830
5072
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
4831
5073
  this._value.value = v[1] ?? '';
4832
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
+ }
4833
5084
  /** @returns {FilterCriterion|null} */
4834
5085
  get criterion() {
4835
5086
  const token = this._value.value;
@@ -5126,6 +5377,15 @@ class Dialog extends ParsedElement {
5126
5377
  this.#body = fragment.querySelector('[data-ref=body]');
5127
5378
  this.#loading = fragment.querySelector('[data-ref=loading]');
5128
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
+ }
5129
5389
  this.#dialog.addEventListener('close', () => {
5130
5390
  const outcome = this.#outcome();
5131
5391
  this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
@@ -5293,6 +5553,69 @@ class Dialog extends ParsedElement {
5293
5553
  this.#loading?.setAttribute('hidden', '');
5294
5554
  this.#body?.removeAttribute('hidden');
5295
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
+ }
5296
5619
  }
5297
5620
 
5298
5621
  /**
@@ -5305,7 +5628,7 @@ class Dialog extends ParsedElement {
5305
5628
  * it.
5306
5629
  */
5307
5630
  class Drawer extends ParsedElement {
5308
- static attributes = ['title', 'placement', 'close-on-submit:presence'];
5631
+ static attributes = ['header', 'placement', 'close-on-submit:presence'];
5309
5632
  static slots = true;
5310
5633
  static template = `
5311
5634
  <dialog data-ref="dialog" class="ful-drawer">
@@ -5330,12 +5653,23 @@ class Drawer extends ParsedElement {
5330
5653
  //a save answering with no body at all is still a save
5331
5654
  /** @type {{ dismissed: boolean, response: any }|null} */
5332
5655
  #answer = null;
5656
+ #closing = false;
5657
+ #slideOutEnded = (/** @type AnimationEvent */ e) => {
5658
+ if (e.target === this.#dialog) {
5659
+ this.#closeNow();
5660
+ }
5661
+ };
5333
5662
  render({ slots }) {
5334
5663
  const fragment = this.template()
5335
- .withOverlay({ slots, title: this.declared('title') ?? '' })
5664
+ .withOverlay({ slots, title: this.declared('header') ?? '' })
5336
5665
  .render();
5337
5666
  this.#dialog = fragment.querySelector('[data-ref=dialog]');
5338
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);
5339
5673
  this.#loading = fragment.querySelector('[data-ref=loading]');
5340
5674
  this.#error = fragment.querySelector('[data-ref=error]');
5341
5675
  this.#content = fragment.querySelector('[data-ref=content]');
@@ -5353,6 +5687,10 @@ class Drawer extends ParsedElement {
5353
5687
  this.close();
5354
5688
  }
5355
5689
  });
5690
+ this.#dialog.addEventListener('cancel', (e) => {
5691
+ e.preventDefault();
5692
+ this.close();
5693
+ });
5356
5694
  this.#dialog.addEventListener('close', () => {
5357
5695
  this.dispatchEvent(
5358
5696
  new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
@@ -5376,23 +5714,23 @@ class Drawer extends ParsedElement {
5376
5714
  this.replaceChildren(fragment);
5377
5715
  wireTargets();
5378
5716
  }
5379
- get title() {
5717
+ get header() {
5380
5718
  return this.#title.textContent;
5381
5719
  }
5382
- set title(v) {
5720
+ set header(v) {
5383
5721
  this.#title.textContent = v ?? '';
5384
5722
  }
5385
5723
  /**
5386
- * Opens the drawer under the given title and waits for the callback: a
5724
+ * Opens the drawer under the given header and waits for the callback: a
5387
5725
  * resolved value paints the content section (which is returned), a
5388
5726
  * rejection paints the problems and travels to the caller, and an update
5389
5727
  * superseded by a newer one paints nothing.
5390
5728
  */
5391
- async update(title, cb) {
5729
+ async update(header, cb) {
5392
5730
  //the claim detaches any update still in flight: its outcome belongs to
5393
5731
  //an abandoned opening and must neither be painted nor own the drawer
5394
5732
  const claim = this.#updates.take();
5395
- this.title = title;
5733
+ this.header = header;
5396
5734
  this.#content.replaceChildren();
5397
5735
  this.#restChrome();
5398
5736
  this.#loading.removeAttribute('hidden');
@@ -5438,10 +5776,33 @@ class Drawer extends ParsedElement {
5438
5776
  this.#requests.request(this, this.#content, null, null)?.catch(() => undefined);
5439
5777
  }
5440
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();
5441
5796
  this.#dialog.close();
5442
5797
  }
5798
+ #stopSlidingOut() {
5799
+ this.#closing = false;
5800
+ this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
5801
+ this.#dialog.removeAttribute('closing');
5802
+ }
5443
5803
  /** Shows the modal, answering whether this call is the one that opened it. */
5444
5804
  #show() {
5805
+ this.#stopSlidingOut();
5445
5806
  if (this.#dialog.open) {
5446
5807
  return false;
5447
5808
  }
@@ -5488,6 +5849,9 @@ class Toasts extends ParsedElement {
5488
5849
  if (!listenerWired) {
5489
5850
  listenerWired = true;
5490
5851
  document.addEventListener('show-toast', (/** @type any */ e) => {
5852
+ if (!e.detail) {
5853
+ return;
5854
+ }
5491
5855
  for (const region of REGIONS) {
5492
5856
  region.show(e.detail.message, e.detail);
5493
5857
  }
@@ -5499,8 +5863,13 @@ class Toasts extends ParsedElement {
5499
5863
  * Appends a toast carrying the message (a Failure shows its problems'
5500
5864
  * reasons, one per line), severity picking the theme and the announcement,
5501
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.
5502
5871
  * @param {any} message
5503
- * @param {any} [options] severity and timeout
5872
+ * @param {any} [options] severity, timeout and action
5504
5873
  * @returns {HTMLElement}
5505
5874
  */
5506
5875
  show(message, options = {}) {
@@ -5517,14 +5886,13 @@ class Toasts extends ParsedElement {
5517
5886
  icon.setAttribute('name', 'x-lg');
5518
5887
  icon.setAttribute('aria-hidden', 'true');
5519
5888
  dismiss.append(icon);
5520
- item.append(body, dismiss);
5521
- item.addEventListener('animationend', () => {
5522
- if (item.classList.contains('ful-toast-out')) {
5523
- item.remove();
5524
- }
5525
- });
5889
+ item.append(body);
5890
+ let remaining = options.timeout ?? this.#timeout;
5891
+ let startedAt = performance.now();
5892
+ let timer = 0;
5526
5893
  const retire = () => {
5527
- //the toast may hold the focus, on its own dismiss button: handing it
5894
+ clearTimeout(timer);
5895
+ //the toast may hold the focus, on its own buttons: handing it
5528
5896
  //back to the region keeps the reader somewhere rather than on <body>
5529
5897
  if (item.contains(document.activeElement)) {
5530
5898
  /** @type HTMLElement */ (this).focus();
@@ -5538,9 +5906,49 @@ class Toasts extends ParsedElement {
5538
5906
  item.remove();
5539
5907
  }
5540
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);
5541
5949
  dismiss.addEventListener('click', retire);
5542
5950
  this.append(item);
5543
- setTimeout(retire, options.timeout ?? this.#timeout);
5951
+ timer = setTimeout(retire, remaining);
5544
5952
  return item;
5545
5953
  }
5546
5954
  }
@@ -5651,7 +6059,7 @@ class Tabs extends ParsedElement {
5651
6059
  this.#active = index;
5652
6060
  this.reflectTo('active', index);
5653
6061
  if (this.rendered && index !== previous) {
5654
- this.dispatchEvent(new CustomEvent('change', { detail: { active: index, previous } }));
6062
+ this.dispatchEvent(new CustomEvent('tabs:change', { detail: { active: index, previous } }));
5655
6063
  }
5656
6064
  if (this.#panels.length > 0 && (index !== previous || !this.rendered)) {
5657
6065
  //the activation is the reader's own gesture: the chrome reports a
@@ -5872,6 +6280,8 @@ var en = {
5872
6280
  'info.tooltip': 'More information',
5873
6281
  'dialog.acknowledge': 'Got it',
5874
6282
  'dialog.close': 'Close',
6283
+ 'dialog.confirm': 'Confirm',
6284
+ 'dialog.cancel': 'Cancel',
5875
6285
  'drawer.close': 'Close',
5876
6286
  'spinner.loading': 'Loading…',
5877
6287
  'toast.region': 'Notifications',
@@ -5914,6 +6324,8 @@ var it = {
5914
6324
  'info.tooltip': 'Maggiori informazioni',
5915
6325
  'dialog.acknowledge': 'Ho capito',
5916
6326
  'dialog.close': 'Chiudi',
6327
+ 'dialog.confirm': 'Conferma',
6328
+ 'dialog.cancel': 'Annulla',
5917
6329
  'drawer.close': 'Chiudi',
5918
6330
  'spinner.loading': 'Caricamento…',
5919
6331
  'toast.region': 'Notifiche',
@@ -5956,6 +6368,8 @@ var es = {
5956
6368
  'info.tooltip': 'Más información',
5957
6369
  'dialog.acknowledge': 'Entendido',
5958
6370
  'dialog.close': 'Cerrar',
6371
+ 'dialog.confirm': 'Confirmar',
6372
+ 'dialog.cancel': 'Cancelar',
5959
6373
  'drawer.close': 'Cerrar',
5960
6374
  'spinner.loading': 'Cargando…',
5961
6375
  'toast.region': 'Notificaciones',
@@ -6001,6 +6415,8 @@ var fr = {
6001
6415
  'info.tooltip': 'Plus d’informations',
6002
6416
  'dialog.acknowledge': 'J’ai compris',
6003
6417
  'dialog.close': 'Fermer',
6418
+ 'dialog.confirm': 'Confirmer',
6419
+ 'dialog.cancel': 'Annuler',
6004
6420
  'drawer.close': 'Fermer',
6005
6421
  'spinner.loading': 'Chargement…',
6006
6422
  'toast.region': 'Notifications',
@@ -6041,7 +6457,7 @@ class Plugin {
6041
6457
  const httpClient =
6042
6458
  this.#httpClient ?? HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
6043
6459
  //the fallback chain is baked here: en, the active language, the consumer's own strings
6044
- const language = this.#language.split('-')[0];
6460
+ const language = this.#language.split('-')[0].toLowerCase();
6045
6461
  const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
6046
6462
  registry
6047
6463
  .defineModule('l10n', Localization)