Skip to content

Instantly share code, notes, and snippets.

@danielfdsilva
Created March 31, 2022 15:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save danielfdsilva/471a8d32c6c6918f837d68856fc46628 to your computer and use it in GitHub Desktop.
Save danielfdsilva/471a8d32c6c6918f837d68856fc46628 to your computer and use it in GitHub Desktop.
🤗 HUG - Human Universal Gridder

🤗 Human Universal Gridder (Hug)

The Human Universal Gridder (Hug) is a layout component with two main purposes:

  1. Create a grid that remains centered on the page and has leading and trailing columns of varying size.
  2. Handle subgrids when a Hug is nested within another Hug.

Human Universal Gridder's grid definition

The image above shows the grid that gets created when a Hug is used. The number of columns varies according to the screen size:

  • Small screens: 4 columns
  • Medium screens: 8 columns
  • Large screens: 12 columns

Hug responsiveness in action:

ug-responsive.mp4
Code
const ExampleHug = styled(Hug)`
  margin-top: 5rem;

  > p {
    overflow: hidden;
    padding: 5rem 0;
    text-align: center;
    background: blanchedalmond;
  }

  .leading {
    grid-column: full-start/content-start;
  }

  .gridder {
    grid-column: content-start/content-end;
    background: aliceblue;
  }

  .trailing {
    grid-column: content-end/full-end;
  }
`;

<ExampleHug>
  <p className='leading'>Leading Column</p>
  <p className='gridder'>Universal Gridder</p>
  <p className='trailing'>Trailing Column</p>
</ExampleHug>

As you can see from the video, the grid will always be centered on the page (with a maximum width bound to the theme property layout.max), the leading/trailing columns will take up the rest of the space and will shrink until they disappear.
The centered grid will also always have a buffer from the side of the page which is something that does not exist in a traditional css grid.

This approach allows the creation of complex and interesting element placement. An example is a block that would be "bleeding" out of the page content (common with images).

Elements in a Human Universal Gridder

The underlying tech of the Human Universal Gridder is a normal css grid, albeit one with some complex calculations.
We're taking advantage of the ability of naming grid lines in css' grid-template-columns, to allow you to easily define start and end positions. Therefore whenever an element is placed inside a Hug you have to define the grid placement of this element using css: grid-column: <start>/<end>.

If you need a refresher on css grids check Css Trick's A Complete Guide to Grid.

You have to use the actual line names with Hug as something like span 2 will cause unexpected behaviors.
You can check the image at the beginning for a visual understanding of the grid lines, but here's the full list:

full-start
content-start
content-2
content-3
content-4
content-5
content-6
content-7
content-8
content-9
content-10
content-11
content-12
content-end
full-end

Note: Even though the line name content-1 does not exist, it is the same as content-start. We considered it a better expertience to have consistent start and end names for the content (content-start/content-end).

Caveat: Lines content-5 though content-12 will exist depending on the media query. For example, for small screens you'll have full-start, content-start, content-2, content-3, content-4, content-end, full-end.

Nested Hug

The beauty of the Hug really shines where they are nested.

Nested Human Universal Gridder

Whenever you nest a Hug inside another, you also have to specify the grid placement, but instead of doing it with css, you must do it with a component prop (grid) and specify the grid position for the different media breakpoints. This is needed so that the subgrid calculations are done properly.

A nested Hug will retain the same columns and spacing as its parent. For example, in the image above the element with a darker green, is placed in the grid lines content-2 to content-9, and its grid is comprised of the subgrid with the same columns.

For context, the available breakpoints are xsmallUp | smallUp | mediumUp | largeUp | xlargeUp.

Example:

<Hug>
  {/*
    This first element will start occupying the full grid (full-start/full-end),
    then at the mediumUp breakpoint will go from content-start to the end of the
    third column (grid line content-4), and on large screens will take up 3
    columns, from content-2 to content-5
  */}
  <Hug
    grid={{
      // Below the smallUp breakpoint full-start/full-end is used by default.
      smallUp: ['full-start', 'full-end'],
      mediumUp: ['content-start', 'content-4'],
      largeUp: ['content-2', 'content-5']
    }}
  >
    <p>Content</p>
  </Hug>
  <Hug
    grid={{
      smallUp: ['full-start', 'full-end'],
      // The mediumUp breakpoint is not defined, so the previous available one
      // (smallUp here) is used until we reach the next one.
      largeUp: ['content-6', 'full-end']
    }}
  >
    <p>Content</p>
  </Hug>
  <Hug grid={['full-start', 'full-end']}>
    <p>Always full-start/full-end</p>
  </Hug>
