diff --git a/docs/plans/sortable-table-finish-deferred.plan.md b/docs/plans/sortable-table-finish-deferred.plan.md
index b0817a4cc..87a3a20ce 100644
--- a/docs/plans/sortable-table-finish-deferred.plan.md
+++ b/docs/plans/sortable-table-finish-deferred.plan.md
@@ -1,24 +1,22 @@
---
name: Sortable table finish (deferred)
-overview: "After Phase 0 merge: steps 5–7 — JS-built filters, cleanup, CKEditor allowlist PR to main."
-status: deferred
+overview: "Steps 5–6 done on branch; step 7 — CKEditor allowlist PR to main."
+status: in-progress
---
# Sortable table: Phase 0, then steps 5–7
-**Deferred until Phase 0 PRs are merged.** Pick up from here for dynamic filters + hack retirement.
-
## Phase 0: Ship current code
Merge as-is: Core-CMS sortableTable, Core-Styles promote util, tup-ui snippet pins.
## Step 5: Dynamic filter markup in JS
-Table-only in CMS; JS builds fieldset/controls/output; ARIA set in code.
+**Done (branch):** Table-only in CMS; `data-sortable-filters` JSON on the table; JS builds fieldset/controls/output with ARIA in code. Legacy CMS-authored filter groups still work.
## Step 6: Clean up
-Remove snippet promote; dedupe JS; bump SHAs.
+**Done (branch):** Snippet promote removed; CDN pins use `Core-Styles@v2.57.0` and `Core-CMS@v4.40.0-rc6`.
## Step 7: CKEditor ARIA — separate branch → `main`
diff --git a/taccsite_cms/static/site_cms/js/modules/sortableTable.html b/taccsite_cms/static/site_cms/js/modules/sortableTable.html
index d4008866a..57f64622d 100644
--- a/taccsite_cms/static/site_cms/js/modules/sortableTable.html
+++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.html
@@ -1,6 +1,8 @@
+
+
-
-
-
-
-
+
+
…
diff --git a/taccsite_cms/static/site_cms/js/modules/sortableTable.js b/taccsite_cms/static/site_cms/js/modules/sortableTable.js
index 28f551605..484d5da32 100644
--- a/taccsite_cms/static/site_cms/js/modules/sortableTable.js
+++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.js
@@ -13,39 +13,297 @@ const SORT_TABLE_CLASS = 'js-sortable';
const FILTER_CLASS = 'js-sortable-filter';
const FILTER_LIST_CLASS = 'js-sortable-filter-list';
const OUTPUT_CLASS = 'js-sortable-total';
-const LIST_CLASS = 'list';
-const SORT_BUTTON_CLASS = 'sort';
+const THEIR_LIST_CLASS = 'list';
+const THEIR_BUTTON_CLASS = 'sort';
const DEFAULT_TABLE_SELECTOR = 'table.' + SORT_TABLE_CLASS;
const NOT_SORTABLE_SELECTOR = 'th.not-sortable';
+const FILTERS_DATA_ATTR = 'data-sortable-filters';
+
+const FILTER_TEMPLATE_ID = 'sortable-table-filters';
+const FILTER_SEARCH_LABEL_SELECTOR = 'label:has(input[type="search"])';
+const FILTER_SELECT_LABEL_SELECTOR = 'label:has(select)';
let listJsMissingLogged = false;
/**
+ * @typedef {FilterSpecForSearch | FilterSpecForSelect} FilterSpec
+ * @typedef {{ type: 'search', placeholder?: string }} FilterSpecForSearch
+ * @typedef {{ type: 'select', column: number, label?: string }} FilterSpecForSelect
+ */
+
+/**
+ * @param {string} tableId
+ * @param {ParentNode} scopeElement
+ * @returns {NodeListOf}
+ */
+function findFilterControls(tableId, scopeElement) {
+ return scopeElement.querySelectorAll(
+ '.' + FILTER_CLASS + '[aria-controls="' + CSS.escape(tableId) + '"]'
+ );
+}
+
+/**
+ * @param {HTMLInputElement | HTMLSelectElement} control
+ * @param {string} tableId
+ */
+function registerFilterControl(control, tableId) {
+ control.classList.add(FILTER_CLASS);
+ control.setAttribute('aria-controls', tableId);
+}
+
+/**
+ * @param {HTMLTableElement} table
+ * @returns {FilterSpec[] | null}
+ */
+function parseFilterSpecs(table) {
+ const json = table.getAttribute(FILTERS_DATA_ATTR);
+ if (!json) {
+ return null;
+ }
+ try {
+ const specs = JSON.parse(json);
+ if (!Array.isArray(specs) || !specs.length) {
+ return null;
+ }
+ return specs;
+ } catch {
+ console.warn(
+ '[sortableTable] Invalid JSON in data-sortable-filters.',
+ table
+ );
+ return null;
+ }
+}
+
+/**
+ * @param {HTMLElement} caption
+ * @param {HTMLTableElement} table
+ * @param {FilterSpecForSelect} spec
+ */
+function setSelectFilterCaption(caption, table, spec) {
+ const columnIndex = spec.column;
+ const textFallback = spec.label ?? `Column ${columnIndex + 1}`;
+ const cell = table.tHead?.rows[0]?.cells[columnIndex];
+
+ if (cell instanceof HTMLTableCellElement && cell.textContent?.trim()) {
+ caption.replaceChildren(
+ ...Array.from(cell.childNodes, (node) => node.cloneNode(true))
+ );
+ return;
+ }
+ caption.textContent = textFallback;
+}
+
+/**
+ * Puts `