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
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 ...
#8: Post edited
- ### 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
- ### 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
- ### 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
- ### 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/
#2: Post edited
- ### 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
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/