@optionfactory/fml 9.0.0-rc3 → 9.0.0-rc4

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.
@@ -11,14 +11,28 @@
11
11
  "name": "Tooltip",
12
12
  "tagName": "ful-tooltip",
13
13
  "customElement": true,
14
- "description": "An info affordance: a themed icon button toggling a small popover with a short explanation, opened by click and by keyboard like a button, dismissed by Escape or an outside click. placement picks the side the note sits on: bottom (default), top, left or right.",
14
+ "description": "An info affordance: a themed icon button toggling a small popover with a short explanation, opened by click and by keyboard like a button, dismissed by Escape or an outside click. placement picks the side the note sits on: top (default), bottom, left or right.",
15
15
  "attributes": [
16
16
  {
17
17
  "name": "placement",
18
- "description": "Which side of the trigger the note opens on: bottom (the default), top, start or end. The library places the note itself on every platform, so the side is the same everywhere.",
18
+ "description": "Which side of the trigger the note opens on: top (the default), bottom, left or right. Above by default because a note opening downwards covers the control the marker explains. The library places the note itself on every platform, so the side is the same everywhere.",
19
19
  "type": {
20
20
  "text": "string"
21
21
  }
22
+ },
23
+ {
24
+ "name": "icon",
25
+ "description": "The ful-icon the marker shows, for a tooltip meaning something other than plain information. Defaults to the page's Tooltip.config.icon; a name the library does not paint is declared by the page as ful-icon[name='...'] { mask-image: ... }.",
26
+ "type": {
27
+ "text": "string"
28
+ }
29
+ },
30
+ {
31
+ "name": "describes",
32
+ "description": "For a tooltip standing in a field: the note joins the accessible description of that field's control, announced on reaching the field rather than only on opening the marker, and the marker leaves the tab order so a form of hinted fields costs no extra keystrokes to walk. The marker stays clickable, and keeps its tab stop wherever nothing took the note.",
33
+ "type": {
34
+ "text": "boolean"
35
+ }
22
36
  }
23
37
  ],
