Skip to content

Commit

Permalink
feat: add application layout Panel component
Browse files Browse the repository at this point in the history
  • Loading branch information
huwshimi authored and edlerd committed Jun 28, 2024
1 parent f704e2e commit 57a9e0c
Show file tree
Hide file tree
Showing 7 changed files with 406 additions and 0 deletions.
62 changes: 62 additions & 0 deletions src/components/Panel/Panel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";

import Panel from "./Panel";
import Button from "components/Button";
import Icon from "components/Icon";

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

export default meta;

type Story = StoryObj<typeof Panel>;

export const Default: Story = {
args: {
children: "Panel content",
title: "Panel",
},
};

export const Header: Story = {
args: {
controls: (
<Button appearance="positive">
<Icon name="plus" light /> Create
</Button>
),
title: "Panel title",
titleComponent: "h1",
},
};

/**
* The logo may be provided as attributes to use the standard logo. If this is
* not sufficient the a `ReactNode` can be passed to the `logo` prop instead.
*/
export const Logo: Story = {
args: {
logo: {
icon: "https://assets.ubuntu.com/v1/7144ec6d-logo-jaas-icon.svg",
name: "https://assets.ubuntu.com/v1/a85f7947-juju_black-text-only.svg",
nameAlt: "Juju",
},
},
};

/**
* If the default header does not meet your needs then a `ReactNode` can be
* passed to the `header` prop to replace the header.
*/
export const CustomHeader: Story = {
args: {
header: (
<div className="p-panel__header">
This header replaces the entire header area
</div>
),
},
};
78 changes: 78 additions & 0 deletions src/components/Panel/Panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ButtonHTMLAttributes } from "react";

import Panel from "./Panel";

it("displays a title", () => {
const title = "Test Panel";
render(<Panel title={title} />);
expect(screen.getByText(title)).toHaveClass("p-panel__title");
});

it("displays a logo", () => {
render(
<Panel
logo={{
href: "http://example.com",
icon: "icon.svg",
iconAlt: "Icon SVG",
name: "name.svg",
nameAlt: "Name SVG",
}}
/>
);
const link = screen.getByRole("link", { name: "Icon SVG Name SVG" });
expect(link).toHaveAttribute("href", "http://example.com");
expect(within(link).getByRole("img", { name: "Icon SVG" })).toHaveAttribute(
"src",
"icon.svg"
);
expect(within(link).getByRole("img", { name: "Name SVG" })).toHaveAttribute(
"src",
"name.svg"
);
});

it("logo handles different components", () => {
const Link = ({ ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props} />
);
render(
<Panel
logo={{
title: "http://example.com",
component: Link,
icon: "icon.svg",
iconAlt: "Icon SVG",
name: "name.svg",
nameAlt: "Name SVG",
}}
/>
);
expect(
screen.getByRole("button", { name: "Icon SVG Name SVG" })
).toHaveAttribute("title", "http://example.com");
});

it("displays a toggle", async () => {
const onClick = jest.fn();
render(<Panel title="Test panel" toggle={{ label: "Toggle", onClick }} />);
const toggle = screen.getByRole("button", { name: "Toggle" });
await userEvent.click(toggle);
expect(onClick).toHaveBeenCalled();
});

it("handles key presses on the toggle", async () => {
const onClick = jest.fn();
render(<Panel title="Test panel" toggle={{ label: "Toggle", onClick }} />);
const toggle = screen.getByRole("button", { name: "Toggle" });
await userEvent.type(toggle, "{Space}");
expect(onClick).toHaveBeenCalled();
});

it("displays a panel with no header", async () => {
render(<Panel>Content</Panel>);
expect(screen.getByText("Content")).toBeInTheDocument();
});
244 changes: 244 additions & 0 deletions src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import React from "react";
import type { PropsWithSpread } from "types";
import classNames from "classnames";
import type {
ComponentType,
ElementType,
HTMLProps,
PropsWithChildren,
ReactNode,
} from "react";

import type { ExclusiveProps } from "types";
import { isReactNode } from "utils";

export type LogoDefaultElement = HTMLProps<HTMLAnchorElement>;

type PanelLogo<L = LogoDefaultElement> =
| ReactNode
| PropsWithSpread<
{
/**
* The url of the icon image.
*/
icon: string;
/**
* The alt text for the icon image.
*/
iconAlt?: string;
/**
* The url of the name image.
*/
name: string;
/**
* The alt text for the name image.
*/
nameAlt?: string;
/**
* The element or component to use for displaying the logo e.g. `a` or `NavLink`.
* @default a
*/
component?: ElementType | ComponentType<L>;
},
L
>;

type PanelToggle = {
/**
* The panel toggle label.
*/
label: string;
/**
* The function to call when clicking the panel toggle.
*/
onClick: () => void;
};

type LogoProps<L = LogoDefaultElement> = {
/**
* The panel logo content or attributes.
*/
logo?: PanelLogo<L>;
};

type TitleProps = {
/**
* The panel title.
*/
title: ReactNode;
/**
* Classes to apply to the title element.
*/
titleClassName?: string;
/**
* The element to use for the panel title e.g. `h1`.
* @default h4
*/
titleComponent?: ElementType;
};

type HeaderProps<L = LogoDefaultElement> = ExclusiveProps<
{
/**
* This prop can be used to replace the header area of the panel when the default implementation is not sufficient.
*/
header: ReactNode;
},
{
/**
* Content that will be displayed in the controls area.
*/
controls?: ReactNode;
/**
* Classes that will be applied to the controls element.
*/
controlsClassName?: string;
/**
* Whether the header should be sticky.
*/
stickyHeader?: boolean;
/**
* The panel toggle attributes.
*/
toggle?: PanelToggle;
} & ExclusiveProps<LogoProps<L>, TitleProps>
>;

export type Props<L = LogoDefaultElement> = {
/**
* The panel content.
*/
children?: PropsWithChildren["children"];
/**
* Classes that are applied to the content container (when using `wrapContent`).
*/
contentClassName?: string | null;
/**
* Classes that are applied to the top level panel element.
*/
className?: string | null;
/**
* Whether to use the dark theme.
*/
dark?: boolean;
/**
* Whether the panel should wrap the content in the `p-panel__content` element.
* @default true
*/
wrapContent?: boolean;
/**
* A ref to pass to the top level panel element.
*/
forwardRef?: React.Ref<HTMLDivElement> | null;
} & HeaderProps<L>;

const generateLogo = <L = LogoDefaultElement,>(logo: PanelLogo<L>) => {
if (isReactNode(logo)) {
return logo;
}
const {
icon,
iconAlt,
name,
nameAlt,
component: Component = "a",
...props
} = logo;
return (
<Component className="p-panel__logo" {...props}>
<img
className="p-panel__logo-icon"
src={icon}
alt={iconAlt}
width="24"
height="24"
/>
<img
className="p-panel__logo-name is-fading-when-collapsed"
src={name}
alt={nameAlt}
height="16"
/>
</Component>
);
};

/**
* This is a [React](https://reactjs.org/) component for panels in the
* [Vanilla](https://vanillaframework.io/docs/) layouts.
*
* The Panel component can be used in many areas of the application layout. It
* can be the child element of `AppAside`, `AppMain`, `AppNavigation`, `AppNavigationBar`
* and `AppStatus`.
*/
const Panel = <L = LogoDefaultElement,>({
forwardRef,
children,
className,
contentClassName,
controlsClassName,
controls,
dark,
header,
logo,
stickyHeader,
title,
titleClassName,
titleComponent: TitleComponent = "h4",
toggle,
wrapContent = true,
...props
}: Props<L>) => {
return (
<div
{...props}
className={classNames("p-panel", className, {
"is-dark": dark,
})}
ref={forwardRef}
>
{logo || title || controls || toggle ? (
<div
className={classNames("p-panel__header", {
"is-sticky": stickyHeader,
})}
>
{logo ? (
generateLogo<L>(logo)
) : (
<TitleComponent
className={classNames("p-panel__title", titleClassName)}
>
{title}
</TitleComponent>
)}
<div className={classNames("p-panel__controls", controlsClassName)}>
{toggle ? (
<span
role="button"
tabIndex={0}
className="p-panel__toggle"
onClick={() => toggle.onClick()}
onKeyDown={() => toggle.onClick()}
>
{toggle.label}
</span>
) : null}
{controls}
</div>
</div>
) : (
header
)}
{children && wrapContent ? (
<div className={classNames("p-panel__content", contentClassName)}>
{children}
</div>
) : (
children
)}
</div>
);
};

export default Panel;
5 changes: 5 additions & 0 deletions src/components/Panel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
default,
type Props as PanelProps,
type LogoDefaultElement as PanelLogoDefaultElement,
} from "./Panel";
Loading

0 comments on commit 57a9e0c

Please sign in to comment.