diff --git a/.changeset/slow-frogs-eat.md b/.changeset/slow-frogs-eat.md new file mode 100644 index 0000000000..273897bf93 --- /dev/null +++ b/.changeset/slow-frogs-eat.md @@ -0,0 +1,6 @@ +--- +"@navikt/ds-css": minor +"@navikt/ds-react": minor +--- + +[Layout] Columns komponent :tada: diff --git a/@navikt/aksel-icons/figma-plugin/src/ui/utils.ts b/@navikt/aksel-icons/figma-plugin/src/ui/utils.ts index 0bb608ae77..c524b73aca 100644 --- a/@navikt/aksel-icons/figma-plugin/src/ui/utils.ts +++ b/@navikt/aksel-icons/figma-plugin/src/ui/utils.ts @@ -1,9 +1,9 @@ import meta from "@navikt/aksel-icons/metadata"; const subCategorizeIcons = ( - icons: typeof meta[1][] -): { sub_category: string; icons: typeof meta[1][] }[] => { - const categories: { sub_category: string; icons: typeof meta[1][] }[] = []; + icons: (typeof meta)[1][] +): { sub_category: string; icons: (typeof meta)[1][] }[] => { + const categories: { sub_category: string; icons: (typeof meta)[1][] }[] = []; for (const icon of icons) { const i = categories.findIndex( @@ -19,14 +19,14 @@ const subCategorizeIcons = ( }; export const categorizeIcons = ( - icons: typeof meta[1][] + icons: (typeof meta)[1][] ): { category: string; - sub_categories: { sub_category: string; icons: typeof meta[1][] }[]; + sub_categories: { sub_category: string; icons: (typeof meta)[1][] }[]; }[] => { const categories: { category: string; - icons: typeof meta[1][]; + icons: (typeof meta)[1][]; }[] = []; for (const icon of icons) { @@ -42,14 +42,14 @@ export const categorizeIcons = ( .map((x) => ({ ...x, sub_categories: subCategorizeIcons(x.icons) })); }; -const noFill = (icon: typeof meta[1], icons: typeof meta[1][]) => { +const noFill = (icon: (typeof meta)[1], icons: (typeof meta)[1][]) => { const foundFill = icons.find( (x) => x.name.endsWith("Fill") && x.name.replace("Fill", "") === icon.name ); return !foundFill; }; -export const getFillIcon = (icons: typeof meta[1][]) => { +export const getFillIcon = (icons: (typeof meta)[1][]) => { return icons.filter( (x, _, z) => x.variant.toLowerCase() === "fill" || noFill(x, z) ); diff --git a/@navikt/core/css/hgrid.css b/@navikt/core/css/hgrid.css new file mode 100644 index 0000000000..6a80ba097d --- /dev/null +++ b/@navikt/core/css/hgrid.css @@ -0,0 +1,55 @@ +.navds-hgrid { + --__ac-hgrid-columns-xs: initial; + --__ac-hgrid-columns-sm: initial; + --__ac-hgrid-columns-md: initial; + --__ac-hgrid-columns-lg: initial; + --__ac-hgrid-columns-xl: initial; + --__ac-hgrid-columns: var(--__ac-hgrid-columns-xs); + --__ac-hgrid-gap-xs: initial; + --__ac-hgrid-gap-sm: initial; + --__ac-hgrid-gap-md: initial; + --__ac-hgrid-gap-lg: initial; + --__ac-hgrid-gap-xl: initial; + --__ac-hgrid-gap: var(--__ac-hgrid-gap-xs); + + display: grid; + grid-template-columns: var(--__ac-hgrid-columns); + gap: var(--__ac-hgrid-gap); +} + +@media (min-width: 480px) { + .navds-hgrid { + --__ac-hgrid-columns: var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs)); + --__ac-hgrid-gap: var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs)); + } +} + +@media (min-width: 768px) { + .navds-hgrid { + --__ac-hgrid-columns: var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs))); + --__ac-hgrid-gap: var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))); + } +} + +@media (min-width: 1024px) { + .navds-hgrid { + --__ac-hgrid-columns: var( + --__ac-hgrid-columns-lg, + var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs))) + ); + --__ac-hgrid-gap: var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs)))); + } +} + +@media (min-width: 1280px) { + .navds-hgrid { + --__ac-hgrid-columns: var( + --__ac-hgrid-columns-xl, + var(--__ac-hgrid-columns-lg, var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs)))) + ); + --__ac-hgrid-gap: var( + --__ac-hgrid-gap-xl, + var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs)))) + ); + } +} diff --git a/@navikt/core/css/index.css b/@navikt/core/css/index.css index 2dad528742..e137c4ab94 100644 --- a/@navikt/core/css/index.css +++ b/@navikt/core/css/index.css @@ -14,6 +14,7 @@ @import "guide-panel.css"; @import "form/index.css"; @import "help-text.css"; +@import "hgrid.css"; @import "internalheader.css"; @import "link.css"; @import "loader.css"; diff --git a/@navikt/core/css/stack.css b/@navikt/core/css/stack.css index bdb45790d2..20f4eb535b 100644 --- a/@navikt/core/css/stack.css +++ b/@navikt/core/css/stack.css @@ -1,22 +1,20 @@ -/* stylelint-disable csstools/value-no-unknown-custom-properties */ -/* stylelint-disable aksel/design-token-exists */ .navds-stack { - --ac-stack-align: initial; - --ac-stack-justify: initial; - --ac-stack-direction: initial; - --ac-stack-wrap: initial; - --ac-stack-gap-xs: initial; - --ac-stack-gap-sm: initial; - --ac-stack-gap-md: initial; - --ac-stack-gap-lg: initial; - --ac-stack-gap-xl: initial; - --__ac-stack-gap: var(--ac-stack-gap-xs); + --__ac-stack-align: initial; + --__ac-stack-justify: initial; + --__ac-stack-direction: initial; + --__ac-stack-wrap: initial; + --__ac-stack-gap-xs: initial; + --__ac-stack-gap-sm: initial; + --__ac-stack-gap-md: initial; + --__ac-stack-gap-lg: initial; + --__ac-stack-gap-xl: initial; + --__ac-stack-gap: var(--__ac-stack-gap-xs); gap: var(--__ac-stack-gap); display: flex; - align-items: var(--ac-stack-align); - justify-content: var(--ac-stack-justify); - flex-flow: var(--ac-stack-direction) var(--ac-stack-wrap); + align-items: var(--__ac-stack-align); + justify-content: var(--__ac-stack-justify); + flex-flow: var(--__ac-stack-direction) var(--__ac-stack-wrap); } .navds-stack__spacer { @@ -35,27 +33,27 @@ @media (min-width: 480px) { .navds-stack { - --__ac-stack-gap: var(--ac-stack-gap-sm, var(--ac-stack-gap-xs)); + --__ac-stack-gap: var(--__ac-stack-gap-sm, var(--__ac-stack-gap-xs)); } } @media (min-width: 768px) { .navds-stack { - --__ac-stack-gap: var(--ac-stack-gap-md, var(--ac-stack-gap-sm, var(--ac-stack-gap-xs))); + --__ac-stack-gap: var(--__ac-stack-gap-md, var(--__ac-stack-gap-sm, var(--__ac-stack-gap-xs))); } } @media (min-width: 1024px) { .navds-stack { - --__ac-stack-gap: var(--ac-stack-gap-lg, var(--ac-stack-gap-md, var(--ac-stack-gap-sm, var(--ac-stack-gap-xs)))); + --__ac-stack-gap: var(--__ac-stack-gap-lg, var(--__ac-stack-gap-md, var(--__ac-stack-gap-sm, var(--__ac-stack-gap-xs)))); } } @media (min-width: 1280px) { .navds-stack { --__ac-stack-gap: var( - --ac-stack-gap-xl, - var(--ac-stack-gap-lg, var(--ac-stack-gap-md, var(--ac-stack-gap-sm, var(--ac-stack-gap-xs)))) + --__ac-stack-gap-xl, + var(--__ac-stack-gap-lg, var(--__ac-stack-gap-md, var(--__ac-stack-gap-sm, var(--__ac-stack-gap-xs)))) ); } } diff --git a/@navikt/core/react/src/index.ts b/@navikt/core/react/src/index.ts index 805e825f8a..1608fe148d 100644 --- a/@navikt/core/react/src/index.ts +++ b/@navikt/core/react/src/index.ts @@ -33,3 +33,4 @@ export * from "./tooltip"; export * from "./typography"; export * from "./util"; export * from "./layout/stack"; +export * from "./layout/grid"; diff --git a/@navikt/core/react/src/layout/grid/HGrid.tsx b/@navikt/core/react/src/layout/grid/HGrid.tsx new file mode 100644 index 0000000000..6a0930a5ba --- /dev/null +++ b/@navikt/core/react/src/layout/grid/HGrid.tsx @@ -0,0 +1,101 @@ +import React, { forwardRef, HTMLAttributes } from "react"; +import cl from "clsx"; +import { + getResponsiveProps, + getResponsiveValue, + ResponsiveProp, + SpacingScale, +} from "../utilities/css"; + +export interface HGridProps extends HTMLAttributes { + children: React.ReactNode; + /** + * Number of columns to display. Can be a number, a string with a unit or tokens for spesific breakpoints. + * Sets `grid-template-columns`, so `fr`, `minmax` etc. works. + * @example + * columns={{ sm: 1, md: 1, lg: "1fr auto", xl: "1fr auto"}} + * @example + * columns={3} + * @example + * columns="repeat(3, minmax(0, 1fr))" + */ + columns?: ResponsiveProp; + /** Spacing between columns. Based on spacing-tokens. + * @example + * gap="6" + * gap={{ sm: "2", md: "2", lg: "6", xl: "6"}} + */ + gap?: ResponsiveProp; +} +/** + * Horizontal Grid Primitive with dynamic columns and gap based on breakpoints. + * + * @see [📝 Documentation](https://aksel.nav.no/komponenter/core/hgrid) + * @see đŸ·ïž {@link HGridProps} + * + * @example + * + *
+ *
+ *
+ * + * @example + * + *
+ *
+ *
+ * + * @example + * + *
+ *
+ *
+ * + */ +export const HGrid = forwardRef( + ({ className, columns, gap, style, ...rest }, ref) => { + const styles: React.CSSProperties = { + ...style, + ...getResponsiveProps(`hgrid`, "gap", "spacing", gap), + ...getResponsiveValue(`hgrid`, "columns", formatGrid(columns)), + }; + + return ( +
+ ); + } +); + +function formatGrid( + props?: ResponsiveProp +): ResponsiveProp { + if (!props) { + return {}; + } + + if (typeof props === "string" || typeof props === "number") { + return getColumnValue(props); + } + + return Object.fromEntries( + Object.entries(props).map(([breakpointToken, columnValue]) => [ + breakpointToken, + getColumnValue(columnValue), + ]) + ); +} + +const getColumnValue = (prop: number | string) => { + if (typeof prop === "number") { + return `repeat(${prop}, minmax(0, 1fr))`; + } + + return prop; +}; + +export default HGrid; diff --git a/@navikt/core/react/src/layout/grid/h-grid.stories.tsx b/@navikt/core/react/src/layout/grid/h-grid.stories.tsx new file mode 100644 index 0000000000..84b40064e1 --- /dev/null +++ b/@navikt/core/react/src/layout/grid/h-grid.stories.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { HGrid } from "."; + +const columnsVariants = { + Number: "columnNumber", + String: "columnString", + Object: "columnObject", +}; + +export default { + title: "ds-react/HGrid", + component: HGrid, + parameters: { + layout: "fullscreen", + }, + argTypes: { + columnsType: { + defaultValue: Object.keys(columnsVariants)[0], + options: Object.keys(columnsVariants), + control: { type: "radio" }, + }, + }, +}; + +/* const getColumnsProp = () */ + +export const Default = { + render: (props) => ( + + + + + + + ), + args: { + columnNumber: 4, + columnObject: { xs: 1, md: 4 }, + columnString: "repeat(3, minmax(0, 1fr))", + gap: "4", + }, +}; + +export const Gap = { + render: () => ( + + + + + + + ), +}; + +export const DynamicGap = { + render: () => ( + + + + + + + ), +}; + +export const Columns = { + render: () => ( + + + + + + + ), +}; + +export const DynamicColumns = { + render: () => ( + + + + + ), +}; + +function Placeholder({ text }) { + return ( +
+ {text} +
+ ); +} diff --git a/@navikt/core/react/src/layout/grid/index.ts b/@navikt/core/react/src/layout/grid/index.ts new file mode 100644 index 0000000000..a0f6aed444 --- /dev/null +++ b/@navikt/core/react/src/layout/grid/index.ts @@ -0,0 +1 @@ +export { default as HGrid, type HGridProps } from "./HGrid"; diff --git a/@navikt/core/react/src/layout/stack/Stack.tsx b/@navikt/core/react/src/layout/stack/Stack.tsx index b4bec67156..c614e32af1 100644 --- a/@navikt/core/react/src/layout/stack/Stack.tsx +++ b/@navikt/core/react/src/layout/stack/Stack.tsx @@ -53,12 +53,12 @@ export const Stack: OverridableComponent = ref ) => { const style = { - "--ac-stack-direction": direction, - "--ac-stack-align": align, - "--ac-stack-justify": justify, - "--ac-stack-wrap": wrap ? "wrap" : "nowrap", - ...getResponsiveProps(`stack`, "gap", "spacing", gap), ..._style, + "--__ac-stack-direction": direction, + "--__ac-stack-align": align, + "--__ac-stack-justify": justify, + "--__ac-stack-wrap": wrap ? "wrap" : "nowrap", + ...getResponsiveProps(`stack`, "gap", "spacing", gap), } as React.CSSProperties; return ( diff --git a/@navikt/core/react/src/layout/utilities/css.ts b/@navikt/core/react/src/layout/utilities/css.ts index 84cc50074d..71473cdb27 100644 --- a/@navikt/core/react/src/layout/utilities/css.ts +++ b/@navikt/core/react/src/layout/utilities/css.ts @@ -21,36 +21,58 @@ export type SpacingScale = | "24" | "32"; -export type ResponsiveProp = - | T - | { - // eslint-disable-next-line no-unused-vars - [Breakpoint in BreakpointsAlias]?: T; - }; +type ResponsivePropConfig = { + // eslint-disable-next-line no-unused-vars + [Breakpoint in BreakpointsAlias]?: T; +}; + +export type ResponsiveProp = T | ResponsivePropConfig; + +export type ResponsiveValue = undefined | ResponsiveProp; -export function getResponsiveProps( +export function getResponsiveProps( componentName: string, componentProp: string, tokenSubgroup: string, - responsiveProp?: - | string - | { - // eslint-disable-next-line no-unused-vars - [Breakpoint in BreakpointsAlias]?: string; - } + responsiveProp?: ResponsiveProp ) { - if (!responsiveProp) return {}; + if (!responsiveProp) { + return {}; + } if (typeof responsiveProp === "string") { return { - [`--ac-${componentName}-${componentProp}-xs`]: `var(--a-${tokenSubgroup}-${responsiveProp})`, + [`--__ac-${componentName}-${componentProp}-xs`]: `var(--a-${tokenSubgroup}-${responsiveProp})`, }; } return Object.fromEntries( Object.entries(responsiveProp).map(([breakpointAlias, aliasOrScale]) => [ - `--ac-${componentName}-${componentProp}-${breakpointAlias}`, + `--__ac-${componentName}-${componentProp}-${breakpointAlias}`, `var(--a-${tokenSubgroup}-${aliasOrScale})`, ]) ); } + +export function getResponsiveValue( + componentName: string, + componentProp: string, + responsiveProp?: ResponsiveValue +) { + if (!responsiveProp) { + return {}; + } + + if (typeof responsiveProp === "string") { + return { + [`--__ac-${componentName}-${componentProp}-xs`]: responsiveProp, + }; + } + + return Object.fromEntries( + Object.entries(responsiveProp).map(([breakpointAlias, responsiveValue]) => [ + `--__ac-${componentName}-${componentProp}-${breakpointAlias}`, + responsiveValue, + ]) + ); +} diff --git a/aksel.nav.no/website/components/website-modules/TOC.tsx b/aksel.nav.no/website/components/website-modules/TOC.tsx index 1713889951..6340cefede 100644 --- a/aksel.nav.no/website/components/website-modules/TOC.tsx +++ b/aksel.nav.no/website/components/website-modules/TOC.tsx @@ -137,7 +137,7 @@ export function TableOfContents({ hidden: !renderToc, "col-start-3 max-w-prose md:sticky md:top-20 lg:flex": aksel && renderToc, - "mt-12 mr-auto h-full xl:flex": !aksel && renderToc, + "mr-auto mt-12 h-full xl:flex": !aksel && renderToc, } )} > diff --git a/aksel.nav.no/website/components/website-modules/icon-page/utils.ts b/aksel.nav.no/website/components/website-modules/icon-page/utils.ts index 0bb608ae77..c524b73aca 100644 --- a/aksel.nav.no/website/components/website-modules/icon-page/utils.ts +++ b/aksel.nav.no/website/components/website-modules/icon-page/utils.ts @@ -1,9 +1,9 @@ import meta from "@navikt/aksel-icons/metadata"; const subCategorizeIcons = ( - icons: typeof meta[1][] -): { sub_category: string; icons: typeof meta[1][] }[] => { - const categories: { sub_category: string; icons: typeof meta[1][] }[] = []; + icons: (typeof meta)[1][] +): { sub_category: string; icons: (typeof meta)[1][] }[] => { + const categories: { sub_category: string; icons: (typeof meta)[1][] }[] = []; for (const icon of icons) { const i = categories.findIndex( @@ -19,14 +19,14 @@ const subCategorizeIcons = ( }; export const categorizeIcons = ( - icons: typeof meta[1][] + icons: (typeof meta)[1][] ): { category: string; - sub_categories: { sub_category: string; icons: typeof meta[1][] }[]; + sub_categories: { sub_category: string; icons: (typeof meta)[1][] }[]; }[] => { const categories: { category: string; - icons: typeof meta[1][]; + icons: (typeof meta)[1][]; }[] = []; for (const icon of icons) { @@ -42,14 +42,14 @@ export const categorizeIcons = ( .map((x) => ({ ...x, sub_categories: subCategorizeIcons(x.icons) })); }; -const noFill = (icon: typeof meta[1], icons: typeof meta[1][]) => { +const noFill = (icon: (typeof meta)[1], icons: (typeof meta)[1][]) => { const foundFill = icons.find( (x) => x.name.endsWith("Fill") && x.name.replace("Fill", "") === icon.name ); return !foundFill; }; -export const getFillIcon = (icons: typeof meta[1][]) => { +export const getFillIcon = (icons: (typeof meta)[1][]) => { return icons.filter( (x, _, z) => x.variant.toLowerCase() === "fill" || noFill(x, z) ); diff --git a/aksel.nav.no/website/pages/eksempler/h-grid/columns-variants.tsx b/aksel.nav.no/website/pages/eksempler/h-grid/columns-variants.tsx new file mode 100644 index 0000000000..ff50af97d7 --- /dev/null +++ b/aksel.nav.no/website/pages/eksempler/h-grid/columns-variants.tsx @@ -0,0 +1,63 @@ +import { HGrid } from "@navikt/ds-react"; +import { withDsExample } from "components/website-modules/examples/withDsExample"; + +const Example = () => { + return ( + + + + + + + + + ); +}; + +const Placeholder = ({ height = "auto", width = "auto" }) => { + return ( +
+ ); +}; + +const Background = ({ + children, + width = "100%", +}: { + children: React.ReactNode; + width?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default withDsExample(Example, "static"); + +/* Storybook story */ +export const Demo = { + render: Example, +}; + +export const args = { + index: 1, + desc: "Columns stÞtter bÄde statisk antall kolonner med 'number' og mer fleksible kolonner med 'string'. ", +}; diff --git a/aksel.nav.no/website/pages/eksempler/h-grid/default.tsx b/aksel.nav.no/website/pages/eksempler/h-grid/default.tsx new file mode 100644 index 0000000000..74edc503ad --- /dev/null +++ b/aksel.nav.no/website/pages/eksempler/h-grid/default.tsx @@ -0,0 +1,59 @@ +import { HGrid } from "@navikt/ds-react"; +import { withDsExample } from "components/website-modules/examples/withDsExample"; + +const Example = () => { + return ( + + + + + + + + ); +}; + +const Placeholder = ({ height = "auto", width = "auto" }) => { + return ( +
+ ); +}; + +const Background = ({ + children, + width = "100%", +}: { + children: React.ReactNode; + width?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default withDsExample(Example, "static"); + +/* Storybook story */ +export const Demo = { + render: Example, +}; + +export const args = { + index: 0, + desc: "HGrid lar deg enkelt dele innholdet opp i kolonner. Basert pÄ CSS-grid", +}; diff --git a/aksel.nav.no/website/pages/eksempler/h-grid/responsive-columns.tsx b/aksel.nav.no/website/pages/eksempler/h-grid/responsive-columns.tsx new file mode 100644 index 0000000000..8dadebed7e --- /dev/null +++ b/aksel.nav.no/website/pages/eksempler/h-grid/responsive-columns.tsx @@ -0,0 +1,60 @@ +import { HGrid } from "@navikt/ds-react"; +import { withDsExample } from "components/website-modules/examples/withDsExample"; + +const Example = () => { + return ( + + + + + + + + + ); +}; + +const Placeholder = ({ height = "auto", width = "auto" }) => { + return ( +
+ ); +}; + +const Background = ({ + children, + width = "100%", +}: { + children: React.ReactNode; + width?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default withDsExample(Example, "static"); + +/* Storybook story */ +export const Demo = { + render: Example, +}; + +export const args = { + index: 2, + desc: "Med responsive kolonner kan man dynamiskt tilpasse dem basert pÄ brekkpunktene vÄre.", +}; diff --git a/aksel.nav.no/website/pages/eksempler/h-grid/responsive-gap.tsx b/aksel.nav.no/website/pages/eksempler/h-grid/responsive-gap.tsx new file mode 100644 index 0000000000..64e61c24a9 --- /dev/null +++ b/aksel.nav.no/website/pages/eksempler/h-grid/responsive-gap.tsx @@ -0,0 +1,60 @@ +import { HGrid } from "@navikt/ds-react"; +import { withDsExample } from "components/website-modules/examples/withDsExample"; + +const Example = () => { + return ( + + + + + + + + + ); +}; + +const Placeholder = ({ height = "auto", width = "auto" }) => { + return ( +
+ ); +}; + +const Background = ({ + children, + width = "100%", +}: { + children: React.ReactNode; + width?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default withDsExample(Example, "static"); + +/* Storybook story */ +export const Demo = { + render: Example, +}; + +export const args = { + index: 3, + desc: "Med responsiv gap kan man dynamiskt tilpasse spacing basert pÄ brekkpunktene vÄre.", +};