@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.
@@ -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
  }
@@ -419,7 +419,7 @@
419
419
  },
420
420
  {
421
421
  "name": "submit:requested",
422
- "description": "Fired after the submit was accepted, to let a listener resolve the response itself.",
422
+ "description": "Fired after the submit was accepted, to let a listener resolve the response itself. Register it through AsyncEvents.asyncOn: the form awaits only such a listener, and one added with addEventListener is not waited for.",
423
423
  "type": {
424
424
  "text": "CustomEvent"
425
425
  }
package/dist/fml.iife.js CHANGED
@@ -6963,6 +6963,11 @@ var fml = (function (exports) {
6963
6963
  * `rest` being the remainder of the path (`''` on an exact match) that a
6964
6964
  * composite can use to route the problem to an inner control.
6965
6965
  *
6966
+ * A control counts only when it can show a problem, through a
6967
+ * `setCustomValidity` of its own: a form-associated element that has none is
6968
+ * skipped, so its problems fall to a shorter prefix or to the banner instead
6969
+ * of being dropped.
6970
+ *
6966
6971
  * Every other problem, and a field problem naming no control, is shown in
6967
6972
  * each `ful-errors` banner of the form: the banner gets `role="alert"`, is
6968
6973
  * revealed and holds the reasons one per line. With no such problem the
@@ -7001,7 +7006,7 @@ var fml = (function (exports) {
7001
7006
  const parts = name.split('.');
7002
7007
  for (let i = parts.length; i !== 0; --i) {
7003
7008
  const prefix = parts.slice(0, i).join('.');
7004
- const targets = named.get(prefix) ?? [];
7009
+ const targets = (named.get(prefix) ?? []).filter((el) => typeof el.setCustomValidity === 'function');
7005
7010
  if (targets.length === 0) {
7006
7011
  continue;
7007
7012
  }
@@ -7459,6 +7464,14 @@ var fml = (function (exports) {
7459
7464
  * accessibility tree.
7460
7465
  * - `also` are further controls that mirror `disabled` and `readOnly`
7461
7466
  * beside the claim target.
7467
+ *
7468
+ * The pieces are elements the field rendered itself, native controls or
7469
+ * wrappers of its own. A ful field is not one: it has no `readOnly`, its
7470
+ * host's role takes no aria, and a label's `for` names its host rather than
7471
+ * its control. A composite built from ful fields answers the inner field as
7472
+ * `control`, which forwards `focus()`, lets it render its own label with
7473
+ * `label` and `announces` null, and forwards the claims in its setters,
7474
+ * calling super first.
7462
7475
  * @param {{ slots: Record<string, DocumentFragment> | undefined }} conf
7463
7476
  * @returns {any} the pieces, or a promise of them
7464
7477
  * @throws {Error} when the subclass does not implement it
@@ -7615,7 +7628,8 @@ var fml = (function (exports) {
7615
7628
  *
7616
7629
  * Attributes on the host starting with `form-` are forwarded, without the
7617
7630
  * prefix, to the native form. `autocomplete` is copied onto the native form as
7618
- * 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.
7619
7633
  * `clear-invalid-on-change` clears the custom validity of a field of this form
7620
7634
  * when a `change` event bubbles from it; the field's form is read from its
7621
7635
  * `form` property, or from `internals.form` for a form-associated element
@@ -7650,8 +7664,8 @@ var fml = (function (exports) {
7650
7664
  const form = document.createElement('form');
7651
7665
  this.form = form;
7652
7666
  form.setAttribute('novalidate', '');
7653
- Attributes.forward('form-', this, form);
7654
7667
  Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
7668
+ Attributes.forward('form-', this, form);
7655
7669
  form.replaceChildren(...this.childNodes);
7656
7670
  form.addEventListener('submit', async (e) => {
7657
7671
  e.preventDefault();
@@ -10846,7 +10860,7 @@ var fml = (function (exports) {
10846
10860
  return;
10847
10861
  }
10848
10862
  /** @type {HTMLInputElement|null} */
10849
- const el = this.querySelector(`input[type=radio][value=${CSS.escape(String(value))}]`);
10863
+ const el = this.querySelector(`input[type=radio][value="${CSS.escape(String(value))}"]`);
10850
10864
  if (el === null) {
10851
10865
  clear();
10852
10866
  return;
@@ -11839,7 +11853,9 @@ var fml = (function (exports) {
11839
11853
  * `items` answers the items in the order they are walked, and is called on
11840
11854
  * every gesture, so a menu that fills itself later needs no rewiring.
11841
11855
  *
11842
- * @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
11843
11859
  * @param {HTMLElement} menu the `[popover]` holding the items
11844
11860
  * @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
11845
11861
  * `items` answers the items; `current` answers the item an opening focuses
@@ -11849,7 +11865,7 @@ var fml = (function (exports) {
11849
11865
  let typedAt = 0;
11850
11866
  menu.addEventListener('beforetoggle', (/** @type any */ evt) => {
11851
11867
  if (evt.newState === 'closed' && menu.contains(document.activeElement)) {
11852
- invoker.focus();
11868
+ invoker()?.focus();
11853
11869
  }
11854
11870
  });
11855
11871
  menu.addEventListener('toggle', (/** @type any */ evt) => {
@@ -11893,11 +11909,11 @@ var fml = (function (exports) {
11893
11909
  case 'Space': {
11894
11910
  evt.preventDefault();
11895
11911
  item.click();
11896
- invoker.focus();
11912
+ invoker()?.focus();
11897
11913
  return;
11898
11914
  }
11899
11915
  case 'Escape': {
11900
- invoker.focus();
11916
+ invoker()?.focus();
11901
11917
  return;
11902
11918
  }
11903
11919
  }
@@ -12122,7 +12138,7 @@ var fml = (function (exports) {
12122
12138
  }
12123
12139
  #wire() {
12124
12140
  Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
12125
- wireMenuKeys(this.#button, this.#menu, {
12141
+ wireMenuKeys(() => this.#button, this.#menu, {
12126
12142
  items: () => this.#items(),
12127
12143
  current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
12128
12144
  });
@@ -13120,11 +13136,12 @@ var fml = (function (exports) {
13120
13136
  * `show`, and fills the content with what the callback resolves to. A
13121
13137
  * rejection is shown, as the problems' reasons, in the error section, and
13122
13138
  * the content is hidden. When a newer `update()` starts before the callback
13123
- * settles, this call paints nothing: it resolves with the section regardless,
13124
- * and still rejects with the callback's error.
13139
+ * settles, this call paints nothing and resolves `null`, the section being
13140
+ * the newer call's; it still rejects with the callback's error.
13125
13141
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
13126
13142
  * @param {() => void} show opens the dialog when it is closed
13127
- * @returns {Promise<HTMLElement>} the content section
13143
+ * @returns {Promise<HTMLElement|null>} the content section, or `null` for a
13144
+ * call a newer one overtook
13128
13145
  * @throws {any} what the callback threw or rejected with
13129
13146
  */
13130
13147
  async update(cb, show) {
@@ -13137,7 +13154,7 @@ var fml = (function (exports) {
13137
13154
  try {
13138
13155
  const delivered = await cb();
13139
13156
  if (claim.stale) {
13140
- return this.#section;
13157
+ return null;
13141
13158
  }
13142
13159
  this.#section.replaceChildren(delivered);
13143
13160
  this.#loading?.setAttribute('hidden', '');
@@ -13383,11 +13400,12 @@ var fml = (function (exports) {
13383
13400
  *
13384
13401
  * No `section:requested` is dispatched, and no `ask()` waiter is added:
13385
13402
  * listen for the `close` event to learn how the dialog ended. When a newer
13386
- * `update()` starts before the callback settles, this call paints nothing:
13387
- * it resolves with the body regardless, and still rejects with the
13388
- * callback's error.
13403
+ * `update()` starts before the callback settles, this call paints nothing
13404
+ * and resolves `null`, the body being the newer call's; it still rejects
13405
+ * with the callback's error.
13389
13406
  * @param {() => Node|string|Promise<Node|string>} cb producing the body content
13390
- * @returns {Promise<Element>} the body section, in the document
13407
+ * @returns {Promise<Element|null>} the body section, in the document, or
13408
+ * `null` for a call a newer one overtook
13391
13409
  * @throws {any} what the callback threw or rejected with
13392
13410
  */
13393
13411
  async update(cb) {
@@ -13677,12 +13695,13 @@ var fml = (function (exports) {
13677
13695
  * which has `role="alert"`, and the content is hidden.
13678
13696
  *
13679
13697
  * No `section:requested` is dispatched. When a newer `update()` starts
13680
- * before the callback settles, this call paints nothing: it resolves with
13681
- * the content section regardless, and still rejects with the callback's
13682
- * error.
13698
+ * before the callback settles, this call paints nothing and resolves
13699
+ * `null`, the content section being the newer call's; it still rejects with
13700
+ * the callback's error.
13683
13701
  * @param {string} header the title, set as text
13684
13702
  * @param {() => Node|string|Promise<Node|string>} cb producing the content
13685
- * @returns {Promise<Element>} the content section, in the document
13703
+ * @returns {Promise<Element|null>} the content section, in the document, or
13704
+ * `null` for a call a newer one overtook
13686
13705
  * @throws {any} what the callback threw or rejected with
13687
13706
  */
13688
13707
  async update(header, cb) {
@@ -13831,11 +13850,15 @@ var fml = (function (exports) {
13831
13850
  }
13832
13851
 
13833
13852
  /**
13834
- * A menu of commands on the native popover platform, opened by the element
13835
- * whose id `for` names. The menu is the popover itself: light dismiss and
13836
- * 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
13837
13858
  * `Anchors.wire`, placed in script where the platform has no css anchor
13838
- * 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)`.
13839
13862
  *
13840
13863
  * The items are the buttons and `a[href]` links written as its direct
13841
13864
  * children, kept as they are. Activation is the item's own click, which the
@@ -13845,34 +13868,44 @@ var fml = (function (exports) {
13845
13868
  * The keyboard is the ARIA menu protocol: the focus moves to the
13846
13869
  * first item as the menu opens, the arrows, Home and End walk the items,
13847
13870
  * typing searches their text, Enter and Space activate the focused one, and
13848
- * 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.
13849
13873
  */
13850
13874
  class Menu extends ParsedElement {
13851
13875
  /** @type {string[]} */
13852
13876
  static attributes = ['for'];
13877
+ /** @type {HTMLElement|null} */
13878
+ #invoker = null;
13879
+ /** @type {HTMLElement|null} */
13880
+ #pressed = null;
13853
13881
  /**
13854
13882
  * Gives the menu the `menu` role and an empty `popover` attribute unless
13855
- * it already has one, gives the invoker `aria-haspopup="menu"`, an
13856
- * `aria-expanded` kept in step, and the toggle of the menu, and marks the
13857
- * 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.
13858
13886
  * @returns {void}
13859
- * @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
13860
13888
  */
13861
13889
  render() {
13862
13890
  const named = this.declared('for');
13863
13891
  const invoker = named ? document.getElementById(named) : null;
13864
- if (!invoker) {
13892
+ if (named && !invoker) {
13865
13893
  throw new Error(
13866
- `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`,
13867
13895
  );
13868
13896
  }
13869
13897
  this.setAttribute('role', 'menu');
13870
13898
  if (!this.hasAttribute('popover')) {
13871
13899
  this.setAttribute('popover', '');
13872
13900
  }
13873
- invoker.setAttribute('aria-haspopup', 'menu');
13874
- Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
13875
- 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 });
13876
13909
  this.#mark();
13877
13910
  this.addEventListener('click', (evt) => {
13878
13911
  const target = /** @type HTMLElement */ (evt.target);
@@ -13881,6 +13914,43 @@ var fml = (function (exports) {
13881
13914
  }
13882
13915
  });
13883
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
+ }
13884
13954
  /**
13885
13955
  * The commands, in the order the arrows walk them: the menu's direct
13886
13956
  * `button` and `a[href]` children, minus those carrying a `disabled` or
@@ -13892,6 +13962,21 @@ var fml = (function (exports) {
13892
13962
  get items() {
13893
13963
  return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
13894
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
+ }
13895
13980
  /** @returns {HTMLElement[]} every item, disabled and hidden ones included, once marked */
13896
13981
  #mark() {
13897
13982
  const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);