@tacc/html-filter-sort 0.1.3

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 ADDED
@@ -0,0 +1,81 @@
1
+ # TACC: HTML 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/html-filter-sort@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/html-filter-sort) */</script>
14
+ <script type="module">
15
+ import filtersort from 'https://cdn.jsdelivr.net/npm/@tacc/html-filter-sort@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,29 @@
1
+ {
2
+ "name": "@tacc/html-filter-sort",
3
+ "version": "0.1.3",
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
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.x",
22
+ "npm": ">=7.x"
23
+ },
24
+ "homepage": "https://github.com/wesleyboar/Core-HTML-Filter-Sort",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+ssh://git@github.com/wesleyboar/Core-HTML-Filter-Sort.git"
28
+ }
29
+ }
@@ -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,2 @@
1
+ @import url("./filtersort.structure.css");
2
+ @import url("./filtersort.core-styles.css");
@@ -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
+ }