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.

Post History

75%
+4 −0
Code Reviews 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 ...

0 answers  ·  posted 1y ago by berrie‭  ·  edited 1y ago by berrie‭

#9: Nominated for promotion by user avatar Alexei‭ · 2024-03-02T07:47:56Z (10 months ago)
#8: Post edited by user avatar berrie‭ · 2023-03-30T14:41:12Z (over 1 year ago)
removed leftover base-1 indexing, for clarity
  • ### 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()`][2]. See animation [requirements][3]:
  • >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][1]), 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
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • [2]: https://developer.mozilla.org/en-US/docs/Web/CSS/repeat
  • [3]: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#formal_definition
  • ### 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()`][2]. See animation [requirements][3]:
  • >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][1]), 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
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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]: https://jsfiddle.net/0jfktdxu/
  • [2]: https://developer.mozilla.org/en-US/docs/Web/CSS/repeat
  • [3]: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#formal_definition
#7: Post edited by user avatar berrie‭ · 2023-03-28T08:19:29Z (over 1 year ago)
  • ### 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()`][2]. See animation [requirements][3]:
  • >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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • [2]: https://developer.mozilla.org/en-US/docs/Web/CSS/repeat
  • [3]: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#formal_definition
  • ### 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()`][2]. See animation [requirements][3]:
  • >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][1]), 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
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • [2]: https://developer.mozilla.org/en-US/docs/Web/CSS/repeat
  • [3]: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#formal_definition
#6: Post edited by user avatar berrie‭ · 2023-03-28T08:16:42Z (over 1 year ago)
attempt to clarify rationale
  • ### 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 using any type of supported unit or function. However, animated transitions do not work with e.g. the `fr` unit, nor do they work if we use e.g. `repeat`. 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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • ### 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()`][2]. See animation [requirements][3]:
  • >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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • [2]: https://developer.mozilla.org/en-US/docs/Web/CSS/repeat
  • [3]: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#formal_definition
#5: Post edited by user avatar berrie‭ · 2023-03-22T07:18:45Z (over 1 year ago)
mention html etc. early in text
  • ### Goal
  • Our goal is to create a kind of table 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 using any type of supported unit or function. However, animated transitions do not work with e.g. the `fr` unit, nor do they work if we use e.g. `repeat`. 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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • ### 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 using any type of supported unit or function. However, animated transitions do not work with e.g. the `fr` unit, nor do they work if we use e.g. `repeat`. 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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
#4: Post edited by user avatar berrie‭ · 2023-03-22T07:16:57Z (over 1 year ago)
shortened title
  • How to improve upon this grid with expanding cells?
  • CSS grid with expanding cells
#3: Nominated for promotion by user avatar Alexei‭ · 2023-03-21T19:42:30Z (over 1 year ago)
#2: Post edited by user avatar berrie‭ · 2023-03-21T10:33:04Z (over 1 year ago)
  • ### Goal
  • Our goal is to create a kind of table 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
  • - dynamic grid creation (variable number of rows/columns)
  • - must work with CSS grids defined using `fr`, `repeat`, etc.
  • - 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 using any type of supported unit or function. However, animated transitions do not work with e.g. the `fr` unit, nor do they work if we use e.g. `repeat`. 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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
  • ### Goal
  • Our goal is to create a kind of table 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 using any type of supported unit or function. However, animated transitions do not work with e.g. the `fr` unit, nor do they work if we use e.g. `repeat`. 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][1]), 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 html `table`?
  • Any constructive criticism is very welcome.
  • #### HTML
  • ```html
  • <div id="id_container">
  • <div id="id_expanding_grid">
  • <!-- grid items are created using javascript -->
  • </div>
  • </div>
  • ```
  • #### CSS
  • ```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
  • ```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 > 1) {
  • 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 - 1);
  • 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 + 1 === 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 + 1, j + 1, 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 > 2) {
  • /* skip cell 1,1 */
  • item.classList.add('xg-clickable');
  • /* distinguish row/column headers from normal cells */
  • if (row > 1 && col > 1) {
  • item.classList.add('xg-cell');
  • } else {
  • if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  • }
  • }
  • expandingGrid.resetGridStyle();
  • ```
  • [1]: https://jsfiddle.net/0jfktdxu/
#1: Initial revision by user avatar berrie‭ · 2023-03-21T10:32:16Z (over 1 year ago)
How to improve upon this grid with expanding cells?
### Goal
Our goal is to create a kind of table 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
- dynamic grid creation (variable number of rows/columns)
- must work with CSS grids defined using `fr`, `repeat`, etc.
- 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 using any type of supported unit or function. However, animated transitions do not work with e.g. the `fr` unit, nor do they work if we use e.g. `repeat`. 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][1]), 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 html `table`? 

Any constructive criticism is very welcome.

#### HTML

```html
<div id="id_container">
  <div id="id_expanding_grid">
    <!-- grid items are created using javascript -->
  </div>
</div>
```

#### CSS
```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
```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 > 1) {
        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 - 1);
        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 + 1 === 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 + 1, j + 1, 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 > 2) {
      /* skip cell 1,1 */
      item.classList.add('xg-clickable');
      /* distinguish row/column headers from normal cells */
      if (row > 1 && col > 1) {
        item.classList.add('xg-cell');
      } else {
        if (row === 1) 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 + 1, j + 1, [i + 1, j + 1].join(','));
  }
}
expandingGrid.resetGridStyle();
```

[1]: https://jsfiddle.net/0jfktdxu/