-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add application layout Panel component
- Loading branch information
Showing
7 changed files
with
406 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
), | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
Oops, something went wrong.