Communities

Writing
Writing
Codidact Meta
Codidact Meta
The Great Outdoors
The Great Outdoors
Photography & Video
Photography & Video
Scientific Speculation
Scientific Speculation
Cooking
Cooking
Electrical Engineering
Electrical Engineering
Judaism
Judaism
Languages & Linguistics
Languages & Linguistics
Software Development
Software Development
Mathematics
Mathematics
Christianity
Christianity
Code Golf
Code Golf
Music
Music
Physics
Physics
Linux Systems
Linux Systems
Power Users
Power Users
Tabletop RPGs
Tabletop RPGs
Community Proposals
Community Proposals
tag:snake search within a tag
answers:0 unanswered questions
user:xxxx search by author id
score:0.5 posts with 0.5+ score
"snake oil" exact phrase
votes:4 posts with 4+ votes
created:<1w created < 1 week ago
post_type:xxxx type of post
Search help
Notifications
Mark all as read See all your notifications »
Code Reviews

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

+4
−0

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();
History
Why does this post require moderator attention?
You might want to add some details to your flag.
Why should this post be closed?

1 comment thread

CSS transitions? (2 comments)

0 answers

Sign up to answer this question »