Search code examples
javascriptcsscss-grid

Grid column span to take full width when remaining columns are empty


I'm working on a calendar element whereby the day events are utilising grid row to span over their specified time frame.

However, I seem to be struggling when it comes to setting the event widths. By default they will take up the full width of the day when it's the only event at that time. And if they are concurring events at any point they will reduce their widths accordingly so they can all fit.

My problem occurs when I have a group of events happening at the same time, which generates multiple columns, and then an event later in the day which doesn't have any coinciding events. This event is now limited to the widths of the columns created from the events earlier in the day.

See image for context: Image of annotated demo

See below for a working demo.

(function() {

  function init() {
    let calendarElement = document.getElementsByClassName('calendar')[0],
      events = calendarElement.getElementsByClassName('calendar__event');
    _positionEventsOnGrid(events);
  }

  function _positionEventsOnGrid(events) {
    Array.from(events).forEach(event => {
      let gridRow = event.getAttribute('data-grid-row');

      if (gridRow) {
        let gridRowSpan = event.getAttribute('data-grid-row-span');

        event.style.gridRow = gridRowSpan ? `${gridRow} / span ${gridRowSpan}` : gridRow;
      }
    });
  }

  init();
})();
body {
  margin: 0;
  block-size: 100vh;
  inline-size: 100vw;
}

.calendar {
  $block: &;
  inline-size: 100%;
  block-size: 100%;
  display: grid;
  grid-template-columns: 2.5rem 1fr;
  grid-template-rows: auto 1fr;
}

.calendar__dayNames {
  grid-row: 1;
  grid-column: 2;
  display: flex;
}

.calendar__dayName {
  flex-grow: 1;
  flex-basis: 0;
}

.calendar__schedule {
  grid-row: 2;
  grid-column: 1/span 2;
  display: grid;
  grid-template-columns: 2.5rem 1fr;
  border-block-start: 1px solid #000;
}

.calendar__timeline {
  display: grid;
  grid-template-rows: repeat(19, 1.375rem);
  gap: 0 0.625rem;
  grid-column: 1/ span 2;
  grid-row: 1;
  position: relative;
}

.calendar__timelineItem {
  display: flex;
  align-items: center;
  border-block-end: 1px dotted #222;
}

.calendar__timelineItem:nth-child(even) {
  border-block-end: 1px solid #000;
}

.calendar__timelineItem:nth-child(odd):after {
  display: inline;
  content: attr(data-time);
}

.calendar__dayEventsContainer {
  position: relative;
  grid-row: 1;
  grid-column: 2;
  display: flex;
}

.calendar__dayEvents {
  display: grid;
  grid-template-rows: repeat(19, 1.375rem);
  gap: 0 0.625rem;
  border-inline-start: 1px solid #000;
  flex-grow: 1;
  flex-basis: 0;
}