</Hug>

Usage details

Nested Hug needs to be a direct descendant of another Hug

Much like with css grids, an element can only be positioned in a grid if its parent has a grid.

// ❌  Bad
<Hug>
  <div>
    <Hug></Hug>
  </div>
</Hug>

// ✅  Good
<Hug>
  <Hug>
    <div></div>
  </Hug>
</Hug>

Hug's grid prop

Hug should only use a grid prop if it is a nested Hug. It uses the provided values to compute its grid in relation to the parent.
The top level Hug does not need to compute anything in relation to a parent and therefore does not need a grid.

// ❌  Bad
<body>
  <Hug grid={{ smallUp: ['content-start', 'content-end'] }}>
    <Hug grid={{ smallUp: ['content-start', 'content-2'] }}></Hug>
  </Hug>
</body>

// ✅  Good
<body>
  <Hug>
    <Hug grid={{ smallUp: ['content-start', 'content-2'] }}></Hug>
  </Hug>
</body>

Contents inside Hug

Hug should be used as a structural layout element and not have inline elements as a direct descendants.
After having a grid defined you should use a block element (div, section, etc) to position your content. Since Hug provides a grid, the positioning of these elements should be done with css as if it were a normal grid.

// ❌  Bad
<Hug>
  <Hug grid={{ smallUp: ['content-start', 'content-2'] }}>Some content</Hug>
  <Hug grid={{ smallUp: ['content-2', 'content-4'] }}>More content</Hug>
</Hug>

// ✅  Good
// Using inline styles only for example purposes. Styling should be done with
// styled-components to account for media queries.
<Hug>
  <div style={{ gridColumn: 'content-start/content-2' }}>Some content</div>
  <div style={{ gridColumn: 'content-2/content-4' }}>More content</div>
</Hug>

A good rule of thumb to decide whether or not to nest Hug is to think if you need your content to have columns.
For example if you need your content to start at the middle of the page with a background that starts at beginning you'd do something like:

<Hug>
  <Hug
    grid={{ largeUp: ['content-start', 'content-end'] }}
    style={{ background: 'red' }}
  >
    <div style={{ gridColumn: 'content-6/content-end' }}>Some content</div>
  </Hug>
</Hug>

The parent Hug provides the main grid. The nested Hug has the background, and the div child positions the content.

Auto-placement of child items

The CSS Grid Layout specification contains rules that control what happens when you create a grid and do not specify a position for the child elements.
The default behavior it to put an element inside each column, which means that when using a Hug with a grid full-start/full-end the fluid columns will also get an element.

The following code:

// ❌  Bad
<Hug>
  {Array(14).fill(0).map((e, idx) => {
    return (
      <div style={{ backgroundColor: 'skyblue', padding: '.5rem' }}>
        column {idx + 1}
      </div>
    );
  })}
</Hug>

Will lead to this following result which looks good, but you probably want your auto-placed items to all have the same size:

Grid auto placement

However, this placement will become a problem when you screen size gets smaller:

Grid auto placement error

This happens because, even though the fluid columns now have a size of 0 they are still columns and are not skipped.

The easiest way to fix this (while still using auto-placement) is to add a nested Hug, which only takes up the content-start/content-end grid:

// ✅  Good
<Hug>
  <Hug grid={{ xsmallUp: ['content-start', 'content-end'] }}>
    {Array(14).fill(0).map((e, idx) => {
      return (
        <div style={{ backgroundColor: 'skyblue', padding: '.5rem' }}>
          column {idx + 1}
        </div>
      );
    })}
  </Hug>
</Hug>

Grid auto placement fix

