@optionfactory/fml 9.0.0-rc30 → 9.0.0-rc32

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.iife.js CHANGED
@@ -798,12 +798,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
798
798
  *
799
799
  * The claim setters, the validity protocol and the aria wiring all act on the
800
800
  * pieces, so a field with no native control returns a focusable piece of its
801
- * own chrome as the control. The claims, `value` and `focus()` are the
802
- * members that tolerate a not yet rendered element: before the render the
803
- * `value` getter answers what the rendered field would answer from its current
804
- * declared state, and a value written then is held and applied by the render
805
- * (see `_holdValue`). The other members need the pieces and so work only after
806
- * the render.
801
+ * own chrome as the control. The claims, `value`, `focus()` and
802
+ * `setCustomValidity` are the members that tolerate a not yet rendered
803
+ * element: before the render the `value` getter answers what the rendered
804
+ * field would answer from its current declared state, a value written then is
805
+ * held and applied by the render (see `_holdValue`), and a problem reported
806
+ * then is shown by it. The other members need the pieces and so work only
807
+ * after the render.
807
808
  *
808
809
  * The inner controls carry `form=""`, so the host is the only element the form
809
810
  * sees. Enter pressed in an inner `<input>` of any type but `file`, `button`,
@@ -841,6 +842,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
841
842
  #also = [];
842
843
  /** @type {{ value: any } | null} the value written before the render, which the render applies */
843
844
  #held = null;
845
+ /** @type {{ error: string, context?: string } | null} the problem reported before the render, which the render shows */
846
+ #reported = null;
844
847
  /** Sets the internals' role from the subclass's `static ROLE`. */
845
848
  constructor() {
846
849
  super();
@@ -905,6 +908,11 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
905
908
  this.#pendingFocus = null;
906
909
  control.focus(options);
907
910
  }
911
+ if (this.#reported !== null) {
912
+ const { error, context } = this.#reported;
913
+ this.#reported = null;
914
+ this.setCustomValidity(error, context);
915
+ }
908
916
  }
909
917
  /**
910
918
  * @param {EventTarget} el
@@ -984,11 +992,17 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
984
992
  * field owning a whole subtree overrides this to route the message to the
985
993
  * inner control the path names.
986
994
  *
987
- * Needs the render: before it there is no error region to write to.
995
+ * Before the render there is no error region to write to: a message is
996
+ * kept (the last one wins) and shown once the field has rendered, and a
997
+ * clear drops the one kept. A field whose render failed shows nothing.
988
998
  * @param {string} [error] the message, empty or missing to clear
989
999
  * @param {string} [context] the path below this field's name, '' on an exact match
990
1000
  */
