argus-table 0.2.1 → 0.3.0

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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/dist/argus-column.js +24 -1
  3. package/dist/argus-column.js.map +1 -1
  4. package/dist/argus-table.js +1026 -167
  5. package/dist/argus-table.js.map +1 -1
  6. package/dist/cdn/argus-table.js +74 -50
  7. package/dist/cdn/argus-table.js.map +1 -1
  8. package/dist/controllers/auto-width.js +11 -0
  9. package/dist/controllers/auto-width.js.map +1 -1
  10. package/dist/controllers/column-window.js +139 -0
  11. package/dist/controllers/column-window.js.map +1 -0
  12. package/dist/controllers/data-controller.js +9 -4
  13. package/dist/controllers/data-controller.js.map +1 -1
  14. package/dist/controllers/plugins.js +102 -0
  15. package/dist/controllers/plugins.js.map +1 -0
  16. package/dist/controllers/reorder-drag.js +9 -10
  17. package/dist/controllers/reorder-drag.js.map +1 -1
  18. package/dist/controllers/row-measure.js +121 -0
  19. package/dist/controllers/row-measure.js.map +1 -0
  20. package/dist/controllers/virtual-controller.js +316 -10
  21. package/dist/controllers/virtual-controller.js.map +1 -1
  22. package/dist/core/src/columns/column-order.js +14 -10
  23. package/dist/core/src/columns/column-order.js.map +1 -1
  24. package/dist/core/src/columns/resolve-columns.js +18 -4
  25. package/dist/core/src/columns/resolve-columns.js.map +1 -1
  26. package/dist/core/src/data/page-cache.js +4 -0
  27. package/dist/core/src/data/page-cache.js.map +1 -1
  28. package/dist/core/src/filters/filter.js +185 -0
  29. package/dist/core/src/filters/filter.js.map +1 -0
  30. package/dist/core/src/state/selection.js +70 -0
  31. package/dist/core/src/state/selection.js.map +1 -0
  32. package/dist/core/src/table/create-table.js +239 -64
  33. package/dist/core/src/table/create-table.js.map +1 -1
  34. package/dist/core/src/table/filtered-row-model.js +48 -0
  35. package/dist/core/src/table/filtered-row-model.js.map +1 -0
  36. package/dist/core/src/table/sorted-row-model.js +13 -2
  37. package/dist/core/src/table/sorted-row-model.js.map +1 -1
  38. package/dist/core/src/virtual/column-window.js +72 -0
  39. package/dist/core/src/virtual/column-window.js.map +1 -0
  40. package/dist/core/src/virtual/row-pool.js +2 -1
  41. package/dist/core/src/virtual/row-pool.js.map +1 -1
  42. package/dist/core/src/virtual/row-sizes.js +156 -0
  43. package/dist/core/src/virtual/row-sizes.js.map +1 -0
  44. package/dist/core/src/virtual/scroll-scale.js +41 -3
  45. package/dist/core/src/virtual/scroll-scale.js.map +1 -1
  46. package/dist/custom-elements.json +1320 -74
  47. package/dist/events.js +9 -1
  48. package/dist/events.js.map +1 -1
  49. package/dist/index.d.ts +609 -74
  50. package/dist/index.js +3 -2
  51. package/dist/interactive.js +22 -0
  52. package/dist/interactive.js.map +1 -0
  53. package/dist/max-height.js +22 -8
  54. package/dist/max-height.js.map +1 -1
  55. package/dist/render/body.js +47 -7
  56. package/dist/render/body.js.map +1 -1
  57. package/dist/render/cells.js +14 -11
  58. package/dist/render/cells.js.map +1 -1
  59. package/dist/render/content-directive.js +115 -0
  60. package/dist/render/content-directive.js.map +1 -0
  61. package/dist/render/content.js +20 -9
  62. package/dist/render/content.js.map +1 -1
  63. package/dist/render/header.js +24 -5
  64. package/dist/render/header.js.map +1 -1
  65. package/dist/render/pinned-rows.js +1 -1
  66. package/dist/render/pinned-rows.js.map +1 -1
  67. package/dist/styles/base.js +1 -1
  68. package/dist/styles/base.js.map +1 -1
  69. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ArgusColumn } from "./argus-column.js";
2
2
  import { ArgusTable } from "./argus-table.js";
3
- import { CELL_CLICK, COLUMN_REORDER, COLUMN_RESIZE, LOAD_ERROR, RANGE_CHANGE, ROW_CLICK, SORT_CHANGE } from "./events.js";
4
- export { ArgusColumn, ArgusTable, CELL_CLICK, COLUMN_REORDER, COLUMN_RESIZE, LOAD_ERROR, RANGE_CHANGE, ROW_CLICK, SORT_CHANGE };
3
+ import { content } from "./render/content-directive.js";
4
+ import { CELL_CLICK, COLUMN_FILTER, COLUMN_HIDE, COLUMN_PIN, COLUMN_REORDER, COLUMN_RESIZE, LOAD_ERROR, RANGE_CHANGE, ROW_CLICK, ROW_SELECT, SORT_CHANGE } from "./events.js";
5
+ export { ArgusColumn, ArgusTable, CELL_CLICK, COLUMN_FILTER, COLUMN_HIDE, COLUMN_PIN, COLUMN_REORDER, COLUMN_RESIZE, LOAD_ERROR, RANGE_CHANGE, ROW_CLICK, ROW_SELECT, SORT_CHANGE, content };
@@ -0,0 +1,22 @@
1
+ // src/interactive.ts
2
+ /**
3
+ * Interactive content a page may put in a header or cell. A press on it belongs
4
+ * to that content: it doesn't sort, start a column drag or select a row. A
5
+ * popover or a dialog (a menu or an editor a plugin opens from a header) counts
6
+ * as a whole, padding included.
7
+ */
8
+ var INTERACTIVE = "button,input,select,textarea,a[href],label,[contenteditable],[popover],dialog";
9
+ /**
10
+ * Whether an event path reaches interactive content before `stop` (the cell or
11
+ * row the table handles the event for).
12
+ */
13
+ function fromInteractive(path, stop) {
14
+ for (const target of path) {
15
+ if (target === stop) return false;
16
+ if (target instanceof Element && target.matches("button,input,select,textarea,a[href],label,[contenteditable],[popover],dialog")) return true;
17
+ }
18
+ return false;
19
+ }
20
+ export { INTERACTIVE, fromInteractive };
21
+
22
+ //# sourceMappingURL=interactive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interactive.js","names":[],"sources":["../src/interactive.ts"],"sourcesContent":["/**\n * Interactive content a page may put in a header or cell. A press on it belongs\n * to that content: it doesn't sort, start a column drag or select a row. A\n * popover or a dialog (a menu or an editor a plugin opens from a header) counts\n * as a whole, padding included.\n */\nexport const INTERACTIVE =\n 'button,input,select,textarea,a[href],label,[contenteditable],[popover],dialog';\n\n/**\n * Whether an event path reaches interactive content before `stop` (the cell or\n * row the table handles the event for).\n */\nexport function fromInteractive(path: readonly EventTarget[], stop: Element): boolean {\n for (const target of path) {\n if (target === stop) return false;\n if (target instanceof Element && target.matches(INTERACTIVE)) return true;\n }\n return false;\n}\n"],"mappings":";;;;;;;AAMA,IAAa,cACX;;;;;AAMF,SAAgB,gBAAgB,MAA8B,MAAwB;CACpF,KAAK,MAAM,UAAU,MAAM;EACzB,IAAI,WAAW,MAAM,OAAO;EAC5B,IAAI,kBAAkB,WAAW,OAAO,QAAA,+EAAmB,GAAG,OAAO;CACvE;CACA,OAAO;AACT"}
@@ -1,12 +1,16 @@
1
1
  // src/max-height.ts
2
2
  /**
3
- * The tallest element the engine renders, measured once per page.
3
+ * The tallest body the engine handles, measured once per page.
4
4
  *
5
5
  * Chromium and WebKit clamp a taller element at about 33.5M px (fewer CSS px
6
6
  * when the page is zoomed in), and Firefox drops anything over about 17.9M px
7
- * to 0. A hidden probe tries a few heights and keeps the first that renders at
8
- * full size. A zoom change (a new `devicePixelRatio` on resize) measures again
9
- * and tells the tables, so a scaled body stays within the new limit.
7
+ * to 0. Firefox also lets go of an element stuck to the top of a scroller (the
8
+ * header, the top pinned rows) once the scroll offset passes about 8.95M px
9
+ * (2^29 of its 1/60 px units). A hidden probe tries a few heights and keeps the
10
+ * first that renders at full size and keeps a sticky element in place when
11
+ * scrolled that far: 32M px in Chromium and WebKit, 8M px in Firefox. A zoom
12
+ * change (a new `devicePixelRatio` on resize) measures again and tells the
13
+ * tables, so a scaled body stays within the new limit.
10
14
  */
