@loadbare/widgets 0.9.1 → 0.10.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.
@@ -1,5 +1,5 @@
1
1
  /// <reference lib="dom" />
2
- import { ATTR_COLUMN, ATTR_FILTER, ATTR_ROW_REQUESTED, CHOICES_QUERY, LB_EVENT_NAME, LIVE_ROW, REQUEST_ROW_INSERT, REQUEST_ROW_UPDATE, REQUESTED_CREATED, REQUESTED_MOVED, } from "@loadbare/app/constants";
2
+ import { ATTR_COLUMN, ATTR_FILTER, ATTR_GROUP, ATTR_ORDER, ATTR_QUERY, ATTR_QUERY_ORDER, ATTR_QUERY_UNSORTED_COUNT, ATTR_ROW_REQUESTED, ATTR_SHOW, CHOICES_QUERY, COMMAND_SORT, LB_EVENT_NAME, LIVE_ROW, ORDER_DESCENDING, REQUEST_ROW_INSERT, REQUEST_ROW_UPDATE, REQUESTED_CREATED, REQUESTED_MOVED, } from "@loadbare/app/constants";
3
3
  /**
4
4
  * A table that owns its own scaffolding.
5
5
  *
@@ -13,8 +13,8 @@ import { ATTR_COLUMN, ATTR_FILTER, ATTR_ROW_REQUESTED, CHOICES_QUERY, LB_EVENT_N
13
13
  *
14
14
  * Placement and groups are the hub's, by the query's order and the page's
15
15
  * group templates. What this widget adds at run time is the ghost row,
16
- * scrolling to the row the page's own request created or moved, and filtering
17
- * by selected text.
16
+ * scrolling to the row the page's own request created or moved, filtering
17
+ * by selected text, and the order shown on the heading.
18
18
  *
19
19
  * The `foot` destination needs no code here. A `<tfoot>` is a place on the
20
20
  * page, not a row the hub delivers, so what lands in it is an aggregate or a
@@ -36,6 +36,24 @@ import { ATTR_COLUMN, ATTR_FILTER, ATTR_ROW_REQUESTED, CHOICES_QUERY, LB_EVENT_N
36
36
  * same column of `lb-choices` shows it and clears it. The selection is
37
37
  * removed once it is used.
38
38
  */
39
+ /**
40
+ * The heading of the column the rows are sorted by carries a triangle for the
41
+ * direction, and `aria-sort`. A heading's column is the one `lb-column` in
42
+ * the cell at the same position in the row template, a branch `lb-show`
43
+ * holds in a template included; a cell with none, or with several, gives its
44
+ * heading no column. Only the order's leading term is
45
+ * shown. A grouped table shows none of this.
46
+ *
47
+ * While rows stand out of order the triangle carries `data-unsorted`, and a
48
+ * click on its heading places them again with `--lb-sort`. Otherwise, on a
49
+ * table with `lb-order`, a click on the sorted heading reverses the order and
50
+ * a click on another heading sorts by its column, ascending: either way the
51
+ * table writes the user's order to the column of `lb-choices` that `lb-order`
52
+ * names. A table with no `lb-order` has nowhere to write one.
53
+ */
54
+ const SORT = "data-sort";
55
+ const UNSORTED = "data-unsorted";
56
+ const SORTABLE = "data-sortable";
39
57
  /**
40
58
  * The ghost row's `<tbody>`, a blank row for entering a new one, between the
41
59
  * rows and the footer. The page writes it as the `ghost` template, so it
@@ -56,7 +74,10 @@ class LbTable extends HTMLElement {
56
74
  constructor() {
57
75
  super();
58
76
  this.addEventListener("mouseup", () => this.filterBySelection());
77
+ this.addEventListener("click", (e) => this.sortFromHeading(e));
59
78
  }
79
+ /** Each heading's column, by position in the row template; none when grouped. */
80
+ headings;
60
81
  lbAttached() {
61
82
  const ghost = this.querySelector(`tbody[${GHOST}]`);
62
83
  if (!ghost?.firstElementChild)
@@ -71,6 +92,7 @@ class LbTable extends HTMLElement {
71
92
  }
72
93
  /** The row this page's request created or moved, brought into view. */
73
94
  lbRowsLanded() {
95
+ this.showOrder();
74
96
  this.querySelector(`[${ATTR_ROW_REQUESTED}="${REQUESTED_CREATED}"], ` +
75
97
  `[${ATTR_ROW_REQUESTED}="${REQUESTED_MOVED}"]`)?.scrollIntoView({ block: "nearest" });
76
98
  }
@@ -88,6 +110,96 @@ class LbTable extends HTMLElement {
88
110
  if (row)
89
111
  firstControl(row)?.focus();
90
112
  }
113
+ /** The headings a column can be read for, and their columns. */
114
+ columns() {
115
+ if (this.headings)
116
+ return this.headings;
117
+ this.headings = new Map();
118
+ const body = this.querySelector(`tbody:not([${GHOST}])`);
119
+ const template = body?.querySelector(":scope > template");
120
+ const head = this.querySelector("thead tr");
121
+ if (!(template instanceof HTMLTemplateElement) || !head) {
122
+ return this.headings;
123
+ }
124
+ if (template.hasAttribute(ATTR_GROUP))
125
+ return this.headings;
126
+ const cells = template.content.querySelector("tr")?.children ?? [];
127
+ [...head.children].forEach((th, i) => {
128
+ const cell = cells[i];
129
+ if (!cell)
130
+ return;
131
+ const named = new Set(columnsIn(cell));
132
+ if (named.size === 1)
133
+ this.headings.set(th, [...named][0]);
134
+ });
135
+ return this.headings;
136
+ }
137
+ /** The order's leading term, from what the hub stamped. */
138
+ leading() {
139
+ const term = (this.getAttribute(ATTR_QUERY_ORDER) ?? "").split(",")[0];
140
+ if (!term)
141
+ return undefined;
142
+ const descending = term.startsWith(ORDER_DESCENDING);
143
+ return {
144
+ column: descending ? term.slice(ORDER_DESCENDING.length) : term,
145
+ descending,
146
+ };
147
+ }
148
+ /** Whether rows stand where the order would not put them. */
149
+ unsorted() {
150
+ const count = this.getAttribute(ATTR_QUERY_UNSORTED_COUNT);
151
+ return count !== null && count !== "0";
152
+ }
153
+ /** The triangle on the sorted heading, and every heading a click sorts. */
154
+ showOrder() {
155
+ const term = this.leading();
156
+ const writable = this.hasAttribute(ATTR_ORDER);
157
+ for (const [th, column] of this.columns()) {
158
+ const sorted = column === term?.column;
159
+ let mark = th.querySelector(`:scope > [${SORT}]`);
160
+ if (sorted) {
161
+ if (!mark) {
162
+ mark = document.createElement("span");
163
+ mark.setAttribute(SORT, "");
164
+ mark.setAttribute("aria-hidden", "true");
165
+ th.append(mark);
166
+ }
167
+ mark.textContent = term.descending ? "▼" : "▲";
168
+ mark.toggleAttribute(UNSORTED, this.unsorted());
169
+ th.setAttribute("aria-sort", term.descending ? "descending" : "ascending");
170
+ }
171
+ else {
172
+ mark?.remove();
173
+ th.removeAttribute("aria-sort");
174
+ }
175
+ th.toggleAttribute(SORTABLE, writable || (sorted && this.unsorted()));
176
+ }
177
+ }
178
+ /** A click on a heading: re-sort, reverse, or sort by its column. */
179
+ sortFromHeading(e) {
180
+ const th = e.target.closest("th");
181
+ const column = th && this.columns().get(th);
182
+ if (!column)
183
+ return;
184
+ const term = this.leading();
185
+ const sorted = column === term?.column;
186
+ if (sorted && this.unsorted()) {
187
+ const command = new Event("command");
188
+ Object.defineProperty(command, "command", { value: COMMAND_SORT });
189
+ this.dispatchEvent(command);
190
+ return;
191
+ }
192
+ const choice = this.getAttribute(ATTR_ORDER);
193
+ if (!choice)
194
+ return;
195
+ const descending = sorted && !term.descending;
196
+ const detail = {
197
+ name: REQUEST_ROW_UPDATE,
198
+ query: CHOICES_QUERY,
199
+ values: { [choice]: (descending ? ORDER_DESCENDING : "") + column },
200
+ };
201
+ this.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
202
+ }
91
203
  /** The columns of this table's rows a choice may filter. */
92
204
  filtered() {
93
205
  return (this.getAttribute(ATTR_FILTER) ?? "")
@@ -124,6 +236,25 @@ class LbTable extends HTMLElement {
124
236
  this.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
125
237
  }
126
238
  }
239
+ /**
240
+ * The columns an element and everything in it show, read as the hub reads a
241
+ * row: into a branch an `lb-show` holds in a template, but not into another
242
+ * template, nor past an element naming a query of its own.
243
+ */
244
+ function columnsIn(el) {
245
+ const own = el.getAttribute(ATTR_COLUMN);
246
+ const out = own ? [own] : [];
247
+ if (el.hasAttribute(ATTR_QUERY))
248
+ return out;
249
+ const children = el instanceof HTMLTemplateElement
250
+ ? el.hasAttribute(ATTR_SHOW)
251
+ ? [...el.content.children]
252
+ : []
253
+ : [...el.children];
254
+ for (const child of children)
255
+ out.push(...columnsIn(child));
256
+ return out;
257
+ }
127
258
  /** A filter's term for text selected from a cell's whole text. */
128
259
  function termFor(selected, whole) {
129
260
  if (selected === "")
@@ -1 +1 @@
1
- {"version":3,"file":"lb-table.browser.js","sourceRoot":"","sources":["../widgets/lb-table.browser.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference lib=\"dom\" />\n\nimport {\n ATTR_COLUMN,\n ATTR_FILTER,\n ATTR_ROW_REQUESTED,\n CHOICES_QUERY,\n LB_EVENT_NAME,\n LIVE_ROW,\n REQUEST_ROW_INSERT,\n REQUEST_ROW_UPDATE,\n REQUESTED_CREATED,\n REQUESTED_MOVED,\n} from \"@loadbare/app/constants\";\nimport type {\n HubLifecycle,\n HubRequest,\n HubRequestDone,\n} from \"@loadbare/app/types\";\n\n/**\n * A table that owns its own scaffolding.\n *\n * A plain `<table lb-query>` with a row template inside it needs no widget at\n * all. This one supplies the table, and the developer writes what only the\n * page knows: the heading row and the row template. That division is forced\n * rather than chosen — a `<thead>` written inside a custom element is not\n * inside a table, so the parser drops the tag and keeps the text. A\n * `<template>` survives anywhere, so the heading row arrives wrapped and\n * expansion unwraps it into the `lb-exp-template` destination beside the slot.\n *\n * Placement and groups are the hub's, by the query's order and the page's\n * group templates. What this widget adds at run time is the ghost row,\n * scrolling to the row the page's own request created or moved, and filtering\n * by selected text.\n *\n * The `foot` destination needs no code here. A `<tfoot>` is a place on the\n * page, not a row the hub delivers, so what lands in it is an aggregate or a\n * `row` query of its own, named on its `<tr>`.\n */\n\n/**\n * Text selected in a cell filters the table by that cell's column. When a\n * selection finishes inside one element of a live row whose `lb-column` the\n * table's `lb-filter` names, the table makes the choice of that name, and the\n * hub hides the rows it does not pass. Where the selection lies in the\n * element's text says how:\n *\n * the whole text `=` equals\n * from its start `^=` begins with\n * up to its end `$=` ends with\n * anywhere else `*=` contains\n *\n * The choice replaces whatever that column held, so a control naming the\n * same column of `lb-choices` shows it and clears it. The selection is\n * removed once it is used.\n */\n\n/**\n * The ghost row's `<tbody>`, a blank row for entering a new one, between the\n * rows and the footer. The page writes it as the `ghost` template, so it\n * ships in the document like the heading and the footer, and the hub lands\n * on whatever in it names a query. It is not a live row: the hub never\n * reconciles it and never counts it.\n *\n * A `<form>` cannot wrap a `<tr>`, so the widget makes one outside the table\n * and joins the row's controls and buttons to it with the HTML `form`\n * attribute. Sending is the page's: a button in the row carrying\n * `lb-row-insert` submits that form, and the hub gathers what the form owns.\n * The hub resets the form once the insert succeeds.\n */\nconst GHOST = \"data-ghost\";\n\n/** Ghost row forms need ids for the `form` attribute; any unique one will do. */\nlet ghostForms = 0;\n\nclass LbTable extends HTMLElement implements HubLifecycle {\n constructor() {\n super();\n this.addEventListener(\"mouseup\", () => this.filterBySelection());\n }\n\n lbAttached(): void {\n const ghost = this.querySelector(`tbody[${GHOST}]`);\n if (!ghost?.firstElementChild) return;\n\n const form = document.createElement(\"form\");\n form.id = `ghost-row-${++ghostForms}`;\n form.hidden = true;\n this.append(form);\n for (const el of ghost.querySelectorAll(`[${ATTR_COLUMN}], button`)) {\n el.setAttribute(\"form\", form.id);\n }\n }\n\n /** The row this page's request created or moved, brought into view. */\n lbRowsLanded(): void {\n this.querySelector(\n `[${ATTR_ROW_REQUESTED}=\"${REQUESTED_CREATED}\"], ` +\n `[${ATTR_ROW_REQUESTED}=\"${REQUESTED_MOVED}\"]`,\n )?.scrollIntoView({ block: \"nearest\" });\n }\n\n /**\n * A ghost row is for entering the next row, so once one is saved the\n * cursor goes back to the start of it. The hub calls this after the\n * response items have landed, so nothing it places afterwards takes the\n * focus away again. A refused insert leaves the cursor where the user left\n * it.\n */\n lbRequestDone({ request, error }: HubRequestDone, origin: Element): void {\n if (error || request.name !== REQUEST_ROW_INSERT) return;\n const row = origin.closest(`[${GHOST}]`);\n if (row) firstControl(row)?.focus();\n }\n\n /** The columns of this table's rows a choice may filter. */\n private filtered(): string[] {\n return (this.getAttribute(ATTR_FILTER) ?? \"\")\n .split(\",\")\n .map((column) => column.trim())\n .filter(Boolean);\n }\n\n /** A finished selection in one filtered cell, made a choice. */\n private filterBySelection(): void {\n const selection = getSelection();\n if (!selection || selection.isCollapsed || selection.rangeCount === 0) {\n return;\n }\n const range = selection.getRangeAt(0);\n const start = range.startContainer;\n const el = (\n start instanceof Element ? start : start.parentElement\n )?.closest(`[${ATTR_COLUMN}]`);\n if (!el || !el.contains(range.endContainer)) return;\n const row = el.closest(LIVE_ROW);\n if (!row || row.closest(\"lb-table\") !== this) return;\n const column = el.getAttribute(ATTR_COLUMN)!;\n if (!this.filtered().includes(column)) return;\n\n const term = termFor(\n selection.toString().trim(),\n (el.textContent ?? \"\").trim(),\n );\n if (term === null) return;\n selection.removeAllRanges();\n const detail: HubRequest = {\n name: REQUEST_ROW_UPDATE,\n query: CHOICES_QUERY,\n values: { [column]: term },\n };\n this.dispatchEvent(\n new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }),\n );\n }\n}\n\n/** A filter's term for text selected from a cell's whole text. */\nfunction termFor(selected: string, whole: string): string | null {\n if (selected === \"\") return null;\n if (selected === whole) return `=${selected}`;\n if (whole.startsWith(selected)) return `^=${selected}`;\n if (whole.endsWith(selected)) return `$=${selected}`;\n return `*=${selected}`;\n}\n\n/**\n * The first control in a row the user can type into or choose from. A\n * control a widget wraps is found inside it, since the wrapper takes no focus.\n */\nfunction firstControl(row: Element): HTMLElement | undefined {\n return [\n ...row.querySelectorAll<HTMLElement>(\n 'input:not([type=\"hidden\"]), select, textarea',\n ),\n ].find((el) => !el.matches(\":disabled\") && el.checkVisibility?.() !== false);\n}\n\ncustomElements.define(\"lb-table\", LbTable);\n"]}
1
+ {"version":3,"file":"lb-table.browser.js","sourceRoot":"","sources":["../widgets/lb-table.browser.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference lib=\"dom\" />\n\nimport {\n ATTR_COLUMN,\n ATTR_FILTER,\n ATTR_GROUP,\n ATTR_ORDER,\n ATTR_QUERY,\n ATTR_QUERY_ORDER,\n ATTR_QUERY_UNSORTED_COUNT,\n ATTR_ROW_REQUESTED,\n ATTR_SHOW,\n CHOICES_QUERY,\n COMMAND_SORT,\n LB_EVENT_NAME,\n LIVE_ROW,\n ORDER_DESCENDING,\n REQUEST_ROW_INSERT,\n REQUEST_ROW_UPDATE,\n REQUESTED_CREATED,\n REQUESTED_MOVED,\n} from \"@loadbare/app/constants\";\nimport type {\n HubLifecycle,\n HubRequest,\n HubRequestDone,\n} from \"@loadbare/app/types\";\n\n/**\n * A table that owns its own scaffolding.\n *\n * A plain `<table lb-query>` with a row template inside it needs no widget at\n * all. This one supplies the table, and the developer writes what only the\n * page knows: the heading row and the row template. That division is forced\n * rather than chosen — a `<thead>` written inside a custom element is not\n * inside a table, so the parser drops the tag and keeps the text. A\n * `<template>` survives anywhere, so the heading row arrives wrapped and\n * expansion unwraps it into the `lb-exp-template` destination beside the slot.\n *\n * Placement and groups are the hub's, by the query's order and the page's\n * group templates. What this widget adds at run time is the ghost row,\n * scrolling to the row the page's own request created or moved, filtering\n * by selected text, and the order shown on the heading.\n *\n * The `foot` destination needs no code here. A `<tfoot>` is a place on the\n * page, not a row the hub delivers, so what lands in it is an aggregate or a\n * `row` query of its own, named on its `<tr>`.\n */\n\n/**\n * Text selected in a cell filters the table by that cell's column. When a\n * selection finishes inside one element of a live row whose `lb-column` the\n * table's `lb-filter` names, the table makes the choice of that name, and the\n * hub hides the rows it does not pass. Where the selection lies in the\n * element's text says how:\n *\n * the whole text `=` equals\n * from its start `^=` begins with\n * up to its end `$=` ends with\n * anywhere else `*=` contains\n *\n * The choice replaces whatever that column held, so a control naming the\n * same column of `lb-choices` shows it and clears it. The selection is\n * removed once it is used.\n */\n\n/**\n * The heading of the column the rows are sorted by carries a triangle for the\n * direction, and `aria-sort`. A heading's column is the one `lb-column` in\n * the cell at the same position in the row template, a branch `lb-show`\n * holds in a template included; a cell with none, or with several, gives its\n * heading no column. Only the order's leading term is\n * shown. A grouped table shows none of this.\n *\n * While rows stand out of order the triangle carries `data-unsorted`, and a\n * click on its heading places them again with `--lb-sort`. Otherwise, on a\n * table with `lb-order`, a click on the sorted heading reverses the order and\n * a click on another heading sorts by its column, ascending: either way the\n * table writes the user's order to the column of `lb-choices` that `lb-order`\n * names. A table with no `lb-order` has nowhere to write one.\n */\nconst SORT = \"data-sort\";\nconst UNSORTED = \"data-unsorted\";\nconst SORTABLE = \"data-sortable\";\n\n/**\n * The ghost row's `<tbody>`, a blank row for entering a new one, between the\n * rows and the footer. The page writes it as the `ghost` template, so it\n * ships in the document like the heading and the footer, and the hub lands\n * on whatever in it names a query. It is not a live row: the hub never\n * reconciles it and never counts it.\n *\n * A `<form>` cannot wrap a `<tr>`, so the widget makes one outside the table\n * and joins the row's controls and buttons to it with the HTML `form`\n * attribute. Sending is the page's: a button in the row carrying\n * `lb-row-insert` submits that form, and the hub gathers what the form owns.\n * The hub resets the form once the insert succeeds.\n */\nconst GHOST = \"data-ghost\";\n\n/** Ghost row forms need ids for the `form` attribute; any unique one will do. */\nlet ghostForms = 0;\n\nclass LbTable extends HTMLElement implements HubLifecycle {\n constructor() {\n super();\n this.addEventListener(\"mouseup\", () => this.filterBySelection());\n this.addEventListener(\"click\", (e) => this.sortFromHeading(e));\n }\n\n /** Each heading's column, by position in the row template; none when grouped. */\n private headings?: Map<Element, string>;\n\n lbAttached(): void {\n const ghost = this.querySelector(`tbody[${GHOST}]`);\n if (!ghost?.firstElementChild) return;\n\n const form = document.createElement(\"form\");\n form.id = `ghost-row-${++ghostForms}`;\n form.hidden = true;\n this.append(form);\n for (const el of ghost.querySelectorAll(`[${ATTR_COLUMN}], button`)) {\n el.setAttribute(\"form\", form.id);\n }\n }\n\n /** The row this page's request created or moved, brought into view. */\n lbRowsLanded(): void {\n this.showOrder();\n this.querySelector(\n `[${ATTR_ROW_REQUESTED}=\"${REQUESTED_CREATED}\"], ` +\n `[${ATTR_ROW_REQUESTED}=\"${REQUESTED_MOVED}\"]`,\n )?.scrollIntoView({ block: \"nearest\" });\n }\n\n /**\n * A ghost row is for entering the next row, so once one is saved the\n * cursor goes back to the start of it. The hub calls this after the\n * response items have landed, so nothing it places afterwards takes the\n * focus away again. A refused insert leaves the cursor where the user left\n * it.\n */\n lbRequestDone({ request, error }: HubRequestDone, origin: Element): void {\n if (error || request.name !== REQUEST_ROW_INSERT) return;\n const row = origin.closest(`[${GHOST}]`);\n if (row) firstControl(row)?.focus();\n }\n\n /** The headings a column can be read for, and their columns. */\n private columns(): Map<Element, string> {\n if (this.headings) return this.headings;\n this.headings = new Map();\n const body = this.querySelector(`tbody:not([${GHOST}])`);\n const template = body?.querySelector(\":scope > template\");\n const head = this.querySelector(\"thead tr\");\n if (!(template instanceof HTMLTemplateElement) || !head) {\n return this.headings;\n }\n if (template.hasAttribute(ATTR_GROUP)) return this.headings;\n const cells = template.content.querySelector(\"tr\")?.children ?? [];\n [...head.children].forEach((th, i) => {\n const cell = cells[i];\n if (!cell) return;\n const named = new Set(columnsIn(cell));\n if (named.size === 1) this.headings!.set(th, [...named][0]!);\n });\n return this.headings;\n }\n\n /** The order's leading term, from what the hub stamped. */\n private leading(): { column: string; descending: boolean } | undefined {\n const term = (this.getAttribute(ATTR_QUERY_ORDER) ?? \"\").split(\",\")[0];\n if (!term) return undefined;\n const descending = term.startsWith(ORDER_DESCENDING);\n return {\n column: descending ? term.slice(ORDER_DESCENDING.length) : term,\n descending,\n };\n }\n\n /** Whether rows stand where the order would not put them. */\n private unsorted(): boolean {\n const count = this.getAttribute(ATTR_QUERY_UNSORTED_COUNT);\n return count !== null && count !== \"0\";\n }\n\n /** The triangle on the sorted heading, and every heading a click sorts. */\n private showOrder(): void {\n const term = this.leading();\n const writable = this.hasAttribute(ATTR_ORDER);\n for (const [th, column] of this.columns()) {\n const sorted = column === term?.column;\n let mark = th.querySelector(`:scope > [${SORT}]`);\n if (sorted) {\n if (!mark) {\n mark = document.createElement(\"span\");\n mark.setAttribute(SORT, \"\");\n mark.setAttribute(\"aria-hidden\", \"true\");\n th.append(mark);\n }\n mark.textContent = term.descending ? \"▼\" : \"▲\";\n mark.toggleAttribute(UNSORTED, this.unsorted());\n th.setAttribute(\"aria-sort\", term.descending ? \"descending\" : \"ascending\");\n } else {\n mark?.remove();\n th.removeAttribute(\"aria-sort\");\n }\n th.toggleAttribute(SORTABLE, writable || (sorted && this.unsorted()));\n }\n }\n\n /** A click on a heading: re-sort, reverse, or sort by its column. */\n private sortFromHeading(e: Event): void {\n const th = (e.target as Element).closest(\"th\");\n const column = th && this.columns().get(th);\n if (!column) return;\n const term = this.leading();\n const sorted = column === term?.column;\n if (sorted && this.unsorted()) {\n const command = new Event(\"command\");\n Object.defineProperty(command, \"command\", { value: COMMAND_SORT });\n this.dispatchEvent(command);\n return;\n }\n const choice = this.getAttribute(ATTR_ORDER);\n if (!choice) return;\n const descending = sorted && !term.descending;\n const detail: HubRequest = {\n name: REQUEST_ROW_UPDATE,\n query: CHOICES_QUERY,\n values: { [choice]: (descending ? ORDER_DESCENDING : \"\") + column },\n };\n this.dispatchEvent(\n new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }),\n );\n }\n\n /** The columns of this table's rows a choice may filter. */\n private filtered(): string[] {\n return (this.getAttribute(ATTR_FILTER) ?? \"\")\n .split(\",\")\n .map((column) => column.trim())\n .filter(Boolean);\n }\n\n /** A finished selection in one filtered cell, made a choice. */\n private filterBySelection(): void {\n const selection = getSelection();\n if (!selection || selection.isCollapsed || selection.rangeCount === 0) {\n return;\n }\n const range = selection.getRangeAt(0);\n const start = range.startContainer;\n const el = (\n start instanceof Element ? start : start.parentElement\n )?.closest(`[${ATTR_COLUMN}]`);\n if (!el || !el.contains(range.endContainer)) return;\n const row = el.closest(LIVE_ROW);\n if (!row || row.closest(\"lb-table\") !== this) return;\n const column = el.getAttribute(ATTR_COLUMN)!;\n if (!this.filtered().includes(column)) return;\n\n const term = termFor(\n selection.toString().trim(),\n (el.textContent ?? \"\").trim(),\n );\n if (term === null) return;\n selection.removeAllRanges();\n const detail: HubRequest = {\n name: REQUEST_ROW_UPDATE,\n query: CHOICES_QUERY,\n values: { [column]: term },\n };\n this.dispatchEvent(\n new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }),\n );\n }\n}\n\n/**\n * The columns an element and everything in it show, read as the hub reads a\n * row: into a branch an `lb-show` holds in a template, but not into another\n * template, nor past an element naming a query of its own.\n */\nfunction columnsIn(el: Element): string[] {\n const own = el.getAttribute(ATTR_COLUMN);\n const out = own ? [own] : [];\n if (el.hasAttribute(ATTR_QUERY)) return out;\n const children =\n el instanceof HTMLTemplateElement\n ? el.hasAttribute(ATTR_SHOW)\n ? [...el.content.children]\n : []\n : [...el.children];\n for (const child of children) out.push(...columnsIn(child));\n return out;\n}\n\n/** A filter's term for text selected from a cell's whole text. */\nfunction termFor(selected: string, whole: string): string | null {\n if (selected === \"\") return null;\n if (selected === whole) return `=${selected}`;\n if (whole.startsWith(selected)) return `^=${selected}`;\n if (whole.endsWith(selected)) return `$=${selected}`;\n return `*=${selected}`;\n}\n\n/**\n * The first control in a row the user can type into or choose from. A\n * control a widget wraps is found inside it, since the wrapper takes no focus.\n */\nfunction firstControl(row: Element): HTMLElement | undefined {\n return [\n ...row.querySelectorAll<HTMLElement>(\n 'input:not([type=\"hidden\"]), select, textarea',\n ),\n ].find((el) => !el.matches(\":disabled\") && el.checkVisibility?.() !== false);\n}\n\ncustomElements.define(\"lb-table\", LbTable);\n"]}
@@ -0,0 +1,25 @@
1
+ /* lb-table: the order on its heading, and rows standing out of it. An
2
+ application's own stylesheet comes later and overrides any of this. */
3
+
4
+ lb-table th[data-sortable] {
5
+ cursor: pointer;
6
+ }
7
+
8
+ lb-table th > [data-sort] {
9
+ display: inline-block;
10
+ margin-inline-start: 0.3em;
11
+ padding: 0 0.15em;
12
+ border: 1px solid transparent;
13
+ border-radius: 50%;
14
+ font-size: 0.8em;
15
+ line-height: 1.3;
16
+ }
17
+
18
+ lb-table th > [data-sort][data-unsorted] {
19
+ color: #b58900;
20
+ border-color: currentColor;
21
+ }
22
+
23
+ lb-table tr[lb-row-unsorted] > :first-child {
24
+ box-shadow: inset 3px 0 0 #b58900;
25
+ }
package/docs/lb-table.md CHANGED
@@ -56,8 +56,11 @@ The server declares the query's key and its order.
56
56
 
57
57
  ## Order and groups
58
58
 
59
- The hub places the rows by the query's order and builds its groups. The
60
- rows land in the table's one `<tbody>`, and a `<tbody>` cannot hold another,
59
+ The hub places the rows by the query's order and builds its groups. An
60
+ edit from a control in a row, or from the ghost row, leaves the rows where
61
+ they stand until the user asks for the order; a button with
62
+ `commandfor` naming the table's `id` and `command="--lb-sort"` does that.
63
+ The rows land in the table's one `<tbody>`, and a `<tbody>` cannot hold another,
61
64
  so a group here is a heading row with its rows after it. For a `<tbody>` per
62
65
  group, write a plain `<table lb-query>` whose group template is the
63
66
  `<tbody>`.
@@ -66,6 +69,35 @@ group, write a plain `<table lb-query>` whose group template is the
66
69
  `lb-query`: the user's order replaces the query's, and a row the user's
67
70
  filter does not pass is hidden.
68
71
 
72
+ ## The order on the heading
73
+
74
+ The heading of the column the rows are sorted by carries a triangle, up for
75
+ ascending and down for descending, and `aria-sort`. Only the order's
76
+ leading term is shown. A heading's column is the `lb-column` in the cell
77
+ at the same position in the row template, on the cell or inside it. A cell
78
+ naming no column, or several, gives its heading none.
79
+
80
+ While rows stand out of order, the triangle carries `data-unsorted`, and a
81
+ click on its heading places them again, as `--lb-sort` does, keeping the
82
+ direction.
83
+
84
+ With `lb-order` on the table, a click on the sorted heading reverses the
85
+ order, and a click on another heading sorts by its column, ascending. The
86
+ widget writes the order to the column of `lb-choices` that `lb-order`
87
+ names. Without `lb-order` there is nowhere to write one, and only the
88
+ re-sort answers a click. There is no click back to the server's order.
89
+
90
+ A heading that takes a click carries `data-sortable`.
91
+
92
+ None of this shows on a grouped table, nor when the order's leading column
93
+ has no heading. There, a button with `commandfor` and `--lb-sort` does the
94
+ re-sort.
95
+
96
+ The package ships `lb-table.css`: the triangle turns amber and circled
97
+ while rows stand out of order, and the first cell of a row carrying
98
+ `lb-row-unsorted` is marked on its leading edge. The application's own
99
+ stylesheet comes after it and overrides any of it.
100
+
69
101
  ## Filtering by selection
70
102
 
71
103
  Text selected in a cell filters the table by that cell's column, when the
@@ -0,0 +1,130 @@
1
+ # Feature request: `lb-table` for maintenance pages
2
+
3
+ Temporary. Written from the rkmedia app, which uses `lb-table` for every
4
+ list it edits (lookup tables with a ghost row, edit-in-place cells,
5
+ drag-to-reorder). Two things every such page wants and today writes for
6
+ itself, or cannot have. To be settled here and then folded into
7
+ `lb-table.md` or deleted.
8
+
9
+ ## 1. The order shown on the heading, and re-sorted from it
10
+
11
+ **Status:** first iteration implemented, as settled below; see
12
+ `lb-table.md`, *The order on the heading*.
13
+
14
+ **Want:** `lb-table` always marks the heading of the column the rows are
15
+ sorted by with an up or down triangle for the direction. While the order is
16
+ dirty from the user's edits (`lb-query-unsorted-count` is not 0), the
17
+ triangle changes color. Clicking the heading sorts by its column.
18
+
19
+ **Why:** an edit from a control in a row, or from the ghost row, leaves the
20
+ rows where they stand (`lb-table.md`, *Order and groups*). Every table
21
+ whose order reads a column the user edits needs a re-sort control, so every
22
+ page writes the same markup and CSS. What rkmedia writes today:
23
+
24
+ ```html
25
+ <div class="list">
26
+ <button type="button" class="resort" commandfor="workshop-topics-list" command="--lb-sort">Re-sort</button>
27
+ <lb-table id="workshop-topics-list" lb-query="workshopTopics" exp-caption="Talks and workshops">
28
+ ...
29
+ </lb-table>
30
+ </div>
31
+ ```
32
+
33
+ ```css
34
+ .resort { visibility: hidden; }
35
+ :has(> [lb-query-unsorted-count]:not([lb-query-unsorted-count="0"])) > .resort { visibility: visible; }
36
+ ```
37
+
38
+ The button cannot go inside the tag, so it needs an `id`, `commandfor` and
39
+ a `:has()` on a wrapper. Shown with `display` it moves the table; shown
40
+ with `visibility` it leaves a blank strip above every table.
41
+
42
+ The heading row is always laid out and the widget owns it, so the
43
+ triangle placed there moves nothing, needs no `id` and no page CSS. The
44
+ hub already knows the order: the server declares it, and `lb-order` names
45
+ the user's.
46
+
47
+ **Settled:**
48
+
49
+ - **The triangle is always shown** on the sorted column's heading, clean or
50
+ dirty.
51
+ - **Dirty is a color change** on the triangle.
52
+ - **Clicking a dirty triangle re-sorts** (`--lb-sort`): it places the rows
53
+ and clears `lb-row-unsorted`, keeping the direction. Only a click on a
54
+ clean triangle reverses.
55
+ - **Clicking another heading sorts by its column**, writing the user's
56
+ order (`lb-order`). That places every row and clears `lb-row-unsorted`.
57
+ - **Raw values.** A column shown formatted sorts by what the query sent,
58
+ so it must be sent raw or declared in `types` (the reference already says
59
+ so); worth repeating in `lb-table.md`.
60
+ - **A default style for `tr[lb-row-unsorted]`** (rkmedia marks the first
61
+ cell's left edge), subtle and overridable, saves every app writing one.
62
+ - **A heading takes its column from the row template.** The nth heading
63
+ shows the column of the nth cell of the row template, the `lb-column` in
64
+ that cell. The page writes nothing on the `<th>`. A cell with no
65
+ `lb-column`, or with several, gives its heading no column, and that
66
+ heading does not sort.
67
+ - **One column at a time.** A heading click writes an order of one term.
68
+ When the order has several terms, the triangle marks the leading term.
69
+ Sub-sorts are a UI question for later, built on this foundation.
70
+ - **Groups leave all of this out.** A table with group templates shows no
71
+ triangle and takes no heading clicks, so none of it triggers there.
72
+ - **Dirty is yellow, circled.** The triangle turns yellow and gains a
73
+ circle around it, so the shape says dirty without the color.
74
+ - **No route back to the server's order.** Once the user sorts by a
75
+ heading, the user's order stands.
76
+ - **No sort is the opt-out.** A table with no order shows no triangle and
77
+ takes no heading clicks.
78
+
79
+ **Open:**
80
+
81
+ - **The order's column not shown.** A drag-to-reorder table orders by a
82
+ position column no one displays, and a server's order may lead with any
83
+ column the template leaves out. No heading carries the triangle, so a
84
+ dirty order has no re-sort control. Either the triangle has an
85
+ always-present fallback, such tables keep a page-written button, or the
86
+ dirty state cannot arise for them (a drag rewrites positions so no row
87
+ stands out of order).
88
+ - **Heading clicks without `lb-order`.** A heading click writes the user's
89
+ order into the column of `lb-choices` that `lb-order` names. A table
90
+ with a server order and no `lb-order` has nowhere to write it: it can
91
+ show the triangle and re-sort from it, but not sort by another heading.
92
+ Whether that is the intended reading of "no sort", or the widget
93
+ supplies a choice of its own.
94
+
95
+ The first iteration takes the plain reading of both: a table whose order's
96
+ leading column has no heading shows no triangle and keeps a page-written
97
+ button, and a table without `lb-order` shows the triangle and re-sorts
98
+ from it but takes no clicks on other headings.
99
+
100
+ ## 2. Highlight-to-filter that works on edit pages
101
+
102
+ **Want:** select text in a cell and the table filters to the rows that
103
+ match, on read-only and editable tables alike, and the table shows what it
104
+ is filtered by.
105
+
106
+ **What exists:** `lb-table.md`, *Filtering by selection*: a selection in a
107
+ cell whose `lb-column` the table's `lb-filter` names becomes that column's
108
+ choice (`=`, `^=`, `$=`, `*=`).
109
+
110
+ **Gaps:**
111
+
112
+ - **Inputs.** The widget reads `getSelection()`, which does not report text
113
+ selected inside an `<input>` or `<textarea>`. An editable cell is an
114
+ input, so on an edit page highlighting never filters. A control reports
115
+ its own range (`selectionStart` / `selectionEnd`); the widget could read
116
+ that when the selection is in one.
117
+ - **Selecting is also editing.** In an input, selecting text is how you
118
+ replace it, so filtering on every selection there would fight editing.
119
+ It needs a gesture that means "filter": a modifier key held on mouse-up,
120
+ or inputs that are `readonly`. Which gesture is Loadbare's call.
121
+ - **No indicator.** Nothing on the table says it is filtered, by what, or
122
+ offers to clear it; a page must add a control bound to `lb-choices`
123
+ itself. With the re-sort on the heading, the caption line is free for
124
+ this: the active filters with a clear button for each, and rows hidden
125
+ ("12 of 40 shown"). Open whether the caption line is the place.
126
+
127
+ ## Order of value to rkmedia
128
+
129
+ 1, then 2. rkmedia has editable ordered tables now, will want heading
130
+ sorts on the first long list, and has no filters yet.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@loadbare/widgets",
3
3
  "description": "The basic widget set for @loadbare/app: input, select, options, picker, table, unknown-page, confirm, form, insert-dialog, master, checkbox, number, date",
4
- "version": "0.9.1",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
@@ -49,10 +49,10 @@
49
49
  "node": ">=22"
50
50
  },
51
51
  "peerDependencies": {
52
- "@loadbare/app": ">=0.17.0 <1.0.0"
52
+ "@loadbare/app": ">=0.20.0 <1.0.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@loadbare/app": ">=0.17.0 <1.0.0",
55
+ "@loadbare/app": ">=0.20.0 <1.0.0",
56
56
  "@types/jsdom": "^21.1.7",
57
57
  "@types/node": "^22.0.0",
58
58
  "jsdom": "^25.0.0",