Drag-and-drop column grouping for DataTables 1.10.x, 2.x and 3.x. An easy-to-use groupable extension intended as an alternative to the rowGroup extension by using the drag an drop native funct...
datatables.groupable.bootstrap.css
| .eyebrow { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.75rem; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .dt-groupable-dropzone { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 0.5rem; | |
| min-height: 3rem; | |
| } | |
| .dt-groupable-dropzone-over { | |
| background-color: var(--bs-primary-bg-subtle) !important; | |
| border-color: var(--bs-primary) !important; | |
| } | |
| .dt-groupable-placeholder { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.8rem; | |
| color: var(--bs-secondary-color); | |
| } | |
| .dt-groupable-chip { | |
| cursor: grab; | |
| user-select: none; | |
| transition: opacity 0.12s ease, border-color 0.12s ease; | |
| } | |
| .dt-groupable-chip.dt-groupable-dragging { | |
| opacity: 0.4; | |
| } | |
| .dt-groupable-chip.dt-groupable-chip-over { | |
| border-color: var(--bs-primary) !important; | |
| } | |
| .dt-groupable-chip-order { | |
| width: 1.1rem; | |
| height: 1.1rem; | |
| font-size: 0.65rem; | |
| } | |
| .dt-groupable-chip-remove { | |
| line-height: 1; | |
| padding: 0 0.15rem; | |
| } | |
| .dt-groupable-header { | |
| cursor: grab; | |
| position: relative; | |
| } | |
| .dt-header-content { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| } | |
| .dt-drag-handle { | |
| color: var(--bs-secondary-color); | |
| font-size: 0.7rem; | |
| opacity: 0.6; | |
| order: 2; | |
| } | |
| .dt-groupable-header::after, .dt-groupable-header::before { | |
| /*content: '⠿';*/ | |
| position: absolute; | |
| display: block; | |
| left: 1.5rem; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| color: var(--bs-secondary-color); | |
| font-size: 0.7rem; | |
| opacity: 0.6; | |
| } | |
| .dt-groupable-header.dt-groupable-dragging { | |
| opacity: 0.4; | |
| } | |
| tr.dt-group-row td { | |
| padding-left: calc( 0.75rem + (var(--dt-group-level, 0) * 1.4rem) ) !important; | |
| } | |
| tr.dt-group-row.dt-group-collapsible { | |
| cursor: pointer; | |
| } | |
| tr.dt-group-row .dt-group-label strong { | |
| color: var(--bs-primary); | |
| } | |
| tr.dt-group-row .dt-group-count { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.72rem; | |
| } | |
| .dt-group-toggle { | |
| display: inline-block; | |
| width: 0.7rem; | |
| margin-right: 0.4rem; | |
| font-family: 'JetBrains Mono', monospace; | |
| color: var(--bs-secondary-color); | |
| } | |
| .dt-group-toggle::before { | |
| content: '▾'; | |
| } | |
| tr.dt-group-collapsed .dt-group-toggle::before { | |
| content: '▸'; | |
| } |
| 'use strict'; | |
| /** | |
| * datatables.groupable.js (No JQuery dependency) | |
| * | |
| * Drag-and-drop column grouping for DataTables 1.10.x, 2.x and 3.x. | |
| * | |
| * An easy-to-use groupable extension intended as an alternative to the rowGroup extension | |
| * by using the drag an drop native functionality from browsers, make it easier to dinamically | |
| * group records without the hassle of writing hardcoded code into the rowGroup methods. | |
| * | |
| * Usage: | |
| * | |
| * const table = new DataTable('#example'); | |
| * | |
| * table.groupable({ | |
| * dropZone: '#zona-agrupacion', // Container where columns are dropped. | |
| * emptyText: 'Drag a column here to group data', // Text shown when the container is empty. | |
| * collapsible: true, // Whether groups can be collapsed. | |
| * hideGroupedColumns: false, // Whether grouped columns remain visible. | |
| * excludeColumns: [], // Columns excluded from grouping. | |
| * groupLabel: (col, value, count, level) => `${col.title()}: ${value} (${count})`, // Template for each group's label. | |
| * stateSave: false, // Whether the grouping state is persisted. | |
| * stateSaveKey: null, // Custom key used to identify the persisted state (overrides the default). | |
| * stateIncludeCollapsed: true, // Whether collapsed state is included when persisting. | |
| * stateSaveCallback: null, // (state, dt) => void | Promise — called to persist the state. Defaults to localStorage if not provided. | |
| * stateLoadCallback: null, // (dt) => state | Promise — called to load the state. Defaults to localStorage if not provided. | |
| * onChange: (groupColumnIndexes, dt) => {} // Called whenever the grouping changes. | |
| * }); | |
| */ | |
| const DEFAULTS = { | |
| dropZone: null, | |
| excludeColumns: [], | |
| collapsible: true, | |
| hideGroupedColumns: false, | |
| emptyText: 'Drag a column here to group data', | |
| groupLabel: null, | |
| onChange: null, | |
| stateSave: false, | |
| stateSaveKey: null, | |
| stateIncludeCollapsed: true, | |
| stateSaveCallback: null, | |
| stateLoadCallback: null | |
| }; | |
| class Groupable { | |
| #dt; | |
| #options; | |
| #dropZone; | |
| #groupColumns = []; | |
| #collapsedKeys = new Set(); | |
| #previousPageLength = null; | |
| #dragChipFrom = null; | |
| #dragSourceColumn = null; | |
| #headerAbortController = new AbortController(); | |
| #dropZoneAbortController = null; | |
| #ready; // Indicates that the promise object has been resolved when the state object is loaded or persisted. | |
| constructor(dtApi, options) { | |
| this.#dt = dtApi; | |
| this.#options = Object.assign({}, DEFAULTS, options); | |
| if (!this.#options.dropZone) | |
| throw new Error( | |
| 'groupable(): "dropZone" option is required.' | |
| ); | |
| this.#dropZone = | |
| typeof this.#options.dropZone === 'string' | |
| ? document.querySelector(this.#options.dropZone) | |
| : this.#options.dropZone; | |
| if (!this.#dropZone) | |
| throw new Error( | |
| 'groupable(): "dropZone" selector not found.' | |
| ); | |
| this.#renderChips(); | |
| this.#bindHeaders(); | |
| this.#dt.on('draw.dtGroupable', () => this.#renderGroups()); | |
| this.#renderGroups(); | |
| this.#ready = this.#loadState(); | |
| } | |
| #createElementFromHTML(html) { | |
| const template = document.createElement('template'); | |
| template.innerHTML = html.trim(); | |
| return template.content.firstElementChild; | |
| } | |
| clear() { | |
| if (!this.#groupColumns.length) return; | |
| this.#groupColumns.forEach((idx) => { | |
| if (this.#options.hideGroupedColumns) | |
| this.#dt.column(idx).visible(true, false); | |
| }); | |
| this.#groupColumns = []; | |
| this.#collapsedKeys.clear(); | |
| this.#restorePageLength(); | |
| this.#renderChips(); | |
| this.#dt.draw(false); | |
| this.#notifyChange(); | |
| this.#saveState(); | |
| } | |
| getState() { | |
| return this.#buildStateObject(); | |
| } | |
| ready() { | |
| return this.#ready; | |
| } | |
| async setState(state) { | |
| this.#applyStateObject(state); | |
| if (this.#groupColumns.length) { | |
| this.#renderChips(); | |
| this.#applyOrderAndDraw(); | |
| } else { | |
| this.#renderChips(); | |
| this.#renderGroups(); | |
| } | |
| this.#notifyChange(); | |
| } | |
| destroy() { | |
| this.clear(); | |
| this.#dt.off('draw.dtGroupable'); | |
| this.#headerAbortController.abort(); | |
| this.#dropZoneAbortController?.abort(); | |
| this.#dropZone.innerHTML = ''; | |
| const headerRow = this.#dt.table().header(); | |
| headerRow.querySelectorAll('th').forEach((th) => { | |
| th.removeAttribute('draggable'); | |
| th.classList.remove('dt-groupable-header', 'dt-groupable-dragging'); | |
| }); | |
| } | |
| /*** | |
| * Binds the datatables header to drag and drop events | |
| * @returns {object} | |
| */ | |
| #bindHeaders() { | |
| const headerRow = this.#dt.table().header(); | |
| const { signal } = this.#headerAbortController; | |
| headerRow.querySelectorAll('th').forEach((th) => { | |
| let colIndex; | |
| try { | |
| colIndex = this.#dt.column(th).index(); | |
| } catch (e) { | |
| return; | |
| } | |
| if (this.#options.excludeColumns.includes(colIndex)) return; | |
| th.setAttribute('draggable', 'true'); | |
| th.classList.add('dt-groupable-header'); | |
| th.addEventListener( | |
| 'dragstart', | |
| (e) => { | |
| e.dataTransfer.effectAllowed = 'copy'; | |
| e.dataTransfer.setData('text/plain', String(colIndex)); | |
| this.#dragSourceColumn = colIndex; | |
| th.classList.add('dt-groupable-dragging'); | |
| }, | |
| { signal } | |
| ); | |
| th.addEventListener( | |
| 'dragend', | |
| () => { | |
| th.classList.remove('dt-groupable-dragging'); | |
| this.#dragSourceColumn = null; | |
| }, | |
| { signal } | |
| ); | |
| }); | |
| } | |
| /*** | |
| * Renders the groupable chips on dropzone container | |
| * and binds drag and drop events. | |
| * @returns {void} | |
| */ | |
| #renderChips() { | |
| const zone = this.#dropZone; | |
| this.#dropZoneAbortController?.abort(); | |
| this.#dropZoneAbortController = new AbortController(); | |
| const { signal } = this.#dropZoneAbortController; | |
| zone.innerHTML = ''; | |
| zone.classList.add('dt-groupable-dropzone'); | |
| if (!this.#groupColumns.length) { | |
| const placeholder = document.createElement('span'); | |
| placeholder.className = 'dt-groupable-placeholder'; | |
| placeholder.textContent = this.#options.emptyText; | |
| zone.append(placeholder); | |
| } else { | |
| this.#groupColumns.forEach((colIndex, position) => { | |
| const title = this.#dt.column(colIndex).title(); | |
| const chip = this.#createElementFromHTML(` | |
| ${position + 1} | |
| </span> | |
| `); | |
| chip.querySelector('.dt-groupable-chip-label').textContent = title; | |
| chip.addEventListener('dragstart', (e) => { | |
| e.stopPropagation(); | |
| e.dataTransfer.effectAllowed = 'move'; | |
| e.dataTransfer.setData('text/plain', String(position)); | |
| this.#dragChipFrom = position; | |
| chip.classList.add('dt-groupable-dragging'); | |
| }); | |
| chip.addEventListener('dragend', () => { | |
| chip.classList.remove('dt-groupable-dragging'); | |
| this.#dragChipFrom = null; | |
| }); | |
| chip.addEventListener('dragover', (e) => { | |
| if (this.#dragChipFrom === null) return; | |
| e.preventDefault(); | |
| chip.classList.add('dt-groupable-chip-over'); | |
| }); | |
| chip.addEventListener('dragleave', () => | |
| chip.classList.remove('dt-groupable-chip-over') | |
| ); | |
| chip.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| chip.classList.remove('dt-groupable-chip-over'); | |
| if (this.#dragChipFrom === null) return; | |
| this.#reorderGroupColumns(this.#dragChipFrom, position); | |
| }); | |
| chip | |
| .querySelector('.dt-groupable-chip-remove') | |
| .addEventListener('click', () => | |
| this.#removeGroupColumn(colIndex) | |
| ); | |
| zone.append(chip); | |
| }); | |
| } | |
| zone.addEventListener( | |
| 'dragover', | |
| (e) => { | |
| e.preventDefault(); | |
| e.dataTransfer.dropEffect = | |
| this.#dragChipFrom === null ? 'copy' : 'move'; | |
| zone.classList.add('dt-groupable-dropzone-over'); | |
| }, | |
| { signal } | |
| ); | |
| zone.addEventListener( | |
| 'dragleave', | |
| (e) => { | |
| if (e.target === zone) | |
| zone.classList.remove('dt-groupable-dropzone-over'); | |
| }, | |
| { signal } | |
| ); | |
| zone.addEventListener( | |
| 'drop', | |
| (e) => { | |
| e.preventDefault(); | |
| zone.classList.remove('dt-groupable-dropzone-over'); | |
| if (this.#dragChipFrom !== null) return; | |
| const colIndex = this.#dragSourceColumn; | |
| if (colIndex === null || colIndex === undefined) return; | |
| this.#addGroupColumn(colIndex); | |
| }, | |
| { signal } | |
| ); | |
| } | |
| #reorderGroupColumns(fromPos, toPos) { | |
| if (fromPos === toPos) return; | |
| const [moved] = this.#groupColumns.splice(fromPos, 1); | |
| this.#groupColumns.splice(toPos, 0, moved); | |
| this.#renderChips(); | |
| this.#applyOrderAndDraw(); | |
| this.#notifyChange(); | |
| this.#saveState(); | |
| } | |
| #addGroupColumn(colIndex) { | |
| if (this.#groupColumns.includes(colIndex)) return; | |
| if (!this.#groupColumns.length) { | |
| this.#previousPageLength = this.#dt.page.len(); | |
| } | |
| this.#groupColumns.push(colIndex); | |
| if (this.#options.hideGroupedColumns) | |
| this.#dt.column(colIndex).visible(false, false); | |
| this.#renderChips(); | |
| this.#applyOrderAndDraw(); | |
| this.#notifyChange(); | |
| this.#saveState(); | |
| } | |
| #removeGroupColumn(colIndex) { | |
| this.#groupColumns = this.#groupColumns.filter( | |
| (c) => c !== colIndex | |
| ); | |
| if (this.#options.hideGroupedColumns) | |
| this.#dt.column(colIndex).visible(true, false); | |
| if (!this.#groupColumns.length) { | |
| this.#restorePageLength(); | |
| this.#collapsedKeys.clear(); | |
| } | |
| this.#renderChips(); | |
| this.#applyOrderAndDraw(); | |
| this.#notifyChange(); | |
| this.#saveState(); | |
| } | |
| #restorePageLength() { | |
| if (this.#previousPageLength !== null) { | |
| this.#dt.page.len(this.#previousPageLength); | |
| this.#previousPageLength = null; | |
| } | |
| } | |
| /** | |
| * Applies the datatables draw() method. When using server-side option | |
| * this will make a round-trip to server | |
| * @returns {void} | |
| */ | |
| #applyOrderAndDraw() { | |
| const order = this.#groupColumns.map((idx) => [idx, 'asc']); | |
| this.#dt.order(order).draw(); | |
| } | |
| /** | |
| * Dispatches the onChange callback to clients. | |
| * @returns {void} | |
| */ | |
| #notifyChange() { | |
| this.#options.onChange?.(this.#groupColumns.slice(), this.#dt); | |
| } | |
| #renderGroups() { | |
| const tbody = this.#dt.table().body(); | |
| tbody.querySelectorAll('tr.dt-group-row').forEach((tr) => tr.remove()); | |
| tbody.querySelectorAll('tr').forEach((tr) => { | |
| tr.style.display = ''; | |
| }); | |
| if (!this.#groupColumns.length) return; | |
| const dt = this.#dt; | |
| const indexes = dt | |
| .rows({ order: 'current', search: 'applied', page: 'all' }) | |
| .indexes() | |
| .toArray(); | |
| if (!indexes.length) return; | |
| const colSpan = dt.columns(':visible').count(); | |
| const levels = this.#groupColumns.length; | |
| let prevKeyParts = null; | |
| const collapseStack = []; | |
| for (let i = 0; i < indexes.length; i++) { | |
| const rowIdx = indexes[i]; | |
| const keyParts = this.#groupColumns.map((colIdx) => | |
| dt.cell(rowIdx, colIdx).data() | |
| ); | |
| let changedAt = prevKeyParts === null ? 0 : levels; | |
| if (prevKeyParts !== null) { | |
| for (let level = 0; level < levels; level++) { | |
| if (String(keyParts[level]) !== String(prevKeyParts[level])) { | |
| changedAt = level; | |
| break; | |
| } | |
| } | |
| } | |
| for (let level = changedAt; level < levels; level++) { | |
| const groupKey = | |
| keyParts | |
| .slice(0, level + 1) | |
| .map(String) | |
| .join('␟') + | |
| '␟L' + | |
| level; | |
| const count = this.#countGroupSize(indexes, i, level); | |
| const colIdx = this.#groupColumns[level]; | |
| const columnApi = dt.column(colIdx); | |
| const value = keyParts[level]; | |
| collapseStack.length = level; | |
| const isCollapsed = this.#collapsedKeys.has(groupKey); | |
| collapseStack[level] = isCollapsed; | |
| const ancestorsCollapsed = collapseStack | |
| .slice(0, level) | |
| .some(Boolean); | |
| const groupRow = this.#buildGroupRow({ | |
| level, | |
| colSpan, | |
| columnApi, | |
| value, | |
| count, | |
| groupKey, | |
| isCollapsed, | |
| }); | |
| if (ancestorsCollapsed) groupRow.style.display = 'none'; | |
| dt.row(rowIdx).node().before(groupRow); | |
| } | |
| const rowHidden = collapseStack.some(Boolean); | |
| if (rowHidden) dt.row(rowIdx).node().style.display = 'none'; | |
| prevKeyParts = keyParts; | |
| } | |
| } | |
| #countGroupSize(indexes, startPos, level) { | |
| const dt = this.#dt; | |
| const refParts = this.#groupColumns | |
| .slice(0, level + 1) | |
| .map((colIdx) => | |
| String(dt.cell(indexes[startPos], colIdx).data()) | |
| ); | |
| let count = 0; | |
| for (let i = startPos; i < indexes.length; i++) { | |
| const parts = this.#groupColumns | |
| .slice(0, level + 1) | |
| .map((colIdx) => String(dt.cell(indexes[i], colIdx).data())); | |
| if (parts.join('␟') !== refParts.join('␟')) break; | |
| count++; | |
| } | |
| return count; | |
| } | |
| #buildGroupRow({ | |
| level, | |
| colSpan, | |
| columnApi, | |
| value, | |
| count, | |
| groupKey, | |
| isCollapsed, | |
| }) { | |
| const label = | |
| typeof this.#options.groupLabel === 'function' | |
| ? this.#options.groupLabel(columnApi, value, count, level) | |
| : `${columnApi.title()}: ${value} (${count})`; | |
| const tr = document.createElement('tr'); | |
| tr.className = 'dt-group-row table-light'; | |
| tr.setAttribute('data-group-key', groupKey); | |
| tr.setAttribute('data-level', level); | |
| tr.style.setProperty('--dt-group-level', level); | |
| const td = document.createElement('td'); | |
| td.setAttribute('colspan', colSpan); | |
| const toggle = document.createElement('span'); | |
| toggle.className = 'dt-group-toggle'; | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.className = 'dt-group-label'; | |
| labelSpan.innerHTML = label; | |
| td.append(toggle, labelSpan); | |
| tr.append(td); | |
| if (this.#options.collapsible) { | |
| tr.classList.add('dt-group-collapsible'); | |
| if (isCollapsed) tr.classList.add('dt-group-collapsed'); | |
| tr.addEventListener('click', () => this.#toggleGroup(groupKey)); | |
| } | |
| return tr; | |
| } | |
| #toggleGroup(groupKey) { | |
| if (this.#collapsedKeys.has(groupKey)) | |
| this.#collapsedKeys.delete(groupKey); | |
| else this.#collapsedKeys.add(groupKey); | |
| this.#renderGroups(); | |
| if (this.#options.stateIncludeCollapsed) this.#saveState(); | |
| } | |
| /** | |
| * Returns the state key for state persistence. | |
| * @returns {string} The unique state key, built from the custom | |
| */ | |
| #stateKey() { | |
| if (this.#options.stateSaveKey) return this.#options.stateSaveKey; | |
| const tableId = this.#dt.table().node().id || 'dt'; | |
| return `groupable_${tableId}_${location.pathname}`; | |
| } | |
| /** | |
| * Build the state object for state persistence. | |
| * @returns {object} | |
| */ | |
| #buildStateObject() { | |
| return { | |
| columns: this.#groupColumns.map((idx) => ({ index: idx, title: this.#dt.column(idx).title() })), | |
| collapsed: this.#options.stateIncludeCollapsed ? Array.from(this.#collapsedKeys) : [] | |
| }; | |
| } | |
| /** | |
| * Applies the state object issued from persistence storage | |
| * to the dropzone container | |
| * @returns {void} | |
| */ | |
| #applyStateObject(state) { | |
| const totalCols = this.#dt.columns().count(); | |
| const columns = Array.isArray(state?.columns) ? state.columns : []; | |
| const indexes = columns | |
| .map((c) => (typeof c === 'object' && c !== null ? c.index : c)) | |
| .filter((idx) => Number.isInteger(idx) && idx >= 0 && idx < totalCols); | |
| if (this.#groupColumns.length && this.#options.hideGroupedColumns) { | |
| this.#groupColumns.forEach((idx) => this.#dt.column(idx).visible(true, false)); | |
| } | |
| this.#groupColumns = indexes; | |
| this.#collapsedKeys = new Set(Array.isArray(state?.collapsed) ? state.collapsed : []); | |
| if (this.#groupColumns.length) { | |
| if (this.#previousPageLength === null) | |
| this.#previousPageLength = this.#dt.page.len(); | |
| if (this.#options.hideGroupedColumns) { | |
| this.#groupColumns.forEach((idx) => this.#dt.column(idx).visible(false, false)); | |
| } | |
| } else { | |
| this.#restorePageLength(); | |
| } | |
| } | |
| /** | |
| * Saves the oject state to the persistence storage. | |
| * @returns {void} | |
| */ | |
| #saveState() { | |
| if (!this.#options.stateSave) return; | |
| const state = this.#buildStateObject(); | |
| if (typeof this.#options.stateSaveCallback === 'function') { | |
| try { | |
| Promise.resolve(this.#options.stateSaveCallback(state, this.#dt)).catch((err) => { | |
| console.error('groupable(): stateSaveCallback has failed to save.', err); | |
| }); | |
| } catch (err) { | |
| console.error('groupable(): stateSaveCallback has failed to save.', err); | |
| } | |
| return; | |
| } | |
| try { | |
| localStorage.setItem(this.#stateKey(), JSON.stringify(state)); | |
| } catch (err) { | |
| console.warn('groupable(): state could not be saved to localStorage.', err); | |
| } | |
| } | |
| /*** | |
| * Loads state from persistence storage. | |
| * @returns {void} | |
| */ | |
| async #loadState() { | |
| if (this.#options.stateSave) { | |
| try { | |
| let state; | |
| if (typeof this.#options.stateLoadCallback === 'function') { | |
| state = await this.#options.stateLoadCallback(this.#dt); | |
| } else { | |
| const raw = localStorage.getItem(this.#stateKey()); | |
| state = raw ? JSON.parse(raw) : null; | |
| } | |
| if (state) this.#applyStateObject(state); | |
| } catch (err) { | |
| console.error('groupable(): no se pudo cargar el estado guardado.', err); | |
| } | |
| } | |
| this.#renderChips(); | |
| if (this.#groupColumns.length) { | |
| this.#applyOrderAndDraw(); | |
| } else { | |
| this.#renderGroups(); | |
| } | |
| this.#notifyChange(); | |
| } | |
| } | |
| /** | |
| * Register public Groupable Api to DataTable.Api | |
| */ | |
| DataTable.Api.register('groupable()', function (options) { | |
| const settings = this.settings()[0]; | |
| settings._groupableInstance?.destroy(); | |
| settings._groupableInstance = new Groupable(this, options); | |
| return this; | |
| }); | |
| DataTable.Api.register('groupable.clear()', function () { | |
| this.settings()[0]._groupableInstance?.clear(); | |
| return this; | |
| }); | |
| DataTable.Api.register('groupable.destroy()', function () { | |
| const settings = this.settings()[0]; | |
| settings._groupableInstance?.destroy(); | |
| settings._groupableInstance = null; | |
| return this; | |
| }); |
评论
?
参与讨论