991
1001
  setCustomValidity(error, context) {
1002
+ if (!this.#fieldError) {
1003
+ this.#reported = error ? { error, context } : null;
1004
+ return;
1005
+ }
992
1006
  index_mjs.Attributes.set(this.#announces ?? this.#control, 'aria-invalid', error ? 'true' : null);
993
1007
  if (!error) {
994
1008
  this.internals.setValidity({});
@@ -1199,11 +1213,16 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1199
1213
  /**
1200
1214
  * Calls `_build(conf)` and wires the pieces it answers. When `_build`
1201
1215
  * returns a promise the pieces are wired once it settles; a field that
1202
- * builds synchronously renders synchronously.
1216
+ * builds synchronously renders synchronously. A default slot holding
1217
+ * nothing but whitespace is emptied first, so the label it becomes is
1218
+ * `:empty` and takes no row.
1203
1219
  * @param {{ slots: Record<string, DocumentFragment> | undefined }} conf
1204
1220
  * @returns {void | Promise<void>}
1205
1221
  */
1206
1222
  render(conf) {
1223
+ if (conf.slots?.default && index_mjs.Fragments.isBlank(conf.slots.default)) {
1224
+ conf.slots.default.replaceChildren();
1225
+ }
1207
1226
  const built = /** @type {any} */ (this._build(conf));
1208
1227
  if (built instanceof Promise) {
1209
1228
  return built.then((pieces) => this.#wire(pieces));
@@ -2543,7 +2562,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2543
2562
  * size and count limits it declares.
2544
2563
  *
2545
2564
  * The `dropzone` and `item-list` attributes show the dropzone and the list of
2546
- * chosen files. A `dropzone` slot replaces the default dropzone's content, and
2565
+ * chosen files, and `no-input` takes the picker control out of sight, leaving
2566
+ * it to the keyboard and to assistive technology. A `dropzone` slot replaces the default dropzone's content, and
2547
2567
  * an `items` slot holding a template replaces the stock item, rendered with the
2548
2568
  * same `files` overlay so that it reads the File objects it lists. Clicking the
2549
2569
  * dropzone opens the picker, and the host carries a `dragover` attribute while
@@ -2586,6 +2606,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2586
2606
  'multiple:presence',
2587
2607
  'item-list:presence',
2588
2608
  'dropzone:presence',
2609
+ 'no-input:presence',
2589
2610
  'max-files:number',
2590
2611
  'max-file-size:number',
2591
2612
  'max-total-size:number',
@@ -2837,7 +2858,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2837
2858
  * @returns {string[]|string|null}
2838
2859
  */
2839
2860
  get value() {
2840
- const names = Array.from(this._input.files).map((f) => f.name);
2861
+ const names = Array.from(this.files).map((f) => f.name);
2841
2862
  return this.multiple ? names : (names[0] ?? null);
2842
2863
  }
2843
2864
  /**
@@ -2860,7 +2881,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2860
2881
  * The size of the selected files together, in bytes.
2861
2882
  * @returns {number}
2862
2883
  */
2863
- get totalsize() {
2884
+ get totalSize() {
2864
2885
  return Array.from(this.files).reduce((a, f) => a + f.size, 0);
2865
2886
  }
2866
2887
  #maxFiles;
@@ -2935,6 +2956,23 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2935
2956
  this.#useDropzone = v;
2936
2957
  this.reflectTo('dropzone', v);
2937
2958
  }
2959
+ #noInput;
2960
+ /**
2961
+ * Whether the picker control is taken out of sight, the dropzone and the
2962
+ * item list carrying the field alone; the stylesheet hides it from the
2963
+ * reflected `no-input` attribute. The control stays in the tab order and
2964
+ * keeps the label, the description and the error, and the dropzone shows
2965
+ * the focus ring while it holds the focus.
2966
+ * @returns {boolean}
2967
+ */
2968
+ get noInput() {
2969
+ return this.#noInput;
2970
+ }
2971
+ /** @param {boolean} v */
2972
+ set noInput(v) {
2973
+ this.#noInput = v;
2974
+ this.reflectTo('no-input', v);
2975
+ }
2938
2976
  }
2939
2977
 
2940
2978
  /** the viewport's breathing room when clamping, in pixels */
@@ -4698,9 +4736,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4698
4736
  static ROLE = 'radiogroup';
4699
4737
  static template = `
4700
4738
  <fieldset>
4701
- <legend>
4702
- {{{{ slots.default }}}}
4703
- </legend>
4739
+ <legend>{{{{ slots.default }}}}</legend>
4704
4740
  <header data-tpl-if="slots.header">
4705
4741
  {{{{ slots.header }}}}
4706
4742
  </header>
@@ -4748,6 +4784,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4748
4784
  radioEls.forEach((el) => {
4749
4785
  el.remove();
4750
4786
  });
4787
+ if (index_mjs.Fragments.isBlank(slots.default)) {
4788
+ slots.default.replaceChildren();
4789
+ }
4751
4790
  const fragment = this.template().withOverlay({ name, slots, inputsAndLabels }).render();
4752
4791
  this.#fieldset = /** @type HTMLElement */ (fragment.firstElementChild);
4753
4792
  this.#firstRadio = fragment.querySelector('input[type=radio]');
@@ -4889,6 +4928,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4889
4928
  * @typedef {{ data: any[], size: number }} TablePageResponse the rows of the
4890
4929
  * requested page and the total number of rows across every page
4891
4930
  */
4931
+ /**
4932
+ * @typedef {{ count: Element, appending: Element, failure: Element, error: Element }} TableStatus the pieces
4933
+ * of the status bar `paging="infinite"` and `paging="virtual"` show in place of the pager
4934
+ */
4892
4935
  /**
4893
4936
  * @typedef {{ load(pageRequest: TablePageRequest, sortRequest: TableSortRequest|null, filterRequest: Record<string, any>): Promise<TablePageResponse> }} TableRowLoader
4894
4937
  */
@@ -4903,6 +4946,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4903
4946
  * current `order` in the cycle `asc`, `desc`, `null`. The button does not
4904
4947
  * change its own `order`: whoever handles the event sets it, which `ful-table`
4905
4948
  * does once the sorted page has loaded.
4949
+ *
4950
+ * The stylesheet draws an up arrow for `asc` and a down arrow for `desc`. An
4951
+ * unsorted button shows the up arrow dimmed, `asc` being the order its first
4952
+ * activation asks for.
4906
4953
  */
4907
4954
  class SortButton extends index_mjs.ParsedElement {
4908
4955
  static attributes = ['sorter'];
@@ -5357,6 +5404,21 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5357
5404
  * for it. Without it the table shows an initial panel until `load`,
5358
5405
  * `reload` or `resetWithFilter` is called.
5359
5406
  *
5407
+ * `paging="infinite"`, read once at the upgrade, replaces the pager with a
5408
+ * status bar saying how many rows of how many are shown, with a reload
5409
+ * button. A load still replaces the rows with one page, scrolling the rows
5410
+ * back to the top; from then on, whenever a row of the last half page
5411
+ * comes into view, the next page is asked with the same size, sort and
5412
+ * filters and appended (see `load`). `reload`, a sort, a filter submit and a
5413
+ * size change start over from the first page.
5414
+ *
5415
+ * `paging="virtual"` shows the same status bar, saying which rows of how many
5416
+ * are in view, and keeps in the page only the page in view and one on either
5417
+ * side of it, the others standing in as empty space sized on the height of
5418
+ * the rows. The rows must scroll inside the table, as `ful-fill` makes them:
5419
+ * a table whose rows the page scrolls appends as `paging="infinite"` does,
5420
+ * and warns. See `load` and `reload`.
5421
+ *
5360
5422
  * Inside the table, and not inside a `ful-table` nested in it, a
5361
5423
  * `page:requested` loads that page with the current size,
5362
5424
  * sort and filters; a `sort:requested` loads the current page with the new
@@ -5372,13 +5434,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5372
5434
  */
5373
5435
  class Table extends index_mjs.ParsedElement {
5374
5436
  /** Read once at the upgrade. */
5375
- static attributes = ['loader', 'autoload:presence'];
5437
+ static attributes = ['loader', 'autoload:presence', 'paging'];
5376
5438
  /** These stay live after the upgrade: see `pageSize`, `src` and `method`. */
5377
5439
  static observed = ['page-size:number', 'src', 'method'];
5378
5440
  static slots = true;
5379
5441
  static config = {
5380
5442
  searchIcon: 'search',
5381
5443
  emptyIcon: 'inbox',
5444
+ reloadIcon: 'arrow-clockwise',
5382
5445
  };
5383
5446
  static template = `
5384
5447
  <ful-form data-tpl-if="slots.filters">
@@ -5388,7 +5451,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5388
5451
  <table>
5389
5452
  <caption data-tpl-if="slots.caption">{{{{ slots.caption }}}}</caption>
5390
5453
  <thead></thead>
5454
+ <tbody data-tpl-if="virtual" data-ref="before" aria-hidden="true"><tr><td data-tpl-colspan="schema.length"></td></tr></tbody>
5391
5455
  <tbody></tbody>
5456
+ <tbody data-tpl-if="virtual" data-ref="after" aria-hidden="true"><tr><td data-tpl-colspan="schema.length"></td></tr></tbody>
5392
5457
  <tbody data-ref="initial">
5393
5458
  <tr>
5394
5459
  <td data-tpl-colspan="schema.length">
@@ -5432,7 +5497,16 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5432
5497
  </tfoot>
5433
5498
  </table>
5434
5499
  </ful-table-wrapper>
5435
- <ful-pagination current="0" total="1"></ful-pagination>
5500
+ <ful-pagination data-tpl-if="!scrolled" current="0" total="1"></ful-pagination>
5501
+ <ful-table-status data-tpl-if="scrolled">
5502
+ <span data-ref="count"></span>
5503
+ <ful-spinner data-ref="appending" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
5504
+ <div data-ref="append-failure" role="alert" hidden>
5505
+ <span>{{ #l10n:t('table.error') }} <span data-ref="append-error"></span></span>
5506
+ <button type="button" data-ref="retry">{{ #l10n:t('table.retry') }}</button>
5507
+ </div>
5508
+ <button type="button" data-ref="reload" data-tpl-aria-label="#l10n:t('pagination.reload')"><ful-icon data-tpl-name="config.reloadIcon" aria-hidden="true"></ful-icon></button>
5509
+ </ful-table-status>
5436
5510
  `;
5437
5511
  static templates = {
5438
5512
  row: `
@@ -5450,6 +5524,31 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5450
5524
  #feedback;
5451
5525
  #paginator;
5452
5526
  #sorters;
5527
+ #wrapper;
5528
+ /** @type {TableStatus|null} null under the pager */
5529
+ #status = null;
5530
+ /** @type {IntersectionObserver|null} */
5531
+ #endWatch = null;
5532
+ /** @type {Element[]} the rows whose coming into view appends the next page, none while no append is awaited */
5533
+ #watched = [];
5534
+ /** the page the next append asks for, the rows shown, the total the last answer reported and the rows it carried */
5535
+ #appended = { next: 1, shown: 0, total: 0, last: 0 };
5536
+ /** whether the table keeps a window of pages, `paging="virtual"` over rows that scroll inside it */
5537
+ #virtual = false;
5538
+ /** @type {{ before: HTMLElement, after: HTMLElement } | null} the cells standing in for the rows above and below the window */
5539
+ #spacers = null;
5540
+ /** @type {{ first: number, last: number, pages: Map<number, Element[]> } | null} the pages in the page, in order, null while no rows show */
5541
+ #window = null;
5542
+ /** the total number of rows the last answer reported */
5543
+ #total = 0;
5544
+ /** the height of a row the spacers are sized on, measured on the first rows a load shows; 0 until measured */
5545
+ #rowHeight = 0;
5546
+ /** @type {{ readonly stale: boolean } | null} the claim of the window shift in flight */
5547
+ #shifting = null;
5548
+ /** whether the last window shift failed: scrolling asks for nothing more until a retry or a load */
5549
+ #windowFailed = false;
5550
+ /** @type {ReturnType<typeof setTimeout>|undefined} */
5551
+ #jump = undefined;
5453
5552
  /** @type {{ pageRequest: { page: number, size: number }, sortRequest: any, filterRequest: any }} */
5454
5553
  #latestRequest = { pageRequest: { page: 0, size: 10 }, sortRequest: null, filterRequest: {} };
5455
5554
  /** whether a load has been asked for, by autoload or by a caller */
@@ -5479,18 +5578,19 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5479
5578
  * writing the size is not a request to start loading, which is what
5480
5579
  * `autoload` and `reload()` are for. A write that does not change the size
5481
5580
  * does nothing. A write made before the render is replaced by the declared
5482
- * `page-size` when the table renders.
5581
+ * `page-size` when the table renders, or by ten when that is not a number
5582
+ * above zero.
5483
5583
  *
5484
5584
  * The reload is not awaited: a failure shows the error panel and its
5485
5585
  * rejection is unhandled.
5486
5586
  * @param {number|string|null|undefined} value a number or a string of
5487
5587
  * one, such as a select's value; absent or null is the default of ten,
5488
5588
  * so removing the attribute restores it, and a value that is not a
5489
- * number is ignored
5589
+ * finite number above zero is ignored
5490
5590
  */
5491
5591
  set pageSize(value) {
5492
5592
  const size = Number(value ?? 10);
5493
- if (Number.isNaN(size) || size === this.#latestRequest.pageRequest.size) {
5593
+ if (!Number.isFinite(size) || size <= 0 || size === this.#latestRequest.pageRequest.size) {
5494
5594
  return;
5495
5595
  }
5496
5596
  this.#latestRequest = { ...this.#latestRequest, pageRequest: { page: 0, size } };
@@ -5576,6 +5676,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5576
5676
  }
5577
5677
  this.#buildLoader();
5578
5678
  this.#latestRequest = { ...this.#latestRequest, pageRequest: { ...this.#latestRequest.pageRequest, page: 0 } };
5679
+ this.#window = null;
5579
5680
  return true;
5580
5681
  }
5581
5682
  /**
@@ -5586,14 +5687,24 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5586
5687
  async render({ slots }) {
5587
5688
  const template = this.template();
5588
5689
  const schema = TableSchemaParser.parse(slots.schema, template);
5589
- const fragment = template.withOverlay({ slots, schema }).render();
5690
+ const paging = this.declared('paging');
5691
+ const fragment = template
5692
+ .withOverlay({
5693
+ slots,
5694
+ schema,
5695
+ scrolled: paging === 'infinite' || paging === 'virtual',
5696
+ virtual: paging === 'virtual',
5697
+ })
5698
+ .render();
5590
5699
  const tableWrapper = /** @type HTMLTableElement */ (index_mjs.Nodes.queryChildren(fragment, 'ful-table-wrapper'));
5591
5700
  const table = /** @type HTMLTableElement */ (tableWrapper.querySelector('table'));
5592
5701
  index_mjs.Attributes.forward('table-', this, table);
5593
5702
  this.#buildLoader();
5594
5703
 
5704
+ this.#wrapper = tableWrapper;
5705
+ const status = index_mjs.Nodes.queryChildren(fragment, 'ful-table-status');
5595
5706
  this.#schema = schema;
5596
- this.#body = table.querySelector(':scope > tbody');
5707
+ this.#body = table.querySelector(':scope > tbody:not([data-ref])');
5597
5708
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
5598
5709
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
5599
5710
  this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
@@ -5603,16 +5714,67 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5603
5714
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
5604
5715
  this.#paginator = index_mjs.Nodes.queryChildren(fragment, 'ful-pagination');
5605
5716
  this.replaceChildren(fragment);
5717
+ if (paging === 'virtual') {
5718
+ const overflow = getComputedStyle(tableWrapper).overflowY;
5719
+ this.#virtual = overflow === 'auto' || overflow === 'scroll';
5720
+ if (!this.#virtual) {
5721
+ console.warn(
5722
+ 'ful-table: paging="virtual" needs rows that scroll inside the table, as ful-fill makes them; this one appends its pages as paging="infinite" does',
5723
+ );
5724
+ }
5725
+ }
5726
+ if (status) {
5727
+ const piece = (/** @type string */ ref) =>
5728
+ /** @type Element */ (status.querySelector(`[data-ref=${ref}]`));
5729
+ this.#status = {
5730
+ count: piece('count'),
5731
+ appending: piece('appending'),
5732
+ failure: piece('append-failure'),
5733
+ error: piece('append-error'),
5734
+ };
5735
+ piece('reload').addEventListener('click', () => this.reload());
5736
+ piece('retry').addEventListener('click', () => {
5737
+ if (!this.#virtual) {
5738
+ this.#appendNext();
5739
+ return;
5740
+ }
5741
+ this.#windowFailed = false;
5742
+ this.#reconcile(true);
5743
+ });
5744
+ }
5745
+ if (this.#virtual) {
5746
+ this.#spacers = {
5747
+ before: /** @type HTMLElement */ (table.querySelector(':scope > tbody[data-ref=before] td')),
5748
+ after: /** @type HTMLElement */ (table.querySelector(':scope > tbody[data-ref=after] td')),
5749
+ };
5750
+ // scroll fires once per frame at most, so it needs no throttle of its own
5751
+ tableWrapper.addEventListener(
5752
+ 'scroll',
5753
+ () => {
5754
+ this.#countInView();
5755
+ this.#reconcile(false);
5756
+ },
5757
+ { passive: true },
5758
+ );
5759
+ } else if (status) {
5760
+ this.#endWatch = new IntersectionObserver((entries) => {
5761
+ // an entry queued before the watch moved on to another row, or stopped, asks for nothing
5762
+ if (entries.some((e) => e.isIntersecting && this.#watched.includes(e.target))) {
5763
+ this.#appendNext();
5764
+ }
5765
+ });
5766
+ }
5606
5767
  const thead = /** @type HTMLTableSectionElement */ (this.querySelector('thead'));
5607
5768
  schema.headersTemplate.renderTo(thead);
5608
5769
  this.#sorters = thead.querySelectorAll('ful-sorter');
5609
5770
  await index_mjs.Rendering.waitForChildren(this);
5610
5771
 
5611
5772
  const maybeForm = /** @type any */ (index_mjs.Nodes.queryChildren(this, 'ful-form'));
5773
+ const declaredSize = this.declared('page-size');
5612
5774
  this.#latestRequest = {
5613
5775
  pageRequest: {
5614
5776
  page: 0,
5615
- size: this.declared('page-size') ?? 10,
5777
+ size: Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : 10,
5616
5778
  },
5617
5779
  sortRequest: schema.sort,
5618
5780
  filterRequest: maybeForm?.values ?? {},
@@ -5658,6 +5820,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5658
5820
  * `submit` listeners as the later ones, and the load its `submit:success`
5659
5821
  * starts is the one awaited. A filter submit that is cancelled, fails or
5660
5822
  * is dropped for one in flight loads nothing, and the promise resolves.
5823
+ *
5824
+ * Under `paging="virtual"`, once rows show, it asks again for the pages in
5825
+ * the page, with the same size, sort and filters, and shows them where
5826
+ * they are, the scroll position and the widths of the columns kept; the
5827
+ * table is busy and settles, fails and dispatches as a load does, one
5828
+ * `load:success` per page. When the answers report fewer rows than the
5829
+ * window starts at, it loads the first page instead. A `src` or `method`
5830
+ * write still starts over from the first page.
5661
5831
  * @returns {Promise<void>} as `load`
5662
5832
  */
5663
5833
  async reload() {
@@ -5667,6 +5837,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5667
5837
  }
5668
5838
  return;
5669
5839
  }
5840
+ if (this.#window) {
5841
+ return await this.#reloadWindow();
5842
+ }
5670
5843
  return await this.load(
5671
5844
  this.#latestRequest.pageRequest,
5672
5845
  this.#latestRequest.sortRequest,
@@ -5696,12 +5869,49 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5696
5869
  * `aria-busy` is removed, `load:failure` is dispatched (bubbling, not
5697
5870
  * cancelable) with `detail` set to
5698
5871
  * `{ pageRequest, sortRequest, filterRequest, exception }`, and the
5699
- * returned promise rejects with the same error.
5872
+ * returned promise rejects with the same error. An answer the table cannot
5873
+ * page fails the same way, with an error saying so: one whose `size` is
5874
+ * not a number, as a response of another shape read without a
5875
+ * `response-mapper` is, or a page size of zero.
5700
5876
  *
5701
5877
  * A load started after this one supersedes it: when this one's answer or
5702
5878
  * failure arrives it changes nothing, dispatches nothing and resolves;
5703
5879
  * superseded while its rows render, it leaves them to the newer load and
5704
5880
  * dispatches nothing.
5881
+ *
5882
+ * Under `paging="infinite"` the status bar takes the pager's place, and
5883
+ * once the load has dispatched `load:success` the next page is awaited:
5884
+ * when a row of the last half page comes into view, through
5885
+ * whatever scrolls it, the following page is asked for with the same
5886
+ * size, sort and filters and its rows are appended. An append runs
5887
+ * without `aria-busy`, a spinner in the status bar standing in, and
5888
+ * dispatches `load:success` or `load:failure` as a load does, its
5889
+ * `pageRequest` naming the appended page. A failed append keeps the
5890
+ * rows, shows the error in the status bar with a retry button and
5891
+ * rejects to nobody, nothing awaiting it. Appending stops once the rows
5892
+ * shown reach the total the last answer reported, or an answer carries
5893
+ * no rows. A load supersedes an append in flight; `reload` repeats this
5894
+ * load, not the pages appended to it.
5895
+ *
5896
+ * Under `paging="virtual"` the load shows its page as the window, scrolled
5897
+ * to where it sits among the total, and measures two things on its rows:
5898
+ * the height the empty space standing in for the other rows is sized on,
5899
+ * and the width of the columns, which then stay as they are, in
5900
+ * proportion, until the next load, so that the pages coming and going do
5901
+ * not move them. From then on the window follows the scroll: the page in
5902
+ * view and one on either side stay in the page and the others are dropped,
5903
+ * the page holding the focus kept while it is within two pages of them. A
5904
+ * page next to the window is asked for at once; a scroll landing farther
5905
+ * waits for the scroll to rest. Window pages are asked for without
5906
+ * `aria-busy`, a spinner in the status bar standing in, and dispatch
5907
+ * `load:success` or `load:failure` per page as appends do; a failure keeps
5908
+ * the rows and offers a retry. The row in view stays where it is as pages
5909
+ * come and go above it: the table measures it before and after and
5910
+ * corrects the scroll by whatever the browser's own scroll anchoring has
5911
+ * not kept, which is all of it where the browser anchors no table rows
5912
+ * (Firefox), has no scroll anchoring or the rows' container declares
5913
+ * `overflow-anchor: none`, and nothing where it has. The table carries
5914
+ * `aria-rowcount` and each row its `aria-rowindex`.
5705
5915
  * @param {TablePageRequest} pageRequest
5706
5916
  * @param {TableSortRequest|null} sortRequest
5707
5917
  * @param {Record<string, any>} filterRequest
@@ -5712,6 +5922,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5712
5922
  this.#loadRequested = true;
5713
5923
  this.#requestedFilters = filterRequest;
5714
5924
  const claim = this.#loads.take();
5925
+ this.#watched = [];
5926
+ this.#endWatch?.disconnect();
5927
+ clearTimeout(this.#jump);
5928
+ this.#shifting = null;
5929
+ this.#windowFailed = false;
5930
+ this.#status?.appending.setAttribute('hidden', '');
5931
+ this.#status?.failure.setAttribute('hidden', '');
5715
5932
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
5716
5933
  this.#feedback.setAttribute('hidden', '');
5717
5934
  this.#noAutoload.setAttribute('hidden', '');
@@ -5724,6 +5941,11 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5724
5941
  return;
5725
5942
  }
5726
5943
  lastPage = Math.max(0, Math.ceil(pageResponse.size / pageRequest.size) - 1);
5944
+ if (!Number.isFinite(lastPage)) {
5945
+ throw new Error(
5946
+ `ful-table cannot page the loader's answer: it needs { data, size }, size being the total number of rows, and got a size of ${pageResponse.size} for pages of ${pageRequest.size}. A response of another shape needs a response-mapper`,
5947
+ );
5948
+ }
5727
5949
  if (pageRequest.page <= lastPage) {
5728
5950
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
5729
5951
  this.#loaded = true;
@@ -5736,6 +5958,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5736
5958
  if (claim.stale) {
5737
5959
  return;
5738
5960
  }
5961
+ if (this.#virtual) {
5962
+ this.#settleWindow();
5963
+ }
5739
5964
  this.removeAttribute('aria-busy');
5740
5965
  this.dispatchEvent(
5741
5966
  new CustomEvent('load:success', {
@@ -5744,24 +5969,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5744
5969
  detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
5745
5970
  }),
5746
5971
  );
5972
+ this.#watchEnd(pageRequest.size);
5973
+ this.#reconcile(false);
5747
5974
  return;
5748
5975
  }
5749
5976
  } catch (/** @type any */ error) {
5750
5977
  if (claim.stale) {
5751
5978
  return;
5752
5979
  }
5753
- this.#loading.setAttribute('hidden', '');
5754
- this.#body.replaceChildren();
5755
- this.#feedback.removeAttribute('hidden');
5756
- this.#feedback.querySelector('[data-ref=feedback-error]').textContent = index_mjs.Localization.of().failure(error);
5757
- this.removeAttribute('aria-busy');
5758
- this.dispatchEvent(
5759
- new CustomEvent('load:failure', {
5760
- bubbles: true,
5761
- cancelable: false,
5762
- detail: { pageRequest, sortRequest, filterRequest, exception: error },
5763
- }),
5764
- );
5980
+ this.#fail(error, { pageRequest, sortRequest, filterRequest });
5765
5981
  throw error;
5766
5982
  }
5767
5983
  return await this.load({ page: lastPage, size: pageRequest.size }, sortRequest, filterRequest);
@@ -5792,19 +6008,471 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5792
6008
  filterRequest,
5793
6009
  );
5794
6010
  }
5795
- #update(pageRequest, sortRequest, filterRequest, pageResponse) {
5796
- const pages = Math.ceil(pageResponse.size / pageRequest.size);
6011
+ /**
6012
+ * Watches the last half page of rows, so that any of them coming into
6013
+ * view appends the next page, unless the rows shown already reach the
6014
+ * total or the last answer carried none. A single row would be missed by
6015
+ * a scroll jumping past it, to the end for instance.
6016
+ * @param {number} size the rows a page asks for
6017
+ */
6018
+ #watchEnd(size) {
6019
+ const { shown, total, last } = this.#appended;
6020
+ if (!this.#endWatch || !(shown < total) || last === 0) {
6021
+ return;
6022
+ }
6023
+ const rows = [...this.#body.children];
6024
+ this.#watched = rows.slice(Math.max(0, rows.length - Math.ceil(size / 2)));
6025
+ for (const row of this.#watched) {
6026
+ this.#endWatch.observe(row);
6027
+ }
6028
+ }
6029
+ async #appendNext() {
6030
+ const status = /** @type TableStatus */ (this.#status);
6031
+ this.#watched = [];
6032
+ this.#endWatch?.disconnect();
6033
+ const { sortRequest, filterRequest } = this.#latestRequest;
6034
+ const pageRequest = { page: this.#appended.next, size: this.#latestRequest.pageRequest.size };
6035
+ const claim = this.#loads.take();
6036
+ status.failure.setAttribute('hidden', '');
6037
+ status.appending.removeAttribute('hidden');
6038
+ try {
6039
+ const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
6040
+ if (claim.stale) {
6041
+ return;
6042
+ }
6043
+ this.#body.append(
6044
+ this.template('row')
6045
+ .withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
6046
+ .render(),
6047
+ );
6048
+ this.#appended = {
6049
+ next: pageRequest.page + 1,
6050
+ shown: this.#appended.shown + pageResponse.data.length,
6051
+ total: pageResponse.size,
6052
+ last: pageResponse.data.length,
6053
+ };
6054
+ status.count.textContent = index_mjs.Localization.of().t('table.loaded', {
6055
+ loaded: this.#appended.shown,
6056
+ total: this.#appended.total,
6057
+ });
6058
+ await index_mjs.Rendering.waitForChildren(this.#body).catch(() => undefined);
6059
+ if (claim.stale) {
6060
+ return;
6061
+ }
6062
+ status.appending.setAttribute('hidden', '');
6063
+ this.dispatchEvent(
6064
+ new CustomEvent('load:success', {
6065
+ bubbles: true,
6066
+ cancelable: false,
6067
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
6068
+ }),
6069
+ );
6070
+ this.#watchEnd(pageRequest.size);
6071
+ } catch (/** @type any */ error) {
6072
+ if (claim.stale) {
6073
+ return;
6074
+ }
6075
+ status.appending.setAttribute('hidden', '');
6076
+ status.error.textContent = index_mjs.Localization.of().failure(error);
6077
+ status.failure.removeAttribute('hidden');
6078
+ this.dispatchEvent(
6079
+ new CustomEvent('load:failure', {
6080
+ bubbles: true,
6081
+ cancelable: false,
6082
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
6083
+ }),
6084
+ );
6085
+ }
6086
+ }
6087
+ /**
6088
+ * Renders the rows of a page, numbering them with `aria-rowindex` under
6089
+ * `paging="virtual"`, the header row being the first.
6090
+ * @param {TablePageRequest} pageRequest
6091
+ * @param {Record<string, any>} filterRequest
6092
+ * @param {TablePageResponse} pageResponse
6093
+ * @returns {Element[]}
6094
+ */
6095
+ #pageRows(pageRequest, filterRequest, pageResponse) {
6096
+ const rows = [
6097
+ ...this.template('row')
6098
+ .withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
6099
+ .render().children,
6100
+ ];
6101
+ if (this.#virtual) {
6102
+ rows.forEach((row, i) => {
6103
+ row.setAttribute('aria-rowindex', String(pageRequest.page * pageRequest.size + i + 2));
6104
+ });
6105
+ }
6106
+ return rows;
6107
+ }
6108
+ /**
6109
+ * Shows a failed load: the rows go, the error panel shows and
6110
+ * `load:failure` is dispatched.
6111
+ * @param {any} error
6112
+ * @param {{ pageRequest: TablePageRequest, sortRequest: TableSortRequest|null, filterRequest: Record<string, any> }} request
6113
+ */
6114
+ #fail(error, request) {
5797
6115
  this.#loading.setAttribute('hidden', '');
5798
- this.#body.replaceChildren(
5799
- this.template('row')
5800
- .withOverlay({
5801
- schema: this.#schema,
5802
- pageRequest,
5803
- filterRequest,
5804
- pageResponse,
5805
- })
5806
- .render(),
6116
+ this.#body.replaceChildren();
6117
+ this.#window = null;
6118
+ this.#fitSpacers();
6119
+ this.#status?.count.replaceChildren();
6120
+ this.#feedback.removeAttribute('hidden');
6121
+ this.#feedback.querySelector('[data-ref=feedback-error]').textContent = index_mjs.Localization.of().failure(error);
6122
+ this.removeAttribute('aria-busy');
6123
+ this.dispatchEvent(
6124
+ new CustomEvent('load:failure', {
6125
+ bubbles: true,
6126
+ cancelable: false,
6127
+ detail: { ...request, exception: error },
6128
+ }),
5807
6129
  );
6130
+ }
6131
+ /** Counts the rows of a load just shown and moves the window where its page sits. */
6132
+ #settleWindow() {
6133
+ const table = /** @type HTMLTableElement */ (this.#body.parentElement);
6134
+ table.setAttribute('aria-rowcount', String(this.#total + 1));
6135
+ table.tHead?.rows[0]?.setAttribute('aria-rowindex', '1');
6136
+ const status = /** @type TableStatus */ (this.#status);
6137
+ const window = this.#window;
6138
+ if (!window) {
6139
+ this.#fitSpacers();
6140
+ status.count.textContent = index_mjs.Localization.of().t('table.loaded', { loaded: 0, total: this.#total });
6141
+ return;
6142
+ }
6143
+ const size = this.#latestRequest.pageRequest.size;
6144
+ const from = window.first * size;
6145
+ status.count.textContent = index_mjs.Localization.of().t('table.range', {
6146
+ from: from + 1,
6147
+ to: from + /** @type Element[] */ (window.pages.get(window.first)).length,
6148
+ total: this.#total,
6149
+ });
6150
+ if (this.#measureRows()) {
6151
+ this.#wrapper.scrollTop = from * this.#rowHeight;
6152
+ }
6153
+ }
6154
+ /**
6155
+ * Measures the height of a row and locks the widths of the columns, once
6156
+ * per load, on the rows it shows, then sizes the spacers. A table that is not laid out, inside a
6157
+ * closed disclosure for instance, measures nothing and is measured when
6158
+ * its rows next scroll.
6159
+ * @returns {boolean} whether the rows are measured
6160
+ */
6161
+ #measureRows() {
6162
+ if (this.#rowHeight > 0) {
6163
+ return true;
6164
+ }
6165
+ const count = this.#body.childElementCount;
6166
+ const height = this.#body.getBoundingClientRect().height;
6167
+ if (count === 0 || height === 0) {
6168
+ return false;
6169
+ }
6170
+ this.#rowHeight = height / count;
6171
+ this.#lockColumns();
6172
+ this.#fitSpacers();
6173
+ return true;
6174
+ }
6175
+ /**
6176
+ * Fixes the widths of the columns, in proportion to the table, at those
6177
+ * the rows in the page give them.
6178
+ */
6179
+ #lockColumns() {
6180
+ const table = /** @type HTMLTableElement */ (this.#body.parentElement);
6181
+ const widths = [...(table.tHead?.rows[0]?.cells ?? [])].map((cell) => cell.getBoundingClientRect().width);
6182
+ const sum = widths.reduce((a, b) => a + b, 0);
6183
+ const columns = document.createElement('colgroup');
6184
+ columns.setAttribute('data-ref', 'widths');
6185
+ for (const width of widths) {
6186
+ const column = document.createElement('col');
6187
+ column.style.width = `${(width / sum) * 100}%`;
6188
+ columns.append(column);
6189
+ }
6190
+ table.insertBefore(columns, table.tHead);
6191
+ }
6192
+ #unlockColumns() {
6193
+ this.#body.parentElement?.querySelector(':scope > colgroup[data-ref=widths]')?.remove();
6194
+ }
6195
+ /** Sizes the space standing in for the rows above and below the window. */
6196
+ #fitSpacers() {
6197
+ if (!this.#spacers) {
6198
+ return;
6199
+ }
6200
+ const window = this.#window;
6201
+ const size = this.#latestRequest.pageRequest.size;
6202
+ const above = window ? window.first * size : 0;
6203
+ const below = window ? Math.max(0, this.#total - (window.last + 1) * size) : 0;
6204
+ this.#spacers.before.style.height = `${above * this.#rowHeight}px`;
6205
+ this.#spacers.after.style.height = `${below * this.#rowHeight}px`;
6206
+ }
6207
+ /** Says in the status bar which rows are in view; nothing changes while none is. */
6208
+ #countInView() {
6209
+ const status = /** @type TableStatus */ (this.#status);
6210
+ const size = this.#latestRequest.pageRequest.size;
6211
+ const view = this.#wrapper.getBoundingClientRect();
6212
+ const top = view.top + (this.#body.parentElement?.querySelector(':scope > thead')?.getBoundingClientRect().height ?? 0);
6213
+ let from = 0;
6214
+ let to = 0;
6215
+ for (const [page, rows] of this.#window?.pages ?? []) {
6216
+ rows.forEach((row, i) => {
6217
+ const rect = row.getBoundingClientRect();
6218
+ if (rect.bottom > top && rect.top < view.bottom) {
6219
+ from ||= page * size + i + 1;
6220
+ to = page * size + i + 1;
6221
+ }
6222
+ });
6223
+ }
6224
+ if (from > 0) {
6225
+ status.count.textContent = index_mjs.Localization.of().t('table.range', { from, to, total: this.#total });
6226
+ }
6227
+ }
6228
+ /**
6229
+ * The page at the middle of the view: found among the rows in the page,
6230
+ * or estimated on the height of a row when the middle falls in the space
6231
+ * standing in for others.
6232
+ * @param {{ first: number, last: number, pages: Map<number, Element[]> }} window
6233
+ * @returns {number}
6234
+ */
6235
+ #pageInView(window) {
6236
+ const pageHeight = this.#latestRequest.pageRequest.size * this.#rowHeight;
6237
+ const view = this.#wrapper.getBoundingClientRect();
6238
+ const middle = view.top + view.height / 2;
6239
+ const rows = this.#body.getBoundingClientRect();
6240
+ if (middle < rows.top) {
6241
+ return Math.max(0, window.first - Math.ceil((rows.top - middle) / pageHeight));
6242
+ }
6243
+ if (middle >= rows.bottom) {
6244
+ return window.last + 1 + Math.floor((middle - rows.bottom) / pageHeight);
6245
+ }
6246
+ let found = window.first;
6247
+ for (const [page, pageRows] of window.pages) {
6248
+ if (pageRows.length > 0 && pageRows[0].getBoundingClientRect().top <= middle) {
6249
+ found = page;
6250
+ }
6251
+ }
6252
+ return found;
6253
+ }
6254
+ /**
6255
+ * Moves the window to the page in view and its neighbours. A window next
6256
+ * to the current one moves at once; one farther away waits for the scroll
6257
+ * to rest, unless `settled` says it has.
6258
+ * @param {boolean} settled
6259
+ */
6260
+ #reconcile(settled) {
6261
+ const window = this.#window;
6262
+ const pageCount = Math.ceil(this.#total / this.#latestRequest.pageRequest.size);
6263
+ if (
6264
+ !window ||
6265
+ this.#shifting ||
6266
+ this.#windowFailed ||
6267
+ this.hasAttribute('aria-busy') ||
6268
+ !this.#wrapper.isConnected ||
6269
+ !(pageCount > 0) ||
6270
+ !this.#measureRows()
6271
+ ) {
6272
+ return;
6273
+ }
6274
+ const target = Math.min(pageCount - 1, this.#pageInView(window));
6275
+ let first = Math.max(0, target - 1);
6276
+ let last = Math.min(pageCount - 1, target + 1);
6277
+ const focused = this.#body.contains(document.activeElement)
6278
+ ? /** @type Element */ (document.activeElement).closest('tr')
6279
+ : null;
6280
+ const focusedPage = [...window.pages].find(([, rows]) => focused && rows.includes(focused))?.[0];
6281
+ if (focusedPage !== undefined && Math.max(last, focusedPage) - Math.min(first, focusedPage) <= 4) {
6282
+ first = Math.min(first, focusedPage);
6283
+ last = Math.max(last, focusedPage);
6284
+ }
6285
+ if (first === window.first && last === window.last) {
6286
+ return;
6287
+ }
6288
+ clearTimeout(this.#jump);
6289
+ if (!settled && (last < window.first - 1 || first > window.last + 1)) {
6290
+ this.#jump = setTimeout(() => this.#reconcile(true), 150);
6291
+ return;
6292
+ }
6293
+ this.#shiftWindow(window, first, last);
6294
+ }
6295
+ /**
6296
+ * Asks for the pages of the new window that are not in the page, drops
6297
+ * the ones outside it and keeps the row in view where it is.
6298
+ * @param {{ first: number, last: number, pages: Map<number, Element[]> }} window
6299
+ * @param {number} first
6300
+ * @param {number} last
6301
+ */
6302
+ async #shiftWindow(window, first, last) {
6303
+ const status = /** @type TableStatus */ (this.#status);
6304
+ const claim = this.#loads.hold();
6305
+ this.#shifting = claim;
6306
+ const { sortRequest, filterRequest } = this.#latestRequest;
6307
+ const size = this.#latestRequest.pageRequest.size;
6308
+ const wanted = Array.from({ length: last - first + 1 }, (_, i) => first + i);
6309
+ const missing = wanted.filter((page) => !window.pages.has(page));
6310
+ status.failure.setAttribute('hidden', '');
6311
+ status.appending.removeAttribute('hidden');
6312
+ try {
6313
+ const answers = await Promise.all(
6314
+ missing.map(async (page) => {
6315
+ const pageRequest = { page, size };
6316
+ return { pageRequest, response: await this.#loader.load(pageRequest, sortRequest, filterRequest) };
6317
+ }),
6318
+ );
6319
+ if (claim.stale) {
6320
+ return;
6321
+ }
6322
+ const kept = wanted.filter((page) => window.pages.has(page));
6323
+ const top = this.#wrapper.getBoundingClientRect().top;
6324
+ const anchor = kept
6325
+ .flatMap((page) => window.pages.get(page) ?? [])
6326
+ .find((row) => row.getBoundingClientRect().bottom > top);
6327
+ const anchorTop = anchor?.getBoundingClientRect().top ?? 0;
6328
+ for (const [page, rows] of window.pages) {
6329
+ if (page < first || page > last) {
6330
+ for (const row of rows) {
6331
+ row.remove();
6332
+ }
6333
+ }
6334
+ }
6335
+ /** @type {Map<number, Element[]>} */
6336
+ const pages = new Map();
6337
+ for (const page of wanted) {
6338
+ const answer = answers.find((a) => a.pageRequest.page === page);
6339
+ pages.set(
6340
+ page,
6341
+ window.pages.get(page) ??
6342
+ (answer ? this.#pageRows(answer.pageRequest, filterRequest, answer.response) : []),
6343
+ );
6344
+ }
6345
+ if (kept.length === 0) {
6346
+ this.#body.replaceChildren(...[...pages.values()].flat());
6347
+ } else {
6348
+ this.#body.prepend(...wanted.filter((page) => page < kept[0]).flatMap((page) => pages.get(page) ?? []));
6349
+ this.#body.append(
6350
+ ...wanted.filter((page) => page > /** @type number */ (kept.at(-1))).flatMap((page) => pages.get(page) ?? []),
6351
+ );
6352
+ }
6353
+ this.#window = { first, last, pages };
6354
+ this.#total = answers.at(-1)?.response.size ?? this.#total;
6355
+ this.#body.parentElement?.setAttribute('aria-rowcount', String(this.#total + 1));
6356
+ this.#fitSpacers();
6357
+ if (anchor) {
6358
+ this.#wrapper.scrollTop += anchor.getBoundingClientRect().top - anchorTop;
6359
+ }
6360
+ await index_mjs.Rendering.waitForChildren(this.#body).catch(() => undefined);
6361
+ if (claim.stale) {
6362
+ return;
6363
+ }
6364
+ if (anchor) {
6365
+ // the elements in the new rows have rendered since, which can change their height
6366
+ this.#wrapper.scrollTop += anchor.getBoundingClientRect().top - anchorTop;
6367
+ }
6368
+ status.appending.setAttribute('hidden', '');
6369
+ this.#countInView();
6370
+ for (const { pageRequest, response } of answers) {
6371
+ this.dispatchEvent(
6372
+ new CustomEvent('load:success', {
6373
+ bubbles: true,
6374
+ cancelable: false,
6375
+ detail: { pageRequest, sortRequest, filterRequest, response },
6376
+ }),
6377
+ );
6378
+ }
6379
+ } catch (/** @type any */ error) {
6380
+ if (claim.stale) {
6381
+ return;
6382
+ }
6383
+ this.#windowFailed = true;
6384
+ status.appending.setAttribute('hidden', '');
6385
+ status.error.textContent = index_mjs.Localization.of().failure(error);
6386
+ status.failure.removeAttribute('hidden');
6387
+ this.dispatchEvent(
6388
+ new CustomEvent('load:failure', {
6389
+ bubbles: true,
6390
+ cancelable: false,
6391
+ detail: { pageRequest: { page: missing[0], size }, sortRequest, filterRequest, exception: error },
6392
+ }),
6393
+ );
6394
+ return;
6395
+ } finally {
6396
+ if (this.#shifting === claim) {
6397
+ this.#shifting = null;
6398
+ }
6399
+ }
6400
+ this.#reconcile(false);
6401
+ }
6402
+ /**
6403
+ * Asks again for the pages of the window and shows them where they are.
6404
+ * @returns {Promise<void>}
6405
+ */
6406
+ async #reloadWindow() {
6407
+ const window = /** @type {{ first: number, last: number, pages: Map<number, Element[]> }} */ (this.#window);
6408
+ const status = /** @type TableStatus */ (this.#status);
6409
+ const { sortRequest, filterRequest } = this.#latestRequest;
6410
+ const size = this.#latestRequest.pageRequest.size;
6411
+ this.#requestedFilters = filterRequest;
6412
+ const claim = this.#loads.take();
6413
+ clearTimeout(this.#jump);
6414
+ this.#shifting = null;
6415
+ this.#windowFailed = false;
6416
+ status.appending.setAttribute('hidden', '');
6417
+ status.failure.setAttribute('hidden', '');
6418
+ this.setAttribute('aria-busy', 'true');
6419
+ const wanted = Array.from({ length: window.last - window.first + 1 }, (_, i) => window.first + i);
6420
+ try {
6421
+ const answers = await Promise.all(
6422
+ wanted.map(async (page) => {
6423
+ const pageRequest = { page, size };
6424
+ return { pageRequest, response: await this.#loader.load(pageRequest, sortRequest, filterRequest) };
6425
+ }),
6426
+ );
6427
+ if (claim.stale) {
6428
+ return;
6429
+ }
6430
+ const total = /** @type {{ response: TablePageResponse }} */ (answers.at(-1)).response.size;
6431
+ const pageCount = Math.ceil(total / size);
6432
+ if (!(window.first < pageCount)) {
6433
+ return await this.load({ page: 0, size }, sortRequest, filterRequest);
6434
+ }
6435
+ const shown = answers.filter((a) => a.pageRequest.page < pageCount);
6436
+ const pages = new Map(
6437
+ shown.map((a) => [a.pageRequest.page, this.#pageRows(a.pageRequest, filterRequest, a.response)]),
6438
+ );
6439
+ this.#body.replaceChildren(...[...pages.values()].flat());
6440
+ this.#window = { first: window.first, last: Math.min(window.last, pageCount - 1), pages };
6441
+ this.#total = total;
6442
+ this.#body.parentElement?.setAttribute('aria-rowcount', String(total + 1));
6443
+ this.#fitSpacers();
6444
+ await index_mjs.Rendering.waitForChildren(this.#body).catch(() => undefined);
6445
+ if (claim.stale) {
6446
+ return;
6447
+ }
6448
+ this.removeAttribute('aria-busy');
6449
+ this.#countInView();
6450
+ for (const { pageRequest, response } of shown) {
6451
+ this.dispatchEvent(
6452
+ new CustomEvent('load:success', {
6453
+ bubbles: true,
6454
+ cancelable: false,
6455
+ detail: { pageRequest, sortRequest, filterRequest, response },
6456
+ }),
6457
+ );
6458
+ }
6459
+ this.#reconcile(false);
6460
+ } catch (/** @type any */ error) {
6461
+ if (claim.stale) {
6462
+ return;
6463
+ }
6464
+ this.#fail(error, { pageRequest: { page: window.first, size }, sortRequest, filterRequest });
6465
+ throw error;
6466
+ }
6467
+ }
6468
+ #update(pageRequest, sortRequest, filterRequest, pageResponse) {
6469
+ this.#loading.setAttribute('hidden', '');
6470
+ if (this.#virtual) {
6471
+ this.#rowHeight = 0;
6472
+ this.#unlockColumns();
6473
+ }
6474
+ const rows = this.#pageRows(pageRequest, filterRequest, pageResponse);
6475
+ this.#body.replaceChildren(...rows);
5808
6476
  this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
5809
6477
  if (this.#emptyTemplate && pageResponse.data.length === 0) {
5810
6478
  this.#emptyAuthors.replaceChildren(
@@ -5813,7 +6481,28 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5813
6481
  .render(),
5814
6482
  );
5815
6483
  }
5816
- this.#paginator.update({ current: pageRequest.page, total: pages });
6484
+ if (this.#virtual) {
6485
+ this.#wrapper.scrollTop = 0;
6486
+ this.#total = pageResponse.size;
6487
+ this.#window =
6488
+ rows.length > 0 ? { first: pageRequest.page, last: pageRequest.page, pages: new Map([[pageRequest.page, rows]]) } : null;
6489
+ return;
6490
+ }
6491
+ if (this.#status) {
6492
+ this.#wrapper.scrollTop = 0;
6493
+ this.#appended = {
6494
+ next: pageRequest.page + 1,
6495
+ shown: pageResponse.data.length,
6496
+ total: pageResponse.size,
6497
+ last: pageResponse.data.length,
6498
+ };
6499
+ this.#status.count.textContent = index_mjs.Localization.of().t('table.loaded', {
6500
+ loaded: this.#appended.shown,
6501
+ total: this.#appended.total,
6502
+ });
6503
+ return;
6504
+ }
6505
+ this.#paginator.update({ current: pageRequest.page, total: Math.ceil(pageResponse.size / pageRequest.size) });
5817
6506
  }
5818
6507
  }
5819
6508
 
@@ -6989,8 +7678,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6989
7678
  * failure's problems, one per line, or its message when it carries none.
6990
7679
  */
6991
7680
  class SectionRequests {
6992
- /** @type {WeakSet<Element>} */
7681
+ /** @type {WeakSet<Element>} the sections a first delivery succeeded for */
6993
7682
  #entered = new WeakSet();
7683
+ /** @type {WeakSet<Element>} the sections whose first delivery is in flight */
7684
+ #entering = new WeakSet();
6994
7685
  /** @type {WeakMap<Element, Claims>} */
6995
7686
  #claims = new WeakMap();
6996
7687
 
@@ -6999,9 +7690,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6999
7690
  * is given, then `section:requested:<name>` when a name is given, each
7000
7691
  * bubbling from `host` with the same detail `{ name, section, index, first }`,
7001
7692
  * and awaits every answer attached to any of them. `first` is true until a
7002
- * request for the section is answered by at least one listener, so a request
7003
- * nobody listened to does not spend it. A request with answers removes the
7004
- * error a previous failure painted before it waits.
7693
+ * delivery of the section succeeds: a request nobody listened to does not
7694
+ * spend it, and neither does one whose answers failed, so the next request
7695
+ * is a first one again. While a first delivery is in flight a newer request
7696
+ * for the same section carries `first` false, so the content is not
7697
+ * delivered twice. A request with answers removes the error a previous
7698
+ * failure painted before it waits.
7005
7699
  * @param {Element} host the component the events are dispatched on
7006
7700
  * @param {Element} section the element the answers deliver into
7007
7701
  * @param {string|null} name the section's `data-step`, when it has one
@@ -7012,7 +7706,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7012
7706
  * problems in the section unless a newer request superseded this one
7013
7707
  */
7014
7708
  async request(host, section, name, index) {
7015
- const first = !this.#entered.has(section);
7709
+ const first = !this.#entered.has(section) && !this.#entering.has(section);
7016
7710
  const detail = { name, section, index, first };
7017
7711
  const types = [
7018
7712
  'section:requested',
@@ -7030,7 +7724,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7030
7724
  if (promises.length === 0) {
7031
7725
  return undefined;
7032
7726
  }
7033
- this.#entered.add(section);
7727
+ if (first) {
7728
+ this.#entering.add(section);
7729
+ }
7034
7730
  let claims = this.#claims.get(section);
7035
7731
  if (!claims) {
7036
7732
  claims = new Claims();
@@ -7046,13 +7742,20 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7046
7742
  }
7047
7743
  });
7048
7744
  try {
7049
- return await Promise.all(promises);
7745
+ const answers = await Promise.all(promises);
7746
+ if (first) {
7747
+ this.#entered.add(section);
7748
+ }
7749
+ return answers;
7050
7750
  } catch (cause) {
7051
7751
  if (owned()) {
7052
7752
  this.#paintError(section, cause);
7053
7753
  }
7054
7754
  throw cause;
7055
7755
  } finally {
7756
+ if (first) {
7757
+ this.#entering.delete(section);
7758
+ }
7056
7759
  cancelAnimationFrame(frame);
7057
7760
  if (owned()) {
7058
7761
  section.toggleAttribute('loading', false);
@@ -7169,15 +7872,27 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7169
7872
  /**
7170
7873
  * Calls `dismiss` when a press and its release both land on the backdrop,
7171
7874
  * so a selection dragged out of the panel and released outside does not.
7875
+ * The backdrop is where the pointer is outside the dialog's rectangle: a
7876
+ * part of the panel no child covers, the room around the error section
7877
+ * for one, is the panel.
7172
7878
  * @param {() => void} dismiss
7173
7879
  */
7174
7880
  onBackdrop(dismiss) {
7881
+ const onBackdrop = (/** @type MouseEvent */ e) => {
7882
+ if (e.target !== this.#dialog) {
7883
+ return false;
7884
+ }
7885
+ const panel = this.#dialog.getBoundingClientRect();
7886
+ return (
7887
+ e.clientX < panel.left || e.clientX > panel.right || e.clientY < panel.top || e.clientY > panel.bottom
7888
+ );
7889
+ };
7175
7890
  let pressedOutside = false;
7176
7891
  this.#dialog.addEventListener('mousedown', (e) => {
7177
- pressedOutside = e.target === this.#dialog;
7892
+ pressedOutside = onBackdrop(e);
7178
7893
  });
7179
7894
  this.#dialog.addEventListener('click', (e) => {
7180
- if (pressedOutside && e.target === this.#dialog) {
7895
+ if (pressedOutside && onBackdrop(e)) {
7181
7896
  dismiss();
7182
7897
  }
7183
7898
  });
@@ -7454,7 +8169,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7454
8169
  * opening, hides the error and loading sections, and dispatches
7455
8170
  * `section:requested` on the element (bubbling, with
7456
8171
  * `detail: { section, first, name: null, index: null }`, `section` being
7457
- * the body and `first` true only on the first answered request), so a
8172
+ * the body and `first` true until a delivery succeeds), so a
7458
8173
  * listener can fill the body. A failed request is painted into the body
7459
8174
  * and does not reject. On a dialog already open nothing is shown or
7460
8175
  * requested, and the call waits for the same ending as the calls before it;
@@ -7706,11 +8421,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7706
8421
  #answer = null;
7707
8422
  #resolvers = [];
7708
8423
  #closing = false;
7709
- #slideOutEnded = (/** @type AnimationEvent */ e) => {
7710
- if (e.target === this.#dialog) {
7711
- this.#closeNow();
7712
- }
7713
- };
8424
+ #slides = new Claims();
7714
8425
  /**
7715
8426
  * @param {{ slots: Record<string, DocumentFragment> }} c
7716
8427
  */
@@ -7837,7 +8548,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7837
8548
  * opening, hides the error and loading sections, and dispatches
7838
8549
  * `section:requested` on the element (bubbling, with
7839
8550
  * `detail: { section, first, name: null, index: null }`, `section` being
7840
- * the content section and `first` true only on the first answered request),
8551
+ * the content section and `first` true until a delivery succeeds),
7841
8552
  * so a listener can fill the content. A failed request is painted into the
7842
8553
  * content section and does not reject. On a drawer already open nothing is
7843
8554
  * shown or requested, except that one sliding out stops and stays open,
@@ -7888,11 +8599,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7888
8599
  /**
7889
8600
  * Slides the drawer out and closes it when the animation ends, setting the
7890
8601
  * `closing` attribute on the native dialog meanwhile; the `close` event is
7891
- * dispatched then, after this call has returned. Where the user prefers
7892
- * reduced motion, or no animation runs, it closes at once. A non-empty
7893
- * result answers as the button carrying that `data-result` would; no
7894
- * result, or an empty one, is a dismissal. Does nothing on a drawer that is
7895
- * closed or already sliding out.
8602
+ * dispatched then, after this call has returned. An animation the page
8603
+ * cancels, by hiding the drawer or an ancestor of it, closes it as one
8604
+ * that ended does. Where the user prefers reduced motion, or no animation
8605
+ * runs, it closes at once. A non-empty result answers as the button
8606
+ * carrying that `data-result` would; no result, or an empty one, is a
8607
+ * dismissal. Does nothing on a drawer that is closed or already sliding
8608
+ * out.
7896
8609
  * @param {string} [result]
7897
8610
  */
7898
8611
  close(result) {
@@ -7907,12 +8620,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7907
8620
  return;
7908
8621
  }
7909
8622
  this.#dialog.setAttribute('closing', '');
7910
- if (this.#dialog.getAnimations().length === 0) {
8623
+ const animations = this.#dialog.getAnimations();
8624
+ if (animations.length === 0) {
7911
8625
  this.#closeNow();
7912
8626
  return;
7913
8627
  }
7914
8628
  this.#closing = true;
7915
- this.#dialog.addEventListener('animationend', this.#slideOutEnded);
8629
+ const slide = this.#slides.take();
8630
+ Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
8631
+ if (!slide.stale) {
8632
+ this.#closeNow();
8633
+ }
8634
+ });
7916
8635
  }
7917
8636
  #closeNow() {
7918
8637
  this.#stopSlidingOut();
@@ -7920,7 +8639,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7920
8639
  }
7921
8640
  #stopSlidingOut() {
7922
8641
  this.#closing = false;
7923
- this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
8642
+ this.#slides.invalidate();
7924
8643
  this.#dialog.removeAttribute('closing');
7925
8644
  }
7926
8645
  /** @returns {boolean} whether this call is the one that opened the modal */
@@ -8465,7 +9184,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8465
9184
  }
8466
9185
  /**
8467
9186
  * Fires the section requests again for a panel, whether it is visible or
8468
- * not, with `first` false once an earlier request was answered.
9187
+ * not, with `first` false once an earlier delivery succeeded.
8469
9188
  * @param {number | Element} ref the panel's index, or the panel itself
8470
9189
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
8471
9190
  * the answers, or to undefined when nobody listened or the delivery
@@ -8688,7 +9407,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8688
9407
  }
8689
9408
  /**
8690
9409
  * Fires the section requests again for a section, whether it is current or
8691
- * not, with `first` false once an earlier request was answered.
9410
+ * not, with `first` false once an earlier delivery succeeded.
8692
9411
  * @param {string | Element} ref the section's `data-step`, or the section
8693
9412
  * itself
8694
9413
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
@@ -8750,6 +9469,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8750
9469
  'table.initial': 'Start searching to see results.',
8751
9470
  'table.error': 'Error while loading data:',
8752
9471
  'table.no-data': 'No elements found.',
9472
+ 'table.loaded': 'Showing {loaded} of {total}',
9473
+ 'table.range': 'Rows {from}-{to} of {total}',
9474
+ 'table.retry': 'Retry',
8753
9475
  'dropdown.empty': 'No results',
8754
9476
  'select.remove': 'Remove',
8755
9477
  'files.dropzone-label': 'Click or drop your files here',
@@ -8801,6 +9523,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8801
9523
  'table.initial': 'Avvia la ricerca per visualizzare i risultati.',
8802
9524
  'table.error': 'Errore nel caricamento dei dati:',
8803
9525
  'table.no-data': 'Nessun elemento trovato.',
9526
+ 'table.loaded': 'Mostrati {loaded} di {total}',
9527
+ 'table.range': 'Righe {from}-{to} di {total}',
9528
+ 'table.retry': 'Riprova',
8804
9529
  'dropdown.empty': 'Nessun risultato',
8805
9530
  'select.remove': 'Rimuovi',
8806
9531
  'files.dropzone-label': 'Clicca o trascina i file qui',
@@ -8852,6 +9577,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8852
9577
  'table.initial': 'Inicia la búsqueda para ver los resultados.',
8853
9578
  'table.error': 'Error al cargar los datos:',
8854
9579
  'table.no-data': 'No se encontraron elementos.',
9580
+ 'table.loaded': 'Mostrando {loaded} de {total}',
9581
+ 'table.range': 'Filas {from}-{to} de {total}',
9582
+ 'table.retry': 'Reintentar',
8855
9583
  'dropdown.empty': 'Sin resultados',
8856
9584
  'select.remove': 'Eliminar',
8857
9585
  'files.dropzone-label': 'Haz clic o arrastra tus archivos aquí',
@@ -8906,6 +9634,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8906
9634
  'table.initial': 'Lancez la recherche pour voir les résultats.',
8907
9635
  'table.error': 'Erreur lors du chargement des données :',
8908
9636
  'table.no-data': 'Aucun élément trouvé.',
9637
+ 'table.loaded': 'Affichage de {loaded} sur {total}',
9638
+ 'table.range': 'Lignes {from} à {to} sur {total}',
9639
+ 'table.retry': 'Réessayer',
8909
9640
  'dropdown.empty': 'Aucun résultat',
8910
9641
  'select.remove': 'Retirer',
8911
9642
  'files.dropzone-label': 'Cliquez ou déposez vos fichiers ici',
@@ -8967,8 +9698,11 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8967
9698
  /**
8968
9699
  * @param {{ language?: string, translations?: Record<string, any>, httpClient?: HttpClient }} [options]
8969
9700
  * `language` is fixed for the page: a full BCP-47 tag or a primary subtag,
8970
- * defaulting to `navigator.language`, then to 'en'. Its primary subtag,
8971
- * lowercased, picks the built-in translations (en, it, es, fr); the messages
9701
+ * defaulting to `navigator.language`, then to 'en', an empty one included.
9702
+ * It is canonicalized (`IT-it` and `it_IT` both read `it-IT`), and one that
9703
+ * is no language tag at all throws here, rather than at the first message
9704
+ * or number formatted in it. Its primary subtag picks the built-in
9705
+ * translations (en, it, es, fr); the messages
8972
9706
  * are the english ones, overridden by that language's, overridden by
8973
9707
  * `translations`, so a language without built-in translations reads in
8974
9708
  * english. `translations` is a flat map of keys to messages: reword built-in
@@ -8976,9 +9710,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8976
9710
  * `httpClient` is the client every ful component fetches through, registered
8977
9711
  * as the `http-client` component; without one, `configure` builds a client
8978
9712
  * that sends the csrf token and navigates to '/' on a 401 response.
9713
+ * @throws {RangeError} when `language` is not a language tag
8979
9714
  */
8980
9715
  constructor(options = {}) {
8981
- this.#language = options.language ?? navigator?.language ?? 'en';
9716
+ const language = options.language || navigator?.language || 'en';
9717
+ try {
9718
+ // unicode locale identifiers separate subtags with an underscore too, Intl takes the dash alone
9719
+ this.#language = Intl.getCanonicalLocales(String(language).replaceAll('_', '-'))[0];
9720
+ } catch {
9721
+ throw new RangeError(
9722
+ `Plugin: language '${language}' is not a language tag: pass a BCP-47 tag such as 'it' or 'it-IT', or leave it out for the browser's language`,
9723
+ );
9724
+ }
8982
9725
  this.#translations = options.translations ?? {};
8983
9726
  this.#httpClient = options.httpClient ?? null;
8984
9727
  }
@@ -8987,7 +9730,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8987
9730
  * Defines on `registry` the `l10n` module, the `http-client` component,
8988
9731
  * every `ful-*` element, the `loaders:select`, `loaders:form` and
8989
9732
  * `loaders:table` components, and an overlay publishing `l10n` (the merged
8990
- * messages) and `locale` (the `language` as given) to every template.
9733
+ * messages) and `locale` (the `language`, canonicalized) to every template.
8991
9734
  * @param {{ defineModule(name: string, module: any): any, defineComponent(name: string, component: any): any, defineElement(tag: string, klass: CustomElementConstructor): any, defineOverlay(...data: any[]): any }} registry
8992
9735
  * the ftl `Registry`, each of those methods answering the registry itself
8993
9736
  * @returns {void}
@@ -8995,7 +9738,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8995
9738
  configure(registry) {
8996
9739
  const httpClient =
8997
9740
  this.#httpClient ?? index_mjs$1.HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
8998
- const language = this.#language.split('-')[0].toLowerCase();
9741
+ const language = this.#language.split('-')[0];
8999
9742
  const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
9000
9743
  registry
9001
9744
  .defineModule('l10n', index_mjs.Localization)
@@ -9076,7 +9819,6 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
9076
9819
  exports.SortButton = SortButton;
9077
9820
  exports.Table = Table;
9078
9821
  exports.TableLoader = TableLoader;
9079
- exports.TableSchemaParser = TableSchemaParser;
9080
9822
  exports.Tabs = Tabs;
9081
9823
  exports.TextFilter = TextFilter;
9082
9824
  exports.Timing = Timing;