@optionfactory/fml 9.0.0-rc23 → 9.0.0-rc25

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
@@ -693,6 +693,11 @@ class Bindings {
693
693
  * `rest` being the remainder of the path (`''` on an exact match) that a
694
694
  * composite can use to route the problem to an inner control.
695
695
  *
696
+ * A control counts only when it can show a problem, through a
697
+ * `setCustomValidity` of its own: a form-associated element that has none is
698
+ * skipped, so its problems fall to a shorter prefix or to the banner instead
699
+ * of being dropped.
700
+ *
696
701
  * Every other problem, and a field problem naming no control, is shown in
697
702
  * each `ful-errors` banner of the form: the banner gets `role="alert"`, is
698
703
  * revealed and holds the reasons one per line. With no such problem the
@@ -731,7 +736,7 @@ class Bindings {
731
736
  const parts = name.split('.');
732
737
  for (let i = parts.length; i !== 0; --i) {
733
738
  const prefix = parts.slice(0, i).join('.');
734
- const targets = named.get(prefix) ?? [];
739
+ const targets = (named.get(prefix) ?? []).filter((el) => typeof el.setCustomValidity === 'function');
735
740
  if (targets.length === 0) {
736
741
  continue;
737
742
  }
@@ -1189,6 +1194,14 @@ class Field extends ParsedElement {
1189
1194
  * accessibility tree.
1190
1195
  * - `also` are further controls that mirror `disabled` and `readOnly`
1191
1196
  * beside the claim target.
1197
+ *
1198
+ * The pieces are elements the field rendered itself, native controls or
1199
+ * wrappers of its own. A ful field is not one: it has no `readOnly`, its
1200
+ * host's role takes no aria, and a label's `for` names its host rather than
1201
+ * its control. A composite built from ful fields answers the inner field as
1202
+ * `control`, which forwards `focus()`, lets it render its own label with
1203
+ * `label` and `announces` null, and forwards the claims in its setters,
1204
+ * calling super first.
1192
1205
  * @param {{ slots: Record<string, DocumentFragment> | undefined }} conf
1193
1206
  * @returns {any} the pieces, or a promise of them
1194
1207
  * @throws {Error} when the subclass does not implement it
@@ -1345,7 +1358,8 @@ const ownerOf = (el) =>
1345
1358
  *
1346
1359
  * Attributes on the host starting with `form-` are forwarded, without the
1347
1360
  * prefix, to the native form. `autocomplete` is copied onto the native form as
1348
- * well and stays on the host, the fields reading it from either.
1361
+ * well and stays on the host, the fields reading it from either; a
1362
+ * `form-autocomplete` has the last word on the native form.
1349
1363
  * `clear-invalid-on-change` clears the custom validity of a field of this form
1350
1364
  * when a `change` event bubbles from it; the field's form is read from its
1351
1365
  * `form` property, or from `internals.form` for a form-associated element
@@ -1380,8 +1394,8 @@ class Form extends ParsedElement {
1380
1394
  const form = document.createElement('form');
1381
1395
  this.form = form;
1382
1396
  form.setAttribute('novalidate', '');
1383
- Attributes.forward('form-', this, form);
1384
1397
  Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
1398
+ Attributes.forward('form-', this, form);
1385
1399
  form.replaceChildren(...this.childNodes);
1386
1400
  form.addEventListener('submit', async (e) => {
1387
1401
  e.preventDefault();
@@ -4576,7 +4590,7 @@ class RadioGroup extends Field {
4576
4590
  return;
4577
4591
  }
4578
4592
  /** @type {HTMLInputElement|null} */
4579
- const el = this.querySelector(`input[type=radio][value=${CSS.escape(String(value))}]`);
4593
+ const el = this.querySelector(`input[type=radio][value="${CSS.escape(String(value))}"]`);
4580
4594
  if (el === null) {
4581
4595
  clear();
4582
4596
  return;
@@ -5569,7 +5583,9 @@ const TYPEAHEAD_WINDOW = 500;
5569
5583
  * `items` answers the items in the order they are walked, and is called on
5570
5584
  * every gesture, so a menu that fills itself later needs no rewiring.
5571
5585
  *
5572
- * @param {HTMLElement} invoker the element the menu belongs to, and where the focus goes back
5586
+ * @param {() => HTMLElement|null} invoker answers the element the menu belongs to
5587
+ * now, where the focus goes back; called on every gesture, so a menu shared
5588
+ * by several invokers hands the focus back to the one that opened it
5573
5589
  * @param {HTMLElement} menu the `[popover]` holding the items
5574
5590
  * @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
5575
5591
  * `items` answers the items; `current` answers the item an opening focuses
@@ -5579,7 +5595,7 @@ const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
5579
5595
  let typedAt = 0;
5580
5596
  menu.addEventListener('beforetoggle', (/** @type any */ evt) => {
5581
5597
  if (evt.newState === 'closed' && menu.contains(document.activeElement)) {
5582
- invoker.focus();
5598
+ invoker()?.focus();
5583
5599
  }
5584
5600
  });
5585
5601
  menu.addEventListener('toggle', (/** @type any */ evt) => {
@@ -5623,11 +5639,11 @@ const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
5623
5639
  case 'Space': {
5624
5640
  evt.preventDefault();
5625
5641
  item.click();
5626
- invoker.focus();
5642
+ invoker()?.focus();
5627
5643
  return;
5628
5644
  }
5629
5645
  case 'Escape': {
5630
- invoker.focus();
5646
+ invoker()?.focus();
5631
5647
  return;
5632
5648
  }
5633
5649
  }
@@ -5852,7 +5868,7 @@ class ChoiceButton {
5852
5868
  }
5853
5869
  #wire() {
5854
5870
  Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
5855
- wireMenuKeys(this.#button, this.#menu, {
5871
+ wireMenuKeys(() => this.#button, this.#menu, {
5856
5872
  items: () => this.#items(),
5857
5873
  current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
5858
5874
  });
@@ -6850,11 +6866,12 @@ class DialogSections {
6850
6866
  * `show`, and fills the content with what the callback resolves to. A
6851
6867
  * rejection is shown, as the problems' reasons, in the error section, and
6852
6868
  * the content is hidden. When a newer `update()` starts before the callback
6853
- * settles, this call paints nothing: it resolves with the section regardless,
6854
- * and still rejects with the callback's error.
6869
+ * settles, this call paints nothing and resolves `null`, the section being
6870
+ * the newer call's; it still rejects with the callback's error.
6855
6871
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
6856
6872
  * @param {() => void} show opens the dialog when it is closed
6857
- * @returns {Promise<HTMLElement>} the content section
6873
+ * @returns {Promise<HTMLElement|null>} the content section, or `null` for a
6874
+ * call a newer one overtook
6858
6875
  * @throws {any} what the callback threw or rejected with
6859
6876
  */
6860
6877
  async update(cb, show) {
@@ -6867,7 +6884,7 @@ class DialogSections {
6867
6884
  try {
6868
6885
  const delivered = await cb();
6869
6886
  if (claim.stale) {
6870
- return this.#section;
6887
+ return null;
6871
6888
  }
6872
6889
  this.#section.replaceChildren(delivered);
6873
6890
  this.#loading?.setAttribute('hidden', '');
@@ -7113,11 +7130,12 @@ class Dialog extends ParsedElement {
7113
7130
  *
7114
7131
  * No `section:requested` is dispatched, and no `ask()` waiter is added:
7115
7132
  * listen for the `close` event to learn how the dialog ended. When a newer
7116
- * `update()` starts before the callback settles, this call paints nothing:
7117
- * it resolves with the body regardless, and still rejects with the
7118
- * callback's error.
7133
+ * `update()` starts before the callback settles, this call paints nothing
7134
+ * and resolves `null`, the body being the newer call's; it still rejects
7135
+ * with the callback's error.
7119
7136
  * @param {() => Node|string|Promise<Node|string>} cb producing the body content
7120
- * @returns {Promise<Element>} the body section, in the document
7137
+ * @returns {Promise<Element|null>} the body section, in the document, or
7138
+ * `null` for a call a newer one overtook
7121
7139
  * @throws {any} what the callback threw or rejected with
7122
7140
  */
7123
7141
  async update(cb) {
@@ -7407,12 +7425,13 @@ class Drawer extends ParsedElement {
7407
7425
  * which has `role="alert"`, and the content is hidden.
7408
7426
  *
7409
7427
  * No `section:requested` is dispatched. When a newer `update()` starts
7410
- * before the callback settles, this call paints nothing: it resolves with
7411
- * the content section regardless, and still rejects with the callback's
7412
- * error.
7428
+ * before the callback settles, this call paints nothing and resolves
7429
+ * `null`, the content section being the newer call's; it still rejects with
7430
+ * the callback's error.
7413
7431
  * @param {string} header the title, set as text
7414
7432
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
7415
- * @returns {Promise<Element>} the content section, in the document
7433
+ * @returns {Promise<Element|null>} the content section, in the document, or
7434
+ * `null` for a call a newer one overtook
7416
7435
  * @throws {any} what the callback threw or rejected with
7417
7436
  */
7418
7437
  async update(header, cb) {
@@ -7561,11 +7580,15 @@ class Drawer extends ParsedElement {
7561
7580
  }
7562
7581
 
7563
7582
  /**
7564
- * A menu of commands on the native popover platform, opened by the element
7565
- * whose id `for` names. The menu is the popover itself: light dismiss and
7566
- * Escape come from the browser, and it is anchored to its invoker through
7583
+ * A menu of commands on the native popover platform. The menu is the popover
7584
+ * itself: light dismiss and Escape come from the browser.
7585
+ *
7586
+ * A menu naming an invoker by id through `for` belongs to that element: the
7587
+ * invoker toggles it, and it is anchored to the invoker through
7567
7588
  * `Anchors.wire`, placed in script where the platform has no css anchor
7568
- * positioning.
7589
+ * positioning. A menu without `for` is shared: one menu serves many
7590
+ * invokers, such as the "more" button of every row of a list, and a page
7591
+ * opens it beside the invoker it is for with `showFor(invoker)`.
7569
7592
  *
7570
7593
  * The items are the buttons and `a[href]` links written as its direct
7571
7594
  * children, kept as they are. Activation is the item's own click, which the
@@ -7575,34 +7598,44 @@ class Drawer extends ParsedElement {
7575
7598
  * The keyboard is the ARIA menu protocol: the focus moves to the
7576
7599
  * first item as the menu opens, the arrows, Home and End walk the items,
7577
7600
  * typing searches their text, Enter and Space activate the focused one, and
7578
- * Escape and a close from inside the menu hand the focus back to the invoker.
7601
+ * Escape and a close from inside the menu hand the focus back to the invoker
7602
+ * that opened it.
7579
7603
  */
7580
7604
  class Menu extends ParsedElement {
7581
7605
  /** @type {string[]} */
7582
7606
  static attributes = ['for'];
7607
+ /** @type {HTMLElement|null} */
7608
+ #invoker = null;
7609
+ /** @type {HTMLElement|null} */
7610
+ #pressed = null;
7583
7611
  /**
7584
7612
  * Gives the menu the `menu` role and an empty `popover` attribute unless
7585
- * it already has one, gives the invoker `aria-haspopup="menu"`, an
7586
- * `aria-expanded` kept in step, and the toggle of the menu, and marks the
7587
- * items. `for` is read here, once.
7613
+ * it already has one, and marks the items. With `for`, read here once, it
7614
+ * also gives the invoker `aria-haspopup="menu"`, an `aria-expanded` kept
7615
+ * in step, and the toggle of the menu.
7588
7616
  * @returns {void}
7589
- * @throws {Error} when `for` names no element in the document at the render
7617
+ * @throws {Error} when `for` is given and names no element in the document at the render
7590
7618
  */
7591
7619
  render() {
7592
7620
  const named = this.declared('for');
7593
7621
  const invoker = named ? document.getElementById(named) : null;
7594
- if (!invoker) {
7622
+ if (named && !invoker) {
7595
7623
  throw new Error(
7596
- `ful-menu names no invoker: for="${named ?? ''}" must name an element already in the page when the menu upgrades`,
7624
+ `ful-menu names no invoker: for="${named}" must name an element already in the page when the menu upgrades`,
7597
7625
  );
7598
7626
  }
7599
7627
  this.setAttribute('role', 'menu');
7600
7628
  if (!this.hasAttribute('popover')) {
7601
7629
  this.setAttribute('popover', '');
7602
7630
  }
7603
- invoker.setAttribute('aria-haspopup', 'menu');
7604
- Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
7605
- wireMenuKeys(invoker, this, { items: () => this.items });
7631
+ if (invoker) {
7632
+ this.#invoker = invoker;
7633
+ invoker.setAttribute('aria-haspopup', 'menu');
7634
+ Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
7635
+ } else {
7636
+ this.#wireShared();
7637
+ }
7638
+ wireMenuKeys(() => this.#invoker, this, { items: () => this.items });
7606
7639
  this.#mark();
7607
7640
  this.addEventListener('click', (evt) => {
7608
7641
  const target = /** @type HTMLElement */ (evt.target);
@@ -7611,6 +7644,43 @@ class Menu extends ParsedElement {
7611
7644
  }
7612
7645
  });
7613
7646
  }
7647
+ /**
7648
+ * Opens a shared menu, one without `for`, beside `invoker`, or moves it
7649
+ * there when it is open for another one. The menu goes below the invoker,
7650
+ * or above it where there is no room below, and is placed once, as
7651
+ * `Anchors.show` places it. The invoker gets `aria-haspopup="menu"` and an
7652
+ * `aria-expanded` that reads true until the menu closes, and the focus
7653
+ * goes back to it on Escape. A call for the invoker whose press has just
7654
+ * closed the menu does nothing, so a second click on the same button
7655
+ * closes the menu rather than opening it again.
7656
+ * @param {HTMLElement} invoker the element the menu opens for
7657
+ * @returns {void}
7658
+ * @throws {Error} when the menu names its invoker through `for`
7659
+ */
7660
+ showFor(invoker) {
7661
+ if (this.declared('for')) {
7662
+ throw new Error('ful-menu: showFor serves a menu without for; a menu with for is opened by its invoker');
7663
+ }
7664
+ const pressed = this.#pressed;
7665
+ this.#pressed = null;
7666
+ if (pressed === invoker) {
7667
+ return;
7668
+ }
7669
+ if (this.#invoker && this.#invoker !== invoker) {
7670
+ this.#invoker.setAttribute('aria-expanded', 'false');
7671
+ }
7672
+ this.#invoker = invoker;
7673
+ invoker.setAttribute('aria-haspopup', 'menu');
7674
+ invoker.setAttribute('aria-expanded', 'true');
7675
+ //the previous placement wrote margin: 0 inline, hiding the stylesheet's gap
7676
+ this.style.margin = '';
7677
+ const gap = Number.parseFloat(getComputedStyle(this).marginBlockStart) || 0;
7678
+ const moving = this.matches(':popover-open');
7679
+ Anchors.show(this, invoker, { flip: true, gap });
7680
+ if (moving) {
7681
+ this.items[0]?.focus();
7682
+ }
7683
+ }
7614
7684
  /**
7615
7685
  * The commands, in the order the arrows walk them: the menu's direct
7616
7686
  * `button` and `a[href]` children, minus those carrying a `disabled` or
@@ -7622,6 +7692,21 @@ class Menu extends ParsedElement {
7622
7692
  get items() {
7623
7693
  return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
7624
7694
  }
7695
+ #wireShared() {
7696
+ document.addEventListener(
7697
+ 'pointerdown',
7698
+ (evt) => {
7699
+ const target = /** @type Node */ (evt.target);
7700
+ this.#pressed = this.matches(':popover-open') && this.#invoker?.contains(target) ? this.#invoker : null;
7701
+ },
7702
+ true,
7703
+ );
7704
+ this.addEventListener('toggle', (/** @type any */ evt) => {
7705
+ if (evt.newState === 'closed') {
7706
+ this.#invoker?.setAttribute('aria-expanded', 'false');
7707
+ }
7708
+ });
7709
+ }
7625
7710
  /** @returns {HTMLElement[]} every item, disabled and hidden ones included, once marked */
7626
7711
  #mark() {
7627
7712
  const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);