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: add application layout components #1090

Merged
merged 5 commits into from
Jul 2, 2024
Merged
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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"version": "0.47.3",
"main": "dist/index.js",
"module": "dist/index.js",
"author": "Huw Wilkins <[email protected]>",
"author": {
"email": "[email protected]",
"name": "Canonical Webteam"
},
"license": "LGPL-3.0",
"files": [
"dist/**/*.js",
Expand Down
139 changes: 139 additions & 0 deletions src/components/ApplicationLayout/AppAside/AppAside.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/* eslint-disable react-hooks/rules-of-hooks */
import React, { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";

import Application from "components/ApplicationLayout/Application";
import AppMain from "components/ApplicationLayout/AppMain";
import Button from "components/Button";
import Col from "components/Col";
import Form from "components/Form";
import Icon from "components/Icon";
import Input from "components/Input";
import Panel from "components/Panel";
import Row from "components/Row";

import AppAside from "./AppAside";

const meta: Meta<typeof AppAside> = {
component: AppAside,
tags: ["autodocs"],
argTypes: {
children: {
control: false,
},
},
};

export default meta;

type Story = StoryObj<typeof AppAside>;

/**
* In most common cases an `AppAside` should contain a `<Panel>` to display the
* content as intended in the application layout.
*
* `AppAside` should be a direct child of an `<Application>` or passed to the
* application layout `<ApplicationLayout aside={<AppAside .../>}>`.
*/
export const Default: Story = {
render: (args) => {
const [pinned, setPinned] = useState(false);
const [width, setWidth] = useState(null);
const [collapsed, setCollapsed] = useState(false);
return (
<Application>
<AppMain>
<p>Scroll to the right to see the panel.</p>
<Button onClick={() => setCollapsed(false)}>Open</Button>
<Button onClick={() => setWidth("narrow")}>Narrow</Button>
<Button onClick={() => setWidth(null)}>Default</Button>
<Button onClick={() => setWidth("wide")}>Wide</Button>
</AppMain>
<AppAside
{...args}
pinned={pinned}
wide={width === "wide"}
narrow={width === "narrow"}
collapsed={collapsed}
>
<Panel
controls={
<>
<Button
onClick={() => setPinned(!pinned)}
dense
className="u-no-margin--bottom"
>
Pin
</Button>
<Button
appearance="base"
className="u-no-margin--bottom"
hasIcon
onClick={() => setCollapsed(!collapsed)}
>
<Icon name="close">Close</Icon>
</Button>
</>
}
title="App aside"
>
<Form stacked>
<Input
label="Full name"
type="text"
name="fullName"
autoComplete="name"
stacked
/>
<Input
label="Username"
type="text"
name="username-stacked"
autoComplete="username"
aria-describedby="exampleHelpTextMessage"
stacked
help="30 characters or fewer."
/>
<Input
type="text"
label="Email address"
aria-invalid="true"
name="username-stackederror"
autoComplete="email"
required
error="This field is required."
stacked
/>
<Input
label="Address line 1"
type="text"
name="address-optional-stacked"
autoComplete="address-line1"
stacked
/>
<Input
label="Address line 2"
type="text"
name="address-optional-stacked"
autoComplete="address-line3"
stacked
/>
<Row>
<Col size={12}>
<Button
appearance="positive"
className="u-float-right"
name="add-details"
>
Add details
</Button>
</Col>
</Row>
</Form>
</Panel>
</AppAside>
</Application>
);
},
};
24 changes: 24 additions & 0 deletions src/components/ApplicationLayout/AppAside/AppAside.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from "react";
import { render, screen } from "@testing-library/react";

import AppAside from "./AppAside";

it("displays collapsed", async () => {
render(<AppAside collapsed>Content</AppAside>);
expect(screen.queryByRole("complementary")).toHaveClass("is-collapsed");
});

it("displays as narrow", async () => {
render(<AppAside narrow>Content</AppAside>);
expect(screen.queryByRole("complementary")).toHaveClass("is-narrow");
});

it("displays pinned", async () => {
render(<AppAside pinned>Content</AppAside>);
expect(screen.queryByRole("complementary")).toHaveClass("is-pinned");
});

it("displays as wide", async () => {
render(<AppAside wide>Content</AppAside>);
expect(screen.queryByRole("complementary")).toHaveClass("is-wide");
});
63 changes: 63 additions & 0 deletions src/components/ApplicationLayout/AppAside/AppAside.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from "react";
import type { PropsWithSpread } from "types";
import classNames from "classnames";
import { type HTMLProps, type PropsWithChildren } from "react";

export type Props = PropsWithSpread<
{
/**
* Whether the aside panel should be collapsed. Toggling this state will animate
* the panel open or closed.
*/
collapsed?: boolean;
/**
* The panel content.
*/
children?: PropsWithChildren["children"];
/**
* A ref that will be passed to the wrapping `<aside>` element.
*/
forwardRef?: React.Ref<HTMLElement> | null;
/**
* Whether the aside panel should be narrow.
*/
narrow?: boolean;
/**
* Whether the aside panel should be pinned. When pinned the panel will appear
* beside the main content, instead of above it.
*/
pinned?: boolean;
/**
* Whether the aside panel should be wide.
*/
wide?: boolean;
},
HTMLProps<HTMLElement>
>;

const AppAside = ({
children,
className,
collapsed,
narrow,
forwardRef,
pinned,
wide,
...props
}: Props) => {
return (
<aside
className={classNames("l-aside", className, {
"is-collapsed": collapsed,
"is-narrow": narrow,
"is-pinned": pinned,
"is-wide": wide,
})}
{...props}
ref={forwardRef}
>
{children}
</aside>
);
};
export default AppAside;
2 changes: 2 additions & 0 deletions src/components/ApplicationLayout/AppAside/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./AppAside";
export type { Props as AppAsideProps } from "./AppAside";
65 changes: 65 additions & 0 deletions src/components/ApplicationLayout/AppMain/AppMain.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react";

import Application from "components/ApplicationLayout/Application";
import Button from "components/Button";
import React from "react";
import Panel from "components/Panel";

import AppMain from "./AppMain";

const meta: Meta<typeof AppMain> = {
component: AppMain,
tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<typeof AppMain>;

/**
* In most common cases an `AppMain` should contain a `<Panel>` to display the
* content as intended in the application layout.
*
* `AppMain` should be a direct child of an `<Application>` or when using `ApplicationLayout`
* it will automatically wrap the component's children.
*/
export const Default: Story = {
args: {
children: "AppMain",
},
};

export const Content: Story = {
render: (args) => {
return (
<Application>
<AppMain {...args}>
<Panel
controls={
<>
<Button
appearance="positive"
onClick={() => {}}
className="u-no-margin--bottom"
>
Add
</Button>
<Button
appearance="negative"
onClick={() => {}}
className="u-no-margin--bottom"
>
Delete
</Button>
</>
}
title="App main"
>
<p>App main content.</p>
<p>Scroll to the right to see the controls.</p>
</Panel>
</AppMain>
</Application>
);
},
};
10 changes: 10 additions & 0 deletions src/components/ApplicationLayout/AppMain/AppMain.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";
import { render, screen } from "@testing-library/react";

import AppMain from "./AppMain";

it("displays children", () => {
const children = "Test content";
render(<AppMain>{children}</AppMain>);
expect(screen.getByText(children)).toBeInTheDocument();
});
24 changes: 24 additions & 0 deletions src/components/ApplicationLayout/AppMain/AppMain.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from "react";
import classNames from "classnames";
import type { HTMLProps, PropsWithChildren } from "react";

export type Props = {
/**
* The main content.
*/
children?: PropsWithChildren["children"];
} & HTMLProps<HTMLDivElement>;

/**
* This is a [React](https://reactjs.org/) component for main content area in the Vanilla
* [Application Layout](https://vanillaframework.io/docs/layouts/application).
*/
const AppMain = ({ children, className, ...props }: Props) => {
return (
<main className={classNames("l-main", className)} {...props}>
{children}
</main>
);
};

export default AppMain;
2 changes: 2 additions & 0 deletions src/components/ApplicationLayout/AppMain/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./AppMain";
export type { Props as AppMainProps } from "./AppMain";
Loading
Loading