11
15
  var HEIGHTS = [
12
16
  32e6,
@@ -28,16 +32,26 @@ function onResize() {
28
32
  measured = void 0;
29
33
  for (const listener of listeners) listener();
30
34
  }
31
- /** The tallest element height in px the engine renders. */
35
+ /**
36
+ * The tallest body height in px the engine renders and scrolls with its header
37
+ * in place.
38
+ */
32
39
  function maxElementHeight() {
33
40
  if (measured !== void 0) return measured;
34
41
  const probe = document.createElement("div");
35
42
  probe.setAttribute("aria-hidden", "true");
36
- probe.style.cssText = "position:fixed;inset-block-start:0;inset-inline-start:0;inline-size:1px;visibility:hidden;pointer-events:none";
43
+ probe.style.cssText = "position:fixed;inset-block-start:0;inset-inline-start:0;inline-size:1px;block-size:10px;overflow:hidden;visibility:hidden;pointer-events:none";
44
+ const sticky = document.createElement("div");
45
+ sticky.style.cssText = "position:sticky;inset-block-start:0;block-size:1px";
46
+ const filler = document.createElement("div");
47
+ probe.append(sticky, filler);
37
48
  document.body.append(probe);
38
49
  measured = HEIGHTS.find((height) => {
39
- probe.style.blockSize = `${String(height)}px`;
40
- return probe.offsetHeight >= height - 1;
50
+ filler.style.blockSize = `${String(height)}px`;
51
+ if (filler.offsetHeight < height - 1) return false;
52
+ probe.scrollTop = height;
53
+ const held = sticky.getBoundingClientRect().top - probe.getBoundingClientRect().top;
54
+ return Math.abs(held) < 1;
41
55
  }) ?? SAFE_HEIGHT;
42
56
  probe.remove();
43
57
  return measured;
@@ -1 +1 @@
1
- {"version":3,"file":"max-height.js","names":[],"sources":["../src/max-height.ts"],"sourcesContent":["/**\n * The tallest element the engine renders, measured once per page.\n *\n * Chromium and WebKit clamp a taller element at about 33.5M px (fewer CSS px\n * when the page is zoomed in), and Firefox drops anything over about 17.9M px\n * to 0. A hidden probe tries a few heights and keeps the first that renders at\n * full size. A zoom change (a new `devicePixelRatio` on resize) measures again\n * and tells the tables, so a scaled body stays within the new limit.\n */\nconst HEIGHTS = [32_000_000, 16_000_000, 8_000_000, 4_000_000, 2_000_000];\n\n/** Content below this never needs the probe: every engine renders it. */\nconst SAFE_HEIGHT = 2_000_000;\n/** Room left for the scrollbar and rounding. */\nconst MARGIN = 1000;\n\nlet measured: number | undefined;\nlet forced: number | undefined;\nlet dpr = 0;\nconst listeners = new Set<() => void>();\n\nfunction onResize() {\n if (window.devicePixelRatio === dpr) return;\n dpr = window.devicePixelRatio;\n measured = undefined;\n for (const listener of listeners) listener();\n}\n\n/** The tallest element height in px the engine renders. */\nfunction maxElementHeight(): number {\n if (forced !== undefined) return forced;\n if (measured !== undefined) return measured;\n // Fixed, so the probe never makes the page scroll.\n const probe = document.createElement('div');\n probe.setAttribute('aria-hidden', 'true');\n probe.style.cssText =\n 'position:fixed;inset-block-start:0;inset-inline-start:0;inline-size:1px;visibility:hidden;pointer-events:none';\n document.body.append(probe);\n measured =\n HEIGHTS.find((height) => {\n probe.style.blockSize = `${String(height)}px`;\n return probe.offsetHeight >= height - 1;\n }) ?? SAFE_HEIGHT;\n probe.remove();\n return measured;\n}\n\n/**\n * The tallest the body may render: the engine's maximum less what scrolls with\n * it (`extra`: the header and pinned rows). `Infinity` for a body of `logical`\n * px that is far from any limit, without measuring.\n */\nexport function bodyCap(logical: number, extra: number): number {\n if (forced === undefined && logical + extra < SAFE_HEIGHT) return Infinity;\n return Math.max(maxElementHeight() - extra - MARGIN, 0);\n}\n\n/** Calls `listener` when the maximum may have changed (the page was zoomed). Returns the unsubscribe. */\nexport function watchMaxElementHeight(listener: () => void): () => void {\n if (listeners.size === 0) {\n dpr = window.devicePixelRatio;\n window.addEventListener('resize', onResize, { passive: true });\n }\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) window.removeEventListener('resize', onResize);\n };\n}\n\n/** Tests: pretend the engine renders at most `height` px (`undefined` measures again). */\nexport function setMaxElementHeightForTests(height: number | undefined): void {\n forced = height;\n}\n"],"mappings":";;;;;;;;;;AASA,IAAM,UAAU;CAAC;CAAY;CAAY;CAAW;CAAW;AAAS;;AAGxE,IAAM,cAAc;;AAEpB,IAAM,SAAS;AAEf,IAAI;AAEJ,IAAI,MAAM;AACV,IAAM,4BAAY,IAAI,IAAgB;AAEtC,SAAS,WAAW;CAClB,IAAI,OAAO,qBAAqB,KAAK;CACrC,MAAM,OAAO;CACb,WAAW,KAAA;CACX,KAAK,MAAM,YAAY,WAAW,SAAS;AAC7C;;AAGA,SAAS,mBAA2B;CAElC,IAAI,aAAa,KAAA,GAAW,OAAO;CAEnC,MAAM,QAAQ,SAAS,cAAc,KAAK;CAC1C,MAAM,aAAa,eAAe,MAAM;CACxC,MAAM,MAAM,UACV;CACF,SAAS,KAAK,OAAO,KAAK;CAC1B,WACE,QAAQ,MAAM,WAAW;EACvB,MAAM,MAAM,YAAY,GAAG,OAAO,MAAM,EAAE;EAC1C,OAAO,MAAM,gBAAgB,SAAS;CACxC,CAAC,KAAK;CACR,MAAM,OAAO;CACb,OAAO;AACT;;;;;;AAOA,SAAgB,QAAQ,SAAiB,OAAuB;CAC9D,IAA4B,UAAU,QAAQ,aAAa,OAAO;CAClE,OAAO,KAAK,IAAI,iBAAiB,IAAI,QAAQ,QAAQ,CAAC;AACxD;;AAGA,SAAgB,sBAAsB,UAAkC;CACtE,IAAI,UAAU,SAAS,GAAG;EACxB,MAAM,OAAO;EACb,OAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,KAAK,CAAC;CAC/D;CACA,UAAU,IAAI,QAAQ;CACtB,aAAa;EACX,UAAU,OAAO,QAAQ;EACzB,IAAI,UAAU,SAAS,GAAG,OAAO,oBAAoB,UAAU,QAAQ;CACzE;AACF"}
1
+ {"version":3,"file":"max-height.js","names":[],"sources":["../src/max-height.ts"],"sourcesContent":["/**\n * The tallest body the engine handles, measured once per page.\n *\n * Chromium and WebKit clamp a taller element at about 33.5M px (fewer CSS px\n * when the page is zoomed in), and Firefox drops anything over about 17.9M px\n * to 0. Firefox also lets go of an element stuck to the top of a scroller (the\n * header, the top pinned rows) once the scroll offset passes about 8.95M px\n * (2^29 of its 1/60 px units). A hidden probe tries a few heights and keeps the\n * first that renders at full size and keeps a sticky element in place when\n * scrolled that far: 32M px in Chromium and WebKit, 8M px in Firefox. A zoom\n * change (a new `devicePixelRatio` on resize) measures again and tells the\n * tables, so a scaled body stays within the new limit.\n */\nconst HEIGHTS = [32_000_000, 16_000_000, 8_000_000, 4_000_000, 2_000_000];\n\n/** Content below this never needs the probe: every engine renders it. */\nconst SAFE_HEIGHT = 2_000_000;\n/** Room left for the scrollbar and rounding. */\nconst MARGIN = 1000;\n\nlet measured: number | undefined;\nlet forced: number | undefined;\nlet dpr = 0;\nconst listeners = new Set<() => void>();\n\nfunction onResize() {\n if (window.devicePixelRatio === dpr) return;\n dpr = window.devicePixelRatio;\n measured = undefined;\n for (const listener of listeners) listener();\n}\n\n/**\n * The tallest body height in px the engine renders and scrolls with its header\n * in place.\n */\nfunction maxElementHeight(): number {\n if (forced !== undefined) return forced;\n if (measured !== undefined) return measured;\n // Fixed, so the probe never makes the page scroll. A scroller (overflow:\n // hidden still scrolls from script) with a sticky element and a filler.\n const probe = document.createElement('div');\n probe.setAttribute('aria-hidden', 'true');\n probe.style.cssText =\n 'position:fixed;inset-block-start:0;inset-inline-start:0;inline-size:1px;block-size:10px;overflow:hidden;visibility:hidden;pointer-events:none';\n const sticky = document.createElement('div');\n sticky.style.cssText = 'position:sticky;inset-block-start:0;block-size:1px';\n const filler = document.createElement('div');\n probe.append(sticky, filler);\n document.body.append(probe);\n measured =\n HEIGHTS.find((height) => {\n filler.style.blockSize = `${String(height)}px`;\n if (filler.offsetHeight < height - 1) return false;\n probe.scrollTop = height;\n const held = sticky.getBoundingClientRect().top - probe.getBoundingClientRect().top;\n return Math.abs(held) < 1;\n }) ?? SAFE_HEIGHT;\n probe.remove();\n return measured;\n}\n\n/**\n * The tallest the body may render: the engine's maximum less what scrolls with\n * it (`extra`: the header and pinned rows). `Infinity` for a body of `logical`\n * px that is far from any limit, without measuring.\n */\nexport function bodyCap(logical: number, extra: number): number {\n if (forced === undefined && logical + extra < SAFE_HEIGHT) return Infinity;\n return Math.max(maxElementHeight() - extra - MARGIN, 0);\n}\n\n/** Calls `listener` when the maximum may have changed (the page was zoomed). Returns the unsubscribe. */\nexport function watchMaxElementHeight(listener: () => void): () => void {\n if (listeners.size === 0) {\n dpr = window.devicePixelRatio;\n window.addEventListener('resize', onResize, { passive: true });\n }\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) window.removeEventListener('resize', onResize);\n };\n}\n\n/** Tests: pretend the engine renders at most `height` px (`undefined` measures again). */\nexport function setMaxElementHeightForTests(height: number | undefined): void {\n forced = height;\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,IAAM,UAAU;CAAC;CAAY;CAAY;CAAW;CAAW;AAAS;;AAGxE,IAAM,cAAc;;AAEpB,IAAM,SAAS;AAEf,IAAI;AAEJ,IAAI,MAAM;AACV,IAAM,4BAAY,IAAI,IAAgB;AAEtC,SAAS,WAAW;CAClB,IAAI,OAAO,qBAAqB,KAAK;CACrC,MAAM,OAAO;CACb,WAAW,KAAA;CACX,KAAK,MAAM,YAAY,WAAW,SAAS;AAC7C;;;;;AAMA,SAAS,mBAA2B;CAElC,IAAI,aAAa,KAAA,GAAW,OAAO;CAGnC,MAAM,QAAQ,SAAS,cAAc,KAAK;CAC1C,MAAM,aAAa,eAAe,MAAM;CACxC,MAAM,MAAM,UACV;CACF,MAAM,SAAS,SAAS,cAAc,KAAK;CAC3C,OAAO,MAAM,UAAU;CACvB,MAAM,SAAS,SAAS,cAAc,KAAK;CAC3C,MAAM,OAAO,QAAQ,MAAM;CAC3B,SAAS,KAAK,OAAO,KAAK;CAC1B,WACE,QAAQ,MAAM,WAAW;EACvB,OAAO,MAAM,YAAY,GAAG,OAAO,MAAM,EAAE;EAC3C,IAAI,OAAO,eAAe,SAAS,GAAG,OAAO;EAC7C,MAAM,YAAY;EAClB,MAAM,OAAO,OAAO,sBAAsB,CAAC,CAAC,MAAM,MAAM,sBAAsB,CAAC,CAAC;EAChF,OAAO,KAAK,IAAI,IAAI,IAAI;CAC1B,CAAC,KAAK;CACR,MAAM,OAAO;CACb,OAAO;AACT;;;;;;AAOA,SAAgB,QAAQ,SAAiB,OAAuB;CAC9D,IAA4B,UAAU,QAAQ,aAAa,OAAO;CAClE,OAAO,KAAK,IAAI,iBAAiB,IAAI,QAAQ,QAAQ,CAAC;AACxD;;AAGA,SAAgB,sBAAsB,UAAkC;CACtE,IAAI,UAAU,SAAS,GAAG;EACxB,MAAM,OAAO;EACb,OAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,KAAK,CAAC;CAC/D;CACA,UAAU,IAAI,QAAQ;CACtB,aAAa;EACX,UAAU,OAAO,QAAQ;EACzB,IAAI,UAAU,SAAS,GAAG,OAAO,oBAAoB,UAAU,QAAQ;CACzE;AACF"}
@@ -1,6 +1,7 @@
1
- import { cellParts, cellSlot, pinStyle } from "./content.js";
1
+ import { cellParts, cellSlot, cellStyle } from "./content.js";
2
2
  import { html, nothing } from "lit";
3
3
  import { styleMap } from "lit/directives/style-map.js";
4
+ import { live } from "lit/directives/live.js";
4
5
  import { repeat } from "lit/directives/repeat.js";
5
6
  // src/render/body.ts
6
7
  /**
@@ -8,35 +9,46 @@ import { repeat } from "lit/directives/repeat.js";
8
9
  * reuses the DOM of one that left (ADR 0001), and they stay in pool order: screen
9
10
  * readers get the position from `aria-rowindex`. `firstRowIndex` is the
10
11
  * `aria-rowindex` of row 0: after the header and the rows pinned to the top.
12
+ * `inset`: rows of different heights (`rowHeight: 'auto'`) are placed with
13
+ * `inset-block-start` and render at least their measured `size`, which is on
14
+ * the controller's px grid, so they stay flush however far down they are.
11
15
  */
12
- function renderBody(rows, columns, totalSize, active, firstRowIndex, loading) {
16
+ function renderBody(rows, columns, totalSize, active, firstRowIndex, loading, selectedAt, checkboxes, inset = false) {
13
17
  return html`<div
14
18
  part="body"
15
19
  role="rowgroup"
16
20
  style=${styleMap({ blockSize: `${String(totalSize)}px` })}
17
21
  >
18
- ${repeat(rows, (r) => r.slot, (r) => renderRow(r, columns, r.index === active?.index ? active.col : void 0, firstRowIndex, loading))}
22
+ ${repeat(rows, (r) => r.slot, (r) => renderRow(r, columns, r.index === active?.index ? active.col : void 0, firstRowIndex, loading, selectedAt?.(r.index), checkboxes, inset))}
19
23
  </div>`;
20
24
  }
21
25
  /**
22
26
  * One body row. Every cell is focusable (`tabindex=-1`) so clicks focus it; the
23
27
  * active one is the grid's single tab stop (roving tabindex). A row whose data
24
28
  * isn't loaded shows skeleton bars, and a Retry button if its page failed.
29
+ * `selected` is `undefined` for a row that can't be selected: it then has no
30
+ * `aria-selected`, and no checkbox in the selection column.
25
31
  */
26
- function renderRow(row, columns, activeCol, firstRowIndex, loading) {
32
+ function renderRow(row, columns, activeCol, firstRowIndex, loading, selected, checkboxes, inset) {
27
33
  const state = loading?.stateAt(row.index) ?? "loaded";
28
34
  let parts = row.index % 2 === 0 ? "row row-even" : "row row-odd";
29
35
  if (state !== "loaded") parts += " row-loading";
36
+ if (selected) parts += " row-selected";
30
37
  const retry = state === "error" ? loading?.retry.get(row.index) : void 0;
38
+ const retryCol = firstDataColumn(columns);
31
39
  return html`<div
32
40
  part=${parts}
33
41
  class="row"
34
42
  role="row"
35
43
  aria-rowindex=${row.index + firstRowIndex}
36
44
  aria-busy=${state === "loading" ? "true" : nothing}
37
- style=${styleMap({ transform: `translateY(${String(row.start)}px)` })}
45
+ aria-selected=${selected === void 0 ? nothing : String(selected)}
46
+ style=${styleMap(inset ? {
47
+ insetBlockStart: `${String(row.start)}px`,
48
+ minBlockSize: row.size === void 0 ? void 0 : `${String(row.size)}px`
49
+ } : { transform: `translateY(${String(row.start)}px)` })}
38
50
  >
39
- ${repeat(columns, (c) => c.id, (c) => renderCell(c, c.id === activeCol, cellSlot(row.slot, c.colIndex), false, placeholder(state, c.colIndex === 1 ? retry : void 0, row.index, loading)))}
51
+ ${repeat(columns, (c) => c.key, (c) => renderCell(c, c.id === activeCol, cellSlot(row.slot, c.slot), false, c.kind === "selection" ? rowCheckbox(row.index, selected, checkboxes) : placeholder(state, c.id === retryCol ? retry : void 0, row.index, loading)))}
40
52
  </div>`;
41
53
  }
42
54
  /**
@@ -52,12 +64,40 @@ function renderCell(c, tabStop, slot, pinnedRow, before = nothing) {
52
64
  tabindex=${tabStop ? 0 : -1}
53
65
  data-col=${c.id}
54
66
  data-align=${c.align}
67
+ data-wrap=${c.wrap ? "" : nothing}
55
68
  data-pin=${c.pin ?? nothing}
56
- style=${pinStyle(c) ?? nothing}
69
+ style=${cellStyle(c) ?? nothing}
57
70
  >
58
71
  ${before}<slot name=${slot}></slot>
59
72
  </div>`;
60
73
  }
74
+ /**
75
+ * The id of the leftmost rendered data column (in reading order). Rendered
76
+ * columns are in pool order with `virtualizeColumns`.
77
+ */
78
+ function firstDataColumn(columns) {
79
+ let first;
80
+ for (const c of columns) if (c.kind === "data" && (!first || c.colIndex < first.colIndex)) first = c;
81
+ return first?.id;
82
+ }
83
+ /**
84
+ * A row's checkbox in the selection column, for a row that can be selected.
85
+ * It isn't focusable: the cell holds focus. `live()` keeps it showing the
86
+ * selection when a controlled host turns a change down.
87
+ */
88
+ function rowCheckbox(index, selected, checkboxes) {
89
+ if (selected === void 0 || !checkboxes) return nothing;
90
+ return html`<input
91
+ type="checkbox"
92
+ part="selection-checkbox"
93
+ tabindex="-1"
94
+ aria-label="Select row"
95
+ data-row=${index}
96
+ .checked=${live(selected)}
97
+ @click=${checkboxes.row}
98
+ @mousedown=${checkboxes.press}
99
+ />`;
100
+ }
61
101
  /** What an unloaded cell shows: a skeleton bar, or the Retry button in a failed page's first cell. */
62
102
  function placeholder(state, retryLabel, index, loading) {
63
103
  if (state === "loaded") return nothing;
@@ -1 +1 @@
1
- {"version":3,"file":"body.js","names":[],"sources":["../../src/render/body.ts"],"sourcesContent":["import { html, nothing } from 'lit';\nimport { repeat } from 'lit/directives/repeat.js';\nimport { styleMap } from 'lit/directives/style-map.js';\nimport type { ColumnView, RowState } from '@argus-table/core';\nimport type { WindowRow } from '../controllers/virtual-controller.js';\nimport type { CellContent } from '../types.js';\nimport { cellParts, cellSlot, pinStyle } from './content.js';\n\ntype Columns<T> = readonly ColumnView<T, CellContent>[];\n\n/** The cell holding the grid's tab stop: a row index and a column id. */\nexport interface ActiveCell {\n index: number;\n col: string;\n}\n\n/** Row loading state for `dataSource` tables. */\nexport interface Loading {\n /** Whether a row's data is loaded, still loading, or its page failed. */\n stateAt(index: number): RowState;\n /** Rows that show a Retry button (one per failed page in view), with its accessible label. */\n retry: ReadonlyMap<number, string>;\n /** Click handler of the Retry buttons; the button carries the row index in `data-row`. */\n onRetry: (event: Event) => void;\n}\n\n/**\n * The virtualized body. Rows are keyed by pool slot, so a row entering the window\n * reuses the DOM of one that left (ADR 0001), and they stay in pool order: screen\n * readers get the position from `aria-rowindex`. `firstRowIndex` is the\n * `aria-rowindex` of row 0: after the header and the rows pinned to the top.\n */\nexport function renderBody<T>(\n rows: readonly WindowRow[],\n columns: Columns<T>,\n totalSize: number,\n active: ActiveCell | undefined,\n firstRowIndex: number,\n loading?: Loading,\n) {\n return html`<div\n part=\"body\"\n role=\"rowgroup\"\n style=${styleMap({ blockSize: `${String(totalSize)}px` })}\n >\n ${repeat(\n rows,\n (r) => r.slot,\n (r) =>\n renderRow(\n r,\n columns,\n r.index === active?.index ? active.col : undefined,\n firstRowIndex,\n loading,\n ),\n )}\n </div>`;\n}\n\n/**\n * One body row. Every cell is focusable (`tabindex=-1`) so clicks focus it; the\n * active one is the grid's single tab stop (roving tabindex). A row whose data\n * isn't loaded shows skeleton bars, and a Retry button if its page failed.\n */\nfunction renderRow<T>(\n row: WindowRow,\n columns: Columns<T>,\n activeCol: string | undefined,\n firstRowIndex: number,\n loading: Loading | undefined,\n) {\n const state = loading?.stateAt(row.index) ?? 'loaded';\n let parts = row.index % 2 === 0 ? 'row row-even' : 'row row-odd';\n if (state !== 'loaded') parts += ' row-loading';\n const retry = state === 'error' ? loading?.retry.get(row.index) : undefined;\n return html`<div\n part=${parts}\n class=\"row\"\n role=\"row\"\n aria-rowindex=${row.index + firstRowIndex}\n aria-busy=${state === 'loading' ? 'true' : nothing}\n style=${styleMap({ transform: `translateY(${String(row.start)}px)` })}\n >\n ${repeat(\n columns,\n (c) => c.id,\n (c) =>\n renderCell(\n c,\n c.id === activeCol,\n cellSlot(row.slot, c.colIndex),\n false,\n placeholder(state, c.colIndex === 1 ? retry : undefined, row.index, loading),\n ),\n )}\n </div>`;\n}\n\n/**\n * A body or pinned-row cell. Every cell is focusable (`tabindex=-1`) so clicks\n * focus it; `tabStop` marks the grid's single tab stop. `before` goes ahead of\n * the slot (a loading row's skeleton bar or Retry button).\n */\nexport function renderCell<T>(\n c: ColumnView<T, CellContent>,\n tabStop: boolean,\n slot: string,\n pinnedRow: boolean,\n before: unknown = nothing,\n) {\n return html`<div\n part=${cellParts(c, pinnedRow)}\n role=\"gridcell\"\n aria-colindex=${c.colIndex}\n tabindex=${tabStop ? 0 : -1}\n data-col=${c.id}\n data-align=${c.align}\n data-pin=${c.pin ?? nothing}\n style=${pinStyle(c) ?? nothing}\n >\n ${before}<slot name=${slot}></slot>\n </div>`;\n}\n\n/** What an unloaded cell shows: a skeleton bar, or the Retry button in a failed page's first cell. */\nfunction placeholder(\n state: RowState,\n retryLabel: string | undefined,\n index: number,\n loading: Loading | undefined,\n) {\n if (state === 'loaded') return nothing;\n if (retryLabel !== undefined && loading) {\n return html`<button\n part=\"retry\"\n type=\"button\"\n tabindex=\"-1\"\n data-row=${index}\n aria-label=${retryLabel}\n @click=${loading.onRetry}\n >\n Retry\n </button>`;\n }\n return html`<div\n part=${state === 'error' ? 'skeleton skeleton-error' : 'skeleton'}\n aria-hidden=\"true\"\n ></div>`;\n}\n"],"mappings":";;;;;;;;;;;AAgCA,SAAgB,WACd,MACA,SACA,WACA,QACA,eACA,SACA;CACA,OAAO,IAAI;;;YAGD,SAAS,EAAE,WAAW,GAAG,OAAO,SAAS,EAAE,IAAI,CAAC,EAAE;;MAExD,OACA,OACC,MAAM,EAAE,OACR,MACC,UACE,GACA,SACA,EAAE,UAAU,QAAQ,QAAQ,OAAO,MAAM,KAAA,GACzC,eACA,OACF,CACJ,EAAE;;AAEN;;;;;;AAOA,SAAS,UACP,KACA,SACA,WACA,eACA,SACA;CACA,MAAM,QAAQ,SAAS,QAAQ,IAAI,KAAK,KAAK;CAC7C,IAAI,QAAQ,IAAI,QAAQ,MAAM,IAAI,iBAAiB;CACnD,IAAI,UAAU,UAAU,SAAS;CACjC,MAAM,QAAQ,UAAU,UAAU,SAAS,MAAM,IAAI,IAAI,KAAK,IAAI,KAAA;CAClE,OAAO,IAAI;WACF,MAAM;;;oBAGG,IAAI,QAAQ,cAAc;gBAC9B,UAAU,YAAY,SAAS,QAAQ;YAC3C,SAAS,EAAE,WAAW,cAAc,OAAO,IAAI,KAAK,EAAE,KAAK,CAAC,EAAE;;MAEpE,OACA,UACC,MAAM,EAAE,KACR,MACC,WACE,GACA,EAAE,OAAO,WACT,SAAS,IAAI,MAAM,EAAE,QAAQ,GAC7B,OACA,YAAY,OAAO,EAAE,aAAa,IAAI,QAAQ,KAAA,GAAW,IAAI,OAAO,OAAO,CAC7E,CACJ,EAAE;;AAEN;;;;;;AAOA,SAAgB,WACd,GACA,SACA,MACA,WACA,SAAkB,SAClB;CACA,OAAO,IAAI;WACF,UAAU,GAAG,SAAS,EAAE;;oBAEf,EAAE,SAAS;eAChB,UAAU,IAAI,GAAG;eACjB,EAAE,GAAG;iBACH,EAAE,MAAM;eACV,EAAE,OAAO,QAAQ;YACpB,SAAS,CAAC,KAAK,QAAQ;;MAE7B,OAAO,aAAa,KAAK;;AAE/B;;AAGA,SAAS,YACP,OACA,YACA,OACA,SACA;CACA,IAAI,UAAU,UAAU,OAAO;CAC/B,IAAI,eAAe,KAAA,KAAa,SAC9B,OAAO,IAAI;;;;iBAIE,MAAM;mBACJ,WAAW;eACf,QAAQ,QAAQ;;;;CAK7B,OAAO,IAAI;WACF,UAAU,UAAU,4BAA4B,WAAW;;;AAGtE"}
1
+ {"version":3,"file":"body.js","names":[],"sources":["../../src/render/body.ts"],"sourcesContent":["import { html, nothing } from 'lit';\nimport { live } from 'lit/directives/live.js';\nimport { repeat } from 'lit/directives/repeat.js';\nimport { styleMap } from 'lit/directives/style-map.js';\nimport type { RowState } from '@argus-table/core';\nimport type { RenderedColumn } from '../controllers/column-window.js';\nimport type { WindowRow } from '../controllers/virtual-controller.js';\nimport { cellParts, cellSlot, cellStyle, type CheckboxHandlers } from './content.js';\n\ntype Columns<T> = readonly RenderedColumn<T>[];\n\n/** The cell holding the grid's tab stop: a row index and a column id. */\nexport interface ActiveCell {\n index: number;\n col: string;\n}\n\n/** Row loading state for `dataSource` tables. */\nexport interface Loading {\n /** Whether a row's data is loaded, still loading, or its page failed. */\n stateAt(index: number): RowState;\n /** Rows that show a Retry button (one per failed page in view), with its accessible label. */\n retry: ReadonlyMap<number, string>;\n /** Click handler of the Retry buttons; the button carries the row index in `data-row`. */\n onRetry: (event: Event) => void;\n}\n\n/**\n * Whether the row at an index is selected; `undefined` when it can't be\n * selected (selection is off, or the row hasn't loaded).\n */\nexport type SelectedAt = (index: number) => boolean | undefined;\n\n/**\n * The virtualized body. Rows are keyed by pool slot, so a row entering the window\n * reuses the DOM of one that left (ADR 0001), and they stay in pool order: screen\n * readers get the position from `aria-rowindex`. `firstRowIndex` is the\n * `aria-rowindex` of row 0: after the header and the rows pinned to the top.\n * `inset`: rows of different heights (`rowHeight: 'auto'`) are placed with\n * `inset-block-start` and render at least their measured `size`, which is on\n * the controller's px grid, so they stay flush however far down they are.\n */\nexport function renderBody<T>(\n rows: readonly WindowRow[],\n columns: Columns<T>,\n totalSize: number,\n active: ActiveCell | undefined,\n firstRowIndex: number,\n loading?: Loading,\n selectedAt?: SelectedAt,\n checkboxes?: CheckboxHandlers,\n inset = false,\n) {\n return html`<div\n part=\"body\"\n role=\"rowgroup\"\n style=${styleMap({ blockSize: `${String(totalSize)}px` })}\n >\n ${repeat(\n rows,\n (r) => r.slot,\n (r) =>\n renderRow(\n r,\n columns,\n r.index === active?.index ? active.col : undefined,\n firstRowIndex,\n loading,\n selectedAt?.(r.index),\n checkboxes,\n inset,\n ),\n )}\n </div>`;\n}\n\n/**\n * One body row. Every cell is focusable (`tabindex=-1`) so clicks focus it; the\n * active one is the grid's single tab stop (roving tabindex). A row whose data\n * isn't loaded shows skeleton bars, and a Retry button if its page failed.\n * `selected` is `undefined` for a row that can't be selected: it then has no\n * `aria-selected`, and no checkbox in the selection column.\n */\nfunction renderRow<T>(\n row: WindowRow,\n columns: Columns<T>,\n activeCol: string | undefined,\n firstRowIndex: number,\n loading: Loading | undefined,\n selected: boolean | undefined,\n checkboxes: CheckboxHandlers | undefined,\n inset: boolean,\n) {\n const state = loading?.stateAt(row.index) ?? 'loaded';\n let parts = row.index % 2 === 0 ? 'row row-even' : 'row row-odd';\n if (state !== 'loaded') parts += ' row-loading';\n if (selected) parts += ' row-selected';\n const retry = state === 'error' ? loading?.retry.get(row.index) : undefined;\n // The Retry button goes in the first data column, after a selection column.\n const retryCol = firstDataColumn(columns);\n return html`<div\n part=${parts}\n class=\"row\"\n role=\"row\"\n aria-rowindex=${row.index + firstRowIndex}\n aria-busy=${state === 'loading' ? 'true' : nothing}\n aria-selected=${selected === undefined ? nothing : String(selected)}\n style=${styleMap(\n inset\n ? {\n insetBlockStart: `${String(row.start)}px`,\n minBlockSize: row.size === undefined ? undefined : `${String(row.size)}px`,\n }\n : { transform: `translateY(${String(row.start)}px)` },\n )}\n >\n ${repeat(\n columns,\n (c) => c.key,\n (c) =>\n renderCell(\n c,\n c.id === activeCol,\n cellSlot(row.slot, c.slot),\n false,\n c.kind === 'selection'\n ? rowCheckbox(row.index, selected, checkboxes)\n : placeholder(state, c.id === retryCol ? retry : undefined, row.index, loading),\n ),\n )}\n </div>`;\n}\n\n/**\n * A body or pinned-row cell. Every cell is focusable (`tabindex=-1`) so clicks\n * focus it; `tabStop` marks the grid's single tab stop. `before` goes ahead of\n * the slot (a loading row's skeleton bar or Retry button).\n */\nexport function renderCell<T>(\n c: RenderedColumn<T>,\n tabStop: boolean,\n slot: string,\n pinnedRow: boolean,\n before: unknown = nothing,\n) {\n return html`<div\n part=${cellParts(c, pinnedRow)}\n role=\"gridcell\"\n aria-colindex=${c.colIndex}\n tabindex=${tabStop ? 0 : -1}\n data-col=${c.id}\n data-align=${c.align}\n data-wrap=${c.wrap ? '' : nothing}\n data-pin=${c.pin ?? nothing}\n style=${cellStyle(c) ?? nothing}\n >\n ${before}<slot name=${slot}></slot>\n </div>`;\n}\n\n/**\n * The id of the leftmost rendered data column (in reading order). Rendered\n * columns are in pool order with `virtualizeColumns`.\n */\nfunction firstDataColumn<T>(columns: Columns<T>): string | undefined {\n let first: RenderedColumn<T> | undefined;\n for (const c of columns) {\n if (c.kind === 'data' && (!first || c.colIndex < first.colIndex)) first = c;\n }\n return first?.id;\n}\n\n/**\n * A row's checkbox in the selection column, for a row that can be selected.\n * It isn't focusable: the cell holds focus. `live()` keeps it showing the\n * selection when a controlled host turns a change down.\n */\nfunction rowCheckbox(\n index: number,\n selected: boolean | undefined,\n checkboxes: CheckboxHandlers | undefined,\n) {\n if (selected === undefined || !checkboxes) return nothing;\n return html`<input\n type=\"checkbox\"\n part=\"selection-checkbox\"\n tabindex=\"-1\"\n aria-label=\"Select row\"\n data-row=${index}\n .checked=${live(selected)}\n @click=${checkboxes.row}\n @mousedown=${checkboxes.press}\n />`;\n}\n\n/** What an unloaded cell shows: a skeleton bar, or the Retry button in a failed page's first cell. */\nfunction placeholder(\n state: RowState,\n retryLabel: string | undefined,\n index: number,\n loading: Loading | undefined,\n) {\n if (state === 'loaded') return nothing;\n if (retryLabel !== undefined && loading) {\n return html`<button\n part=\"retry\"\n type=\"button\"\n tabindex=\"-1\"\n data-row=${index}\n aria-label=${retryLabel}\n @click=${loading.onRetry}\n >\n Retry\n </button>`;\n }\n return html`<div\n part=${state === 'error' ? 'skeleton skeleton-error' : 'skeleton'}\n aria-hidden=\"true\"\n ></div>`;\n}\n"],"mappings":";;;;;;;;;;;;;;;AA0CA,SAAgB,WACd,MACA,SACA,WACA,QACA,eACA,SACA,YACA,YACA,QAAQ,OACR;CACA,OAAO,IAAI;;;YAGD,SAAS,EAAE,WAAW,GAAG,OAAO,SAAS,EAAE,IAAI,CAAC,EAAE;;MAExD,OACA,OACC,MAAM,EAAE,OACR,MACC,UACE,GACA,SACA,EAAE,UAAU,QAAQ,QAAQ,OAAO,MAAM,KAAA,GACzC,eACA,SACA,aAAa,EAAE,KAAK,GACpB,YACA,KACF,CACJ,EAAE;;AAEN;;;;;;;;AASA,SAAS,UACP,KACA,SACA,WACA,eACA,SACA,UACA,YACA,OACA;CACA,MAAM,QAAQ,SAAS,QAAQ,IAAI,KAAK,KAAK;CAC7C,IAAI,QAAQ,IAAI,QAAQ,MAAM,IAAI,iBAAiB;CACnD,IAAI,UAAU,UAAU,SAAS;CACjC,IAAI,UAAU,SAAS;CACvB,MAAM,QAAQ,UAAU,UAAU,SAAS,MAAM,IAAI,IAAI,KAAK,IAAI,KAAA;CAElE,MAAM,WAAW,gBAAgB,OAAO;CACxC,OAAO,IAAI;WACF,MAAM;;;oBAGG,IAAI,QAAQ,cAAc;gBAC9B,UAAU,YAAY,SAAS,QAAQ;oBACnC,aAAa,KAAA,IAAY,UAAU,OAAO,QAAQ,EAAE;YAC5D,SACN,QACI;EACE,iBAAiB,GAAG,OAAO,IAAI,KAAK,EAAE;EACtC,cAAc,IAAI,SAAS,KAAA,IAAY,KAAA,IAAY,GAAG,OAAO,IAAI,IAAI,EAAE;CACzE,IACA,EAAE,WAAW,cAAc,OAAO,IAAI,KAAK,EAAE,KAAK,CACxD,EAAE;;MAEA,OACA,UACC,MAAM,EAAE,MACR,MACC,WACE,GACA,EAAE,OAAO,WACT,SAAS,IAAI,MAAM,EAAE,IAAI,GACzB,OACA,EAAE,SAAS,cACP,YAAY,IAAI,OAAO,UAAU,UAAU,IAC3C,YAAY,OAAO,EAAE,OAAO,WAAW,QAAQ,KAAA,GAAW,IAAI,OAAO,OAAO,CAClF,CACJ,EAAE;;AAEN;;;;;;AAOA,SAAgB,WACd,GACA,SACA,MACA,WACA,SAAkB,SAClB;CACA,OAAO,IAAI;WACF,UAAU,GAAG,SAAS,EAAE;;oBAEf,EAAE,SAAS;eAChB,UAAU,IAAI,GAAG;eACjB,EAAE,GAAG;iBACH,EAAE,MAAM;gBACT,EAAE,OAAO,KAAK,QAAQ;eACvB,EAAE,OAAO,QAAQ;YACpB,UAAU,CAAC,KAAK,QAAQ;;MAE9B,OAAO,aAAa,KAAK;;AAE/B;;;;;AAMA,SAAS,gBAAmB,SAAyC;CACnE,IAAI;CACJ,KAAK,MAAM,KAAK,SACd,IAAI,EAAE,SAAS,WAAW,CAAC,SAAS,EAAE,WAAW,MAAM,WAAW,QAAQ;CAE5E,OAAO,OAAO;AAChB;;;;;;AAOA,SAAS,YACP,OACA,UACA,YACA;CACA,IAAI,aAAa,KAAA,KAAa,CAAC,YAAY,OAAO;CAClD,OAAO,IAAI;;;;;eAKE,MAAM;eACN,KAAK,QAAQ,EAAE;aACjB,WAAW,IAAI;iBACX,WAAW,MAAM;;AAElC;;AAGA,SAAS,YACP,OACA,YACA,OACA,SACA;CACA,IAAI,UAAU,UAAU,OAAO;CAC/B,IAAI,eAAe,KAAA,KAAa,SAC9B,OAAO,IAAI;;;;iBAIE,MAAM;mBACJ,WAAW;eACf,QAAQ,QAAQ;;;;CAK7B,OAAO,IAAI;WACF,UAAU,UAAU,4BAA4B,WAAW;;;AAGtE"}
@@ -1,4 +1,5 @@
1
- import { cellSlot, formatValue, headerSlot, pinnedSlot, toChild } from "./content.js";
1
+ import { cellSlot, formatValue, headerSlot, pinnedSlot } from "./content.js";
2
+ import { content } from "./content-directive.js";
2
3
  import { html, render } from "lit";
3
4
  import { repeat } from "lit/directives/repeat.js";
4
5
  // src/render/cells.ts
@@ -7,35 +8,37 @@ import { repeat } from "lit/directives/repeat.js";
7
8
  * (a slot only takes the host's own children). Keyed and ordered by pool slot,
8
9
  * so recycled content updates in place instead of moving (ADR 0001). The part
9
10
  * ends at `renderBefore`, so the host's other children (`<argus-column>`) stay
10
- * outside it, wherever they are added.
11
+ * outside it, wherever they are added. `rendered` are the columns the shadow
12
+ * root renders, with their slots and keys. What `header` and `cell` return goes
13
+ * through `content()`, so foreign content reaches the table's `contentRenderer`.
14
+ * Returns the part, which the table disconnects when it is disconnected.
11
15
  */
12
- function renderLightDom(host, model, rows, renderBefore) {
13
- const columns = model.columns;
14
- render(html`${repeat(columns, (c) => c.id, (c) => html`<div slot=${headerSlot(c.colIndex)}>${headerContent(c)}</div>`)}${pinnedContent(model, "top")}${repeat(rows, (r) => r.slot, (r) => repeat(columns, (c) => c.id, (c) => html`<div slot=${cellSlot(r.slot, c.colIndex)}>${cellContent(model, r.index, c)}</div>`))}${pinnedContent(model, "bottom")}`, host, {
16
+ function renderLightDom(host, model, rows, rendered, renderBefore) {
17
+ const columns = rendered.filter((c) => c.kind === "data");
18
+ return render(html`${repeat(columns, (c) => c.key, (c) => html`<div slot=${headerSlot(c.slot)}>${headerContent(c)}</div>`)}${pinnedContent(model, columns, "top")}${repeat(rows, (r) => r.slot, (r) => repeat(columns, (c) => c.key, (c) => html`<div slot=${cellSlot(r.slot, c.slot)}>${cellContent(model, r.index, c)}</div>`))}${pinnedContent(model, columns, "bottom")}`, host, {
15
19
  host,
16
20
  renderBefore
17
21
  });
18
22
  }
19
23
  function headerContent(column) {
20
24
  const { header } = column;
21
- return toChild(typeof header === "function" ? header() : header);
25
+ return content(typeof header === "function" ? header() : header);
22
26
  }
23
27
  /** Content of the rows pinned to one side, slotted by side, index and column. */
24
- function pinnedContent(model, side) {
25
- const columns = model.columns;
26
- return Array.from({ length: model.pinnedCount(side) }, (_, i) => i).map((i) => repeat(columns, (c) => c.id, (c) => html`<div slot=${pinnedSlot(side, i, c.colIndex)}>${pinnedCell(model, side, i, c)}</div>`));
28
+ function pinnedContent(model, columns, side) {
29
+ return Array.from({ length: model.pinnedCount(side) }, (_, i) => i).map((i) => repeat(columns, (c) => c.key, (c) => html`<div slot=${pinnedSlot(side, i, c.slot)}>${pinnedCell(model, side, i, c)}</div>`));
27
30
  }
28
31
  function pinnedCell(model, side, index, column) {
29
32
  if (column.cell) {
30
33
  const context = model.pinnedCellContext(side, index, column.id);
31
- return context ? toChild(column.cell(context.row, context)) : "";
34
+ return context ? content(column.cell(context.row, context)) : "";
32
35
  }
33
36
  return column.hasAccessor ? formatValue(model.pinnedValueAt(side, index, column.id)) : "";
34
37
  }
35
38
  function cellContent(model, index, column) {
36
39
  if (column.cell) {
37
40
  const context = model.cellContext(index, column.id);
38
- return context ? toChild(column.cell(context.row, context)) : "";
41
+ return context ? content(column.cell(context.row, context)) : "";
39
42
  }
40
43
  return column.hasAccessor ? formatValue(model.valueAt(index, column.id)) : "";
41
44
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cells.js","names":[],"sources":["../../src/render/cells.ts"],"sourcesContent":["import { html, render } from 'lit';\nimport { repeat } from 'lit/directives/repeat.js';\nimport type { ColumnView, RowPin, TableModel } from '@argus-table/core';\nimport type { WindowRow } from '../controllers/virtual-controller.js';\nimport type { CellContent } from '../types.js';\nimport { cellSlot, formatValue, headerSlot, pinnedSlot, toChild } from './content.js';\n\ntype Column<T> = ColumnView<T, CellContent>;\n\n/**\n * Renders header and cell content into the host's light DOM, as direct children\n * (a slot only takes the host's own children). Keyed and ordered by pool slot,\n * so recycled content updates in place instead of moving (ADR 0001). The part\n * ends at `renderBefore`, so the host's other children (`<argus-column>`) stay\n * outside it, wherever they are added.\n */\nexport function renderLightDom<T>(\n host: HTMLElement,\n model: TableModel<T, CellContent>,\n rows: readonly WindowRow[],\n renderBefore: ChildNode,\n): void {\n const columns = model.columns;\n render(\n html`${repeat(\n columns,\n (c) => c.id,\n (c) => html`<div slot=${headerSlot(c.colIndex)}>${headerContent(c)}</div>`,\n )}${pinnedContent(model, 'top')}${repeat(\n rows,\n (r) => r.slot,\n (r) =>\n repeat(\n columns,\n (c) => c.id,\n (c) =>\n html`<div slot=${cellSlot(r.slot, c.colIndex)}>${cellContent(model, r.index, c)}</div>`,\n ),\n )}${pinnedContent(model, 'bottom')}`,\n host,\n { host, renderBefore },\n );\n}\n\nfunction headerContent<T>(column: Column<T>): unknown {\n const { header } = column;\n return toChild(typeof header === 'function' ? header() : header);\n}\n\n/** Content of the rows pinned to one side, slotted by side, index and column. */\nfunction pinnedContent<T>(model: TableModel<T, CellContent>, side: RowPin) {\n const columns = model.columns;\n const rows = Array.from({ length: model.pinnedCount(side) }, (_, i) => i);\n return rows.map((i) =>\n repeat(\n columns,\n (c) => c.id,\n (c) =>\n html`<div slot=${pinnedSlot(side, i, c.colIndex)}>${pinnedCell(model, side, i, c)}</div>`,\n ),\n );\n}\n\nfunction pinnedCell<T>(\n model: TableModel<T, CellContent>,\n side: RowPin,\n index: number,\n column: Column<T>,\n) {\n if (column.cell) {\n const context = model.pinnedCellContext(side, index, column.id);\n return context ? toChild(column.cell(context.row, context)) : '';\n }\n return column.hasAccessor ? formatValue(model.pinnedValueAt(side, index, column.id)) : '';\n}\n\nfunction cellContent<T>(model: TableModel<T, CellContent>, index: number, column: Column<T>) {\n if (column.cell) {\n const context = model.cellContext(index, column.id);\n return context ? toChild(column.cell(context.row, context)) : '';\n }\n return column.hasAccessor ? formatValue(model.valueAt(index, column.id)) : '';\n}\n"],"mappings":";;;;;;;;;;;AAgBA,SAAgB,eACd,MACA,OACA,MACA,cACM;CACN,MAAM,UAAU,MAAM;CACtB,OACE,IAAI,GAAG,OACL,UACC,MAAM,EAAE,KACR,MAAM,IAAI,aAAa,WAAW,EAAE,QAAQ,EAAE,GAAG,cAAc,CAAC,EAAE,OACrE,IAAI,cAAc,OAAO,KAAK,IAAI,OAChC,OACC,MAAM,EAAE,OACR,MACC,OACE,UACC,MAAM,EAAE,KACR,MACC,IAAI,aAAa,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,YAAY,OAAO,EAAE,OAAO,CAAC,EAAE,OACpF,CACJ,IAAI,cAAc,OAAO,QAAQ,KACjC,MACA;EAAE;EAAM;CAAa,CACvB;AACF;AAEA,SAAS,cAAiB,QAA4B;CACpD,MAAM,EAAE,WAAW;CACnB,OAAO,QAAQ,OAAO,WAAW,aAAa,OAAO,IAAI,MAAM;AACjE;;AAGA,SAAS,cAAiB,OAAmC,MAAc;CACzE,MAAM,UAAU,MAAM;CAEtB,OADa,MAAM,KAAK,EAAE,QAAQ,MAAM,YAAY,IAAI,EAAE,IAAI,GAAG,MAAM,CAChE,CAAA,CAAK,KAAK,MACf,OACE,UACC,MAAM,EAAE,KACR,MACC,IAAI,aAAa,WAAW,MAAM,GAAG,EAAE,QAAQ,EAAE,GAAG,WAAW,OAAO,MAAM,GAAG,CAAC,EAAE,OACtF,CACF;AACF;AAEA,SAAS,WACP,OACA,MACA,OACA,QACA;CACA,IAAI,OAAO,MAAM;EACf,MAAM,UAAU,MAAM,kBAAkB,MAAM,OAAO,OAAO,EAAE;EAC9D,OAAO,UAAU,QAAQ,OAAO,KAAK,QAAQ,KAAK,OAAO,CAAC,IAAI;CAChE;CACA,OAAO,OAAO,cAAc,YAAY,MAAM,cAAc,MAAM,OAAO,OAAO,EAAE,CAAC,IAAI;AACzF;AAEA,SAAS,YAAe,OAAmC,OAAe,QAAmB;CAC3F,IAAI,OAAO,MAAM;EACf,MAAM,UAAU,MAAM,YAAY,OAAO,OAAO,EAAE;EAClD,OAAO,UAAU,QAAQ,OAAO,KAAK,QAAQ,KAAK,OAAO,CAAC,IAAI;CAChE;CACA,OAAO,OAAO,cAAc,YAAY,MAAM,QAAQ,OAAO,OAAO,EAAE,CAAC,IAAI;AAC7E"}
1
+ {"version":3,"file":"cells.js","names":[],"sources":["../../src/render/cells.ts"],"sourcesContent":["import { html, render, type RootPart } from 'lit';\nimport { repeat } from 'lit/directives/repeat.js';\nimport type { ColumnView, RowPin, TableModel } from '@argus-table/core';\nimport type { RenderedColumn } from '../controllers/column-window.js';\nimport type { WindowRow } from '../controllers/virtual-controller.js';\nimport type { CellContent } from '../types.js';\nimport { cellSlot, formatValue, headerSlot, pinnedSlot } from './content.js';\nimport { content } from './content-directive.js';\n\ntype Column<T> = ColumnView<T, CellContent>;\n\n/**\n * Renders header and cell content into the host's light DOM, as direct children\n * (a slot only takes the host's own children). Keyed and ordered by pool slot,\n * so recycled content updates in place instead of moving (ADR 0001). The part\n * ends at `renderBefore`, so the host's other children (`<argus-column>`) stay\n * outside it, wherever they are added. `rendered` are the columns the shadow\n * root renders, with their slots and keys. What `header` and `cell` return goes\n * through `content()`, so foreign content reaches the table's `contentRenderer`.\n * Returns the part, which the table disconnects when it is disconnected.\n */\nexport function renderLightDom<T>(\n host: HTMLElement,\n model: TableModel<T, CellContent>,\n rows: readonly WindowRow[],\n rendered: readonly RenderedColumn<T>[],\n renderBefore: ChildNode,\n): RootPart {\n // The selection column renders its checkboxes in the shadow root: no content here.\n const columns = rendered.filter((c) => c.kind === 'data');\n return render(\n html`${repeat(\n columns,\n (c) => c.key,\n (c) => html`<div slot=${headerSlot(c.slot)}>${headerContent(c)}</div>`,\n )}${pinnedContent(model, columns, 'top')}${repeat(\n rows,\n (r) => r.slot,\n (r) =>\n repeat(\n columns,\n (c) => c.key,\n (c) =>\n html`<div slot=${cellSlot(r.slot, c.slot)}>${cellContent(model, r.index, c)}</div>`,\n ),\n )}${pinnedContent(model, columns, 'bottom')}`,\n host,\n { host, renderBefore },\n );\n}\n\nfunction headerContent<T>(column: Column<T>): unknown {\n const { header } = column;\n return content(typeof header === 'function' ? header() : header);\n}\n\n/** Content of the rows pinned to one side, slotted by side, index and column. */\nfunction pinnedContent<T>(\n model: TableModel<T, CellContent>,\n columns: readonly RenderedColumn<T>[],\n side: RowPin,\n) {\n const rows = Array.from({ length: model.pinnedCount(side) }, (_, i) => i);\n return rows.map((i) =>\n repeat(\n columns,\n (c) => c.key,\n (c) => html`<div slot=${pinnedSlot(side, i, c.slot)}>${pinnedCell(model, side, i, c)}</div>`,\n ),\n );\n}\n\nfunction pinnedCell<T>(\n model: TableModel<T, CellContent>,\n side: RowPin,\n index: number,\n column: Column<T>,\n) {\n if (column.cell) {\n const context = model.pinnedCellContext(side, index, column.id);\n return context ? content(column.cell(context.row, context)) : '';\n }\n return column.hasAccessor ? formatValue(model.pinnedValueAt(side, index, column.id)) : '';\n}\n\nfunction cellContent<T>(model: TableModel<T, CellContent>, index: number, column: Column<T>) {\n if (column.cell) {\n const context = model.cellContext(index, column.id);\n return context ? content(column.cell(context.row, context)) : '';\n }\n return column.hasAccessor ? formatValue(model.valueAt(index, column.id)) : '';\n}\n"],"mappings":";;;;;;;;;;;;;;;AAqBA,SAAgB,eACd,MACA,OACA,MACA,UACA,cACU;CAEV,MAAM,UAAU,SAAS,QAAQ,MAAM,EAAE,SAAS,MAAM;CACxD,OAAO,OACL,IAAI,GAAG,OACL,UACC,MAAM,EAAE,MACR,MAAM,IAAI,aAAa,WAAW,EAAE,IAAI,EAAE,GAAG,cAAc,CAAC,EAAE,OACjE,IAAI,cAAc,OAAO,SAAS,KAAK,IAAI,OACzC,OACC,MAAM,EAAE,OACR,MACC,OACE,UACC,MAAM,EAAE,MACR,MACC,IAAI,aAAa,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,YAAY,OAAO,EAAE,OAAO,CAAC,EAAE,OAChF,CACJ,IAAI,cAAc,OAAO,SAAS,QAAQ,KAC1C,MACA;EAAE;EAAM;CAAa,CACvB;AACF;AAEA,SAAS,cAAiB,QAA4B;CACpD,MAAM,EAAE,WAAW;CACnB,OAAO,QAAQ,OAAO,WAAW,aAAa,OAAO,IAAI,MAAM;AACjE;;AAGA,SAAS,cACP,OACA,SACA,MACA;CAEA,OADa,MAAM,KAAK,EAAE,QAAQ,MAAM,YAAY,IAAI,EAAE,IAAI,GAAG,MAAM,CAChE,CAAA,CAAK,KAAK,MACf,OACE,UACC,MAAM,EAAE,MACR,MAAM,IAAI,aAAa,WAAW,MAAM,GAAG,EAAE,IAAI,EAAE,GAAG,WAAW,OAAO,MAAM,GAAG,CAAC,EAAE,OACvF,CACF;AACF;AAEA,SAAS,WACP,OACA,MACA,OACA,QACA;CACA,IAAI,OAAO,MAAM;EACf,MAAM,UAAU,MAAM,kBAAkB,MAAM,OAAO,OAAO,EAAE;EAC9D,OAAO,UAAU,QAAQ,OAAO,KAAK,QAAQ,KAAK,OAAO,CAAC,IAAI;CAChE;CACA,OAAO,OAAO,cAAc,YAAY,MAAM,cAAc,MAAM,OAAO,OAAO,EAAE,CAAC,IAAI;AACzF;AAEA,SAAS,YAAe,OAAmC,OAAe,QAAmB;CAC3F,IAAI,OAAO,MAAM;EACf,MAAM,UAAU,MAAM,YAAY,OAAO,OAAO,EAAE;EAClD,OAAO,UAAU,QAAQ,OAAO,KAAK,QAAQ,KAAK,OAAO,CAAC,IAAI;CAChE;CACA,OAAO,OAAO,cAAc,YAAY,MAAM,QAAQ,OAAO,OAAO,EAAE,CAAC,IAAI;AAC7E"}
@@ -0,0 +1,115 @@
1
+ import { toChild } from "./content.js";
2
+ import { nothing } from "lit";
3
+ import { AsyncDirective, PartType, directive } from "lit/async-directive.js";
4
+ // src/render/content-directive.ts
5
+ /** Renderers that rendered or released since the last flush, per table. */
6
+ var dirty = /* @__PURE__ */ new WeakMap();
7
+ var warned = false;
8
+ function markDirty(host, renderer) {
9
+ let set = dirty.get(host);
10
+ if (!set) dirty.set(host, set = /* @__PURE__ */ new Set());
11
+ set.add(renderer);
12
+ }
13
+ /** Calls `flush()` on the renderers that rendered or released for `host` since the last call. */
14
+ function flushContent(host) {
15
+ const set = dirty.get(host);
16
+ if (!set?.size) return;
17
+ const renderers = [...set];
18
+ set.clear();
19
+ for (const renderer of renderers) renderer.flush?.();
20
+ }
21
+ /**
22
+ * Whether the table renders `value` itself: strings, numbers, Lit templates and
23
+ * directive results, DOM nodes, `nothing` and empty values, and arrays of those.
24
+ * Anything else is foreign: it goes to the `contentRenderer`.
25
+ */
26
+ function isForeign(value) {
27
+ if (value === null || typeof value !== "object") return false;
28
+ if (Array.isArray(value)) return value.some(isForeign);
29
+ if (value instanceof Node) return false;
30
+ return !("_$litType$" in value) && !("_$litDirective$" in value);
31
+ }
32
+ /**
33
+ * Header and cell content. Foreign content renders through the table's
34
+ * `contentRenderer` into a target element kept while this part lives, so a pooled
35
+ * cell updates its framework content in place as it shows other rows.
36
+ */
37
+ var ContentDirective = class extends AsyncDirective {
38
+ #target;
39
+ /** The renderer that rendered into the target, and the table it belongs to. */
40
+ #renderer;
41
+ #host;
42
+ /** The last foreign content, rendered again on reconnect. */
43
+ #content;
44
+ constructor(part) {
45
+ super(part);
46
+ if (part.type !== PartType.CHILD) throw new Error("argus-table: content() only works in child positions.");
47
+ }
48
+ render(value) {
49
+ return isForeign(value) ? nothing : toChild(value);
50
+ }
51
+ update(part, [value]) {
52
+ if (!isForeign(value)) {
53
+ this.#release();
54
+ this.#content = void 0;
55
+ return this.render(value);
56
+ }
57
+ const host = part.options?.host;
58
+ const renderer = host?.contentRenderer;
59
+ if (!host || !renderer) {
60
+ this.#release();
61
+ if (!warned) {
62
+ warned = true;
63
+ console.warn("argus-table: a header or cell returned content the table can’t render (set contentRenderer, or use a framework wrapper).");
64
+ }
65
+ return nothing;
66
+ }
67
+ if (renderer !== this.#renderer) this.#release();
68
+ const target = this.#target ??= targetElement();
69
+ this.#renderer = renderer;
70
+ this.#host = host;
71
+ this.#content = value;
72
+ renderer.render(target, value);
73
+ markDirty(host, renderer);
74
+ return target;
75
+ }
76
+ disconnected() {
77
+ const content = this.#content;
78
+ this.#release();
79
+ this.#content = content;
80
+ }
81
+ reconnected() {
82
+ const host = this.#host;
83
+ const renderer = host?.contentRenderer;
84
+ if (!host || !renderer || this.#content === void 0 || !this.#target) return;
85
+ this.#renderer = renderer;
86
+ renderer.render(this.#target, this.#content);
87
+ markDirty(host, renderer);
88
+ }
89
+ #release() {
90
+ const renderer = this.#renderer;
91
+ const host = this.#host;
92
+ if (!renderer || !host || !this.#target) return;
93
+ this.#renderer = void 0;
94
+ renderer.release(this.#target);
95
+ markDirty(host, renderer);
96
+ }
97
+ };
98
+ /** A target: invisible to layout, so the content lays out as the cell's own. */
99
+ function targetElement() {
100
+ const target = document.createElement("div");
101
+ target.className = "argus-content";
102
+ target.style.display = "contents";
103
+ return target;
104
+ }
105
+ /**
106
+ * Header or cell content for a template of your own. The table renders what
107
+ * `header` and `cell` return through it already; a plugin that nests a column's
108
+ * content in its own template (`html\`<b>${content(own)}</b>\``) wraps it, so
109
+ * framework content (React elements, Vue VNodes) still reaches the
110
+ * `contentRenderer`. Only in templates the table renders.
111
+ */
112
+ var content = directive(ContentDirective);
113
+ export { content, flushContent, isForeign };
114
+
115
+ //# sourceMappingURL=content-directive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-directive.js","names":[],"sources":["../../src/render/content-directive.ts"],"sourcesContent":["import { nothing, type ChildPart } from 'lit';\nimport { AsyncDirective, directive, PartType, type PartInfo } from 'lit/async-directive.js';\nimport { toChild } from './content.js';\nimport type { CellContent } from '../types.js';\n\n/**\n * Renders content the table doesn't render itself, such as React elements, Vue\n * VNodes or Angular templates, into a target element the table keeps. Set it as the\n * table's `contentRenderer`; the framework wrappers do.\n */\nexport interface ContentRenderer {\n /**\n * Renders `content` into `target`, or updates what is there. Called on every update\n * for each header and cell showing foreign content. A target is reused: when the\n * table scrolls, the same target shows other rows.\n */\n render(target: HTMLElement, content: unknown): void;\n /**\n * `target` shows no foreign content any more: its cell scrolled away and wasn't\n * reused, its row isn't loaded, its column left, or the table was disconnected.\n * Unmount what `render` put there.\n */\n release(target: HTMLElement): void;\n /**\n * Called once after each update in which `render` or `release` ran, before the\n * table measures content (`width: 'auto'`). Commit batched work here, synchronously.\n */\n flush?(): void;\n}\n\n/** What the directive reads from the table it renders into. */\ninterface ContentHost {\n readonly contentRenderer?: ContentRenderer | undefined;\n}\n\n/** Renderers that rendered or released since the last flush, per table. */\nconst dirty = new WeakMap<object, Set<ContentRenderer>>();\nlet warned = false;\n\nfunction markDirty(host: object, renderer: ContentRenderer): void {\n let set = dirty.get(host);\n if (!set) dirty.set(host, (set = new Set()));\n set.add(renderer);\n}\n\n/** Calls `flush()` on the renderers that rendered or released for `host` since the last call. */\nexport function flushContent(host: object): void {\n const set = dirty.get(host);\n if (!set?.size) return;\n const renderers = [...set];\n set.clear();\n for (const renderer of renderers) renderer.flush?.();\n}\n\n/**\n * Whether the table renders `value` itself: strings, numbers, Lit templates and\n * directive results, DOM nodes, `nothing` and empty values, and arrays of those.\n * Anything else is foreign: it goes to the `contentRenderer`.\n */\nexport function isForeign(value: unknown): boolean {\n if (value === null || typeof value !== 'object') return false;\n if (Array.isArray(value)) return value.some(isForeign);\n if (value instanceof Node) return false;\n return !('_$litType$' in value) && !('_$litDirective$' in value);\n}\n\n/**\n * Header and cell content. Foreign content renders through the table's\n * `contentRenderer` into a target element kept while this part lives, so a pooled\n * cell updates its framework content in place as it shows other rows.\n */\nclass ContentDirective extends AsyncDirective {\n #target: HTMLElement | undefined;\n /** The renderer that rendered into the target, and the table it belongs to. */\n #renderer: ContentRenderer | undefined;\n #host: object | undefined;\n /** The last foreign content, rendered again on reconnect. */\n #content: unknown;\n\n constructor(part: PartInfo) {\n super(part);\n if (part.type !== PartType.CHILD) {\n throw new Error('argus-table: content() only works in child positions.');\n }\n }\n\n render(value: unknown): unknown {\n return isForeign(value) ? nothing : toChild(value as CellContent | null | undefined);\n }\n\n override update(part: ChildPart, [value]: [unknown]): unknown {\n if (!isForeign(value)) {\n this.#release();\n this.#content = undefined;\n return this.render(value);\n }\n const host = part.options?.host;\n const renderer = (host as ContentHost | undefined)?.contentRenderer;\n if (!host || !renderer) {\n this.#release();\n if (!warned) {\n warned = true;\n console.warn(\n 'argus-table: a header or cell returned content the table can’t render (set contentRenderer, or use a framework wrapper).',\n );\n }\n return nothing;\n }\n if (renderer !== this.#renderer) this.#release();\n const target = (this.#target ??= targetElement());\n this.#renderer = renderer;\n this.#host = host;\n this.#content = value;\n renderer.render(target, value);\n markDirty(host, renderer);\n return target;\n }\n\n protected override disconnected(): void {\n const content = this.#content;\n this.#release();\n // Kept for reconnected(): the part is the same, and so is its content.\n this.#content = content;\n }\n\n protected override reconnected(): void {\n const host = this.#host;\n const renderer = (host as ContentHost | undefined)?.contentRenderer;\n if (!host || !renderer || this.#content === undefined || !this.#target) return;\n this.#renderer = renderer;\n renderer.render(this.#target, this.#content);\n markDirty(host, renderer);\n }\n\n #release(): void {\n const renderer = this.#renderer;\n const host = this.#host;\n if (!renderer || !host || !this.#target) return;\n this.#renderer = undefined;\n renderer.release(this.#target);\n markDirty(host, renderer);\n }\n}\n\n/** A target: invisible to layout, so the content lays out as the cell's own. */\nfunction targetElement(): HTMLElement {\n const target = document.createElement('div');\n target.className = 'argus-content';\n target.style.display = 'contents';\n return target;\n}\n\n/**\n * Header or cell content for a template of your own. The table renders what\n * `header` and `cell` return through it already; a plugin that nests a column's\n * content in its own template (`html\\`<b>${content(own)}</b>\\``) wraps it, so\n * framework content (React elements, Vue VNodes) still reaches the\n * `contentRenderer`. Only in templates the table renders.\n */\nexport const content = directive(ContentDirective);\n"],"mappings":";;;;;AAoCA,IAAM,wBAAQ,IAAI,QAAsC;AACxD,IAAI,SAAS;AAEb,SAAS,UAAU,MAAc,UAAiC;CAChE,IAAI,MAAM,MAAM,IAAI,IAAI;CACxB,IAAI,CAAC,KAAK,MAAM,IAAI,MAAO,sBAAM,IAAI,IAAI,CAAE;CAC3C,IAAI,IAAI,QAAQ;AAClB;;AAGA,SAAgB,aAAa,MAAoB;CAC/C,MAAM,MAAM,MAAM,IAAI,IAAI;CAC1B,IAAI,CAAC,KAAK,MAAM;CAChB,MAAM,YAAY,CAAC,GAAG,GAAG;CACzB,IAAI,MAAM;CACV,KAAK,MAAM,YAAY,WAAW,SAAS,QAAQ;AACrD;;;;;;AAOA,SAAgB,UAAU,OAAyB;CACjD,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO;CACxD,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,MAAM,KAAK,SAAS;CACrD,IAAI,iBAAiB,MAAM,OAAO;CAClC,OAAO,EAAE,gBAAgB,UAAU,EAAE,qBAAqB;AAC5D;;;;;;AAOA,IAAM,mBAAN,cAA+B,eAAe;CAC5C;;CAEA;CACA;;CAEA;CAEA,YAAY,MAAgB;EAC1B,MAAM,IAAI;EACV,IAAI,KAAK,SAAS,SAAS,OACzB,MAAM,IAAI,MAAM,uDAAuD;CAE3E;CAEA,OAAO,OAAyB;EAC9B,OAAO,UAAU,KAAK,IAAI,UAAU,QAAQ,KAAuC;CACrF;CAEA,OAAgB,MAAiB,CAAC,QAA4B;EAC5D,IAAI,CAAC,UAAU,KAAK,GAAG;GACrB,KAAK,SAAS;GACd,KAAK,WAAW,KAAA;GAChB,OAAO,KAAK,OAAO,KAAK;EAC1B;EACA,MAAM,OAAO,KAAK,SAAS;EAC3B,MAAM,WAAY,MAAkC;EACpD,IAAI,CAAC,QAAQ,CAAC,UAAU;GACtB,KAAK,SAAS;GACd,IAAI,CAAC,QAAQ;IACX,SAAS;IACT,QAAQ,KACN,0HACF;GACF;GACA,OAAO;EACT;EACA,IAAI,aAAa,KAAK,WAAW,KAAK,SAAS;EAC/C,MAAM,SAAU,KAAK,YAAY,cAAc;EAC/C,KAAK,YAAY;EACjB,KAAK,QAAQ;EACb,KAAK,WAAW;EAChB,SAAS,OAAO,QAAQ,KAAK;EAC7B,UAAU,MAAM,QAAQ;EACxB,OAAO;CACT;CAEA,eAAwC;EACtC,MAAM,UAAU,KAAK;EACrB,KAAK,SAAS;EAEd,KAAK,WAAW;CAClB;CAEA,cAAuC;EACrC,MAAM,OAAO,KAAK;EAClB,MAAM,WAAY,MAAkC;EACpD,IAAI,CAAC,QAAQ,CAAC,YAAY,KAAK,aAAa,KAAA,KAAa,CAAC,KAAK,SAAS;EACxE,KAAK,YAAY;EACjB,SAAS,OAAO,KAAK,SAAS,KAAK,QAAQ;EAC3C,UAAU,MAAM,QAAQ;CAC1B;CAEA,WAAiB;EACf,MAAM,WAAW,KAAK;EACtB,MAAM,OAAO,KAAK;EAClB,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,KAAK,SAAS;EACzC,KAAK,YAAY,KAAA;EACjB,SAAS,QAAQ,KAAK,OAAO;EAC7B,UAAU,MAAM,QAAQ;CAC1B;AACF;;AAGA,SAAS,gBAA6B;CACpC,MAAM,SAAS,SAAS,cAAc,KAAK;CAC3C,OAAO,YAAY;CACnB,OAAO,MAAM,UAAU;CACvB,OAAO;AACT;;;;;;;;AASA,IAAa,UAAU,UAAU,gBAAgB"}
@@ -12,18 +12,20 @@ function formatValue(value) {
12
12
  return typeof value === "object" ? JSON.stringify(value) : String(value);
13
13
  }
14
14
  /**
15
- * Private slot names (ADR 0001): cells by pool slot and column, headers by
16
- * column, pinned rows by side, index and column.
15
+ * Private slot names (ADR 0001): cells by row pool slot and column slot,
16
+ * headers by column slot, pinned rows by side, index and column slot. A
17
+ * column's slot is its `colIndex`, or its pool slot with `virtualizeColumns`.
17
18
  */
18
- var cellSlot = (slot, colIndex) => `c-${String(slot)}-${String(colIndex)}`;
19
- var headerSlot = (colIndex) => `h-${String(colIndex)}`;
20
- var pinnedSlot = (side, index, colIndex) => `${side === "top" ? "t" : "b"}-${String(index)}-${String(colIndex)}`;
19
+ var cellSlot = (slot, colSlot) => `c-${String(slot)}-${String(colSlot)}`;
20
+ var headerSlot = (colSlot) => `h-${String(colSlot)}`;
21
+ var pinnedSlot = (side, index, colSlot) => `${side === "top" ? "t" : "b"}-${String(index)}-${String(colSlot)}`;
21
22
  /**
22
23
  * Part list of a body or pinned-row cell: state is in the part name (`::part()`
23
24
  * can't take attribute selectors).
24
25
  */
25
26
  function cellParts(column, pinnedRow = false) {
26
27
  let parts = pinnedRow ? "cell cell-pinned-row" : "cell";
28
+ if (column.kind === "selection") parts += " cell-selection";
27
29
  if (column.pin) parts += column.pinEdge ? " cell-pinned cell-pinned-edge" : " cell-pinned";
28
30
  if (column.sorted) parts += " cell-sorted";
29
31
  return parts;
@@ -33,10 +35,19 @@ function ariaSort(sorted) {
33
35
  if (!sorted) return void 0;
34
36
  return sorted === "asc" ? "ascending" : "descending";
35
37
  }
36
- /** Inline style of a pinned cell: its sticky inset, from the start or the end edge. */
37
- function pinStyle(column) {
38
- return column.pin ? `--_pin-offset: ${String(column.offset)}px` : void 0;
38
+ /**
39
+ * Inline style of a cell or header: a pinned one's sticky inset, from the
40
+ * start or the end edge, and with `virtualizeColumns` the grid cell it is
41
+ * placed in. Cells are in pool order there, not in column order, so the row is
42
+ * explicit too: auto-placement would wrap a cell that comes after one of a
43
+ * later column into a new grid row.
44
+ */
45
+ function cellStyle(column) {
46
+ const pin = column.pin ? `--_pin-offset: ${String(column.offset)}px` : void 0;
47
+ if (column.place === void 0) return pin;
48
+ const place = `grid-area: 1 / ${String(column.place)}`;
49
+ return pin ? `${pin}; ${place}` : place;
39
50
  }
40
- export { ariaSort, cellParts, cellSlot, formatValue, headerSlot, pinStyle, pinnedSlot, toChild };
51
+ export { ariaSort, cellParts, cellSlot, cellStyle, formatValue, headerSlot, pinnedSlot, toChild };
41
52
 
42
53
  //# sourceMappingURL=content.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"content.js","names":[],"sources":["../../src/render/content.ts"],"sourcesContent":["import { nothing } from 'lit';\nimport type { CellContent } from '../types.js';\n\n/** Turns header or cell content into a Lit child. */\nexport function toChild(content: CellContent | null | undefined): unknown {\n if (content === null || content === undefined) return nothing;\n return typeof content === 'number' ? String(content) : content;\n}\n\n/** Default cell text for an accessor value. */\nexport function formatValue(value: unknown): string {\n if (value === null || value === undefined) return '';\n if (value instanceof Date) return value.toLocaleString();\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- objects are handled above\n return typeof value === 'object' ? JSON.stringify(value) : String(value);\n}\n\n/**\n * Private slot names (ADR 0001): cells by pool slot and column, headers by\n * column, pinned rows by side, index and column.\n */\nexport const cellSlot = (slot: number, colIndex: number) => `c-${String(slot)}-${String(colIndex)}`;\nexport const headerSlot = (colIndex: number) => `h-${String(colIndex)}`;\nexport const pinnedSlot = (side: 'top' | 'bottom', index: number, colIndex: number) =>\n `${side === 'top' ? 't' : 'b'}-${String(index)}-${String(colIndex)}`;\n\ninterface Pinnable {\n pin: 'start' | 'end' | undefined;\n pinEdge: boolean;\n}\n\n/**\n * Part list of a body or pinned-row cell: state is in the part name (`::part()`\n * can't take attribute selectors).\n */\nexport function cellParts(\n column: Pinnable & { sorted: 'asc' | 'desc' | false },\n pinnedRow = false,\n): string {\n let parts = pinnedRow ? 'cell cell-pinned-row' : 'cell';\n if (column.pin) parts += column.pinEdge ? ' cell-pinned cell-pinned-edge' : ' cell-pinned';\n if (column.sorted) parts += ' cell-sorted';\n return parts;\n}\n\n/** `aria-sort` for a header: only the sorted column carries it (WAI-ARIA APG). */\nexport function ariaSort(sorted: 'asc' | 'desc' | false): 'ascending' | 'descending' | undefined {\n if (!sorted) return undefined;\n return sorted === 'asc' ? 'ascending' : 'descending';\n}\n\n/** Inline style of a pinned cell: its sticky inset, from the start or the end edge. */\nexport function pinStyle(column: Pinnable & { offset: number }): string | undefined {\n return column.pin ? `--_pin-offset: ${String(column.offset)}px` : undefined;\n}\n"],"mappings":";;;AAIA,SAAgB,QAAQ,SAAkD;CACxE,IAAI,YAAY,QAAQ,YAAY,KAAA,GAAW,OAAO;CACtD,OAAO,OAAO,YAAY,WAAW,OAAO,OAAO,IAAI;AACzD;;AAGA,SAAgB,YAAY,OAAwB;CAClD,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;CAClD,IAAI,iBAAiB,MAAM,OAAO,MAAM,eAAe;CAEvD,OAAO,OAAO,UAAU,WAAW,KAAK,UAAU,KAAK,IAAI,OAAO,KAAK;AACzE;;;;;AAMA,IAAa,YAAY,MAAc,aAAqB,KAAK,OAAO,IAAI,EAAE,GAAG,OAAO,QAAQ;AAChG,IAAa,cAAc,aAAqB,KAAK,OAAO,QAAQ;AACpE,IAAa,cAAc,MAAwB,OAAe,aAChE,GAAG,SAAS,QAAQ,MAAM,IAAI,GAAG,OAAO,KAAK,EAAE,GAAG,OAAO,QAAQ;;;;;AAWnE,SAAgB,UACd,QACA,YAAY,OACJ;CACR,IAAI,QAAQ,YAAY,yBAAyB;CACjD,IAAI,OAAO,KAAK,SAAS,OAAO,UAAU,kCAAkC;CAC5E,IAAI,OAAO,QAAQ,SAAS;CAC5B,OAAO;AACT;;AAGA,SAAgB,SAAS,QAAwE;CAC/F,IAAI,CAAC,QAAQ,OAAO,KAAA;CACpB,OAAO,WAAW,QAAQ,cAAc;AAC1C;;AAGA,SAAgB,SAAS,QAA2D;CAClF,OAAO,OAAO,MAAM,kBAAkB,OAAO,OAAO,MAAM,EAAE,MAAM,KAAA;AACpE"}
1
+ {"version":3,"file":"content.js","names":[],"sources":["../../src/render/content.ts"],"sourcesContent":["import { nothing } from 'lit';\nimport type { CellContent } from '../types.js';\n\n/** Turns header or cell content into a Lit child. */\nexport function toChild(content: CellContent | null | undefined): unknown {\n if (content === null || content === undefined) return nothing;\n return typeof content === 'number' ? String(content) : content;\n}\n\n/** Default cell text for an accessor value. */\nexport function formatValue(value: unknown): string {\n if (value === null || value === undefined) return '';\n if (value instanceof Date) return value.toLocaleString();\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- objects are handled above\n return typeof value === 'object' ? JSON.stringify(value) : String(value);\n}\n\n/**\n * Private slot names (ADR 0001): cells by row pool slot and column slot,\n * headers by column slot, pinned rows by side, index and column slot. A\n * column's slot is its `colIndex`, or its pool slot with `virtualizeColumns`.\n */\nexport const cellSlot = (slot: number, colSlot: number) => `c-${String(slot)}-${String(colSlot)}`;\nexport const headerSlot = (colSlot: number) => `h-${String(colSlot)}`;\nexport const pinnedSlot = (side: 'top' | 'bottom', index: number, colSlot: number) =>\n `${side === 'top' ? 't' : 'b'}-${String(index)}-${String(colSlot)}`;\n\ninterface Pinnable {\n pin: 'start' | 'end' | undefined;\n pinEdge: boolean;\n}\n\n/**\n * Part list of a body or pinned-row cell: state is in the part name (`::part()`\n * can't take attribute selectors).\n */\nexport function cellParts(\n column: Pinnable & { sorted: 'asc' | 'desc' | false; kind: 'data' | 'selection' },\n pinnedRow = false,\n): string {\n let parts = pinnedRow ? 'cell cell-pinned-row' : 'cell';\n if (column.kind === 'selection') parts += ' cell-selection';\n if (column.pin) parts += column.pinEdge ? ' cell-pinned cell-pinned-edge' : ' cell-pinned';\n if (column.sorted) parts += ' cell-sorted';\n return parts;\n}\n\n/** Handlers of the selection checkboxes (`selectionCheckboxes`). */\nexport interface CheckboxHandlers {\n /** A row's checkbox; it carries the row index in `data-row`. */\n row: (event: MouseEvent) => void;\n /** The header's select-all checkbox. */\n all: (event: MouseEvent) => void;\n /** A press on any of them: keeps focus on its cell, not the checkbox. */\n press: (event: MouseEvent) => void;\n}\n\n/** `aria-sort` for a header: only the sorted column carries it (WAI-ARIA APG). */\nexport function ariaSort(sorted: 'asc' | 'desc' | false): 'ascending' | 'descending' | undefined {\n if (!sorted) return undefined;\n return sorted === 'asc' ? 'ascending' : 'descending';\n}\n\n/**\n * Inline style of a cell or header: a pinned one's sticky inset, from the\n * start or the end edge, and with `virtualizeColumns` the grid cell it is\n * placed in. Cells are in pool order there, not in column order, so the row is\n * explicit too: auto-placement would wrap a cell that comes after one of a\n * later column into a new grid row.\n */\nexport function cellStyle(\n column: Pinnable & { offset: number; place: number | undefined },\n): string | undefined {\n const pin = column.pin ? `--_pin-offset: ${String(column.offset)}px` : undefined;\n if (column.place === undefined) return pin;\n const place = `grid-area: 1 / ${String(column.place)}`;\n return pin ? `${pin}; ${place}` : place;\n}\n"],"mappings":";;;AAIA,SAAgB,QAAQ,SAAkD;CACxE,IAAI,YAAY,QAAQ,YAAY,KAAA,GAAW,OAAO;CACtD,OAAO,OAAO,YAAY,WAAW,OAAO,OAAO,IAAI;AACzD;;AAGA,SAAgB,YAAY,OAAwB;CAClD,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;CAClD,IAAI,iBAAiB,MAAM,OAAO,MAAM,eAAe;CAEvD,OAAO,OAAO,UAAU,WAAW,KAAK,UAAU,KAAK,IAAI,OAAO,KAAK;AACzE;;;;;;AAOA,IAAa,YAAY,MAAc,YAAoB,KAAK,OAAO,IAAI,EAAE,GAAG,OAAO,OAAO;AAC9F,IAAa,cAAc,YAAoB,KAAK,OAAO,OAAO;AAClE,IAAa,cAAc,MAAwB,OAAe,YAChE,GAAG,SAAS,QAAQ,MAAM,IAAI,GAAG,OAAO,KAAK,EAAE,GAAG,OAAO,OAAO;;;;;AAWlE,SAAgB,UACd,QACA,YAAY,OACJ;CACR,IAAI,QAAQ,YAAY,yBAAyB;CACjD,IAAI,OAAO,SAAS,aAAa,SAAS;CAC1C,IAAI,OAAO,KAAK,SAAS,OAAO,UAAU,kCAAkC;CAC5E,IAAI,OAAO,QAAQ,SAAS;CAC5B,OAAO;AACT;;AAaA,SAAgB,SAAS,QAAwE;CAC/F,IAAI,CAAC,QAAQ,OAAO,KAAA;CACpB,OAAO,WAAW,QAAQ,cAAc;AAC1C;;;;;;;;AASA,SAAgB,UACd,QACoB;CACpB,MAAM,MAAM,OAAO,MAAM,kBAAkB,OAAO,OAAO,MAAM,EAAE,MAAM,KAAA;CACvE,IAAI,OAAO,UAAU,KAAA,GAAW,OAAO;CACvC,MAAM,QAAQ,kBAAkB,OAAO,OAAO,KAAK;CACnD,OAAO,MAAM,GAAG,IAAI,IAAI,UAAU;AACpC"}
@@ -1,5 +1,6 @@
1
- import { ariaSort, headerSlot, pinStyle } from "./content.js";
1
+ import { ariaSort, cellStyle, headerSlot } from "./content.js";
2
2
  import { html, nothing } from "lit";
3
+ import { live } from "lit/directives/live.js";
3
4
  import { repeat } from "lit/directives/repeat.js";
4
5
  // src/render/header.ts
5
6
  /**
@@ -16,7 +17,7 @@ import { repeat } from "lit/directives/repeat.js";
16
17
  * column. While one is dragged (`drag`), its header adds `header-cell-dragging`
17
18
  * and the target header shows a `drop-indicator` on the edge it would land at.
18
19
  */
19
- function renderHeader(columns, handlers, activeCol, drag) {
20
+ function renderHeader(columns, handlers, activeCol, drag, checkAll) {
20
21
  return html`<div part="header" role="rowgroup">
21
22
  <div
22
23
  part="header-row"
@@ -27,21 +28,23 @@ function renderHeader(columns, handlers, activeCol, drag) {
27
28
  @pointerdown=${handlers.pointerdown}
28
29
  @dblclick=${handlers.dblclick}
29
30
  >
30
- ${repeat(columns, (c) => c.id, (c) => html`<div
31
+ ${repeat(columns, (c) => c.key, (c) => html`<div
31
32
  part=${headerParts(c, drag)}
32
33
  role="columnheader"
33
34
  aria-colindex=${c.colIndex}
34
35
  aria-sort=${ariaSort(c.sorted) ?? nothing}
36
+ aria-description=${c.filtered ? "Filtered" : nothing}
35
37
  data-col=${c.id}
36
38
  data-sort-col=${c.sortable ? c.id : nothing}
37
39
  data-move-col=${c.reorderable ? c.id : nothing}
38
40
  tabindex=${c.id === activeCol ? 0 : -1}
39
41
  data-align=${c.align}
40
42
  data-pin=${c.pin ?? nothing}
41
- style=${pinStyle(c) ?? nothing}
43
+ style=${cellStyle(c) ?? nothing}
42
44
  >
43
- <slot name=${headerSlot(c.colIndex)}></slot>
45
+ ${c.kind === "selection" ? selectAllCheckbox(checkAll) : html`<slot name=${headerSlot(c.slot)}></slot>`}
44
46
  ${c.sortable ? html`<span class="sort-indicator" aria-hidden="true"></span>` : nothing}
47
+ ${c.filtered ? html`<span part="filter-indicator" aria-hidden="true"></span>` : nothing}
45
48
  ${c.resizable ? resizeHandle(c) : nothing}
46
49
  ${drag?.target?.columnId === c.id ? html`<span
47
50
  part="drop-indicator"
@@ -54,9 +57,25 @@ function renderHeader(columns, handlers, activeCol, drag) {
54
57
  }
55
58
  function headerParts(c, drag) {
56
59
  let parts = c.sorted ? "header-cell header-cell-sorted" : "header-cell";
60
+ if (c.filtered) parts += " header-cell-filtered";
61
+ if (c.kind === "selection") parts += " header-cell-selection";
57
62
  if (drag?.columnId === c.id) parts += " header-cell-dragging";
58
63
  return parts;
59
64
  }
65
+ /** The header's select-all checkbox: not focusable, like the rows' (the header cell holds focus). */
66
+ function selectAllCheckbox(checkAll) {
67
+ if (!checkAll) return nothing;
68
+ return html`<input
69
+ type="checkbox"
70
+ part="selection-checkbox"
71
+ tabindex="-1"
72
+ aria-label="Select all rows"
73
+ .checked=${live(checkAll.state === "all")}
74
+ .indeterminate=${live(checkAll.state === "some")}
75
+ @click=${checkAll.handlers.all}
76
+ @mousedown=${checkAll.handlers.press}
77
+ />`;
78
+ }
60
79
  /**
61
80
  * A vertical separator with the column's width as its value. A flex column has no
62
81
  * fixed width until it is resized, so it reports none.