Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Container grid [Prototype / Demo] #1042

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
"format": "prettier --loglevel warn --write \"**/*.{ts,tsx,css,md,mdx}\""
},
"dependencies": {
"@emotion/styled": "^11.11.5",
"@homebound/form-state": "^2.20.1",
"@internationalized/number": "^3.0.3",
"@mui/material": "^5.15.19",
"@popperjs/core": "^2.11.6",
"@react-aria/utils": "^3.18.0",
"change-case": "^4.1.2",
Expand Down
230 changes: 230 additions & 0 deletions src/components/ContainerGrid/ContainerGrid.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { Meta } from "@storybook/react";
import { ContainerGrid, ContainerGridProps } from "src/components/ContainerGrid/ContainerGrid";
import { Button } from "src/components";
import { useCallback, useRef, useState } from "react";
import {
ContainerBreakpointDef,
ContainerGridItem,
ContainerGridItemProps,
} from "src/components/ContainerGrid/ContainerGridItem";
import { useResizeObserver } from "@react-aria/utils";
import { useContainerBreakpoint } from "src/components/ContainerGrid/useContainerBreakpoint";
import { ContainerBreakpoint } from "src/components/ContainerGrid/utils";
import { Css } from "src/Css";
import { NumberField } from "src/inputs";
import { Unstable_Grid2 as Grid } from "@mui/material";

export default {
component: ContainerGrid,
} as Meta<ContainerGridProps>;

export function Example() {
const [num, setNum] = useState(0);
return (
<div css={Css.maxwPx(2000).mx("auto").$}>
<div css={Css.df.ais.$}>
<div css={Css.add("resize", "horizontal").mwPx(120).hPx(150).bshBasic.ba.bcGray400.p1.oa.mr2.$}>Resize Me</div>
<div css={Css.mx1.fg1.$}>
<ContainerGrid xss={Css.aifs.$}>
<ContainerGridItem xss={Css.jcsb.df.gap2.my3.flexWrap("wrap").$}>
<h1 css={Css.xl3Md.$}>Title of the Page</h1>
<Button label={`Clicked: ${num} times`} onClick={() => setNum((prevState) => (prevState += 1))} />
</ContainerGridItem>

<ContainerGridItem sm={{ columns: 12, xss: Css.add("order", 1).$ }} lg={3} xss={Css.df.fdc.gap2.$}>
<RailItem left />
<RailItem left />
</ContainerGridItem>

<ContainerGridItem xss={Css.br8.bgWhite.bshBasic.p2.sm.$} lg={6}>
<MainContent />
</ContainerGridItem>

<ContainerGridItem sm={{ columns: 12, xss: Css.add("order", 2).$ }} lg={3} xss={Css.df.fdc.gap2.$}>
<RailItem />
<RailItem />
</ContainerGridItem>
</ContainerGrid>
</div>
</div>
</div>
);
}

export function MuiGrid() {
const [num, setNum] = useState(0);
const [expanded, setExpanded] = useState(false);
const toggleSize = () => setExpanded((prev) => !prev);
return (
<div css={Css.df.ais.$}>
<div
css={
Css.add("resize", "horizontal")
.mwPx(expanded ? 800 : 160)
.hPx(150).bshBasic.ba.bcGray400.p1.oa.mr2.df.gap1.fdc.ais.$
}
>
Resize Me
<Button label="Toggle" onClick={toggleSize} variant="secondary" />
</div>
<div css={Css.fg1.$}>
<Grid container spacing={2} mx={2}>
<Grid xs={12}>
<div css={Css.jcsb.df.gap2.my3.flexWrap("wrap").$}>
<h1 css={Css.xl3Md.$}>Title of the Page</h1>
<Button label={`Clicked: ${num} times`} onClick={() => setNum((prevState) => (prevState += 1))} />
</div>
</Grid>
<Grid xs={12} lg={3}>
<div css={Css.df.fdc.gap2.$}>
<RailItem left />
<RailItem left />
</div>
</Grid>
<Grid lg={6}>
<div css={Css.br8.bgWhite.bshBasic.p2.sm.$}>
<MainContent />
</div>
</Grid>
<Grid xs={12} lg={3}>
<div css={Css.df.fdc.gap2.$}>
<RailItem />
<RailItem />
</div>
</Grid>
</Grid>
</div>
</div>
);
}

function MainContent() {
const paragraph = (
<p css={Css.my1.base.$}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Amet est placerat
in egestas erat imperdiet sed. Ultrices neque ornare aenean euismod elementum. Faucibus in ornare quam viverra
orci sagittis. Aliquet bibendum enim facilisis gravida neque. Porttitor rhoncus dolor purus non enim. Dolor morbi
non arcu risus quis varius. Pellentesque pulvinar pellentesque habitant morbi tristique senectus et. Cursus risus
at ultrices mi tempus imperdiet nulla. A lacus vestibulum sed arcu non odio. Et ultrices neque ornare aenean
euismod elementum nisi quis eleifend. Malesuada bibendum arcu vitae elementum curabitur vitae nunc sed velit.
Tortor consequat id porta nibh venenatis cras. At volutpat diam ut venenatis tellus in metus vulputate eu. Erat
nam at lectus urna. Et magnis dis parturient montes nascetur. Quis hendrerit dolor magna eget est lorem ipsum.
Libero enim sed faucibus turpis in eu mi.
</p>
);
return (
<>
<h1 css={Css.xl2Md.$}>Main Content</h1>
{paragraph}
{paragraph}
{paragraph}
</>
);
}

function RailItem({ left }: { left?: boolean }) {
return (
<div css={Css.br8.bgWhite.bshBasic.p2.smMd.$}>
<h2 css={Css.lgSb.$}>{left ? "Left" : "Right"} Rail Item</h2>
<ul css={Css.mx0.px3.mb1.$}>
<li>List Item</li>
<li>List Item</li>
<li>List Item</li>
<li>List Item</li>
<li>List Item</li>
<li>List Item</li>
</ul>
</div>
);
}

export function Playground() {
const [sm, setSm] = useState<number | undefined>(600);
const [md, setMd] = useState<number | undefined>(1024);
const [lg, setLg] = useState<number | undefined>(1440);
const [columns, setColumns] = useState<number | undefined>(12);
const [gap, setGap] = useState<number | undefined>(16);

return (
<div css={Css.df.fdc.gap2.$}>
<div css={Css.df.gap1.bshBasic.p2.$}>
<NumberField labelStyle="inline" label="sm Upper Limit" value={sm} onChange={setSm} />
<NumberField labelStyle="inline" label="md Upper Limit" value={md} onChange={setMd} />
<NumberField labelStyle="inline" label="lg Upper Limit" value={lg} onChange={setLg} />
<NumberField labelStyle="inline" label="Number Columns" value={columns} onChange={setColumns} />
<NumberField labelStyle="inline" label="Grid Gap" value={gap} onChange={setGap} />
</div>

<div css={Css.df.ais.$}>
<div css={Css.add("resize", "horizontal").mwPx(120).hPx(150).bshBasic.ba.bcGray400.p1.oa.mr2.$}>Resize Me</div>
<div css={Css.fg1.$}>
<GridHeader />
<ContainerGrid sm={sm} md={md} lg={lg} columns={columns} gap={gap}>
<GridItemCard sm={12} md={8} lg={6} xl={4} />
<GridItemCard sm={12} md={4} lg={6} xl={2} />
<GridItemCard sm={12} md={4} lg={6} xl={2} />
<GridItemCard sm={12} md={8} lg={6} xl={4} />
</ContainerGrid>
</div>
</div>
</div>
);
}

function GridItemCard(props: Omit<ContainerGridItemProps, "children">) {
function resolveColumnSize(bp: ContainerBreakpointDef | undefined): number | undefined {
return typeof bp === "number" ? bp : bp?.columns;
}
const [sm, setSm] = useState<number | undefined>(resolveColumnSize(props.sm));
const [md, setMd] = useState<number | undefined>(resolveColumnSize(props.md));
const [lg, setLg] = useState<number | undefined>(resolveColumnSize(props.lg));
const [xl, setXl] = useState<number | undefined>(resolveColumnSize(props.xl));
const matchedBreakpoint = useContainerBreakpoint();
const fields: { bp: ContainerBreakpoint; value: number | undefined; onChange: (v: number | undefined) => void }[] = [
{ bp: "sm", value: sm, onChange: setSm },
{ bp: "md", value: md, onChange: setMd },
{ bp: "lg", value: lg, onChange: setLg },
{ bp: "xl", value: xl, onChange: setXl },
];
return (
<ContainerGridItem sm={sm} md={md} lg={lg} xl={xl} xss={Css.br8.bgWhite.bshBasic.df.jcc.p1.smMd.$}>
<div css={Css.df.wPx(200).fdc.aic.jcc.gap1.$}>
<h2 css={Css.smBd.$}>Breakpoint Definition</h2>
{fields.map(({ bp, value, onChange }) => {
return (
<div key={bp} css={Css.if(matchedBreakpoint === bp).bcGreen600.ba.bw2.br4.bshBasic.$}>
<NumberField
labelStyle="inline"
compact
label={`${bp.toUpperCase()}`}
value={value}
onChange={onChange}
sizeToContent
/>
</div>
);
})}
</div>
</ContainerGridItem>
);
}

function GridHeader() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState("auto");

const onResize = useCallback(() => {
if (ref.current) {
const width = ref.current.offsetWidth;
setWidth(`${width}px`);
}
}, [setWidth]);
useResizeObserver({ ref, onResize });

return (
<div ref={ref} css={Css.mb1.$}>
<strong>Container Width:</strong> {width}
</div>
);
}
55 changes: 55 additions & 0 deletions src/components/ContainerGrid/ContainerGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Css, Properties } from "src";
import { ReactNode, useRef } from "react";
import { ContainerGridProvider } from "src/components/ContainerGrid/ContainerGridContext";
import { defaultGridProps } from "src/components/ContainerGrid/utils";