.calendar__event {
  background-color: #346DA8;
  border: none;
}
<div class="calendar">
  <div class="calendar__dayNames">
    <div class="calendar__dayName">Monday</div>
    <div class="calendar__dayName">Tuesday</div>
    <div class="calendar__dayName">Wednesday</div>
    <div class="calendar__dayName">Thursday</div>
    <div class="calendar__dayName">Friday</div>
  </div>
  <div class="calendar__schedule">
    <div class="calendar__timeline">
      <div class="calendar__timelineItem" data-time="09:00"></div>
      <div class="calendar__timelineItem" data-time="09:15"></div>
      <div class="calendar__timelineItem" data-time="09:30"></div>
      <div class="calendar__timelineItem" data-time="09:45"></div>
      <div class="calendar__timelineItem" data-time="10:00"></div>
      <div class="calendar__timelineItem" data-time="10:15"></div>
      <div class="calendar__timelineItem" data-time="10:30"></div>
      <div class="calendar__timelineItem" data-time="10:45"></div>
      <div class="calendar__timelineItem" data-time="11:00"></div>
      <div class="calendar__timelineItem" data-time="11:15"></div>
      <div class="calendar__timelineItem" data-time="11:30"></div>
      <div class="calendar__timelineItem" data-time="11:45"></div>
      <div class="calendar__timelineItem" data-time="12:00"></div>
      <div class="calendar__timelineItem" data-time="12:15"></div>
      <div class="calendar__timelineItem" data-time="12:30"></div>
      <div class="calendar__timelineItem" data-time="12:45"></div>
      <div class="calendar__timelineItem" data-time="13:00"></div>
      <div class="calendar__timelineItem" data-time="13:15"></div>
      <div class="calendar__timelineItem" data-time="13:30"></div>

    </div>
    <div class="calendar__dayEventsContainer">
      <div class="calendar__dayEvents">

        <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="6"></button>
        <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="3"></button>
        <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1"></button>
        <button type="button" class="calendar__event" data-grid-row="10" data-grid-row-span="1"></button>
      </div>
      <div class="calendar__dayEvents">
      </div>
      <div class="calendar__dayEvents">
        <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="6"></button>
        <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1"></button>
      </div>
      <div class="calendar__dayEvents">

      </div>
      <div class="calendar__dayEvents">
        <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1"></button>
      </div>
    </div>
  </div>
</div>

I have tried a few things: grid-column: 1/-1 but this will do it to all the events and therefore incorrectly format the concurrent events.

grid-auto-flow: dense however, this didn't seem to do the trick in my example.

grid-template-columns: repeat(auto-fit, minmax()), but the min value as part of the minmax needs to be a fixed size.

I do wonder whether I need to extend the positionEventsOnGrid() function so it checks whether 'grid-column: 1/-1' can be applied to specific events. If it isn't coinciding with other events at that time. However, I feel this might be quite complex so thought I'd ask around in case there was a simpler way.

I'm wanting singular events to span the full width of their day, regardless of whether there are concurrent events earlier or later in the day.