24
38
  "slots": [
package/dist/fml.iife.js CHANGED
@@ -6462,6 +6462,48 @@ var fml = (function (exports) {
6462
6462
  }
6463
6463
  }
6464
6464
 
6465
+ /**
6466
+ * The protocol by which content standing inside a field becomes part of the
6467
+ * accessible description of that field's control.
6468
+ *
6469
+ * A field owns its control's `aria-describedby`: it is the only thing that
6470
+ * knows which element the description belongs on, and it already writes the
6471
+ * entry for its own error region. Content the author slotted into the field
6472
+ * cannot write that attribute itself without becoming a second owner of it, and
6473
+ * it cannot be wired by the field either, because a slotted custom element
6474
+ * renders after the field has mounted and has nothing to point at when the
6475
+ * field looks.
6476
+ *
6477
+ * So the content asks, once it has something to offer. `describable(el)`
6478
+ * answers the nearest ancestor that accepts a description, and the caller hands
6479
+ * its element to that ancestor's `describedBy`, which answers whether it was
6480
+ * taken. Nothing here names a field or a tooltip: the relation is expressed as
6481
+ * a capability, so the two ends need not import each other, which matters
6482
+ * because the library's own arrow runs from the forms to the disclosures.
6483
+ *
6484
+ * The lookup lives here rather than at its one call site so the protocol has a
6485
+ * name, a place to be documented and a single definition to change.
6486
+ */
6487
+
6488
+ /**
6489
+ * @typedef {{ describedBy(el: HTMLElement): boolean }} Describable
6490
+ */
6491
+
6492
+ /**
6493
+ * The nearest ancestor of `el` that accepts elements into the description of
6494
+ * whatever it considers its control, or null when nothing in the ancestry does.
6495
+ * @param {Element} el
6496
+ * @returns {(Element & Describable) | null}
6497
+ */
6498
+ const describable = (el) => {
6499
+ for (let at = el.parentElement; at; at = at.parentElement) {
6500
+ if (typeof (/** @type {any} */ (at).describedBy) === 'function') {
6501
+ return /** @type {any} */ (at);
6502
+ }
6503
+ }
6504
+ return null;
6505
+ };
6506
+
6465
6507
  /**
6466
6508
  * Sleeping, debouncing and throttling. Debounce and throttle both return the
6467
6509
  * wrapped function together with a cancel function.
@@ -6856,6 +6898,9 @@ var fml = (function (exports) {
6856
6898
  /** the role the element internals carry, 'presentation' unless the control is its own */
6857
6899
  static ROLE = 'presentation';
6858
6900
  #control;
6901
+ #described;
6902
+ #descriptions = [];
6903
+ #errorId = null;
6859
6904
  #fieldError;
6860
6905
  #claims;
6861
6906
  #announces;
@@ -6907,17 +6952,16 @@ var fml = (function (exports) {
6907
6952
  true,
6908
6953
  );
6909
6954
  }
6910
- //the error region describes the control, or the host where there is no
6955
+ //the description lands on the control, or on the host where there is no
6911
6956
  //single control to describe (a radio group's legend names its fieldset)
6957
+ this.#described = described ?? control;
6912
6958
  if (error) {
6913
- //an attribute, not the aria element property: the property reflects to
6914
- //nothing, so the description lived in the accessibility tree alone and
6915
- //vanished entirely on a browser without aria element reflection
6916
- if (!error.id) {
6917
- error.id = Attributes.uid('ful-field-error');
6918
- }
6919
- (described ?? control).setAttribute('aria-describedby', error.id);
6959
+ //named for what it is, the generic id being for whoever brings no name
6960
+ error.id = error.id || Attributes.uid('ful-field-error');
6961
+ this.#errorId = error.id;
6920
6962
  }
6963
+ //anything handed over before the field had a target lands here
6964
+ this.#describe();
6921
6965
  if (label) {
6922
6966
  Field.#name(this, label, control);
6923
6967
  }
@@ -6952,6 +6996,61 @@ var fml = (function (exports) {
6952
6996
  static #submitsOnEnter(el) {
6953
6997
  return el instanceof HTMLInputElement && !['file', 'button', 'submit', 'reset', 'image'].includes(el.type);
6954
6998
  }
6999
+ /**
7000
+ * Adds an element to the accessible description of the field's control and
7001
+ * answers whether the field took it.
7002
+ *
7003
+ * A field takes one whenever it is offered, before its own render as
7004
+ * readily as after: content slotted into a field is a custom element of its
7005
+ * own and may upgrade on either side of the field it stands in, which
7006
+ * happens in both directions in practice, a tooltip beating an async select
7007
+ * to its render while losing to a plain input. A description handed over
7008
+ * early waits here and is written the moment the field has somewhere to
7009
+ * write it, so the caller never has to know the order.
7010
+ *
7011
+ * The reference lands on the element handed over rather than on a wrapper
7012
+ * around it: a hidden element is included in a description only where it is
7013
+ * named directly, and content that reaches the description through a
7014
+ * wrapper is skipped while it is hidden. A popover closed until someone
7015
+ * opens it is exactly that, so the caller passes the popover itself.
7016
+ *
7017
+ * An attribute rather than `ariaDescribedByElements`: the property reflects
7018
+ * to nothing, so the description would live in the accessibility tree alone
7019
+ * and vanish entirely on a browser without aria element reflection.
7020
+ *
7021
+ * This is the field's half of the description protocol; `describable` in
7022
+ * `ful/descriptions.mjs` is the half the content uses to find the field.
7023
+ * @param {HTMLElement} el
7024
+ * @returns {boolean}
7025
+ */
7026
+ describedBy(el) {
7027
+ if (!el) {
7028
+ return false;
7029
+ }
7030
+ if (!el.id) {
7031
+ el.id = Attributes.uid('ful-described');
7032
+ }
7033
+ if (!this.#descriptions.includes(el.id)) {
7034
+ this.#descriptions.push(el.id);
7035
+ }
7036
+ this.#describe();
7037
+ return true;
7038
+ }
7039
+ /**
7040
+ * Writes the description the field has collected, the error region last:
7041
+ * the standing explanations are what the field always says, the problem is
7042
+ * the news. The field owns the attribute outright rather than appending to
7043
+ * whatever is there, so the order does not depend on who arrived when.
7044
+ */
7045
+ #describe() {
7046
+ if (!this.#described) {
7047
+ return;
7048
+ }
7049
+ const ids = [...this.#descriptions, this.#errorId].filter((id) => id);
7050
+ if (ids.length) {
7051
+ this.#described.setAttribute('aria-describedby', ids.join(' '));
7052
+ }
7053
+ }
6955
7054
  focus(options) {
6956
7055
  this.#control?.focus(options);
6957
7056
  }
@@ -7188,8 +7287,9 @@ var fml = (function (exports) {
7188
7287
  * three claims reach it
7189
7288
  * - `error` is the field's live region
7190
7289
  * - `label`, when given, names the control and focuses it on click
7191
- * - `described` moves the error's description off the control and onto
7192
- * another element, the host where no single control can carry it
7290
+ * - `described` moves the description off the control and onto another
7291
+ * element, the host where no single control can carry it: the error
7292
+ * region and anything `describedBy` is later handed both land there
7193
7293
  * - `claims` moves the three claims onto a wrapper the field disables as a
7194
7294
  * whole, leaving focus and aria on the control
7195
7295
  * - `announces` is the element whose role carries `aria-readonly` and
@@ -10919,30 +11019,63 @@ var fml = (function (exports) {
10919
11019
  });
10920
11020
  };
10921
11021
 
10922
- /** An info icon button toggling a popover with a short explanation. */
11022
+ /**
11023
+ * An info icon button toggling a popover with a short explanation.
11024
+ *
11025
+ * The marker is the page's `config.icon`, and the `icon` attribute names a
11026
+ * `ful-icon` for the tooltip that means something other than plain information:
11027
+ * a caveat, a warning, a setting. A name the library does not paint is the
11028
+ * page's own, declared as `ful-icon[name='...'] { mask-image: ... }`.
11029
+ *
11030
+ * `describes` is for the tooltip standing in a field: the note becomes part of
11031
+ * the accessible description of that field's control, so it is announced on
11032
+ * reaching the field rather than only on opening the marker, and the marker
11033
+ * leaves the tab order, so a form of hinted fields costs no extra keystrokes to
11034
+ * walk. The marker stays clickable, and stays a tab stop wherever the note was
11035
+ * not taken, a tooltip claiming `describes` outside a field among them: the
11036
+ * stop only goes where something else delivers the content.
11037
+ */
10923
11038
  class Tooltip extends ParsedElement {
10924
11039
  static slots = true;
10925
- static attributes = ['placement'];
11040
+ static attributes = ['placement', 'icon', 'describes:presence'];
10926
11041
  static config = {
10927
11042
  icon: 'info-circle-fill',
10928
11043
  };
10929
11044
  static template = `
10930
- <button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="config.icon" aria-hidden="true"></ful-icon></button>
11045
+ <button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="icon ?? config.icon" aria-hidden="true"></ful-icon></button>
10931
11046
  <ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
10932
11047
  `;
10933
11048
  render({ slots }) {
10934
- const fragment = this.template().withOverlay({ slots }).render();
11049
+ const fragment = this.template().withOverlay({ slots, icon: this.declared('icon') }).render();
10935
11050
  const trigger = fragment.querySelector('[data-ref=trigger]');
10936
11051
  const content = fragment.querySelector('[data-ref=content]');
10937
11052
  //placed here rather than by the anchor css: the note draws a callout that
10938
11053
  //has to point at the trigger wherever the viewport left room for the note,
10939
11054
  //which is a measurement the stylesheet cannot make for a pseudo-element
10940
11055
  Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
10941
- const placement = this.declared('placement');
10942
- if (placement) {
10943
- content.setAttribute('placement', placement);
10944
- }
11056
+ //above the marker by default: a note opening downwards covers the control
11057
+ //the marker explains, the marker riding the field's label
11058
+ content.setAttribute('placement', this.declared('placement') ?? 'top');
10945
11059
  this.replaceChildren(fragment);
11060
+ if (this.declared('describes')) {
11061
+ Tooltip.#describe(this, trigger, content);
11062
+ }
11063
+ }
11064
+ /**
11065
+ * Offers the note to the field the tooltip stands in, and takes the trigger
11066
+ * out of the tab order only where the offer was accepted: a note nothing
11067
+ * carries is reachable by the keyboard through the marker alone, so
11068
+ * dropping the stop there would leave it reachable by nothing at all.
11069
+ *
11070
+ * The offer goes through the description protocol rather than naming a
11071
+ * field, the library's own arrow running from the forms to the disclosures.
11072
+ */
11073
+ static #describe(tooltip, trigger, content) {
11074
+ if (!describable(tooltip)?.describedBy(content)) {
11075
+ console.warn('a ful-tooltip declares describes but stands in nothing that takes a description', tooltip);
11076
+ return;
11077
+ }
11078
+ trigger.tabIndex = -1;
10946
11079
  }
10947
11080
  }
10948
11081
 
@@ -11828,7 +11961,8 @@ var fml = (function (exports) {
11828
11961
  Tooltip: Tooltip,
11829
11962
  VersionedLocalStorage: VersionedLocalStorage,
11830
11963
  VersionedSessionStorage: VersionedSessionStorage,
11831
- Wizard: Wizard
11964
+ Wizard: Wizard,
11965
+ describable: describable
11832
11966
  });
11833
11967
 
11834
11968
  if (typeof window !== 'undefined') {
@@ -11906,6 +12040,7 @@ var fml = (function (exports) {
11906
12040
  exports.VersionedLocalStorage = VersionedLocalStorage;
11907
12041
  exports.VersionedSessionStorage = VersionedSessionStorage;
11908
12042
  exports.Wizard = Wizard;
12043
+ exports.describable = describable;
11909
12044
  exports.registry = registry;
11910
12045
 
11911
12046
  return exports;