Skip to content

Instantly share code, notes, and snippets.

@Bibo-Joshi
Last active October 19, 2025 13:16
Show Gist options
  • Select an option

  • Save Bibo-Joshi/78bc5f513193649d6ac78ccec747c268 to your computer and use it in GitHub Desktop.

Select an option

Save Bibo-Joshi/78bc5f513193649d6ac78ccec747c268 to your computer and use it in GitHub Desktop.
DataTables.net Tags MWE

DataTables Tags MWE

Purpose / Use case

  • Minimal Working Example that shows how to render multi-value tags per row as colored "pill" badges and provide advanced column filtering (AND / OR) via ColumnControl.
  • Input formats accepted: JS Array, JSON-array string, or comma-separated string.
  • Tags are displayed sorted alphabetically; filtering supports "must have all selected tags" (AND) and "must have at least one" (OR).

Key components

  1. Custom DataType: DataTable.type('tags', { ... })

    • Detection: recognizes Arrays, comma-separated strings or JSON-array strings as the tags type.
    • Rendering:
      • display: renders tags alphabetically as Bootstrap rounded-pill badges (uses textContent for safety).
      • filter: returns tags joined by spaces for DataTables filtering.
      • default (other types): returns tags as a comma-separated string.
    • Ordering: order.pre normalizes tags to lower-case, sorts (stable), reverses and joins with | to produce a sortable key.
    • CSS class: adds dt-data-tags to cells.
    • Customization: badge colors and presentation are configurable via TAG_COLORS and the tagBadge() helper.
  2. ColumnControl content plugin: DataTable.ColumnControl.content.tagsSearchList = { ... }

    • UI: checklist of tag items each prefixed with a badge; toolbar with two buttons And / Or to switch filter mode.
    • Filter logic:
      • AND mode: builds a lookahead-based regex that requires all selected tags as whole words.
      • OR mode: builds a regex that matches any selected tag as a whole word.
      • The plugin applies the filter with api.column(...).search(regex, true, false, true).draw() (Regex=true, smart=false, case-insensitive).
    • Tag source resolution (priority):
      1. config.extends.tags (if present and array)
      2. config.tags
      3. window._columnControlPreconfig[resolvedColIdx] (global preconfig)
      4. scan of the column data (api.column(resolvedColIdx, {search: 'none'}).data())
    • Interaction:
      • Checklist items toggle active state on click; counts for Select-All / Select-None are updated.
      • Mode buttons toggle active state and reapply the filter.
    • Safety: special characters in tags are escaped before being included in regex to avoid injection issues.

Configuration / example

  • Basic table initialization (excerpt):
