Welcome to Software Development on Codidact!
Will you help us build our independent community of developers helping developers? We're small and trying to grow. We welcome questions about all aspects of software development, from design to code to QA and more. Got questions? Got answers? Got code you'd like someone to review? Please join us.
CSS grid with expanding cells
Goal
Our goal is to create a kind of table, using HTML/CSS/JS, with cells that expand when you click on them. If you click on a header, the whole row or column will expand.
This in itself is not so difficult to achieve using e.g. CSS-grid and a little bit of JavaScript, but there's an additional requirement that complicates matters: the expansion (and collapse) must be animated.
Requirements
- grid expansion/collapse must be animated
- must work with CSS grids defined using
fr
,repeat
, etc. - dynamic grid creation (variable number of rows/columns)
- vanilla JS
- primarily oriented at desktop sites, but proper mobile/responsiveness would be a bonus
- code must be easy to understand, rather than optimized for performance
Approach
The approach adopted here is to modify the values of grid-template-rows
and grid-template-columns
dynamically, and specify transition
to achieve animated transitions.
Rationale
We allow grid-template-*
definitions (rows/columns) using any type of supported unit or notation. However, animated transitions break down if we use notations like repeat()
. See animation requirements:
simple list of length, percentage, or calc, provided the only differences are in the values of the length, percentage, or calc components in the list
For this reason we let the browser create the initial CSS grid, then use the computed styles (in px
) to override the style on the element. When a grid item is clicked, we build new grid-template-*
strings, and update the element style accordingly.
This is where most of the complexity comes from.
Code
The example below works (fiddle), mostly, but it looks a bit messy to me.
I suppose there must be a better (cleaner) way to achieve what we're after? Perhaps using another technology, e.g. flex
, or just plain html table
?
Any constructive criticism would be very welcome.
HTML
<div id="id_container">
<div id="id_expanding_grid">
<!-- grid items are created using javascript -->
</div>
</div>
CSS
/* grid definition example */
#id_expanding_grid {
display: grid;
grid-template-rows: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
row-gap: 10px;
column-gap: 10px;
background-color: lightgray;
}
/* backgrounds etc. are just for show */
#id_container {
max-width: 800px;
}
.xg-cell {
background-color: lightcoral;
}
.xg-header {
background-color: cornflowerblue;
}
.xg-expanded {
background-color: mediumpurple;
}
.xg-clickable {
cursor: pointer;
}
JavaScript
class ExpandingGrid {
constructor(gridId, transition) {
this.transition = transition;
this.grid = document.getElementById(gridId);
this.computedStyle = window.getComputedStyle(this.grid);
this.rowCount = null;
this.columnCount = null;
this.defaults = {};
this.expandedItemSize = {};
this.timer = null;
this.debounceTimeOut = 100; // ms
this.initialize();
}
initialize() {
this.addResizeListener();
this.grid.addEventListener('click', this.toggleExpanded.bind(this)); // or use arrow function to bind 'this': (event) => this.toggleExpanded(event));
this.rowCount = this.computedStyle.gridTemplateRows.split(' ').length;
this.columnCount = this.computedStyle.gridTemplateColumns.split(' ').length;
this.populate();
this.resetGridStyle();
this.updateExpandedItemSize();
}
toggleExpanded(event) {
/* determine correct target even if we clicked on a child element */
let target = event.target.closest('.xg-clickable');
/* expand or collapse */
if (target) {
if (target.classList.contains('xg-expanded')) {
this.collapse();
} else {
this.expand(target);
}
}
}
collapse() {
/* collapse expanded items (reset everything to default) */
for (const item of document.querySelectorAll('.xg-expanded')) {
item.classList.remove('xg-expanded');
}
this.applyDefaults();
}
expand(target) {
/* expand specified grid item */
let index;
let gapSize;
let gapName;
let templateName;
target.classList.add('xg-expanded');
for (const dim of ['row', 'column']) {
index = parseInt(target.getAttribute(`data-${dim}`));
if (index > 0) {
templateName = `grid-template-${dim}s`;
gapName = `${dim}-gap`;
this.grid.style.setProperty(
templateName,
ExpandingGrid.buildExpandedTemplate(
this[dim + 'Count'],
index,
this.expandedItemSize[dim],
this.defaults[templateName].split(' ')[0],
)
);
/* adjust gap dynamically (grid items with zero size still
have a nonzero gap, so the gap needs to become smaller) */
gapSize = parseFloat(this.defaults[gapName]) / index;
this.grid.style.setProperty(gapName, gapSize + 'px');
}
}
}
static buildExpandedTemplate(count, index, expandedSize, headerSize) {
/* construct new grid template string, e.g. '100px 0px 50px 0px' */
const templateItems = [headerSize];
for (let i = templateItems.length; i < count; i++) {
templateItems.push((i === index) ? expandedSize : '0px');
}
return templateItems.join(' ');
}
populate() {
/* create all grid items */
for (let i = 0; i < this.rowCount; i++) {
for (let j = 0; j < this.columnCount; j++) {
this.grid.appendChild(
ExpandingGrid.createGridItem(i, j, this.transition));
}
}
}
static createGridItem(row, col, transition) {
/* create a single grid item with appropriate attributes and classes */
const item = document.createElement('div');
item.setAttribute('data-row', row.toString());
item.setAttribute('data-column', col.toString());
/* add classes */
item.classList.add('xg-item');
if (row + col > 0) {
/* skip cell 0,0 */
item.classList.add('xg-clickable');
/* distinguish row/column headers from normal cells */
if (row > 0 && col > 0) {
item.classList.add('xg-cell');
} else {
if (row === 0) item.classList.add('xg-column');
else item.classList.add('xg-row');
item.classList.add('xg-header');
}
}
/* essential styles */
ExpandingGrid.setEssentialStyles(item, transition);
return item;
}
setContent(row, col, innerHTML) {
/* returns the item to which content was added */
const item = this.grid.querySelector(`[data-row="${row}"][data-column="${col}"]`);
const container = document.createElement('div');
container.classList.add('xg-content-container');
container.innerHTML = innerHTML;
item.appendChild(container);
return item;
}
resetGridStyle() {
if (document.querySelector('.xg-expanded')) this.collapse();
this.grid.removeAttribute('style');
// reset styles accordingly
this.updateDefaults();
this.applyDefaults();
this.updateExpandedItemSize();
/* use current grid size as maximum to prevent grid re-sizing
due to finite gaps */
this.grid.style.maxWidth = this.computedStyle.width;
this.grid.style.maxHeight = this.computedStyle.height;
/* essential styles */
ExpandingGrid.setEssentialStyles(this.grid, this.transition);
}
static setEssentialStyles(element, transition) {
/* animate grid transitions and hide content from collapsed cells */
if (transition) element.style.transition = transition;
element.style.overflow = 'hidden';
}
updateDefaults() {
/* these need to be set after the grid has been initialized, so the
computed grid styles are available (in px instead of e.g. fr) */
this.computedStyle = window.getComputedStyle(this.grid);
this.defaults['grid-template-rows'] = this.computedStyle.gridTemplateRows;
this.defaults['grid-template-columns'] = this.computedStyle.gridTemplateColumns;
this.defaults['row-gap'] = this.computedStyle.rowGap;
this.defaults['column-gap'] = this.computedStyle.columnGap;
}
applyDefaults() {
/* note it would be much simpler just to remove the inline
style using e.g. grid.removeAttribute('style'), but that
would break the animated grid transitions */
this.grid.style.gridTemplateRows = this.defaults['grid-template-rows'];
this.grid.style.gridTemplateColumns = this.defaults['grid-template-columns'];
this.grid.style.rowGap = this.defaults['row-gap'];
this.grid.style.columnGap = this.defaults['column-gap'];
}
updateExpandedItemSize() {
/* determine the required sizes for expanded row and column */
for (const [dim, propName] of Object.entries({
row: 'height',
column: 'width'
})) {
const gridSize = parseInt(this.computedStyle.getPropertyValue(propName));
const header = document.querySelector((dim === 'row') ? '.xg-column' : '.xg-row');
const headerStyle = window.getComputedStyle(header);
const headerSize = parseInt(headerStyle.getPropertyValue(propName));
const gapSize = parseInt(this.defaults[dim + '-gap']);
const size = gridSize - headerSize - gapSize;
this.expandedItemSize[dim] = size + 'px';
}
}
addResizeListener() {
/* reset grid when window is resized */
window.addEventListener('resize', (event) => {
/* if there are multiple resize events within the debounce
timeout, the timer is cleared before it can run. only the last
resize event will cause the callback to run */
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(this.resetGridStyle.bind(this), this.debounceTimeOut, event);
})
}
}
/* instantiate */
const expandingGrid = new ExpandingGrid('id_expanding_grid', '500ms');
/* set some dummy content */
for (let i = 0; i < expandingGrid.rowCount; i++) {
for (let j = 0; j < expandingGrid.columnCount; j++) {
expandingGrid.setContent(i, j, [i, j].join(','));
}
}
expandingGrid.resetGridStyle();
1 comment thread