@optionfactory/fml 9.0.0-rc24 → 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.
@@ -192,11 +192,11 @@
192
192
  "name": "Menu",
193
193
  "tagName": "ful-menu",
194
194
  "customElement": true,
195
- "description": "A menu of commands on the native popover platform: the invoker named by for toggles it, light dismiss and Escape come from the browser, and the element adds the menu semantics and the keyboard protocol (roving focus with the arrows, Home and End, type-ahead, Enter and Space to activate, Escape and every close handing the focus back to the invoker). The items are the buttons and links written inside it, kept as they are and given their menuitem role; activation is the item's own click, which the menu only follows by closing.",
195
+ "description": "A menu of commands on the native popover platform: the invoker named by for toggles it, or, without for, one menu serves many invokers and showFor(invoker) opens it beside the one it is for. Light dismiss and Escape come from the browser, and the element adds the menu semantics and the keyboard protocol (roving focus with the arrows, Home and End, type-ahead, Enter and Space to activate, Escape and every close handing the focus back to the invoker). The items are the buttons and links written inside it, kept as they are and given their menuitem role; activation is the item's own click, which the menu only follows by closing.",
196
196
  "attributes": [
197
197
  {
198
198
  "name": "for",
199
- "description": "The id of the invoker the menu belongs to: it toggles the menu, carries aria-haspopup and aria-expanded, and takes the focus back when the menu closes. Read once at the upgrade, so the invoker must already be in the page by then.",
199
+ "description": "The id of the invoker the menu belongs to: it toggles the menu, carries aria-haspopup and aria-expanded, and takes the focus back when the menu closes. Read once at the upgrade, so the invoker must already be in the page by then. Without it the menu is shared, opened by showFor(invoker).",
200
200
  "type": {
201
201
  "text": "string"
202
202
  }
package/dist/fml.iife.js CHANGED
@@ -7628,7 +7628,8 @@ var fml = (function (exports) {
7628
7628
  *
7629
7629
  * Attributes on the host starting with `form-` are forwarded, without the
7630
7630
  * prefix, to the native form. `autocomplete` is copied onto the native form as
7631
- * well and stays on the host, the fields reading it from either.
7631
+ * well and stays on the host, the fields reading it from either; a
7632
+ * `form-autocomplete` has the last word on the native form.
7632
7633
  * `clear-invalid-on-change` clears the custom validity of a field of this form
7633
7634
  * when a `change` event bubbles from it; the field's form is read from its
7634
7635
  * `form` property, or from `internals.form` for a form-associated element
@@ -7663,8 +7664,8 @@ var fml = (function (exports) {
7663
7664
  const form = document.createElement('form');
7664
7665
  this.form = form;
7665
7666
  form.setAttribute('novalidate', '');
7666
- Attributes.forward('form-', this, form);
7667
7667
  Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
7668
+ Attributes.forward('form-', this, form);
7668
7669
  form.replaceChildren(...this.childNodes);
7669
7670
  form.addEventListener('submit', async (e) => {
7670
7671
  e.preventDefault();
@@ -11852,7 +11853,9 @@ var fml = (function (exports) {
11852
11853
  * `items` answers the items in the order they are walked, and is called on
11853
11854
  * every gesture, so a menu that fills itself later needs no rewiring.
11854
11855
  *
11855
- * @param {HTMLElement} invoker the element the menu belongs to, and where the focus goes back
11856
+ * @param {() => HTMLElement|null} invoker answers the element the menu belongs to
11857
+ * now, where the focus goes back; called on every gesture, so a menu shared
11858
+ * by several invokers hands the focus back to the one that opened it
11856
11859
  * @param {HTMLElement} menu the `[popover]` holding the items
11857
11860
  * @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
11858
11861
  * `items` answers the items; `current` answers the item an opening focuses
@@ -11862,7 +11865,7 @@ var fml = (function (exports) {
11862
11865
  let typedAt = 0;
11863
11866
  menu.addEventListener('beforetoggle', (/** @type any */ evt) => {
11864
11867
  if (evt.newState === 'closed' && menu.contains(document.activeElement)) {
11865
- invoker.focus();
11868
+ invoker()?.focus();
11866
11869
  }
11867
11870
  });
11868
11871
  menu.addEventListener('toggle', (/** @type any */ evt) => {
@@ -11906,11 +11909,11 @@ var fml = (function (exports) {
11906
11909
  case 'Space': {
11907
11910
  evt.preventDefault();
11908
11911
  item.click();
11909
- invoker.focus();
11912
+ invoker()?.focus();
11910
11913
  return;
11911
11914
  }
11912
11915
  case 'Escape': {
11913
- invoker.focus();
11916
+ invoker()?.focus();
11914
11917
  return;
11915
11918
  }
11916
11919
  }
@@ -12135,7 +12138,7 @@ var fml = (function (exports) {
12135
12138
  }
12136
12139
  #wire() {
12137
12140
  Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
12138
- wireMenuKeys(this.#button, this.#menu, {
12141
+ wireMenuKeys(() => this.#button, this.#menu, {
12139
12142
  items: () => this.#items(),
12140
12143
  current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
12141
12144
  });
@@ -13847,11 +13850,15 @@ var fml = (function (exports) {
13847
13850
  }
13848
13851
 
13849
13852
  /**
13850
- * A menu of commands on the native popover platform, opened by the element
13851
- * whose id `for` names. The menu is the popover itself: light dismiss and
13852
- * Escape come from the browser, and it is anchored to its invoker through
13853
+ * A menu of commands on the native popover platform. The menu is the popover
13854
+ * itself: light dismiss and Escape come from the browser.
13855
+ *
13856
+ * A menu naming an invoker by id through `for` belongs to that element: the
13857
+ * invoker toggles it, and it is anchored to the invoker through
13853
13858
  * `Anchors.wire`, placed in script where the platform has no css anchor
13854
- * positioning.
13859
+ * positioning. A menu without `for` is shared: one menu serves many
13860
+ * invokers, such as the "more" button of every row of a list, and a page
13861
+ * opens it beside the invoker it is for with `showFor(invoker)`.
13855
13862
  *
13856
13863
  * The items are the buttons and `a[href]` links written as its direct
13857
13864
  * children, kept as they are. Activation is the item's own click, which the
@@ -13861,34 +13868,44 @@ var fml = (function (exports) {
13861
13868
  * The keyboard is the ARIA menu protocol: the focus moves to the
13862
13869
  * first item as the menu opens, the arrows, Home and End walk the items,
13863
13870
  * typing searches their text, Enter and Space activate the focused one, and
13864
- * Escape and a close from inside the menu hand the focus back to the invoker.
13871
+ * Escape and a close from inside the menu hand the focus back to the invoker
13872
+ * that opened it.
13865
13873
  */
13866
13874
  class Menu extends ParsedElement {
13867
13875
  /** @type {string[]} */
13868
13876
  static attributes = ['for'];
13877
+ /** @type {HTMLElement|null} */
13878
+ #invoker = null;
13879
+ /** @type {HTMLElement|null} */
13880
+ #pressed = null;
13869
13881
  /**
13870
13882
  * Gives the menu the `menu` role and an empty `popover` attribute unless
13871
- * it already has one, gives the invoker `aria-haspopup="menu"`, an
13872
- * `aria-expanded` kept in step, and the toggle of the menu, and marks the
13873
- * items. `for` is read here, once.
13883
+ * it already has one, and marks the items. With `for`, read here once, it
13884
+ * also gives the invoker `aria-haspopup="menu"`, an `aria-expanded` kept
13885
+ * in step, and the toggle of the menu.
13874
13886
  * @returns {void}
13875
- * @throws {Error} when `for` names no element in the document at the render
13887
+ * @throws {Error} when `for` is given and names no element in the document at the render
13876
13888
  */
13877
13889
  render() {
13878
13890
  const named = this.declared('for');
13879
13891
  const invoker = named ? document.getElementById(named) : null;
13880
- if (!invoker) {
13892
+ if (named && !invoker) {
13881
13893
  throw new Error(
13882
- `ful-menu names no invoker: for="${named ?? ''}" must name an element already in the page when the menu upgrades`,
13894
+ `ful-menu names no invoker: for="${named}" must name an element already in the page when the menu upgrades`,
13883
13895
  );
13884
13896
  }
13885
13897
  this.setAttribute('role', 'menu');
13886
13898
  if (!this.hasAttribute('popover')) {
13887
13899
  this.setAttribute('popover', '');
13888
13900
  }
13889
- invoker.setAttribute('aria-haspopup', 'menu');
13890
- Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
13891
- wireMenuKeys(invoker, this, { items: () => this.items });
13901
+ if (invoker) {
13902
+ this.#invoker = invoker;
13903
+ invoker.setAttribute('aria-haspopup', 'menu');
13904
+ Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
13905
+ } else {
13906
+ this.#wireShared();
13907
+ }
13908
+ wireMenuKeys(() => this.#invoker, this, { items: () => this.items });
13892
13909
  this.#mark();
13893
13910
  this.addEventListener('click', (evt) => {
13894
13911
  const target = /** @type HTMLElement */ (evt.target);
@@ -13897,6 +13914,43 @@ var fml = (function (exports) {
13897
13914
  }
13898
13915
  });
13899
13916
  }
13917
+ /**
13918
+ * Opens a shared menu, one without `for`, beside `invoker`, or moves it
13919
+ * there when it is open for another one. The menu goes below the invoker,
13920
+ * or above it where there is no room below, and is placed once, as
13921
+ * `Anchors.show` places it. The invoker gets `aria-haspopup="menu"` and an
13922
+ * `aria-expanded` that reads true until the menu closes, and the focus
13923
+ * goes back to it on Escape. A call for the invoker whose press has just
13924
+ * closed the menu does nothing, so a second click on the same button
13925
+ * closes the menu rather than opening it again.
13926
+ * @param {HTMLElement} invoker the element the menu opens for
13927
+ * @returns {void}
13928
+ * @throws {Error} when the menu names its invoker through `for`
13929
+ */
13930
+ showFor(invoker) {
13931
+ if (this.declared('for')) {
13932
+ throw new Error('ful-menu: showFor serves a menu without for; a menu with for is opened by its invoker');
13933
+ }
13934
+ const pressed = this.#pressed;
13935
+ this.#pressed = null;
13936
+ if (pressed === invoker) {
13937
+ return;
13938
+ }
13939
+ if (this.#invoker && this.#invoker !== invoker) {
13940
+ this.#invoker.setAttribute('aria-expanded', 'false');
13941
+ }
13942
+ this.#invoker = invoker;
13943
+ invoker.setAttribute('aria-haspopup', 'menu');
13944
+ invoker.setAttribute('aria-expanded', 'true');
13945
+ //the previous placement wrote margin: 0 inline, hiding the stylesheet's gap
13946
+ this.style.margin = '';
13947
+ const gap = Number.parseFloat(getComputedStyle(this).marginBlockStart) || 0;
13948
+ const moving = this.matches(':popover-open');
13949
+ Anchors.show(this, invoker, { flip: true, gap });
13950
+ if (moving) {
13951
+ this.items[0]?.focus();
13952
+ }
13953
+ }
13900
13954
  /**
13901
13955
  * The commands, in the order the arrows walk them: the menu's direct
13902
13956
  * `button` and `a[href]` children, minus those carrying a `disabled` or
@@ -13908,6 +13962,21 @@ var fml = (function (exports) {
13908
13962
  get items() {
13909
13963
  return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
13910
13964
  }
13965
+ #wireShared() {
13966
+ document.addEventListener(
13967
+ 'pointerdown',
13968
+ (evt) => {
13969
+ const target = /** @type Node */ (evt.target);
13970
+ this.#pressed = this.matches(':popover-open') && this.#invoker?.contains(target) ? this.#invoker : null;
13971
+ },
13972
+ true,
13973
+ );
13974
+ this.addEventListener('toggle', (/** @type any */ evt) => {
13975
+ if (evt.newState === 'closed') {
13976
+ this.#invoker?.setAttribute('aria-expanded', 'false');
13977
+ }
13978
+ });
13979
+ }
13911
13980
  /** @returns {HTMLElement[]} every item, disabled and hidden ones included, once marked */
13912
13981
  #mark() {
13913
13982
  const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);