Solution

  • The grid columns in your solution are implicitly generated because your CSS does not explicitly set a column layout. See @Michael Benjamin's answer here.

    The problem is that numeric indexes (1 / -1) only work with explicit grids. In other words, grids with defined columns and rows. You're using an implicit grid, where columns and rows are automatically generated, as needed.

    event.style.gridColumn = 1 / -1; applied to a .calendar__event element stretches across all columns if you set grid-template-columns: repeat() to the CSS rule for .calendar__dayEvents container. This is similar to your comment "the min value as part of the minmax needs to be a fixed size."

    Overlapping and non-overlapping events

    I do wonder whether I need to extend the positionEventsOnGrid() function so it checks whether 'grid-column: 1/-1' can be applied to specific events. If it isn't coinciding with other events at that time. However, I feel this might be quite complex so thought I'd ask around in case there was a simpler way.

    I have not found a way around this. Fortunately, there are existing solutions on SO that can group overlapping time or integer intervals. In my calendar components, I've applied/adapted the function from GitHub that I posted on the linked SO post. The adaption is applied to the code snippet below.

    The following code snippet does the following:

    • Maintains your HTML but includes additional events for Tues and Thurs.
    • Maintains your CSS but includes additional CSS rules to set the background color for events with the custom data-type attribute.
    • Extends your JS.

    JS execution

    • Group overlapping/non-overlapping events for each event day container.
    • Event items for each day are laid out using event.style.gridRow.
    • The number of implicitly generated grid columns in each day's event grid container is retrieved using the built-in window function getComputedStyle(), as provided by this SO post: count columns in a responsive grid.
    • Apply the number of columns value to each day's .calendar__dayEvents grid container and set non-overlapping event items to event.style.gridColumn = 1 / -1;.
    • Alternately, the number of columns value can be set directly to each non-overlapping event item event.style.gridColumn = 1 / span ${gridColumnCount}; instead of setting the number of grid columns to the grid-template-columns event grid container. (See comments in code snippet.)

    (function() {
    
      function init() {
        //const calendarElement = document.getElementsByClassName('calendar')[0];
        //const events = calendarElement.getElementsByClassName('calendar__event');
        //_positionEventsOnGrid(events);
        const dayEventsElems = document.querySelectorAll('.calendar__dayEvents');
        _positionEventsOnGrid2(dayEventsElems);
      }
    
      /*
      function _positionEventsOnGrid(events) {
          Array.from(events).forEach(event => {
              let gridRow = event.getAttribute('data-grid-row');
    
              if (gridRow) {
                  let gridRowSpan = event.getAttribute('data-grid-row-span');
    
                  event.style.gridRow = gridRowSpan ? `${gridRow} / span ${gridRowSpan}` : gridRow;
              }
          });
      }
      */
    
      function _positionEventsOnGrid2(dayEventsElems) {
        if (!dayEventsElems) {
          return;
        }
    
        dayEventsElems.forEach(dayEventElem => {
          const events = dayEventElem.querySelectorAll('.calendar__event');
    
          // if there are no event elements in the
          // '.calendar__dayEvents' element then exit function.
          if (events.length === 0) {
            return;
          }
    
          // Grid layout using "implicit grid, where columns and rows
          // are automatically generated, as needed."
          // Source: https://stackoverflow.com/a/75250349
          // Also see: https://stackoverflow.com/a/73624201
    
          const overlappingGroups = getOverlappingEventGroups(events);
          overlappingGroups.forEach(group => {
            group.forEach(event => {
              event.style.gridRow = `${event.startRow} / ${event.endRow}`;
            });
          });
    
          // After the grid is laid out, count the number of columns
          // created using the built-in 'gridComputedStyle()'.
          // See the function 'getGridRowColCount()' below.
    
          const gridElem = events[0].closest('.calendar__dayEvents');
          // https://www.javascripttutorial.net/javascript-return-multiple-values
          const [gridRowCount, gridColumnCount] = getGridRowColCount(gridElem);
          gridElem.style.gridTemplateColumns = `repeat(${gridColumnCount}, 1fr)`;
          overlappingGroups.forEach(group => {
            // If the event group does not contain one entry,
            // then exit function.
            if (group.length !== 1) {
              return;
            }
            // Otherwise, explicitly set the 'gridColumn'
            // CSS style property to '1 / -1' so that the event
            // element spans all columns in the 'gridElem' grid.
            group[0].style.gridColumn = `1 / -1`;
            // Or set the span value and remove the code above that
            // sets the CSS property 'gridTemplateColumns'.
            //group[0].style.gridColumn = `1 / span ${gridColumnCount}`;
          });
        });
      }
    
      // Source: https://stackoverflow.com/questions/55204205/a-way-to-count-columns-in-a-responsive-grid
      function getGridRowColCount(grid) {
        const gridComputedStyle = window.getComputedStyle(grid);
    
        // get number of grid rows
        const gridRowCount = gridComputedStyle.getPropertyValue("grid-template-rows").split(" ").length;
    
        // get number of grid columns
        const gridColumnCount = gridComputedStyle.getPropertyValue("grid-template-columns").split(" ").length;
    
        //console.log(gridRowCount, gridColumnCount);
    
        // https://www.javascripttutorial.net/javascript-return-multiple-values
        return [gridRowCount, gridColumnCount];
      }
    
      /*
      https://stackoverflow.com/a/75486209
      Format of intervals array from:
      https://gist.githubusercontent.com/blasten/acfaafc8247e37abf23f/raw/c71f42428e34dbb7ff2d1b5e932fdf4152fe1ad2/group-intervals.js
      const intervals = [
          [2, 5],
          [5, 6],
          [3, 4],
          [7, 8],
          [6.5, 9],
          [10, 11.5]
      ];
    
      The function 'getOverlappingEventGroups()' returns a
      modified intervals array format containing '.calendar__event' 
      DOM elements with custom 'startRow' and 'endRow' properties.
      */
      function getOverlappingEventGroups(events) {
        const eventIntervals = [];
    
        events.forEach(event => {
          const gridRow = event.getAttribute('data-grid-row');
          const gridRowSpan = event.getAttribute('data-grid-row-span');
          if (isNaN(gridRow) || isNaN(gridRowSpan)) {
            return;
          }
          const startRow = parseInt(gridRow, 10);
          const endRow = startRow + parseInt(gridRowSpan, 10);
          event.startRow = startRow;
          event.endRow = endRow;
          eventIntervals.push(event);
        });
    
        const eventGroups = groupOverlapingEventIntervals(eventIntervals);
        //console.log("Overlapping event groups: ", eventGroups);
    
        return eventGroups;
      }
    
      // Source: https://gist.githubusercontent.com/blasten/acfaafc8247e37abf23f/raw/c71f42428e34dbb7ff2d1b5e932fdf4152fe1ad2/group-intervals.js
      function groupOverlapingEventIntervals(eventIntervals) {
        eventIntervals.sort((a, b) => a.startRow - b.startRow);
    
        const groups = [
          [eventIntervals[0]]
        ];
    
        let j = 0;
        let end = eventIntervals[0].endRow;
    
        for (let i = 1; i < eventIntervals.length; i++) {
          if (eventIntervals[i].startRow <= end) {
            if (eventIntervals[i].endRow > end) {
              end = eventIntervals[i].endRow;
            }
            groups[j].push(eventIntervals[i]);
          } else {
            groups.push([eventIntervals[i]]);
            j++;
            end = eventIntervals[i].endRow;
          }
        }
        return groups;
      }
    
      init();
    })();
    body {
      margin: 0;
      block-size: 100vh;
      inline-size: 100vw;
    }
    
    .calendar {
      inline-size: 100%;
      block-size: 100%;
      display: grid;
      grid-template-columns: 2.5rem 1fr;
      grid-template-rows: auto 1fr;
    }
    
    .calendar__dayNames {
      grid-row: 1;
      grid-column: 2;
      display: flex;
    }
    
    .calendar__dayName {
      flex-grow: 1;
      flex-basis: 0;
    }
    
    .calendar__schedule {
      grid-row: 2;
      grid-column: 1/span 2;
      display: grid;
      grid-template-columns: 2.5rem 1fr;
      border-block-start: 1px solid #000;
    }
    
    .calendar__timeline {
      display: grid;
      grid-template-rows: repeat(19, 1.375rem);
      gap: 0 0.625rem;
      grid-column: 1/ span 2;
      grid-row: 1;
      position: relative;
    }
    
    .calendar__timelineItem {
      display: flex;
      align-items: center;
      border-block-end: 1px dotted #222;
    }
    
    .calendar__timelineItem:nth-child(even) {
      border-block-end: 1px solid #000;
    }
    
    .calendar__timelineItem:nth-child(odd):after {
      display: inline;
      content: attr(data-time);
    }
    
    .calendar__dayEventsContainer {
      position: relative;
      grid-row: 1;
      grid-column: 2;
      display: flex;
    }
    
    .calendar__dayEvents {
      display: grid;
      grid-template-rows: repeat(19, 1.375rem);
      gap: 0 0.625rem;
      border-inline-start: 1px solid #000;
      flex-grow: 1;
      flex-basis: 0;
    }
    
    .calendar__event {
      background-color: #346DA8;
      border: none;
    }
    
    
    /* Color-coded event elements */
    
    .calendar__event[data-type=aaa] {
      background-color: rgb(243 165 97 / 80%);
    }
    
    .calendar__event[data-type=bbb] {
      background-color: rgb(80 142 77 / 80%);
    }
    
    .calendar__event[data-type=ccc] {
      background-color: rgb(228 53 53 / 80%);
    }
    
    .calendar__event[data-type=ddd] {
      background-color: rgb(96 177 218 / 80%);
    }
    <div class="calendar">
      <div class="calendar__dayNames">
        <div class="calendar__dayName">Monday</div>
        <div class="calendar__dayName">Tuesday</div>
        <div class="calendar__dayName">Wednesday</div>
        <div class="calendar__dayName">Thursday</div>
        <div class="calendar__dayName">Friday</div>
      </div>
      <div class="calendar__schedule">
        <div class="calendar__timeline">
          <div class="calendar__timelineItem" data-time="09:00"></div>
          <div class="calendar__timelineItem" data-time="09:15"></div>
          <div class="calendar__timelineItem" data-time="09:30"></div>
          <div class="calendar__timelineItem" data-time="09:45"></div>
          <div class="calendar__timelineItem" data-time="10:00"></div>
          <div class="calendar__timelineItem" data-time="10:15"></div>
          <div class="calendar__timelineItem" data-time="10:30"></div>
          <div class="calendar__timelineItem" data-time="10:45"></div>
          <div class="calendar__timelineItem" data-time="11:00"></div>
          <div class="calendar__timelineItem" data-time="11:15"></div>
          <div class="calendar__timelineItem" data-time="11:30"></div>
          <div class="calendar__timelineItem" data-time="11:45"></div>
          <div class="calendar__timelineItem" data-time="12:00"></div>
          <div class="calendar__timelineItem" data-time="12:15"></div>
          <div class="calendar__timelineItem" data-time="12:30"></div>
          <div class="calendar__timelineItem" data-time="12:45"></div>
          <div class="calendar__timelineItem" data-time="13:00"></div>
          <div class="calendar__timelineItem" data-time="13:15"></div>
          <div class="calendar__timelineItem" data-time="13:30"></div>
    
        </div>
        <div class="calendar__dayEventsContainer">
          <div class="calendar__dayEvents">
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="6"></button>
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="3"></button>
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1"></button>
            <button type="button" class="calendar__event" data-grid-row="10" data-grid-row-span="1"></button>
          </div>
          <div class="calendar__dayEvents">
            <button type="button" class="calendar__event" data-grid-row="3" data-grid-row-span="7" data-type="bbb"></button>
            <button type="button" class="calendar__event" data-grid-row="12" data-grid-row-span="3" data-type="aaa"></button>
            <button type="button" class="calendar__event" data-grid-row="14" data-grid-row-span="2" data-type="ccc"></button>
          </div>
          <div class="calendar__dayEvents">
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="6"></button>
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1"></button>
          </div>
          <div class="calendar__dayEvents">
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="6" data-type="aaa"></button>
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="3" data-type="bbb"></button>
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1" data-type="aaa"></button>
            <button type="button" class="calendar__event" data-grid-row="2" data-grid-row-span="1" data-type="ccc"></button>
            <button type="button" class="calendar__event" data-grid-row="2" data-grid-row-span="4" data-type="bbb"></button>
            <button type="button" class="calendar__event" data-grid-row="10" data-grid-row-span="1" data-type="ddd"></button>
            <button type="button" class="calendar__event" data-grid-row="14" data-grid-row-span="3" data-type="ddd"></button>
          </div>
          <div class="calendar__dayEvents">
            <button type="button" class="calendar__event" data-grid-row="1" data-grid-row-span="1"></button>
          </div>
        </div>
      </div>
    </div>

    Update (9/26/2023)

    Added image with annotations as an alternative code execution description.

    enter image description here

    • Iterate .calendar__dayEvents div containers (that is, each day-of-week column outlined in yellow).
    • Within each day-of-week column, group .calendar__event elements with overlapping intervals (outlined in red boxes).
    • Lay out all .calendar__event elements within the .calendar__dayEvents div container/day-of-week column.
    • Retrieve number of columns within day-of-week column.
    • Apply number of columns value directly to the .calendar__event element in overlap groups with only 1 .calendar__event entry
    • OR Apply number of columns to .calendar__dayEvents div container and set .calendar__event element in overlap groups with only 1 .calendar__event entry to span all columns (via style.gridColumn = 1 / -1).