@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/ful.mjs CHANGED
@@ -2304,7 +2304,7 @@ const unplace = (popover) => {
2304
2304
 
2305
2305
  const showOncePlaced = (popover, anchored) => {
2306
2306
  popover.style.visibility = 'hidden';
2307
- requestAnimationFrame(() => {
2307
+ queueMicrotask(() => {
2308
2308
  if (popover.matches(':popover-open')) {
2309
2309
  place(popover, anchored);
2310
2310
  }
@@ -2443,8 +2443,9 @@ class Anchors {
2443
2443
  * start-aligned, clamped into the viewport; `flip` moves it above the
2444
2444
  * anchor where the viewport leaves no room below, as the css
2445
2445
  * `position-try-fallbacks` the paired popovers declare. A popover not
2446
- * already open is shown before it is placed, so the first position anybody
2447
- * sees is the right one.
2446
+ * already open is shown and placed before the call returns, so the first
2447
+ * position anybody sees is the right one and the caller can move the focus
2448
+ * into it on the next line.
2448
2449
  *
2449
2450
  * @param {HTMLElement} popover the `[popover]` element to show, or an
2450
2451
  * already-shown element to place
@@ -2470,16 +2471,10 @@ class Anchors {
2470
2471
  popover.style.left = `${clamp(box.left, PAD, Math.max(PAD, viewport.clientWidth - here.width - PAD))}px`;
2471
2472
  popover.style.top = `${clamp(top, PAD, Math.max(PAD, viewport.clientHeight - here.height - PAD))}px`;
2472
2473
  };
2473
- if (popover.matches(':popover-open') || typeof popover.showPopover !== 'function') {
2474
- placeShown();
2475
- return popover;
2476
- }
2477
- popover.style.visibility = 'hidden';
2478
- popover.showPopover();
2479
- requestAnimationFrame(() => {
2480
- placeShown();
2481
- popover.style.removeProperty('visibility');
2482
- });
2474
+ if (typeof popover.showPopover === 'function' && !popover.matches(':popover-open')) {
2475
+ popover.showPopover();
2476
+ }
2477
+ placeShown();
2483
2478
  return popover;
2484
2479
  }
2485
2480
  }
@@ -2771,6 +2766,11 @@ class Dropdown extends ParsedElement {
2771
2766
  //from the host when it gave one, since it has to set aria-controls before
2772
2767
  //this element upgrades
2773
2768
  this.#menu.id = this.declared('listbox') || Attributes.uid('ful-listbox');
2769
+ this.#menu.addEventListener('mousedown', (evt) => {
2770
+ if (evt.target.closest('li')) {
2771
+ evt.preventDefault();
2772
+ }
2773
+ });
2774
2774
  this.#menu.addEventListener('click', (evt) => {
2775
2775
  evt.stopPropagation();
2776
2776
  const li = evt.target.closest('li');
@@ -2778,7 +2778,7 @@ class Dropdown extends ParsedElement {
2778
2778
  this.hide();
2779
2779
  return;
2780
2780
  }
2781
- if (li.hasAttribute('aria-disabled')) {
2781
+ if (li.matches('[aria-disabled="true"]')) {
2782
2782
  return;
2783
2783
  }
2784
2784
  this.#change(li);
@@ -2787,8 +2787,8 @@ class Dropdown extends ParsedElement {
2787
2787
  }
2788
2788
  #selected() {
2789
2789
  return (
2790
- this.#menu?.querySelector('[selected]:not([aria-disabled])') ??
2791
- this.#menu?.querySelector('li:not([aria-disabled])') ??
2790
+ this.#menu?.querySelector('[selected]:not([aria-disabled="true"])') ??
2791
+ this.#menu?.querySelector('li:not([aria-disabled="true"])') ??
2792
2792
  null
2793
2793
  );
2794
2794
  }
@@ -2830,7 +2830,11 @@ class Dropdown extends ParsedElement {
2830
2830
  li.setAttribute('aria-selected', picked ? 'true' : 'false');
2831
2831
  //metadata.disabled refuses the entry, metadata.reason saying why
2832
2832
  const disabled = !!entry?.metadata?.disabled;
2833
- li.toggleAttribute('aria-disabled', disabled);
2833
+ if (disabled) {
2834
+ li.setAttribute('aria-disabled', 'true');
2835
+ } else {
2836
+ li.removeAttribute('aria-disabled');
2837
+ }
2834
2838
  if (disabled && entry.metadata.reason) {
2835
2839
  li.setAttribute('title', String(entry.metadata.reason));
2836
2840
  } else {
@@ -2919,7 +2923,7 @@ class Dropdown extends ParsedElement {
2919
2923
  }
2920
2924
  #walk(from, forward) {
2921
2925
  for (let li = from; li; li = li[`${forward ? 'next' : 'previous'}ElementSibling`]) {
2922
- if (!li.hasAttribute('aria-disabled')) {
2926
+ if (!li.matches('[aria-disabled="true"]')) {
2923
2927
  return li;
2924
2928
  }
2925
2929
  }
@@ -4363,15 +4367,110 @@ class Table extends ParsedElement {
4363
4367
  }
4364
4368
  }
4365
4369
 
4370
+ /** how long a type-ahead buffer stands before the next character starts a new search */
4371
+ const TYPEAHEAD_WINDOW = 500;
4372
+
4373
+ /**
4374
+ * The keyboard protocol of a menu popover: the focus moving into the menu as it
4375
+ * opens and back to the invoker as it closes, roving focus over the items with
4376
+ * the arrows, Home and End, type-ahead over their text, and Enter and Space
4377
+ * activating the focused one.
4378
+ *
4379
+ * The menu owns what an item is: `items` answers them in the order they are
4380
+ * walked, read on every gesture so a menu that fills itself later needs no
4381
+ * rewiring, and `current` names the one an opening focuses, the first otherwise.
4382
+ *
4383
+ * @param {HTMLElement} invoker the element the menu belongs to, and where the focus goes back
4384
+ * @param {HTMLElement} menu the `[popover]` holding the items
4385
+ * @param {{items: () => HTMLElement[], current?: () => HTMLElement|null|undefined}} conf
4386
+ */
4387
+ const wireMenuKeys = (invoker, menu, { items, current = () => null }) => {
4388
+ let typed = '';
4389
+ let typedAt = 0;
4390
+ menu.addEventListener('toggle', (/** @type any */ evt) => {
4391
+ if (evt.newState !== 'open') {
4392
+ //give the invoker back the focus the menu had borrowed, without
4393
+ //stealing it from wherever else the close came from
4394
+ if (menu.contains(document.activeElement)) {
4395
+ invoker.focus();
4396
+ }
4397
+ return;
4398
+ }
4399
+ const all = items();
4400
+ (current() ?? all[0])?.focus();
4401
+ });
4402
+ menu.addEventListener('keydown', (evt) => {
4403
+ const target = /** @type HTMLElement */ (evt.target);
4404
+ const all = items();
4405
+ const item = all.find((candidate) => candidate === target || candidate.contains(target));
4406
+ if (!item) {
4407
+ return;
4408
+ }
4409
+ const at = all.indexOf(item);
4410
+ switch (evt.code) {
4411
+ case 'ArrowDown': {
4412
+ evt.preventDefault();
4413
+ all[(at + 1) % all.length]?.focus();
4414
+ return;
4415
+ }
4416
+ case 'ArrowUp': {
4417
+ evt.preventDefault();
4418
+ all[(at - 1 + all.length) % all.length]?.focus();
4419
+ return;
4420
+ }
4421
+ case 'Home': {
4422
+ evt.preventDefault();
4423
+ all[0]?.focus();
4424
+ return;
4425
+ }
4426
+ case 'End': {
4427
+ evt.preventDefault();
4428
+ all[all.length - 1]?.focus();
4429
+ return;
4430
+ }
4431
+ case 'Enter':
4432
+ case 'Space': {
4433
+ evt.preventDefault();
4434
+ item.click();
4435
+ invoker.focus();
4436
+ return;
4437
+ }
4438
+ case 'Escape': {
4439
+ //the platform's close request hides the menu, the focus is placed
4440
+ //on the invoker before the focused item is detached from it
4441
+ invoker.focus();
4442
+ return;
4443
+ }
4444
+ }
4445
+ if (evt.key.length !== 1 || evt.ctrlKey || evt.altKey || evt.metaKey) {
4446
+ return;
4447
+ }
4448
+ const now = Date.now();
4449
+ typed = now - typedAt > TYPEAHEAD_WINDOW ? evt.key : typed + evt.key;
4450
+ typedAt = now;
4451
+ const wanted = typed.toLowerCase();
4452
+ const repeated = [...wanted].every((character) => character === wanted[0]);
4453
+ const search = repeated ? wanted[0] : wanted;
4454
+ const from = repeated ? at + 1 : at;
4455
+ const found = [...all.slice(from), ...all.slice(0, from)].find((candidate) =>
4456
+ (candidate.textContent ?? '').trim().toLowerCase().startsWith(search),
4457
+ );
4458
+ if (found) {
4459
+ evt.preventDefault();
4460
+ found.focus();
4461
+ }
4462
+ });
4463
+ };
4464
+
4366
4465
  /**
4367
4466
  * An invoker button paired with the `ul[popover][role=menu]` that follows it:
4368
4467
  * the chrome behind every filter's operator, sensitivity and boolean value.
4369
4468
  *
4370
4469
  * It fills the menu from a vocabulary, wires it the first time more than one
4371
4470
  * choice survives the whitelist, pins the button to a static glyph when a
4372
- * single one does, owns the roving focus and the Escape/Enter handling, and
4373
- * keeps the button's value, glyph and aria-label in step. A pick that changes
4374
- * the value calls back; the host decides what that means.
4471
+ * single one does, takes the shared menu keyboard protocol, and keeps the
4472
+ * button's value, glyph and aria-label in step. A pick that changes the value
4473
+ * calls back; the host decides what that means.
4375
4474
  *
4376
4475
  * The button's `value` attribute is the store, as it is for a native control:
4377
4476
  * the menu protocol finds the current item by it, and nothing mirrors it.
@@ -4529,64 +4628,10 @@ class ChoiceButton {
4529
4628
  return Array.from(this.#menu.querySelectorAll('li > a'), (a) => /** @type HTMLAnchorElement */ (a));
4530
4629
  }
4531
4630
  #wire() {
4532
- const button = this.#button;
4533
- const menu = this.#menu;
4534
- Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
4535
- menu.addEventListener('toggle', (/** @type any */ evt) => {
4536
- if (evt.newState !== 'open') {
4537
- //give the invoker back the focus the menu had borrowed, without
4538
- //stealing it from wherever else the close came from
4539
- if (menu.contains(document.activeElement)) {
4540
- button.focus();
4541
- }
4542
- return;
4543
- }
4544
- const items = this.#items();
4545
- (items.find((a) => a.getAttribute('value') === this.value) ?? items[0])?.focus();
4546
- });
4547
- menu.addEventListener('keydown', (evt) => {
4548
- const target = /** @type HTMLElement */ (evt.target);
4549
- const item = /** @type HTMLAnchorElement | null */ (target.closest('li > a'));
4550
- if (!item) {
4551
- return;
4552
- }
4553
- const items = this.#items();
4554
- const at = items.indexOf(item);
4555
- switch (evt.code) {
4556
- case 'ArrowDown': {
4557
- evt.preventDefault();
4558
- items[(at + 1) % items.length]?.focus();
4559
- break;
4560
- }
4561
- case 'ArrowUp': {
4562
- evt.preventDefault();
4563
- items[(at - 1 + items.length) % items.length]?.focus();
4564
- break;
4565
- }
4566
- case 'Home': {
4567
- evt.preventDefault();
4568
- items[0]?.focus();
4569
- break;
4570
- }
4571
- case 'End': {
4572
- evt.preventDefault();
4573
- items[items.length - 1]?.focus();
4574
- break;
4575
- }
4576
- case 'Enter':
4577
- case 'Space': {
4578
- evt.preventDefault();
4579
- item.click();
4580
- button.focus();
4581
- break;
4582
- }
4583
- case 'Escape': {
4584
- //the platform's close request hides the menu, the focus is placed
4585
- //on the invoker before the focused item is detached from it
4586
- button.focus();
4587
- break;
4588
- }
4589
- }
4631
+ Anchors.wire(this.#button, this.#menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
4632
+ wireMenuKeys(this.#button, this.#menu, {
4633
+ items: () => this.#items(),
4634
+ current: () => this.#items().find((a) => a.getAttribute('value') === this.value),
4590
4635
  });
4591
4636
  }
4592
4637
  }
@@ -5561,15 +5606,22 @@ class Dialog extends ParsedElement {
5561
5606
  *
5562
5607
  * The body is text or a node: a string is never read as markup, an
5563
5608
  * `innerHTML` sink being a thing the library does not carry. Each button is
5564
- * a `[result, label, className?]` tuple, rendered as its answer.
5609
+ * a `[result, label, className?]` tuple rendered as its answer, or a node
5610
+ * the caller has built itself, which is how a button carries anything a
5611
+ * tuple cannot say and how a footer gets a spacer.
5565
5612
  * @param {string} header
5566
5613
  * @param {string|Node} body
5567
- * @param {[string, string, string?][]} [buttons]
5614
+ * @param {([string, string, string?]|Node)[]} [buttons]
5615
+ * @param {{ className?: string }} [options] `className` dresses the dialog,
5616
+ * the way a declared `ful-dialog` is given one to size it
5568
5617
  * @returns {Promise<DialogOutcome>}
5569
5618
  */
5570
- static async ask(header, body, buttons = []) {
5619
+ static async ask(header, body, buttons = [], { className } = {}) {
5571
5620
  const dialog = document.createElement('ful-dialog');
5572
5621
  dialog.setAttribute('header', header);
5622
+ if (className) {
5623
+ dialog.className = className;
5624
+ }
5573
5625
  if (typeof body === 'string') {
5574
5626
  dialog.textContent = body;
5575
5627
  } else if (body) {
@@ -5580,12 +5632,17 @@ class Dialog extends ParsedElement {
5580
5632
  if (buttons.length > 0) {
5581
5633
  const choices = document.createElement('template');
5582
5634
  choices.setAttribute('slot', 'buttons');
5583
- for (const [result, label, className] of buttons) {
5635
+ for (const choice of buttons) {
5636
+ if (choice instanceof Node) {
5637
+ choices.content.append(choice);
5638
+ continue;
5639
+ }
5640
+ const [result, label, buttonClass] = choice;
5584
5641
  const button = document.createElement('button');
5585
5642
  button.type = 'button';
5586
5643
  button.dataset.result = result;
5587
- if (className) {
5588
- button.className = className;
5644
+ if (buttonClass) {
5645
+ button.className = buttonClass;
5589
5646
  }
5590
5647
  button.textContent = label;
5591
5648
  choices.content.append(button);
@@ -5607,13 +5664,19 @@ class Dialog extends ParsedElement {
5607
5664
  * @param {string} header
5608
5665
  * @param {string|Node} body
5609
5666
  * @param {{ confirm?: string, cancel?: string }} [labels] overriding the localized defaults
5667
+ * @param {{ className?: string }} [options] as `ask` takes them
5610
5668
  * @returns {Promise<boolean>}
5611
5669
  */
5612
- static async confirm(header, body, labels = {}) {
5613
- const outcome = await Dialog.ask(header, body, [
5614
- ['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
5615
- ['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
5616
- ]);
5670
+ static async confirm(header, body, labels = {}, options = {}) {
5671
+ const outcome = await Dialog.ask(
5672
+ header,
5673
+ body,
5674
+ [
5675
+ ['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
5676
+ ['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
5677
+ ],
5678
+ options,
5679
+ );
5617
5680
  return !outcome.dismissed && outcome.result === 'confirm';
5618
5681
  }
5619
5682
  }
@@ -5820,6 +5883,65 @@ class Drawer extends ParsedElement {
5820
5883
  }
5821
5884
  }
5822
5885
 
5886
+ /**
5887
+ * A menu of commands on the native popover platform: the invoker named by
5888
+ * `for` toggles it and light dismiss and Escape come from the browser, while
5889
+ * the element adds the menu semantics and the keyboard protocol.
5890
+ *
5891
+ * The items are the buttons and links written inside it, kept as they are and
5892
+ * given their menu role and their place in the roving focus. Activation is the
5893
+ * item's own click, which the element only follows by closing.
5894
+ */
5895
+ class Menu extends ParsedElement {
5896
+ static attributes = ['for'];
5897
+ render() {
5898
+ const named = this.declared('for');
5899
+ const invoker = named ? document.getElementById(named) : null;
5900
+ if (!invoker) {
5901
+ throw new Error(
5902
+ `ful-menu names no invoker: for="${named ?? ''}" must name an element already in the page when the menu upgrades`,
5903
+ );
5904
+ }
5905
+ this.setAttribute('role', 'menu');
5906
+ if (!this.hasAttribute('popover')) {
5907
+ this.setAttribute('popover', '');
5908
+ }
5909
+ invoker.setAttribute('aria-haspopup', 'menu');
5910
+ Anchors.wire(invoker, this, { prefix: 'ful-menu', invoke: true, expanded: true });
5911
+ wireMenuKeys(invoker, this, { items: () => this.items });
5912
+ this.#mark();
5913
+ this.addEventListener('click', (evt) => {
5914
+ const target = /** @type HTMLElement */ (evt.target);
5915
+ if (this.items.some((item) => item === target || item.contains(target))) {
5916
+ this.hidePopover();
5917
+ }
5918
+ });
5919
+ }
5920
+ /**
5921
+ * The commands, in the order the arrows walk them: the menu's own buttons
5922
+ * and links, minus the disabled and hidden ones, which keep their role and
5923
+ * leave the walk.
5924
+ */
5925
+ get items() {
5926
+ return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
5927
+ }
5928
+ /**
5929
+ * Gives every button and link of the menu its item role and takes it out of
5930
+ * the tab order, and answers them. Read on every gesture as well as at the
5931
+ * render, so a menu a page fills or empties later needs no rewiring.
5932
+ */
5933
+ #mark() {
5934
+ const all = /** @type HTMLElement[] */ ([...this.querySelectorAll(':scope > button, :scope > a[href]')]);
5935
+ for (const item of all) {
5936
+ if (!item.hasAttribute('role')) {
5937
+ item.setAttribute('role', 'menuitem');
5938
+ }
5939
+ item.tabIndex = -1;
5940
+ }
5941
+ return all;
5942
+ }
5943
+ }
5944
+
5823
5945
  const SEVERITIES = ['info', 'success', 'warning', 'error'];
5824
5946
 
5825
5947
  //the regions alive in the document: the show-toast listener is wired once and
@@ -6465,6 +6587,7 @@ class Plugin {
6465
6587
  .defineElement('ful-tooltip', Tooltip)
6466
6588
  .defineElement('ful-dialog', Dialog)
6467
6589
  .defineElement('ful-drawer', Drawer)
6590
+ .defineElement('ful-menu', Menu)
6468
6591
  .defineElement('ful-toasts', Toasts)
6469
6592
  .defineElement('ful-tabs', Tabs)
6470
6593
  .defineElement('ful-accordion', Accordion)
@@ -6504,5 +6627,5 @@ class Plugin {
6504
6627
  }
6505
6628
  }
6506
6629
 
6507
- export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
6630
+ export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, Menu, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
6508
6631
  //# sourceMappingURL=ful.mjs.map