$('#example').DataTable({
  data: tableData,
  columns: [
    { data: 'name', title: 'Name' },
    { data: 'tags', title: 'Tags' }
  ],
  columnDefs: [
    {
      targets: 1,
      columnControl: [
        {
          target: 0,
          content: ['orderStatus', [{
            extend: 'tagsSearchList',
            tags: availableTags   // optional: preloaded tag list
          }]]
        }
      ]
    }
  ]
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>DataTables Tags with Custom DataType via DataTable.type()</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous">
<link href="https://cdn.datatables.net/2.3.4/css/dataTables.bootstrap5.min.css" rel="stylesheet"
integrity="sha384-zmMNeKbOwzvUmxN8Z/VoYM+i+cwyC14+U9lq4+ZL0Ro7p1GMoh8uq8/HvIBgnh9+"
crossorigin="anonymous">
<link href="https://cdn.datatables.net/columncontrol/1.1.1/css/columnControl.bootstrap5.min.css"
rel="stylesheet"
integrity="sha384-7CoZnsZoCNq4KW3KkVlviA3k7muuMmoNzO9owW/X5cxkKf7mOt8bgItbLHs2gxMS"
crossorigin="anonymous">
<style>
.tag-pill {
margin-right: 0.25rem;
margin-bottom: 0.25rem;
display: inline-block;
}
.dtcc-mode-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="container my-4">
<h3>DataTables Tags with Custom DataType via DataTable.type()</h3>
<table id="example" class="table table-striped table-hover table-bordered" style="width:100%">
<thead>
<tr>
<th id="th-name" class="sortable">Name <span class="sort-indicator"></span></th>
<th id="th-tags" class="sortable">Tags <span class="sort-indicator"></span></th>
</tr>
</thead>
<tbody>
<!-- populated by DataTables -->
</tbody>
</table>
</div>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"
integrity="sha384-NXgwF8Kv9SSAr+jemKKcbvQsz+teULH/a5UNJvZc6kP47hZgl62M1vGnw6gHQhb1"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/js/bootstrap.bundle.min.js"
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.min.js"
integrity="sha384-n+4gDsCQ+x0o5YCJeYqFY1Yl+qgW41qmMAilIyF/bJwIUeTK00QB845/VijT5pQq"
crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.bootstrap5.min.js"
integrity="sha384-G85lmdZCo2WkHaZ8U1ZceHekzKcg37sFrs4St2+u/r2UtfvSDQmQrkMsEx4Cgv/W"
crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/columncontrol/1.1.1/js/dataTables.columnControl.min.js"
integrity="sha384-54KDBrrLqJ67PBkEV2Ej6Jld+sMGBTOvXTSUIUjoDk1an4CQA3ixhqqH/gENp/H7"
crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/columncontrol/1.1.1/js/columnControl.bootstrap5.min.js"
integrity="sha384-8/VMWgNQydL78AUoxpPO74i5Doeh3fkhJPcMHcWB7I92dF+cGc7t6L6x1/7pCJQf"
crossorigin="anonymous"></script>
<script>
(function () {
'use strict';
// Enable extra debug logging when true
const DEBUG = false;
// Map of tag -> Bootstrap color class
const TAG_COLORS = {
red: 'danger',
blue: 'primary',
green: 'success',
yellow: 'warning',
cyan: 'info',
important: 'dark',
default: 'secondary'
};
// Return bootstrap color class for a tag (case-insensitive)
const getTagColor = tag => TAG_COLORS[(String(tag || '').toLowerCase())] || TAG_COLORS.default;
// Create a badge element or HTML string for a tag. Keeps textContent for safety.
const tagBadge = (tag, asHtml = true) => {
const t = String(tag || '').trim();
const span = document.createElement('span');
span.className = 'badge rounded-pill bg-' + getTagColor(t) + ' tag-pill';
span.textContent = t;
span.setAttribute('title', t);
span.setAttribute('aria-label', `Tag: ${t}`);
return asHtml ? span.outerHTML : span;
};
// Normalize a single tag value
const normalizeTag = v => String(v || '').trim();
// Normalize input into an array of non-empty, trimmed strings.
// Accepts Array, JSON-style array string, comma-separated string, or other scalars.
const normalizeToArray = data => {
if (data === null || data === undefined) return [];
if (Array.isArray(data)) return data.map(normalizeTag).filter(Boolean);
if (typeof data === 'string') {
const s = data.trim();
// Try JSON parse for array-like strings
if (/^\s*\[.*]$/.test(s)) {
try {
const parsed = JSON.parse(s);
if (Array.isArray(parsed)) return parsed.map(normalizeTag).filter(Boolean);
} catch (e) {
// fallthrough to comma split
if (DEBUG) console.debug('JSON parse failed for tags string', s, e);
}
}
return s.split(',').map(p => p.trim()).filter(Boolean);
}
// Fallback: single value to string
return [String(data).trim()].filter(Boolean);
};
// Normalize and sort tags (case-insensitive, locale aware)
const normalizeAndSort = data => normalizeToArray(data).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
// Escape special regex characters
const escapeRegex = v => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Build lookahead regex that requires all values (AND semantics)
const buildAndLookaheadRegex = values => {
if (!values || !values.length) return '';
const lookaheads = values.map(v => `(?=.*\\b${escapeRegex(v)}\\b)`).join('');
return `^${lookaheads}.*$`;
};
// Build OR regex matching any value as a whole word
const buildOrRegex = values => {
if (!values || !values.length) return '';
const inner = values.map(v => escapeRegex(v)).join('|');
return `\\b(?:${inner})\\b`;
};
// Resolve DataTable API from a ColumnControl config or fallback to #example instance
const resolveDataTableApi = config => {
if (config && (config.dt || (config.table && config.table.api))) return config.dt || config.table.api;
if (typeof window !== 'undefined' && window.$) {
const el = $('#example');
if (el && el.DataTable) return el.DataTable();
}
return null;
};
// Register a custom 'tags' type: detection, ordering key and rendering
DataTable.type('tags', {
detect: data => {
if (Array.isArray(data)) return 'tags';
if (typeof data === 'string') {
if (data.indexOf(',') !== -1) return 'tags';
if (/^\s*\[.*]$/.test(data)) return 'tags';
}
return null;
},
order: {
pre: data => {
const tags = normalizeToArray(data).map(t => String(t).toLowerCase()).filter(Boolean);
// Maintain previous stable ordering behaviour
tags.sort().reverse();
return tags.join('|');
}
},
className: 'dt-data-tags',
render: function (data, type) {
const arr = normalizeAndSort(data);
if (type === 'display') return arr.map(tag => tagBadge(tag, true)).join(' ');
if (type === 'filter') return arr.join(' ');
return arr.join(',');
}
});
// ColumnControl content plugin: checklist with badges and AND/OR filtering
DataTable.ColumnControl.content.tagsSearchList = {
init: function (config) {
const container = document.createElement('div');
container.className = 'tags-search-list p-2';
const api = resolveDataTableApi(config);
if (!api || typeof api.column !== 'function') {
container.textContent = 'DataTable API not found';
return container;
}
// Attempt to find the ColumnControl host
let host = (config && (config.host || config.columnControl || config.hostColumnControl || config.ccHost)) || null;
if (!host && typeof this === 'object' && this !== null && typeof this.idx === 'function') host = this;
// Final fallback: try to obtain host from a tentative column index
if (!host) {
const tentativeIdx = (typeof config?.target === 'number') ? config.target : ((typeof config?.column === 'number') ? config.column : 1);
try {
const colApi = api.column && api.column(tentativeIdx);
if (colApi && typeof colApi.columnControl === 'function') host = colApi.columnControl();
} catch (e) {
if (DEBUG) console.debug('host resolution failed', e);
}
}
const resolvedColIdx = (host && typeof host.idx === 'function') ? host.idx() : ((typeof config?.target === 'number') ? config.target : ((typeof config?.column === 'number') ? config.column : 1));
// Determine available tags: prefer config, then global preconfig, then scan column data
let tags;
const cfgTags = (config && config.extends && Array.isArray(config.extends.tags)) ? config.extends.tags : (Array.isArray(config.tags) ? config.tags : null);
const globalPre = (typeof window !== 'undefined' && window._columnControlPreconfig) ? window._columnControlPreconfig[resolvedColIdx] : null;
if (Array.isArray(cfgTags) && cfgTags.length) {
tags = Array.from(new Set(cfgTags.map(normalizeTag))).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
} else if (Array.isArray(globalPre) && globalPre.length) {
tags = Array.from(new Set(globalPre.map(normalizeTag))).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
} else {
const rawData = api.column(resolvedColIdx, {search: 'none'}).data().toArray();
const tagSet = new Set();
rawData.forEach(d => normalizeToArray(d).forEach(t => {
const s = normalizeTag(t);
if (s) tagSet.add(s);
}));
tags = Array.from(tagSet).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
}
if (!host) {
container.textContent = 'ColumnControl host not found';
return container;
}
try {
const checklist = new DataTable.ColumnControl.CheckList(api, host, {
search: true,
select: true
});
const items = tags.map(t => ({label: t, value: t}));
checklist.add(items);
const updateCounts = () => {
const vals = checklist.values() || [];
const selectNoneSpan = checklist.element().querySelector('.dtcc-list-selectNone span');
const selectAllSpan = checklist.element().querySelector('.dtcc-list-selectAll span');
if (selectNoneSpan) selectNoneSpan.innerHTML = vals.length ? '(' + vals.length + ')' : '';
if (selectAllSpan) {
const total = items.length || checklist.element().querySelectorAll('.dtcc-list-buttons > *').length;
selectAllSpan.innerHTML = total ? '(' + total + ')' : '';
}
};
let filterMode = 'and';
const applyFilter = () => {
const vals = checklist.values() || [];
if (!vals.length) {
api.column(resolvedColIdx).search('').draw();
return;
}
const regex = (filterMode === 'and') ? buildAndLookaheadRegex(vals) : buildOrRegex(vals);
api.column(resolvedColIdx).search(regex, true, false, true).draw();
};
// Mode buttons toolbar (And / Or)
const toolbar = document.createElement('div');
toolbar.className = 'dtcc-mode-toolbar';
const btnAnd = new DataTable.ColumnControl.Button(api, host).text('And').handler(() => {
filterMode = 'and';
btnAnd.active(true);
btnOr.active(false);
applyFilter();
});
const btnOr = new DataTable.ColumnControl.Button(api, host).text('Or').handler(() => {
filterMode = 'or';
btnOr.active(true);
btnAnd.active(false);
applyFilter();
});
btnAnd.active(true);
toolbar.appendChild(btnAnd.element());
toolbar.appendChild(btnOr.element());
container.appendChild(toolbar);
// Attach badges and handlers to checklist buttons
items.forEach(item => {
const btn = checklist.button(item.value);
if (!btn) return;
const el = btn.element();
if (!el) return;
const badgeEl = tagBadge(item.value, false);
el.prepend(badgeEl);
const textSpan = el.querySelector('.dtcc-button-text');
if (textSpan) textSpan.remove();
el.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
const newState = !btn.active();
btn.active(newState);
updateCounts();
applyFilter();
});
});
checklist.searchListener(api);
checklist.handler(applyFilter);
container.appendChild(checklist.element());
return container;
} catch (err) {
if (DEBUG) console.error('CheckList initialization failed', err);
container.textContent = 'CheckList initialization failed';
return container;
}
}
};
// Sample data
const TAG_POOL = ['red', 'blue', 'green', 'yellow', 'cyan', 'important', 'default'];
const tableData = Array.from({length: 100}, (_, i) => {
const name = `Item ${i + 1}`;
// random count between 0 and 5
const count = Math.floor(Math.random() * 6);
const tags = [];
for (let j = 0; j < count; j++) {
const tag = TAG_POOL[(i * 5 + j * 3) % TAG_POOL.length];
tags.push(tag);
}
// ensure unique tags per row and preserve order
const uniqueTags = Array.from(new Set(tags));
return {name, tags: uniqueTags};
});
// Initialize table on DOM ready
$(document).ready(function () {
// Precompute tags from source to avoid scanning the table at runtime
const availableTags = Array.from(new Set(tableData.flatMap(r => normalizeToArray(r.tags).map(normalizeTag).filter(Boolean)))).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
$('#example').DataTable({
data: tableData,
columns: [
{data: 'name', title: 'Name'},
{data: 'tags', title: 'Tags'}
],
order: [[0, 'asc']],
ordering: {indicators: false},
columnControl: [
{target: 0, content: ['orderStatus', ['searchList']]}
],
columnDefs: [
{
targets: 1,
columnControl: [
{
target: 0,
content: ['orderStatus', [{
extend: 'tagsSearchList',
tags: availableTags
}]]
}
]
}
],
// scrollY: '400px',
paging: false,
});
});
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment