@optionfactory/fml 9.0.0-rc14 → 9.0.0-rc15

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/fml.iife.js CHANGED
@@ -8533,7 +8533,7 @@ var fml = (function (exports) {
8533
8533
 
8534
8534
  const showOncePlaced = (popover, anchored) => {
8535
8535
  popover.style.visibility = 'hidden';
8536
- requestAnimationFrame(() => {
8536
+ queueMicrotask(() => {
8537
8537
  if (popover.matches(':popover-open')) {
8538
8538
  place(popover, anchored);
8539
8539
  }
@@ -8672,8 +8672,9 @@ var fml = (function (exports) {
8672
8672
  * start-aligned, clamped into the viewport; `flip` moves it above the
8673
8673
  * anchor where the viewport leaves no room below, as the css
8674
8674
  * `position-try-fallbacks` the paired popovers declare. A popover not
8675
- * already open is shown before it is placed, so the first position anybody
8676
- * sees is the right one.
8675
+ * already open is shown and placed before the call returns, so the first
8676
+ * position anybody sees is the right one and the caller can move the focus
8677
+ * into it on the next line.
8677
8678
  *
8678
8679
  * @param {HTMLElement} popover the `[popover]` element to show, or an
8679
8680
  * already-shown element to place
@@ -8699,16 +8700,10 @@ var fml = (function (exports) {
8699
8700
  popover.style.left = `${clamp(box.left, PAD, Math.max(PAD, viewport.clientWidth - here.width - PAD))}px`;
8700
8701
  popover.style.top = `${clamp(top, PAD, Math.max(PAD, viewport.clientHeight - here.height - PAD))}px`;
8701
8702
  };
8702
- if (popover.matches(':popover-open') || typeof popover.showPopover !== 'function') {
8703
- placeShown();
8704
- return popover;
8705
- }
8706
- popover.style.visibility = 'hidden';
8707
- popover.showPopover();
8708
- requestAnimationFrame(() => {
8709
- placeShown();
8710
- popover.style.removeProperty('visibility');
8711
- });
8703
+ if (typeof popover.showPopover === 'function' && !popover.matches(':popover-open')) {
8704
+ popover.showPopover();
8705
+ }
8706
+ placeShown();
8712
8707
  return popover;
8713
8708
  }
8714
8709
  }
@@ -9000,6 +8995,11 @@ var fml = (function (exports) {
9000
8995
  //from the host when it gave one, since it has to set aria-controls before
9001
8996
  //this element upgrades
9002
8997
  this.#menu.id = this.declared('listbox') || Attributes.uid('ful-listbox');
8998
+ this.#menu.addEventListener('mousedown', (evt) => {
8999
+ if (evt.target.closest('li')) {
9000
+ evt.preventDefault();
9001
+ }
9002
+ });
9003
9003
  this.#menu.addEventListener('click', (evt) => {
9004
9004
  evt.stopPropagation();
9005
9005
  const li = evt.target.closest('li');
@@ -9007,7 +9007,7 @@ var fml = (function (exports) {
9007
9007
  this.hide();
9008
9008
  return;
9009
9009
  }
9010
- if (li.hasAttribute('aria-disabled')) {
9010
+ if (li.matches('[aria-disabled="true"]')) {
9011
9011
  return;
9012
9012
  }
9013
9013
  this.#change(li);
@@ -9016,8 +9016,8 @@ var fml = (function (exports) {
9016
9016
  }
9017
9017
  #selected() {
9018
9018
  return (
9019
- this.#menu?.querySelector('[selected]:not([aria-disabled])') ??
9020
- this.#menu?.querySelector('li:not([aria-disabled])') ??
9019
+ this.#menu?.querySelector('[selected]:not([aria-disabled="true"])') ??
9020
+ this.#menu?.querySelector('li:not([aria-disabled="true"])') ??
9021
9021
  null
9022
9022
  );
9023
9023
  }
@@ -9059,7 +9059,11 @@ var fml = (function (exports) {
9059
9059
  li.setAttribute('aria-selected', picked ? 'true' : 'false');
9060
9060
  //metadata.disabled refuses the entry, metadata.reason saying why
9061
9061
  const disabled = !!entry?.metadata?.disabled;
9062
- li.toggleAttribute('aria-disabled', disabled);
9062
+ if (disabled) {
9063
+ li.setAttribute('aria-disabled', 'true');
9064
+ } else {
9065
+ li.removeAttribute('aria-disabled');
9066
+ }
9063
9067
  if (disabled && entry.metadata.reason) {
9064
9068
  li.setAttribute('title', String(entry.metadata.reason));
9065
9069
  } else {
@@ -9148,7 +9152,7 @@ var fml = (function (exports) {
9148
9152
  }
9149
9153
  #walk(from, forward) {
9150
9154
  for (let li = from; li; li = li[`${forward ? 'next' : 'previous'}ElementSibling`]) {
9151
- if (!li.hasAttribute('aria-disabled')) {
9155
+ if (!li.matches('[aria-disabled="true"]')) {
9152
9156
  return li;
9153
9157
  }
9154
9158
  }
@@ -10592,15 +10596,110 @@ var fml = (function (exports) {
10592
10596
  }
10593
10597
  }
10594
10598
 
10599
+ /** how long a type-ahead buffer stands before the next character starts a new search */
10600
+ const TYPEAHEAD_WINDOW = 500;
10601
+
10602
+ /**
10603
+ * The keyboard protocol of a menu popover: the focus moving into the menu as it
10604
+ * opens and back to the invoker as it closes, roving focus over the items with
10605
+ * the arrows, Home and End, type-ahead over their text, and Enter and Space
10606
+ * activating the focused one.
10607
+ *
10608
+ * The menu owns what an item is: `items` answers them in the order they are
10609
+ * walked, read on every gesture so a menu that fills itself later needs no
10610
+ * rewiring, and `current` names the one an opening focuses, the first otherwise.
10611
+ *
10612
+ * @param {HTMLElement} invoker the element the menu belongs to, and where the focus goes back
10613
+ * @param {HTMLElement} menu the `[popover]` holding the items
10614
+ * @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
10615
+ */
10616
+ const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
10617
+ let typed = '';
10618
+ let typedAt = 0;
10619
+ menu.addEventListener('toggle', (/** @type any */ evt) => {
10620
+ if (evt.newState !== 'open') {
10621
+ //give the invoker back the focus the menu had borrowed, without
10622
+ //stealing it from wherever else the close came from
10623
+ if (menu.contains(document.activeElement)) {
10624
+ invoker.focus();
10625
+ }
10626
+ return;
10627
+ }
10628
+ const all = items();
10629
+ (current() ?? all[0])?.focus();
10630
+ });
10631
+ menu.addEventListener('keydown', (evt) => {
10632
+ const target = /** @type HTMLElement */ (evt.target);
10633
+ const all = items();
10634
+ const item = all.find((candidate) => candidate === target || candidate.contains(target));
10635
+ if (!item) {
10636
+ return;
10637
+ }
10638
+ const at = all.indexOf(item);
10639
+ switch (evt.code) {
10640
+ case 'ArrowDown': {
10641
+ evt.preventDefault();
10642
+ all[(at + 1) % all.length]?.focus();
10643
+ return;
10644
+ }
10645
+ case 'ArrowUp': {
10646
+ evt.preventDefault();
10647
+ all[(at - 1 + all.length) % all.length]?.focus();
10648
+ return;
10649
+ }
10650
+ case 'Home': {
10651
+ evt.preventDefault();
10652
+ all[0]?.focus();
10653
+ return;
10654
+ }
10655
+ case 'End': {
10656
+ evt.preventDefault();
10657
+ all[all.length - 1]?.focus();
10658
+ return;
10659
+ }
10660
+ case 'Enter':
10661
+ case 'Space': {
10662
+ evt.preventDefault();
10663
+ item.click();
10664
+ invoker.focus();
10665
+ return;
10666
+ }
10667
+ case 'Escape': {
10668
+ //the platform's close request hides the menu, the focus is placed
10669
+ //on the invoker before the focused item is detached from it
10670
+ invoker.focus();
10671
+ return;
10672
+ }
10673
+ }
10674
+ if (evt.key.length !== 1 || evt.ctrlKey || evt.altKey || evt.metaKey) {
10675
+ return;
10676
+ }
10677
+ const now = Date.now();
10678
+ typed = now - typedAt > TYPEAHEAD_WINDOW ? evt.key : typed + evt.key;
10679
+ typedAt = now;
10680
+ const wanted = typed.toLowerCase();
10681
+ const repeated = [...wanted].every((character) => character === wanted[0]);
10682
+ const search = repeated ? wanted[0] : wanted;
10683
+ const from = repeated ? at + 1 : at;
10684
+ const found = [...all.slice(from), ...all.slice(0, from)].find((candidate) =>
10685
+ (candidate.textContent ?? '').trim().toLowerCase().startsWith(search),
10686
+ );
10687
+ if (found) {
10688
+ evt.preventDefault();
10689
+ found.focus();
10690
+ }
10691
+ });
10692
+ };
10693
+
10595
10694
  /**
10596
10695
  * An invoker button paired with the `ul[popover][role=menu]` that follows it:
10597
10696
  * the chrome behind every filter's operator, sensitivity and boolean value.
10598
10697
  *
10599
10698
  * It fills the menu from a vocabulary, wires it the first time more than one
10600
10699
  * choice survives the whitelist, pins the button to a static glyph when a
10601
- * single one does, owns the roving focus and the Escape/Enter handling, and
10602
- * keeps the button's value, glyph and aria-label in step. A pick that changes
10603
- * the value calls back; the host decides what that means.
10700
+ * single one does, takes the shared menu keyboard protocol, and keeps the
10701
+ * button's value, glyph and aria-label in step. A pick that changes the value
10702
+ * calls back; the host decides what that means.
10604
10703
  *
10605
10704
  * The button's `value` attribute is the store, as it is for a native control:
10606
10705
  * the menu protocol finds the current item by it, and nothing mirrors it.
@@ -10758,64 +10857,10 @@ var fml = (function (exports) {
10758
10857
  return Array.from(this.#menu.querySelectorAll('li > a'), (a) => /** @type HTMLAnchorElement */ (a));
10759
10858
  }
10760
10859
  #wire() {
10761
- const button = this.#button;
10762
- const menu = this.#menu;
10763
- Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
10764
- menu.addEventListener('toggle', (/** @type any */ evt) => {
10765
- if (evt.newState !== 'open') {
10766
- //give the invoker back the focus the menu had borrowed, without
10767
- //stealing it from wherever else the close came from
10768
- if (menu.contains(document.activeElement)) {
10769
- button.focus();
10770
- }
10771
- return;
10772
- }
10773
- const items = this.#items();
10774
- (items.find((a) => a.getAttribute('value') === this.value) ?? items[0])?.focus();
10775
- });
10776
- menu.addEventListener('keydown', (evt) => {
10777
- const target = /** @type HTMLElement */ (evt.target);
10778
- const item = /** @type HTMLAnchorElement | null */ (target.closest('li > a'));
10779
- if (!item) {
10780
- return;
10781
- }
10782
- const items = this.#items();
10783
- const at = items.indexOf(item);
10784
- switch (evt.code) {
10785
- case 'ArrowDown': {
10786
- evt.preventDefault();
10787
- items[(at + 1) % items.length]?.focus();
10788
- break;
10789
- }
10790
- case 'ArrowUp': {
10791
- evt.preventDefault();
10792
- items[(at - 1 + items.length) % items.length]?.focus();
10793
- break;
10794
- }
10795
- case 'Home': {
10796
- evt.preventDefault();
10797
- items[0]?.focus();
10798
- break;
10799
- }
10800
- case 'End': {
10801
- evt.preventDefault();
10802
- items[items.length - 1]?.focus();
10803
- break;
10804
- }
10805
- case 'Enter':
10806
- case 'Space': {
10807
- evt.preventDefault();
10808
- item.click();
10809
- button.focus();
10810
- break;
10811
- }
10812
- case 'Escape': {
10813
- //the platform's close request hides the menu, the focus is placed
10814
- //on the invoker before the focused item is detached from it
10815
- button.focus();
10816
- break;
10817
- }
10818
- }
10860
+ Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
10861
+ wireMenuKeys(this.#button, this.#menu, {
10862
+ items: () => this.#items(),
10863
+ current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
10819
10864
  });
10820
10865
  }
10821
10866
  }
@@ -11790,15 +11835,22 @@ var fml = (function (exports) {
11790
11835
  *
11791
11836
  * The body is text or a node: a string is never read as markup, an
11792
11837
  * `innerHTML` sink being a thing the library does not carry. Each button is
11793
- * a `[result, label, className?]` tuple, rendered as its answer.
11838
+ * a `[result, label, className?]` tuple rendered as its answer, or a node
11839
+ * the caller has built itself, which is how a button carries anything a
11840
+ * tuple cannot say and how a footer gets a spacer.
11794
11841
  * @param {string} header
11795
11842
  * @param {string|Node} body
11796
- * @param {[string, string, string?][]} [buttons]
11843
+ * @param {([string, string, string?]|Node)[]} [buttons]
11844
+ * @param {{ className?: string }} [options] `className` dresses the dialog,
11845
+ * the way a declared `ful-dialog` is given one to size it
11797
11846
  * @returns {Promise<DialogOutcome>}
11798
11847
  */
11799
- static async ask(header, body, buttons = []) {
11848
+ static async ask(header, body, buttons = [], { className } = {}) {
11800
11849
  const dialog = document.createElement('ful-dialog');
11801
11850
  dialog.setAttribute('header', header);
11851
+ if (className) {
11852
+ dialog.className = className;
11853
+ }
11802
11854
  if (typeof body === 'string') {
11803
11855
  dialog.textContent = body;
11804
11856
  } else if (body) {
@@ -11809,12 +11861,17 @@ var fml = (function (exports) {
11809
11861
  if (buttons.length > 0) {
11810
11862
  const choices = document.createElement('template');
11811
11863
  choices.setAttribute('slot', 'buttons');
11812
- for (const [result, label, className] of buttons) {
11864
+ for (const choice of buttons) {
11865
+ if (choice instanceof Node) {
11866
+ choices.content.append(choice);
11867
+ continue;
11868
+ }
11869
+ const [result, label, buttonClass] = choice;
11813
11870
  const button = document.createElement('button');
11814
11871
  button.type = 'button';
11815
11872
  button.dataset.result = result;
11816
- if (className) {
11817
- button.className = className;
11873
+ if (buttonClass) {
11874
+ button.className = buttonClass;
11818
11875
  }
11819
11876
  button.textContent = label;
11820
11877
  choices.content.append(button);
@@ -11836,13 +11893,19 @@ var fml = (function (exports) {
11836
11893
  * @param {string} header
11837
11894
  * @param {string|Node} body
11838
11895
  * @param {{ confirm?: string, cancel?: string }} [labels] overriding the localized defaults
11896
+ * @param {{ className?: string }} [options] as `ask` takes them
11839
11897
  * @returns {Promise<boolean>}
11840
11898
  */
11841
- static async confirm(header, body, labels = {}) {
11842
- const outcome = await Dialog.ask(header, body, [
11843
- ['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
11844
- ['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
11845
- ]);
11899
+ static async confirm(header, body, labels = {}, options = {}) {
11900
+ const outcome = await Dialog.ask(
11901
+ header,
11902
+ body,
11903
+ [
11904
+ ['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
11905
+ ['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
11906
+ ],
11907
+ options,
11908
+ );
11846
11909
  return !outcome.dismissed && outcome.result === 'confirm';
11847
11910
  }
11848
11911
  }
@@ -12049,6 +12112,65 @@ var fml = (function (exports) {
12049
12112
  }
12050
12113
  }
12051
12114
 
12115
+ /**
12116
+ * A menu of commands on the native popover platform: the invoker named by
12117
+ * `for` toggles it and light dismiss and Escape come from the browser, while
12118
+ * the element adds the menu semantics and the keyboard protocol.
12119
+ *
12120
+ * The items are the buttons and links written inside it, kept as they are and
12121
+ * given their menu role and their place in the roving focus. Activation is the
12122
+ * item's own click, which the element only follows by closing.
12123
+ */
12124
+ class Menu extends ParsedElement {
12125
+ static attributes = ['for'];
12126
+ render() {
12127
+ const named = this.declared('for');
12128
+ const invoker = named ? document.getElementById(named) : null;
12129
+ if (!invoker) {
12130
+ throw new Error(
12131
+ `ful-menu names no invoker: for="${named ?? ''}" must name an element already in the page when the menu upgrades`,
12132
+ );
12133
+ }
12134
+ this.setAttribute('role', 'menu');
12135
+ if (!this.hasAttribute('popover')) {
12136
+ this.setAttribute('popover', '');
12137
+ }
12138
+ invoker.setAttribute('aria-haspopup', 'menu');
12139
+ Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
12140
+ wireMenuKeys(invoker, this, { items: () => this.items });
12141
+ this.#mark();
12142
+ this.addEventListener('click', (evt) => {
12143
+ const target = /** @type HTMLElement */ (evt.target);
12144
+ if (this.items.some((item) => item === target || item.contains(target))) {
12145
+ this.hidePopover();
12146
+ }
12147
+ });
12148
+ }
12149
+ /**
12150
+ * The commands, in the order the arrows walk them: the menu's own buttons
12151
+ * and links, minus the disabled and hidden ones, which keep their role and
12152
+ * leave the walk.
12153
+ */
12154
+ get items() {
12155
+ return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
12156
+ }
12157
+ /**
12158
+ * Gives every button and link of the menu its item role and takes it out of
12159
+ * the tab order, and answers them. Read on every gesture as well as at the
12160
+ * render, so a menu a page fills or empties later needs no rewiring.
12161
+ */
12162
+ #mark() {
12163
+ const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);
12164
+ for (const item of all) {
12165
+ if (!item.hasAttribute('role')) {
12166
+ item.setAttribute('role', 'menuitem');
12167
+ }
12168
+ item.tabIndex = -1;
12169
+ }
12170
+ return all;
12171
+ }
12172
+ }
12173
+
12052
12174
  const SEVERITIES = ['info', 'success', 'warning', 'error'];
12053
12175
 
12054
12176
  //the regions alive in the document: the show-toast listener is wired once and
@@ -12694,6 +12816,7 @@ var fml = (function (exports) {
12694
12816
  .defineElement('ful-tooltip', Tooltip)
12695
12817
  .defineElement('ful-dialog', Dialog)
12696
12818
  .defineElement('ful-drawer', Drawer)
12819
+ .defineElement('ful-menu', Menu)
12697
12820
  .defineElement('ful-toasts', Toasts)
12698
12821
  .defineElement('ful-tabs', Tabs)
12699
12822
  .defineElement('ful-accordion', Accordion)
@@ -12760,6 +12883,7 @@ var fml = (function (exports) {
12760
12883
  LocalDate: LocalDate,
12761
12884
  LocalDateFilter: LocalDateFilter,
12762
12885
  LocalStorage: LocalStorage,
12886
+ Menu: Menu,
12763
12887
  NumberFilter: NumberFilter,
12764
12888
  Pagination: Pagination,
12765
12889
  Plugin: Plugin,
@@ -12832,6 +12956,7 @@ var fml = (function (exports) {
12832
12956
  exports.LocalStorage = LocalStorage;
12833
12957
  exports.Localization = Localization;
12834
12958
  exports.MediaType = MediaType;
12959
+ exports.Menu = Menu;
12835
12960
  exports.Nodes = Nodes;
12836
12961
  exports.NumberFilter = NumberFilter;
12837
12962
  exports.Pagination = Pagination;