export type ContainerGridProps = {
/** Default to 12 columns */
columns?: number;
/** Default to 16px gap */
gap?: number;
/** Default to 1440px (upper bounds of the `lg` breakpoint) */
lg?: number;
/** Default to 1024px (upper bounds of the `md` breakpoint) */
md?: number;
/** Default to 600px (upper bounds of the `sm` breakpoint) */
sm?: number;
children: ReactNode;
xss?: Properties;
};

/**
* Creates the styles needed for a CSS Grid layout with container queries.
* Breakpoint properties represent the upper bound of the breakpoint.
* Example: { sm: 600, md: 1024, lg: 1440 } Results in:
* sm: 0 - 600px
* md: 601px - 1024px
* lg: 1025px - 1440px
* xl: 1441px - Infinity
*/
export function ContainerGrid(props: ContainerGridProps) {
const {
columns = defaultGridProps.columns,
gap = defaultGridProps.gap,
lg = defaultGridProps.lg,
md = defaultGridProps.md,
sm = defaultGridProps.sm,
children,
xss,
} = props;
const ref = useRef<HTMLDivElement>(null);

return (
<div
ref={ref}
css={{
...Css.ctis.dg.gtc(`repeat(${columns}, minmax(0, 1fr))`).gapPx(gap).$,
...xss,
}}
>
<ContainerGridProvider sm={sm} lg={lg} md={md} columns={columns} gap={gap} containerRef={ref}>
{children}
</ContainerGridProvider>
</div>
);
}
25 changes: 25 additions & 0 deletions src/components/ContainerGrid/ContainerGridContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createContext, MutableRefObject, PropsWithChildren, useContext, useMemo } from "react";
import { defaultGridProps } from "src/components/ContainerGrid/utils";