/* eslint-disable prettier/prettier */
import styled, {
css,
DefaultTheme,
ThemedCssFunction
} from 'styled-components';
import { themeVal, media } from '@devseed-ui/theme-provider';
// Similar behavior to glsp but varies according to the current media query. The
// layout.space is scaled taking into account the multipliers in
// layout.glspMultiplier. Parameters can be provided to further scale the value.
import { variableGlsp } from '../variable-utils';
// 🤗 Human Universal Gridder
//
// Grid:
// start 1 2 3 4 5 6 7 8 9 10 11 12 end
// | |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |
// | |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |
// | |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |
// | |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |
// | |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |*| |
//
// The start and end take up 1 fraction and its size is fluid, depending on
// window size.
// Each column takes up a 12th of the max content width (defined in the theme).
// Grid gaps are marked with an asterisk.
// Each instance of Human Universal Gridder, nested inside another Human
// Universal Gridder must define its grid for the different media queries,
// through a grid prop.
// If the grid for a given media query is not defined the previous one will be
// used (<media query>Up pattern).
// The value for each media query breakpoint is an array with a start and an end
// column. It works much like the `grid-column` property of css.
// <Hug>
// <Hug
// grid={{
// smallUp: ['full-start', 'full-end'],
// mediumUp: ['content-2', 'content-4'],
// largeUp: ['content-2', 'content-5'],
// }}
// >
// Subgrid 1
// </Hug>
// <Hug
// grid={{
// smallUp: ['full-start', 'full-end'],
// // mediumUp is not defined, so smallUp will be used until largeUp.
// largeUp: ['content-6', 'full-end'],
// }}
// >
// Subgrid 2
// </Hug>
// </Hug>
//
// The Human Universal Gridder will define a grid whose line names are always
// the same regardless of how many nested grids there are. Therefore an element
// placed on `content-5` will be aligned with the top most `content-5`.
// Line names to be used on the grid.
// In a css grid, the lines are named, not the columns.
const gridLineNames = [
'full-start',
'content-start',
// content-2 to content-12
// content-1 does not exist as it is named content-start
'content-2',
'content-3',
'content-4',
'content-5',
'content-6',
'content-7',
'content-8',
'content-9',
'content-10',
'content-11',
'content-12',
'content-end',
'full-end'
];
// List of media queries from the smallest to the largest.
const mdQueryOrdered = ['xsmall', 'small', 'medium', 'large', 'xlarge'];
// Util from https://stackoverflow.com/a/49725198
// At least one key required in object.
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
T,
Exclude<keyof T, Keys>
> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
// Type from array
// https://steveholgado.com/typescript-types-from-arrays/
type MdQuery = typeof mdQueryOrdered[number];
type GridLines = typeof gridLineNames[number];
type GridderRange = [GridLines, GridLines];
// Remap the keys to <name>Up by creating an interface and then get the keys to have a union type.
type MdQueryUp = keyof { [K in MdQuery as `${K}Up`]: true };
type GridderDefinition = {
[K in MdQueryUp]?: GridderRange;
};
interface HugProps {
// Remap the keys
// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
readonly grid?:
| RequireAtLeastOne<GridderDefinition, MdQueryUp>
| GridderRange;
}
/**
* Check that the provided range is in the expected format which is an array
* with the start and end grid lines.
* For example: ['full-start', 'full-end']
*
* @throws Error if the value is not in correct format or names are invalid.
*
* @param {any} cols The grid range to validate
*/
const validateGridLineNames = (cols) => {
let error = '';
if (!Array.isArray(cols) || cols.length !== 2) {
error = `The grid definition format is not valid. Please use an array defining the start and end range. Example:
['full-start', 'full-end']`;
}
if (cols.some((v) => gridLineNames.indexOf(v) === -1)) {
error = `The grid line names not valid. Please provide a valid name for the grid definition`;
}
// There was an error. Show the user info for debugging.
if (error) {
throw new Error(`🤗 Human Universal Gridder
${JSON.stringify(cols)}
${error}`);
}
};
/**
* Creates the universal grid for this component.
*
* @param {number} columns Number of columns for the grid.
* @param {string} mdQuery Media query at which this grid is shown
* @returns css
*/
function makeGrid(columns: number, mdQuery: MdQuery) {
return ({ grid }: HugProps) => {
const gridGap = variableGlsp();
const layoutMax = themeVal('layout.max');
// Discard the base padding to ensure that gridded folds have the same size as
// the constrainers.
const layoutMaxNoPadding = css`calc(${layoutMax} - ${gridGap})`;
// Calculate how much of the content block (with is the layoutMaxNoPadding)
// each column takes up.
const fullColumn = css`calc(${layoutMaxNoPadding} / ${columns})`;
// To get the usable size of each column we need to account for the gap.
const contentColWidth = css`calc(${fullColumn} - ${gridGap})`;
// Create the columns as:
// [content-<num>] minmax(0, <size>)
// Content columns start at index 2.
const contentColumns = Array(columns - 1)
.fill(0)
.map((_, i) => ({
name: `content-${i + 2}`,
value: css`
[content-${i + 2}] minmax(0, ${contentColWidth})
`
}));
// Create an array with all the columns definitions. It will be used to
// filter out the ones that are not needed when taking the user's grid
// definition into account.
const columnTemplate = [
{ name: 'full-start', value: css`[full-start] minmax(0, 1fr)` },
{
name: 'content-start',
value: css`[content-start] minmax(0, ${contentColWidth})`
},
...contentColumns,
{ name: 'content-end', value: css`[content-end] minmax(0, 1fr)` },
{ name: 'full-end', value: '[full-end]' }
];
let gridTemplateColumns = null;
let gridColumn = undefined;
// If the user defined a grid property compute the subgrid.
// This does two things:
// - Set the start and end columns to what the user defined.
// - Set the template-columns of this element to a subset of the parent (columnTemplate list)
if (grid) {
const [start, end] = getGridProp(grid, mdQuery);
gridColumn = css`
grid-column: ${start} / ${end};
`;
const startIdx = columnTemplate.findIndex((col) => col.name === start);
const endIdx = columnTemplate.findIndex((col) => col.name === end);
if (startIdx === -1 || endIdx === -1) {
const line = startIdx === -1 ? start : end;
throw new Error(`🤗 Human Universal Gridder
The grid line \`${line}\` does not exist in the ${mdQuery} media query which has ${columns} columns.
Grid lines for ${mdQuery}: ${columnTemplate.map(c => c.name).join(' | ')}`);
}
const lastColumn = columnTemplate[endIdx];
gridTemplateColumns = [
...columnTemplate.slice(startIdx, endIdx),
// Add the name of the last column without a size so we can use it for
// naming purposes.
{ name: lastColumn.name, value: `[${lastColumn.name}]` }
];
} else {
// If we're not using a subset, just use all the columns.
gridTemplateColumns = columnTemplate;
}
// The grid-template-columns will be a subset of this, depending on the grid
// defined by the user.
// grid-template-columns:
// [full-start] minmax(0, 1fr)
// [content-start] minmax(0, 000px)
// [content-2] minmax(0, 000px)
// [content-3] minmax(0, 000px)
// [content-4] minmax(0, 000px)
// ...
// [content-end] minmax(0, 1fr)
// [full-end];
return css`
${gridColumn}
grid-gap: ${gridGap};
grid-template-columns: ${gridTemplateColumns.map((col) => col.value)};
`;
};
}
/**
* Get the correct grid range for the given media query. If the grid for a given
* media query is not defined the previous one will be used (<media query>Up
* pattern).
*
* @param {number} columns Number of columns for the grid.
* @param {string} mdQuery Media query at which this grid is shown
*
* @returns array
*/
const getGridProp = (grid, mdQuery) => {
// If the user provided an array, assume it is the same on all media queries.
if (Array.isArray(grid)) {
validateGridLineNames(grid);
return grid;
}
// From the current media query go back until we find one defined by the user
// or reach the default. The replicates the behavior of <mediaQuery>Up
const mdIndex = mdQueryOrdered.findIndex((v) => v === mdQuery);
for (let i = mdIndex; i >= 0; i--) {
const m = mdQueryOrdered[i];
const key = `${m}Up`;
// Did the user provide an override for this media query?
if (grid[key]) {
validateGridLineNames(grid[key]);
return grid[key];
}
// No override. Check previous media range.
}
// content-start to content-end
return [gridLineNames[1], gridLineNames[gridLineNames.length - 2]];
};
// Redeclare the media function to fix the types defined in the UI library.
const _media = media as unknown as {
[K in keyof typeof media]: ThemedCssFunction<DefaultTheme>;
};
const Hug = styled.div<HugProps>`
display: grid;
${makeGrid(4, mdQueryOrdered[0])}
${_media.smallUp`
${makeGrid(4, mdQueryOrdered[1])}
`}
${_media.mediumUp`
${makeGrid(8, mdQueryOrdered[2])}
`}
${_media.largeUp`
${makeGrid(12, mdQueryOrdered[3])}
`}
${_media.xlargeUp`
${makeGrid(12, mdQueryOrdered[4])}
`}
`;
export default Hug;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment