@optionfactory/fml 9.0.0-rc24 → 9.0.0-rc26

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
@@ -1358,7 +1358,8 @@ const ownerOf = (el) =>
1358
1358
  *
1359
1359
  * Attributes on the host starting with `form-` are forwarded, without the
1360
1360
  * prefix, to the native form. `autocomplete` is copied onto the native form as
1361
- * 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.
1362
1363
  * `clear-invalid-on-change` clears the custom validity of a field of this form
1363
1364
  * when a `change` event bubbles from it; the field's form is read from its
1364
1365
  * `form` property, or from `internals.form` for a form-associated element
@@ -1393,8 +1394,8 @@ class Form extends ParsedElement {
1393
1394
  const form = document.createElement('form');
1394
1395
  this.form = form;
1395
1396
  form.setAttribute('novalidate', '');
1396
- Attributes.forward('form-', this, form);
1397
1397
  Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
1398
+ Attributes.forward('form-', this, form);
1398
1399
  form.replaceChildren(...this.childNodes);
1399
1400
  form.addEventListener('submit', async (e) => {
1400
1401
  e.preventDefault();
@@ -5582,7 +5583,9 @@ const TYPEAHEAD_WINDOW = 500;
5582
5583
  * `items` answers the items in the order they are walked, and is called on
5583
5584
  * every gesture, so a menu that fills itself later needs no rewiring.
5584
5585
  *
5585
- * @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
5586
5589
  * @param {HTMLElement} menu the `[popover]` holding the items
5587
5590
  * @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
5588
5591
  * `items` answers the items; `current` answers the item an opening focuses
@@ -5592,7 +5595,7 @@ const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
5592
5595
  let typedAt = 0;
5593
5596
  menu.addEventListener('beforetoggle', (/** @type any */ evt) => {
5594
5597
  if (evt.newState === 'closed' && menu.contains(document.activeElement)) {
5595
- invoker.focus();
5598
+ invoker()?.focus();
5596
5599
  }
5597
5600
  });
5598
5601
  menu.addEventListener('toggle', (/** @type any */ evt) => {
@@ -5636,11 +5639,11 @@ const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
5636
5639
  case 'Space': {
5637
5640
  evt.preventDefault();
5638
5641
  item.click();
5639
- invoker.focus();
5642
+ invoker()?.focus();
5640
5643
  return;
5641
5644
  }
5642
5645
  case 'Escape': {
5643
- invoker.focus();
5646
+ invoker()?.focus();
5644
5647
  return;
5645
5648
  }
5646
5649
  }
@@ -5865,7 +5868,7 @@ class ChoiceButton {
5865
5868
  }
5866
5869
  #wire() {
5867
5870
  Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
5868
- wireMenuKeys(this.#button, this.#menu, {
5871
+ wireMenuKeys(() => this.#button, this.#menu, {
5869
5872
  items: () => this.#items(),
5870
5873
  current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
5871
5874
  });
@@ -7577,11 +7580,15 @@ class Drawer extends ParsedElement {
7577
7580
  }
7578
7581
 
7579
7582
  /**
7580
- * A menu of commands on the native popover platform, opened by the element
7581
- * whose id `for` names. The menu is the popover itself: light dismiss and
7582
- * 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
7583
7588
  * `Anchors.wire`, placed in script where the platform has no css anchor
7584
- * 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)`.
7585
7592
  *
7586
7593
  * The items are the buttons and `a[href]` links written as its direct
7587
7594
  * children, kept as they are. Activation is the item's own click, which the
@@ -7591,34 +7598,44 @@ class Drawer extends ParsedElement {
7591
7598
  * The keyboard is the ARIA menu protocol: the focus moves to the
7592
7599
  * first item as the menu opens, the arrows, Home and End walk the items,
7593
7600
  * typing searches their text, Enter and Space activate the focused one, and
7594
- * 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.
7595
7603
  */
7596
7604
  class Menu extends ParsedElement {
7597
7605
  /** @type {string[]} */
7598
7606
  static attributes = ['for'];
7607
+ /** @type {HTMLElement|null} */
7608
+ #invoker = null;
7609
+ /** @type {HTMLElement|null} */
7610
+ #pressed = null;
7599
7611
  /**
7600
7612
  * Gives the menu the `menu` role and an empty `popover` attribute unless
7601
- * it already has one, gives the invoker `aria-haspopup="menu"`, an
7602
- * `aria-expanded` kept in step, and the toggle of the menu, and marks the
7603
- * 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.
7604
7616
  * @returns {void}
7605
- * @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
7606
7618
  */
7607
7619
  render() {
7608
7620
  const named = this.declared('for');
7609
7621
  const invoker = named ? document.getElementById(named) : null;
7610
- if (!invoker) {
7622
+ if (named && !invoker) {
7611
7623
  throw new Error(
7612
- `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`,
7613
7625
  );
7614
7626
  }
7615
7627
  this.setAttribute('role', 'menu');
7616
7628
  if (!this.hasAttribute('popover')) {
7617
7629
  this.setAttribute('popover', '');
7618
7630
  }
7619
- invoker.setAttribute('aria-haspopup', 'menu');
7620
- Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
7621
- 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 });
7622
7639
  this.#mark();
7623
7640
  this.addEventListener('click', (evt) => {
7624
7641
  const target = /** @type HTMLElement */ (evt.target);
@@ -7627,6 +7644,43 @@ class Menu extends ParsedElement {
7627
7644
  }
7628
7645
  });
7629
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
+ }
7630
7684
  /**
7631
7685
  * The commands, in the order the arrows walk them: the menu's direct
7632
7686
  * `button` and `a[href]` children, minus those carrying a `disabled` or
@@ -7638,6 +7692,21 @@ class Menu extends ParsedElement {
7638
7692
  get items() {
7639
7693
  return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
7640
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
+ }
7641
7710
  /** @returns {HTMLElement[]} every item, disabled and hidden ones included, once marked */
7642
7711
  #mark() {
7643
7712
  const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);