type ContainerGridContextProps = {
columns: number;
gap: number;
lg: number;
md: number;
sm: number;
containerRef?: MutableRefObject<HTMLDivElement | null>;
};

export const ContainerGridContext = createContext<ContainerGridContextProps>({
...defaultGridProps,
});

export function ContainerGridProvider(props: PropsWithChildren<ContainerGridContextProps>) {
const { columns, gap, lg, md, sm, containerRef, children } = props;
const value = useMemo(() => ({ columns, gap, lg, md, sm, containerRef }), [columns, gap, lg, md, sm, containerRef]);
return <ContainerGridContext.Provider value={value}>{children}</ContainerGridContext.Provider>;
}

export function useContainerGridContext() {
return useContext(ContainerGridContext);
}
48 changes: 48 additions & 0 deletions src/components/ContainerGrid/ContainerGridItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ReactNode, useMemo } from "react";
import { Container, Css, Properties } from "src";
import { useContainerGridContext } from "src/components/ContainerGrid/ContainerGridContext";
import { ContainerBreakpoint } from "src/components/ContainerGrid/utils";

export type ContainerGridItemProps = {
sm?: ContainerBreakpointDef;
md?: ContainerBreakpointDef;
lg?: ContainerBreakpointDef;
xl?: ContainerBreakpointDef;
children: ReactNode;
xss?: Properties;
};

export function ContainerGridItem(props: ContainerGridItemProps) {
const { sm: smBp, md: mdBp, lg: lgBp, columns } = useContainerGridContext();
const { sm: smDefs = columns, md: mdDefs = smDefs, lg: lgDefs = mdDefs, xl: xlDefs = lgDefs, xss, children } = props;

const gridStyles: Properties = useMemo(() => {
const bps: [ContainerBreakpoint, { lt: number } | { gt: number } | { lt: number; gt: number }][] = [
["xl", { gt: lgBp }],
["lg", { gt: mdBp, lt: lgBp }],
["md", { gt: smBp, lt: mdBp }],
["sm", { lt: smBp }],
];
const styleDefs: Record<ContainerBreakpoint, ContainerBreakpointDef> = {
sm: smDefs,
md: mdDefs,
lg: lgDefs,
xl: xlDefs,
};
const styles = bps.map(([bp, containerProps]) => {
const bpDefs = styleDefs[bp];
const colSpan = typeof bpDefs === "number" ? bpDefs : bpDefs.columns;
const bpStyles = typeof bpDefs === "number" ? {} : bpDefs.xss;

return Css.ifContainer(containerProps).gc(`span ${colSpan}`).addIn(Container(containerProps), bpStyles).$;
});

return Object.assign({}, ...styles);
}, [smBp, mdBp, lgBp, smDefs, mdDefs, lgDefs, xlDefs]);

const styles = useMemo(() => ({ ...gridStyles, ...xss }), [gridStyles, xss]);

return <div css={styles}>{children}</div>;
}

export type ContainerBreakpointDef = number | { columns?: number; xss?: Properties };
Loading
Loading