@tacc/filter-sort 0.1.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.
- package/README.md +81 -0
- package/package.json +26 -0
- package/src/filtersort.core-styles.css +23 -0
- package/src/filtersort.css +2 -0
- package/src/filtersort.html.js +28 -0
- package/src/filtersort.js +589 -0
- package/src/filtersort.structure.css +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# TACC: Filter-Sort
|
|
2
|
+
|
|
3
|
+
Filterable, sortable HTML tables powered by [List.js](https://listjs.com/) — optimized for [TACC/Core-CMS](https://github.com/TACC/Core-CMS) pages.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
### via CDN
|
|
8
|
+
|
|
9
|
+
E.g. [JSDelivr](https://www.jsdelivr.com/):
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tacc/filter-sort@0.1.0/src/filtersort.css" />
|
|
13
|
+
<script src="https://cdn.jsdelivr.net/npm/list.js@2.3.1/dist/list.min.js" crossorigin="anonymous">/* List.js (required global dependency for @tacc/filter-sort) */</script>
|
|
14
|
+
<script type="module">
|
|
15
|
+
import filtersort from 'https://cdn.jsdelivr.net/npm/@tacc/filter-sort@0.1.0/src/filtersort.js';
|
|
16
|
+
filtersort();
|
|
17
|
+
</script>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> [!NOTE]
|
|
21
|
+
> During pre-release, use commit SHA URLs instead of version tags:
|
|
22
|
+
> ```
|
|
23
|
+
> https://cdn.jsdelivr.net/gh/wesleyboar/filter-sort@__SHA__/src/filtersort.js
|
|
24
|
+
> ```
|
|
25
|
+
|
|
26
|
+
### Table Markup
|
|
27
|
+
|
|
28
|
+
Add `class="js-filtersort"` to any `<table>`. A `<thead>` with column headers and a `<tbody>` are required.
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
<table class="js-filtersort">
|
|
32
|
+
<thead>
|
|
33
|
+
<tr>
|
|
34
|
+
<th>Name</th>
|
|
35
|
+
<th>Status</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<tr><td>Frontera</td><td>Active</td></tr>
|
|
40
|
+
<tr><td>Stampede3</td><td>Active</td></tr>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Filter UI
|
|
46
|
+
|
|
47
|
+
To auto-build a filter bar above a table, add `id` and filter attributes to the table:
|
|
48
|
+
|
|
49
|
+
- `data-filtersort-search` — include a search input (boolean presence attribute)
|
|
50
|
+
- `data-filtersort-select-cols` — comma-separated column numbers, one select filter per number
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<table id="my-table" class="js-filtersort"
|
|
54
|
+
data-filtersort-search
|
|
55
|
+
data-filtersort-select-cols="1,3">
|
|
56
|
+
…
|
|
57
|
+
</table>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
> [!NOTE]
|
|
61
|
+
> `data-filtersort-select-cols="1"` creates a select filter for the **1st** column, `"2"` for the **2nd**, _et cetera_. Select filter labels are auto-derived from `<th>` text. The filter markup is self-injected by `filtersort.js` on first call (no extra manual HTML required).
|
|
62
|
+
|
|
63
|
+
### `filtersort()` Options
|
|
64
|
+
|
|
65
|
+
| Option | Default | Description |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `scopeElement` | `document` | Root element to search for tables |
|
|
68
|
+
| `tableSelector` | `table.js-filtersort` | CSS selector for target tables |
|
|
69
|
+
| `notSortableSelector` | `th.not-filtersort` | Columns matching this are excluded |
|
|
70
|
+
| `buttonClass` | `''` | Extra class(es) on sort `<button>` elements (e.g. `'btn btn-link'`) |
|
|
71
|
+
|
|
72
|
+
## Third-Party Skin Support
|
|
73
|
+
|
|
74
|
+
### [TACC/Core-Styles](https://github.com/TACC/Core-Styles) v2+
|
|
75
|
+
- `--global-font-size--small`
|
|
76
|
+
- `.c-button--as-link`
|
|
77
|
+
- [Bootstrap](https://getbootstrap.com/) `.btn-link`
|
|
78
|
+
|
|
79
|
+
## Requirements
|
|
80
|
+
|
|
81
|
+
- `list.js` ≥2 must be loaded as `window.List` before `filtersort()` is called.
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tacc/filter-sort",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "TACC COA CMD <coa-cmd@tacc.utexas.edu>",
|
|
6
|
+
"contributors": [
|
|
7
|
+
"TACC ACI WMA <wma-portals@tacc.utexas.edu>"
|
|
8
|
+
],
|
|
9
|
+
"description": "Filterable, sortable HTML tables powered by List.js.",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.x",
|
|
19
|
+
"npm": ">=7.x"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/TACC/filter-sort",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+ssh://git@github.com/TACC/filter-sort.git"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* CORE-STYLES */
|
|
2
|
+
.filtersort__filter output {
|
|
3
|
+
font-size: var(--global-font-size--small);
|
|
4
|
+
}
|
|
5
|
+
label.filtersort__filter output {
|
|
6
|
+
font-weight: normal;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* CORE-STYLES + BOOTSTRAP */
|
|
10
|
+
/* To fix sort button height and alignment */
|
|
11
|
+
button.js-sort {
|
|
12
|
+
&.btn-link,
|
|
13
|
+
&.c-button--as-link {
|
|
14
|
+
line-height: inherit;
|
|
15
|
+
height: 1lh;
|
|
16
|
+
justify-content: start;
|
|
17
|
+
align-items: baseline;
|
|
18
|
+
vertical-align: baseline;
|
|
19
|
+
}
|
|
20
|
+
&.btn-link {
|
|
21
|
+
padding: 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inner HTML for the `<template id="filtersort-form">` element.
|
|
3
|
+
* Injected into the DOM by `filtersort.js` if not already present.
|
|
4
|
+
*/
|
|
5
|
+
export const FILTER_TEMPLATE_HTML = /* html */`
|
|
6
|
+
<fieldset class="js-filtersort-form filtersort-form">
|
|
7
|
+
<legend class="sr-only">Results in the table update as you type or select filters</legend>
|
|
8
|
+
|
|
9
|
+
<label class="filtersort__filter mr-auto">
|
|
10
|
+
<i
|
|
11
|
+
class="filtersort__icon"
|
|
12
|
+
aria-label="Search"
|
|
13
|
+
>Search</i>
|
|
14
|
+
<input
|
|
15
|
+
type="search"
|
|
16
|
+
class="filtersort__input"
|
|
17
|
+
placeholder="Search…"
|
|
18
|
+
/>
|
|
19
|
+
<output class="js-filtersort-total" aria-atomic="true"></output>
|
|
20
|
+
</label>
|
|
21
|
+
|
|
22
|
+
<label class="filtersort__filter">
|
|
23
|
+
<span class="filtersort__label"></span>
|
|
24
|
+
<select class="filtersort__input">
|
|
25
|
+
<option value="">any</option>
|
|
26
|
+
</select>
|
|
27
|
+
</label>
|
|
28
|
+
</fieldset>`;
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { FILTER_TEMPLATE_HTML } from './filtersort.html.js';
|
|
2
|
+
|
|
3
|
+
const SORT_TABLE_CLASS = 'js-filtersort';
|
|
4
|
+
const FILTER_CLASS = 'js-filtersort-filter';
|
|
5
|
+
const FILTER_LIST_CLASS = 'js-filtersort-form';
|
|
6
|
+
const OUTPUT_CLASS = 'js-filtersort-total';
|
|
7
|
+
const DEPEND_LIST_CLASS = 'js-list';
|
|
8
|
+
const DEPEND_BUTTON_CLASS = 'js-sort';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TABLE_SELECTOR = 'table.' + SORT_TABLE_CLASS;
|
|
11
|
+
const NOT_SORTABLE_SELECTOR = 'th.not-filtersort';
|
|
12
|
+
|
|
13
|
+
const FILTER_TEMPLATE_ID = 'filtersort-form';
|
|
14
|
+
const FILTER_SEARCH_LABEL_SELECTOR = 'label:has(input[type="search"])';
|
|
15
|
+
const FILTER_SELECT_LABEL_SELECTOR = 'label:has(select)';
|
|
16
|
+
|
|
17
|
+
let listJsMissingLogged = false;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {FilterSpecForSearch | FilterSpecForSelect} FilterSpec
|
|
21
|
+
* @typedef {{ type: 'search' }} FilterSpecForSearch
|
|
22
|
+
* @typedef {{ type: 'select', column: number }} FilterSpecForSelect - column is 1-based
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} tableId
|
|
27
|
+
* @param {ParentNode} scopeElement
|
|
28
|
+
* @returns {NodeListOf<Element>}
|
|
29
|
+
*/
|
|
30
|
+
function findFilterControls(tableId, scopeElement) {
|
|
31
|
+
return scopeElement.querySelectorAll(
|
|
32
|
+
'.' + FILTER_CLASS + '[aria-controls="' + CSS.escape(tableId) + '"]'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {HTMLInputElement | HTMLSelectElement} control
|
|
38
|
+
* @param {string} tableId
|
|
39
|
+
*/
|
|
40
|
+
function registerFilterControl(control, tableId) {
|
|
41
|
+
control.classList.add(FILTER_CLASS);
|
|
42
|
+
control.setAttribute('aria-controls', tableId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {HTMLTableElement} table
|
|
47
|
+
* @returns {FilterSpec[] | null}
|
|
48
|
+
*/
|
|
49
|
+
function readFilterAttrs(table) {
|
|
50
|
+
const specs = [];
|
|
51
|
+
|
|
52
|
+
if (table.hasAttribute('data-filtersort-search')) {
|
|
53
|
+
specs.push({ type: 'search' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const selectCols = table.getAttribute('data-filtersort-select-cols');
|
|
57
|
+
if (selectCols) {
|
|
58
|
+
for (const raw of selectCols.split(',')) {
|
|
59
|
+
const col = parseInt(raw.trim(), 10);
|
|
60
|
+
if (!isNaN(col) && col > 0) {
|
|
61
|
+
specs.push({ type: 'select', column: col });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return specs.length ? specs : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {HTMLElement} caption
|
|
71
|
+
* @param {HTMLTableElement} table
|
|
72
|
+
* @param {FilterSpecForSelect} spec
|
|
73
|
+
*/
|
|
74
|
+
function setSelectFilterCaption(caption, table, spec) {
|
|
75
|
+
const columnIndex = spec.column - 1;
|
|
76
|
+
const textFallback = spec.label ?? `Column ${spec.column}`;
|
|
77
|
+
const cell = table.tHead?.rows[0]?.cells[columnIndex];
|
|
78
|
+
|
|
79
|
+
if (cell instanceof HTMLTableCellElement && cell.textContent?.trim()) {
|
|
80
|
+
caption.replaceChildren(
|
|
81
|
+
...Array.from(cell.childNodes, (node) => node.cloneNode(true))
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
caption.textContent = textFallback;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Puts `<option>` text in A–Z order (browser locale, case-insensitive).
|
|
90
|
+
*
|
|
91
|
+
* @param {string[]} optionTexts
|
|
92
|
+
*/
|
|
93
|
+
function sortSelectOptions(optionTexts) {
|
|
94
|
+
optionTexts.sort((a, b) =>
|
|
95
|
+
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {HTMLTableElement} table
|
|
101
|
+
* @param {number} columnIndex
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
function collectSelectOptions(table, columnIndex) {
|
|
105
|
+
const tbody = table.tBodies[0];
|
|
106
|
+
if (!tbody) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
/** @type {string[]} */
|
|
111
|
+
const options = [];
|
|
112
|
+
|
|
113
|
+
for (const row of tbody.rows) {
|
|
114
|
+
const cell = row.cells[columnIndex];
|
|
115
|
+
warnIfRowCellMissing(table, cell);
|
|
116
|
+
|
|
117
|
+
const optionsFromCellText = getCellText(cell)
|
|
118
|
+
.split(',')
|
|
119
|
+
.map((piece) => piece.trim())
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
|
|
122
|
+
for (const optionText of optionsFromCellText) {
|
|
123
|
+
if (seen.has(optionText)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
seen.add(optionText);
|
|
127
|
+
options.push(optionText);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
sortSelectOptions(options);
|
|
132
|
+
return options;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} tableId
|
|
137
|
+
* @param {string} key
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function getFilterControlId(tableId, key) {
|
|
141
|
+
return `${tableId}-filter-${key}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} templateId
|
|
146
|
+
* @returns {DocumentFragment}
|
|
147
|
+
*/
|
|
148
|
+
function clonePageTemplate(templateId) {
|
|
149
|
+
const template = document.getElementById(templateId);
|
|
150
|
+
|
|
151
|
+
if (!(template instanceof HTMLTemplateElement)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
'[filtersort] Missing <template id="' + templateId + '">.'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return template.content.cloneNode(true);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {HTMLLabelElement} searchLabel
|
|
162
|
+
* @param {string} tableId
|
|
163
|
+
* @returns {string} control id
|
|
164
|
+
*/
|
|
165
|
+
function wireSearchFilterLabel(searchLabel, tableId) {
|
|
166
|
+
const input = searchLabel.querySelector('input[type="search"]');
|
|
167
|
+
const controlId = getFilterControlId(tableId, 'search');
|
|
168
|
+
|
|
169
|
+
searchLabel.htmlFor = controlId;
|
|
170
|
+
input.id = controlId;
|
|
171
|
+
registerFilterControl(input, tableId);
|
|
172
|
+
|
|
173
|
+
return controlId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {HTMLLabelElement} label
|
|
178
|
+
* @param {HTMLTableElement} table
|
|
179
|
+
* @param {FilterSpecForSelect} spec
|
|
180
|
+
* @returns {string} control id
|
|
181
|
+
*/
|
|
182
|
+
function wireSelectFilterLabel(label, table, spec) {
|
|
183
|
+
const caption = label.querySelector('.filtersort__label');
|
|
184
|
+
const select = label.querySelector('select.filtersort__input');
|
|
185
|
+
const controlId = getFilterControlId(table.id, `col-${spec.column}`);
|
|
186
|
+
|
|
187
|
+
label.htmlFor = controlId;
|
|
188
|
+
setSelectFilterCaption(caption, table, spec);
|
|
189
|
+
select.id = controlId;
|
|
190
|
+
registerFilterControl(select, table.id);
|
|
191
|
+
|
|
192
|
+
for (const optionText of collectSelectOptions(table, spec.column - 1)) {
|
|
193
|
+
const option = document.createElement('option');
|
|
194
|
+
option.textContent = optionText;
|
|
195
|
+
select.append(option);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return controlId;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {HTMLTableElement} table
|
|
203
|
+
* @param {FilterSpec[]} specs
|
|
204
|
+
* @param {string} searchIconClass
|
|
205
|
+
* @param {string} countClass
|
|
206
|
+
* @returns {HTMLFieldSetElement}
|
|
207
|
+
*/
|
|
208
|
+
function buildFilterFieldset(table, specs, searchIconClass, countClass) {
|
|
209
|
+
const fragment = clonePageTemplate(FILTER_TEMPLATE_ID);
|
|
210
|
+
const fieldset = /** @type {HTMLFieldSetElement} */ (
|
|
211
|
+
fragment.querySelector('fieldset')
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (searchIconClass) {
|
|
215
|
+
fieldset.querySelector('.filtersort__icon')
|
|
216
|
+
?.classList.add(...searchIconClass.split(' ').filter(Boolean));
|
|
217
|
+
}
|
|
218
|
+
if (countClass) {
|
|
219
|
+
fieldset.querySelector('output.' + OUTPUT_CLASS)
|
|
220
|
+
?.classList.add(...countClass.split(' ').filter(Boolean));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const searchSpec = specs.find((spec) => spec.type === 'search');
|
|
224
|
+
const selectSpecs = specs.filter((spec) => spec.type === 'select');
|
|
225
|
+
|
|
226
|
+
const searchField = fieldset.querySelector(FILTER_SEARCH_LABEL_SELECTOR);
|
|
227
|
+
const selectField = fieldset.querySelector(FILTER_SELECT_LABEL_SELECTOR);
|
|
228
|
+
const filterControlIds = [];
|
|
229
|
+
|
|
230
|
+
/* buildSearchField */
|
|
231
|
+
if (searchSpec) {
|
|
232
|
+
filterControlIds.push(
|
|
233
|
+
wireSearchFilterLabel(searchField, table.id)
|
|
234
|
+
);
|
|
235
|
+
} else {
|
|
236
|
+
searchField.remove();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* buildSelectField */
|
|
240
|
+
for (const spec of selectSpecs) {
|
|
241
|
+
const newSelectField = selectField.cloneNode(true);
|
|
242
|
+
filterControlIds.push(
|
|
243
|
+
wireSelectFilterLabel(newSelectField, table, spec)
|
|
244
|
+
);
|
|
245
|
+
fieldset.append(newSelectField);
|
|
246
|
+
}
|
|
247
|
+
selectField.remove();
|
|
248
|
+
|
|
249
|
+
/* buildOutputField */
|
|
250
|
+
if (searchSpec) {
|
|
251
|
+
const output = fieldset.querySelector('output.' + OUTPUT_CLASS);
|
|
252
|
+
output.setAttribute('for', filterControlIds.join(' '));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return fieldset;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {HTMLTableElement} table
|
|
260
|
+
* @param {string} searchIconClass
|
|
261
|
+
* @param {string} countClass
|
|
262
|
+
*/
|
|
263
|
+
function buildFilters(table, searchIconClass, countClass) {
|
|
264
|
+
const specs = readFilterAttrs(table);
|
|
265
|
+
if (!specs) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!table.id) {
|
|
269
|
+
console.warn(
|
|
270
|
+
'[filtersort] Filter attributes require a table id; skipping filter UI.',
|
|
271
|
+
table
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const fieldset = buildFilterFieldset(table, specs, searchIconClass, countClass);
|
|
277
|
+
|
|
278
|
+
table.parentNode?.insertBefore(fieldset, table);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @param {HTMLTableElement} table
|
|
283
|
+
* @param {HTMLTableCellElement | undefined} cell
|
|
284
|
+
*/
|
|
285
|
+
function warnIfRowCellMissing(table, cell) {
|
|
286
|
+
if (!cell) {
|
|
287
|
+
console.warn(
|
|
288
|
+
'[filtersort] A row is missing a cell for the sorted column. Use the same number of columns on every row in the CMS table (watch colspan/rowspan).',
|
|
289
|
+
table
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Text List.js uses for sort, search, and filter options (link text when present).
|
|
296
|
+
*
|
|
297
|
+
* @param {HTMLTableCellElement | undefined} cell
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
function getCellText(cell) {
|
|
301
|
+
if (!cell) {
|
|
302
|
+
return '';
|
|
303
|
+
}
|
|
304
|
+
const link = cell.querySelector('a');
|
|
305
|
+
const text = link ? link.textContent : cell.textContent;
|
|
306
|
+
return (text ?? '').trim();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {HTMLTableCellElement} th
|
|
311
|
+
* @param {'ascending' | 'descending' | 'none'} ariaSort
|
|
312
|
+
*/
|
|
313
|
+
function setHeaderSortState(th, ariaSort) {
|
|
314
|
+
th.setAttribute('aria-sort', ariaSort);
|
|
315
|
+
const button = th.querySelector('button');
|
|
316
|
+
if (!button) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const label = th.dataset.sortLabel ?? '';
|
|
320
|
+
button.setAttribute(
|
|
321
|
+
'aria-label',
|
|
322
|
+
ariaSort === 'none' ? label : `${label}, sorted ${ariaSort}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @typedef {{ th: HTMLTableCellElement, button: HTMLButtonElement, key: string, columnIndex: number }} SortableColumn
|
|
328
|
+
*/
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* List.js instance after sortable prep (subset used for client-side filter)
|
|
332
|
+
* @typedef {object} SortableTableList
|
|
333
|
+
* @property {(query?: string) => void} search
|
|
334
|
+
* @property {object[]} matchingItems
|
|
335
|
+
* @property {(event: string, callback: () => void) => void} on
|
|
336
|
+
*/
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {SortableColumn[]} columns
|
|
340
|
+
*/
|
|
341
|
+
function syncAriaFromListButtons(columns) {
|
|
342
|
+
for (const { th, button } of columns) {
|
|
343
|
+
let ariaSort = 'none';
|
|
344
|
+
if (button.classList.contains('asc')) {
|
|
345
|
+
ariaSort = 'ascending';
|
|
346
|
+
} else if (button.classList.contains('desc')) {
|
|
347
|
+
ariaSort = 'descending';
|
|
348
|
+
}
|
|
349
|
+
setHeaderSortState(th, ariaSort);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {string} tableId
|
|
355
|
+
* @param {ParentNode} scopeElement
|
|
356
|
+
* @returns {ParentNode | null}
|
|
357
|
+
*/
|
|
358
|
+
function findFilterGroupRoot(tableId, scopeElement) {
|
|
359
|
+
const filters = findFilterControls(tableId, scopeElement);
|
|
360
|
+
if (!filters.length) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return filters[0].closest('.' + FILTER_LIST_CLASS) ?? scopeElement;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {string} tableId
|
|
368
|
+
* @param {ParentNode} scopeElement
|
|
369
|
+
* @returns {HTMLOutputElement[]}
|
|
370
|
+
*/
|
|
371
|
+
function findFilterCountElements(tableId, scopeElement) {
|
|
372
|
+
const root = findFilterGroupRoot(tableId, scopeElement);
|
|
373
|
+
if (!root) {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
return [ ...root.querySelectorAll('output.' + OUTPUT_CLASS) ].filter(
|
|
377
|
+
(el) => el instanceof HTMLOutputElement
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {number} count
|
|
383
|
+
* @returns {string}
|
|
384
|
+
*/
|
|
385
|
+
function formatCount(count) {
|
|
386
|
+
return (count === 1) ? '1 result' : `${count} results`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* @param {string} tableId
|
|
391
|
+
* @param {SortableTableList} list
|
|
392
|
+
* @param {ParentNode} scopeElement
|
|
393
|
+
*/
|
|
394
|
+
function wireFilterCount(tableId, list, scopeElement) {
|
|
395
|
+
const countElements = findFilterCountElements(tableId, scopeElement);
|
|
396
|
+
if (!countElements.length) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const sync = () => {
|
|
401
|
+
const text = formatCount(list.matchingItems.length);
|
|
402
|
+
for (const el of countElements) {
|
|
403
|
+
el.value = text;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
list.on('searchComplete', sync);
|
|
408
|
+
sync();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @param {HTMLTableElement} table
|
|
413
|
+
* @param {SortableTableList} list
|
|
414
|
+
* @param {ParentNode} scopeElement
|
|
415
|
+
*/
|
|
416
|
+
function wireFilters(table, list, scopeElement) {
|
|
417
|
+
const tableId = table.id;
|
|
418
|
+
if (!tableId) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const filterControls = findFilterControls(tableId, scopeElement);
|
|
423
|
+
if (!filterControls.length) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const applyFilters = () => {
|
|
428
|
+
const terms = [ ...filterControls ].map(el =>
|
|
429
|
+
(el instanceof HTMLInputElement || el instanceof HTMLSelectElement
|
|
430
|
+
? el.value
|
|
431
|
+
: ''
|
|
432
|
+
).trim())
|
|
433
|
+
.filter(Boolean);
|
|
434
|
+
if (terms.length) {
|
|
435
|
+
list.search(terms.join(' '));
|
|
436
|
+
} else {
|
|
437
|
+
list.search();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
for (const el of filterControls) {
|
|
442
|
+
let eventName = 'change';
|
|
443
|
+
if (el instanceof HTMLInputElement) {
|
|
444
|
+
eventName =
|
|
445
|
+
(el.type === 'search' || el.type === 'text' || el.type === '')
|
|
446
|
+
? 'input'
|
|
447
|
+
: 'change';
|
|
448
|
+
}
|
|
449
|
+
el.addEventListener(eventName, applyFilters);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @param {HTMLTableElement} table
|
|
455
|
+
* @param {ParentNode} scopeElement
|
|
456
|
+
* @param {string} notSortableSelector
|
|
457
|
+
* @param {string} buttonClass
|
|
458
|
+
* @param {string} searchIconClass
|
|
459
|
+
* @param {string} countClass
|
|
460
|
+
*/
|
|
461
|
+
function prepSortableTable(table, scopeElement, notSortableSelector, buttonClass, searchIconClass, countClass) {
|
|
462
|
+
buildFilters(table, searchIconClass, countClass);
|
|
463
|
+
|
|
464
|
+
const headerRow = table.tHead?.rows[0];
|
|
465
|
+
if (!headerRow) {
|
|
466
|
+
console.warn(
|
|
467
|
+
'[filtersort] Table has no thead; skipping sortable enhancement.',
|
|
468
|
+
table
|
|
469
|
+
);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const tbody = table.tBodies[0];
|
|
474
|
+
if (!tbody) {
|
|
475
|
+
console.warn('[filtersort] Table has no tbody; skipping.', table);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
tbody.classList.add(DEPEND_LIST_CLASS);
|
|
480
|
+
|
|
481
|
+
/** @type {SortableColumn[]} */
|
|
482
|
+
const columns = [];
|
|
483
|
+
/** @type {Array<{ data: string[] }>} */
|
|
484
|
+
const valueNames = [];
|
|
485
|
+
[ ...headerRow.cells ].forEach((cell, columnIndex) => {
|
|
486
|
+
if (!(cell instanceof HTMLTableCellElement)) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (cell.matches(notSortableSelector)) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const label = (cell.textContent ?? '').trim();
|
|
494
|
+
if (!label) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const key = `col-${columnIndex}`;
|
|
499
|
+
cell.dataset.sortLabel = label;
|
|
500
|
+
|
|
501
|
+
const button = document.createElement('button');
|
|
502
|
+
button.type = 'button';
|
|
503
|
+
button.className = [ buttonClass, DEPEND_BUTTON_CLASS ].filter(Boolean).join(' ');
|
|
504
|
+
button.setAttribute('data-sort', key);
|
|
505
|
+
while (cell.firstChild) {
|
|
506
|
+
button.append(cell.firstChild);
|
|
507
|
+
}
|
|
508
|
+
cell.append(button);
|
|
509
|
+
|
|
510
|
+
valueNames.push({ data: [ key ] });
|
|
511
|
+
columns.push({ th: cell, button, key, columnIndex });
|
|
512
|
+
|
|
513
|
+
for (const row of tbody.rows) {
|
|
514
|
+
const rowCell = row.cells[columnIndex];
|
|
515
|
+
warnIfRowCellMissing(table, rowCell);
|
|
516
|
+
row.setAttribute(`data-${key}`, getCellText(rowCell));
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
if (!columns.length) {
|
|
521
|
+
console.warn(
|
|
522
|
+
'[filtersort] No sortable columns after prep; skipping.',
|
|
523
|
+
table
|
|
524
|
+
);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const list = new window.List(table, {
|
|
529
|
+
valueNames,
|
|
530
|
+
listClass: DEPEND_LIST_CLASS,
|
|
531
|
+
sortClass: DEPEND_BUTTON_CLASS,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
list.on('sortComplete', () => syncAriaFromListButtons(columns));
|
|
535
|
+
syncAriaFromListButtons(columns);
|
|
536
|
+
if (table.id) {
|
|
537
|
+
wireFilters(table, list, scopeElement);
|
|
538
|
+
wireFilterCount(table.id, list, scopeElement);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Inject the filter `<template>` into the DOM if not already present.
|
|
544
|
+
*/
|
|
545
|
+
function ensureFilterTemplate() {
|
|
546
|
+
if (document.getElementById(FILTER_TEMPLATE_ID)) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const tpl = document.createElement('template');
|
|
550
|
+
tpl.id = FILTER_TEMPLATE_ID;
|
|
551
|
+
tpl.innerHTML = FILTER_TEMPLATE_HTML;
|
|
552
|
+
document.body.appendChild(tpl);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {object} [options]
|
|
557
|
+
* @param {ParentNode} [options.scopeElement=document]
|
|
558
|
+
* @param {string} [options.tableSelector=table.js-filtersort]
|
|
559
|
+
* @param {string} [options.notSortableSelector=th.not-filtersort]
|
|
560
|
+
* @param {string} [options.buttonClass=''] // e.g. 'c-button c-button--as-link'
|
|
561
|
+
* @param {string} [options.searchIconClass=''] // e.g. 'icon icon-search icon-md'
|
|
562
|
+
* @param {string} [options.countClass=''] // e.g. 'text-truncate'
|
|
563
|
+
*/
|
|
564
|
+
export default function filtersort({
|
|
565
|
+
scopeElement = document,
|
|
566
|
+
tableSelector = DEFAULT_TABLE_SELECTOR,
|
|
567
|
+
notSortableSelector = NOT_SORTABLE_SELECTOR,
|
|
568
|
+
buttonClass = '',
|
|
569
|
+
searchIconClass = '',
|
|
570
|
+
countClass = '',
|
|
571
|
+
} = {}) {
|
|
572
|
+
if (typeof window.List !== 'function') {
|
|
573
|
+
if (!listJsMissingLogged) {
|
|
574
|
+
listJsMissingLogged = true;
|
|
575
|
+
console.error(
|
|
576
|
+
'[filtersort] List.js is not loaded; sortable tables will not be enhanced.'
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
ensureFilterTemplate();
|
|
583
|
+
|
|
584
|
+
scopeElement.querySelectorAll(tableSelector).forEach((table) => {
|
|
585
|
+
if (table instanceof HTMLTableElement) {
|
|
586
|
+
prepSortableTable(table, scopeElement, notSortableSelector, buttonClass, searchIconClass, countClass);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* FILTERS */
|
|
2
|
+
|
|
3
|
+
.filtersort-form {
|
|
4
|
+
--icon-width: 2.4rem;
|
|
5
|
+
--icon-pad-horz: 10px;
|
|
6
|
+
|
|
7
|
+
display: flex;
|
|
8
|
+
gap: 2rem;
|
|
9
|
+
|
|
10
|
+
/* to align items (only effectual when items have unequal height) */
|
|
11
|
+
align-items: start;
|
|
12
|
+
|
|
13
|
+
&:is(fieldset) {
|
|
14
|
+
margin-block-end: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* to prevent wide fields from exceeding fieldset width */
|
|
18
|
+
& .filtersort__filter,
|
|
19
|
+
& .filtersort__input {
|
|
20
|
+
min-width: 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.filtersort__filter {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
|
|
28
|
+
&:is(label) {
|
|
29
|
+
margin-block-end: 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.filtersort__label + .filtersort__input {
|
|
34
|
+
margin-inline-start: 1em;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.filtersort__filter output {
|
|
38
|
+
min-width: 4ch;
|
|
39
|
+
}
|
|
40
|
+
.filtersort__input + output {
|
|
41
|
+
margin-left: 1em;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* to align icon atop search field */
|
|
45
|
+
.filtersort__filter:has([type="search"]) {
|
|
46
|
+
display: grid;
|
|
47
|
+
grid-auto-flow: column;
|
|
48
|
+
|
|
49
|
+
& .filtersort__input {
|
|
50
|
+
grid-row: 1 / 1;
|
|
51
|
+
grid-column: 1 / 2;
|
|
52
|
+
}
|
|
53
|
+
& .filtersort__icon {
|
|
54
|
+
grid-row: 1 / 1;
|
|
55
|
+
grid-column: 1 / 1;
|
|
56
|
+
z-index: 1;
|
|
57
|
+
align-self: center;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* to place icon inside search field */
|
|
61
|
+
.filtersort__input {
|
|
62
|
+
width: 30ch;
|
|
63
|
+
text-indent: calc(var(--icon-width) + var(--icon-pad-horz));
|
|
64
|
+
}
|
|
65
|
+
.filtersort__icon {
|
|
66
|
+
box-sizing: content-box;
|
|
67
|
+
margin-inline: var(--icon-pad-horz);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
/* TABLE */
|
|
73
|
+
|
|
74
|
+
table.js-filtersort {
|
|
75
|
+
& tbody:empty::before {
|
|
76
|
+
content: 'No results found';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
& button.js-sort::after {
|
|
80
|
+
font-size: smaller;
|
|
81
|
+
}
|
|
82
|
+
& th[aria-sort="ascending"] > button.js-sort::after { content: '▲'; }
|
|
83
|
+
& th[aria-sort="descending"] > button.js-sort::after { content: '▼'; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
button.js-sort {
|
|
87
|
+
width: 100%; /* so entire cell is clickable */
|
|
88
|
+
|
|
89
|
+
&:focus-visible {
|
|
90
|
+
outline-offset: 0.25em;
|
|
91
|
+
}
|
|
92
|
+
}
|