diff --git a/src/lib/sections/ContentSection/ContentSection.scss b/src/lib/sections/ContentSection/ContentSection.scss new file mode 100644 index 00000000..1ae99be2 --- /dev/null +++ b/src/lib/sections/ContentSection/ContentSection.scss @@ -0,0 +1,17 @@ +@import "vanilla-framework"; + +$section-padding: 1.5rem; + +section.content-section { + padding: $section-padding 0; + + .content-section__footer { + background: white; + width: 100%; + position: sticky; + margin-top: $section-padding; + bottom: 0; + border-top: $border; + padding: 1rem 0; + } +} diff --git a/src/lib/sections/ContentSection/ContentSection.stories.tsx b/src/lib/sections/ContentSection/ContentSection.stories.tsx new file mode 100644 index 00000000..2a62fead --- /dev/null +++ b/src/lib/sections/ContentSection/ContentSection.stories.tsx @@ -0,0 +1,57 @@ +import { Button } from "@canonical/react-components"; +import { Meta } from "@storybook/react"; + +import { ContentSection } from "@/lib/sections/ContentSection/ContentSection"; +import { WithInputGroup } from "@/lib/sections/FormSection/FormSection.stories"; + +const meta: Meta = { + title: "sections/ContentSection", + component: ContentSection, + tags: ["autodocs"], + argTypes: { + as: { + control: "text", + }, + children: { + control: { + disable: true, + }, + }, + }, + render: (args) => ( + // fixed height container to demonstrate sticky footer +
+ +
+ ), +}; + +export default meta; + +export const Example = { + args: { + children: ( + <> + Section Title + Section Content + Section Footer + + ), + }, +}; + +export const WithForm = { + args: { + children: ( + <> + Section Title + + + + + + + + ), + }, +}; diff --git a/src/lib/sections/ContentSection/ContentSection.test.tsx b/src/lib/sections/ContentSection/ContentSection.test.tsx new file mode 100644 index 00000000..2c0319d7 --- /dev/null +++ b/src/lib/sections/ContentSection/ContentSection.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; + +import { ContentSection } from "."; + +it("renders all elements correctly", () => { + render( + + Test Title + Test Content + Test Footer + , + ); + expect( + screen.getByRole("heading", { name: "Test Title" }), + ).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + expect(screen.getByText("Test Footer")).toBeInTheDocument(); +}); + +it("renders custom classNames", () => { + render( + + + Test Title + + + Test Content + + + Test Footer + + , + ); + expect( + document.querySelector(".custom-content-section-class"), + ).toBeInTheDocument(); + expect(document.querySelector(".custom-title-class")).toBeInTheDocument(); + expect(document.querySelector(".custom-content-class")).toBeInTheDocument(); + expect(document.querySelector(".custom-footer-class")).toBeInTheDocument(); +}); + +it("renders custom element for the title", () => { + render( + + Test Title + Test Content + Test Footer + , + ); + expect( + screen.getByRole("heading", { level: 5, name: "Test Title" }), + ).toBeInTheDocument(); +}); diff --git a/src/lib/sections/ContentSection/ContentSection.tsx b/src/lib/sections/ContentSection/ContentSection.tsx new file mode 100644 index 00000000..1e3f036b --- /dev/null +++ b/src/lib/sections/ContentSection/ContentSection.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; + +import { Col } from "@canonical/react-components"; +import classNames from "classnames"; + +import "./ContentSection.scss"; +import { AsProp } from "@/types"; + +interface CommonContentSectionProps extends React.PropsWithChildren { + className?: string; + align?: "left" | "center" | "right"; +} +export interface ContentSectionProps + extends CommonContentSectionProps, + React.HTMLAttributes, + AsProp, + React.PropsWithChildren {} + +/** + * A content section layout component for one of the primary content areas (e.g. main or sidebar). + * + * `ContentSection` has three child components: + * - `ContentSection.Title` + * - `ContentSection.Content` + * - `ContentSection.Footer` + * + * `ContentSection.Footer` is made sticky by default. + */ +export const ContentSection = ({ + children, + className, + as, + ...props +}: ContentSectionProps) => { + const Component = as || "section"; + return ( + + {children} + + ); +}; + +const Title = ({ children, className, as, ...props }: ContentSectionProps) => { + const Component = as || "h1"; + return ( + + {children} + + ); +}; + +const Content = ({ children, className }: CommonContentSectionProps) => ( +
+ {children} +
+); + +const Footer = ({ children, className }: CommonContentSectionProps) => ( +
+ {children} +
+); + +ContentSection.Title = Title; +ContentSection.Content = Content; +ContentSection.Footer = Footer; diff --git a/src/lib/sections/ContentSection/index.ts b/src/lib/sections/ContentSection/index.ts new file mode 100644 index 00000000..0e466ed4 --- /dev/null +++ b/src/lib/sections/ContentSection/index.ts @@ -0,0 +1 @@ +export * from "./ContentSection"; diff --git a/src/lib/sections/Navigation/Link/Link.tsx b/src/lib/sections/Navigation/Link/Link.tsx index d6b5bd4f..3bb3930a 100644 --- a/src/lib/sections/Navigation/Link/Link.tsx +++ b/src/lib/sections/Navigation/Link/Link.tsx @@ -2,7 +2,7 @@ import { ComponentProps, ElementType } from "react"; import classNames from "classnames"; -import { AsProp } from "@/lib/sections/Navigation/types"; +import { AsProp } from "@/types"; export interface NavigationLinkProps extends ComponentProps {} diff --git a/src/lib/sections/Navigation/Logo/Logo.tsx b/src/lib/sections/Navigation/Logo/Logo.tsx index b707b86f..3ea491c8 100644 --- a/src/lib/sections/Navigation/Logo/Logo.tsx +++ b/src/lib/sections/Navigation/Logo/Logo.tsx @@ -2,22 +2,25 @@ import { ComponentProps, ElementType, ReactNode } from "react"; import classNames from "classnames"; -import { AsProp } from "@/lib/sections/Navigation/types"; +import { AsProp } from "@/types"; export interface NavigationLogoProps extends ComponentProps { children: ReactNode; } export const Logo = < -C extends ElementType = "a", -T extends ComponentProps = ComponentProps, ->({ as, children, className, ...props }: AsProp & Omit) => { + C extends ElementType = "a", + T extends ComponentProps = ComponentProps, +>({ + as, + children, + className, + ...props +}: AsProp & Omit) => { const Component = as || "a"; - return ( + return ( -
- {children} -
+
{children}
- ) -} + ); +}; diff --git a/src/lib/sections/Navigation/types.ts b/src/lib/sections/Navigation/types.ts deleted file mode 100644 index 7ad3a7ee..00000000 --- a/src/lib/sections/Navigation/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ElementType } from "react"; - -export interface AsProp { - as?: C; -} diff --git a/src/lib/sections/index.ts b/src/lib/sections/index.ts index d1514ba5..5a56292e 100644 --- a/src/lib/sections/index.ts +++ b/src/lib/sections/index.ts @@ -1,6 +1,5 @@ export * from "./Navigation"; - export * from "./InputGroup"; export * from "./FormSection"; - -export * from "./MainToolbar"; \ No newline at end of file +export * from "./MainToolbar"; +export * from "./ContentSection"; diff --git a/src/types.ts b/src/types.ts index a7068d0f..1302249a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,10 @@ +import { ElementType } from "react"; + export type Prettify = { [K in keyof T]: T[K]; // eslint-disable-next-line @typescript-eslint/ban-types } & {}; + +export interface AsProp { + as?: C; +}