Skip to content

Commit

Permalink
WD-3251 - Unify panel implementations (#1461)
Browse files Browse the repository at this point in the history
* Add top level panel tests. Unify panel implementations.
  • Loading branch information
huwshimi authored Apr 27, 2023
1 parent 8f3c469 commit 075cefe
Show file tree
Hide file tree
Showing 39 changed files with 1,122 additions and 1,264 deletions.
15 changes: 10 additions & 5 deletions src/animations/SlideInOut.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { PropsWithSpread } from "@canonical/react-components";
import type { HTMLMotionProps } from "framer-motion";
import { motion } from "framer-motion";

type Props = {
isActive: boolean;
children: JSX.Element;
className: string;
};
export type Props = PropsWithSpread<
{
isActive: boolean;
children: JSX.Element;
className: string;
},
HTMLMotionProps<"div">
>;

export default function SlideInOut({
isActive = true,
Expand Down
23 changes: 15 additions & 8 deletions src/components/Aside/Aside.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import type { PropsWithSpread } from "@canonical/react-components";
import { Spinner } from "@canonical/react-components";
import classnames from "classnames";

import SlideInOut from "animations/SlideInOut";
import type { Props as SlideInOutProps } from "animations/SlideInOut";

import "./_aside.scss";

type Props = {
children: JSX.Element;
width?: "wide" | "narrow";
pinned?: boolean;
loading?: boolean;
isSplit?: boolean;
};
export type Props = PropsWithSpread<
{
children: JSX.Element;
className?: string;
width?: "wide" | "narrow";
pinned?: boolean;
loading?: boolean;
isSplit?: boolean;
},
Partial<SlideInOutProps>
>;

export default function Aside({
children,
className,
width,
pinned = false,
loading = false,
Expand All @@ -24,7 +31,7 @@ export default function Aside({
return (
<SlideInOut
isActive={true}
className={classnames("l-aside", {
className={classnames("l-aside", className, {
"is-narrow": width === "narrow",
"is-wide": width === "wide",
"is-pinned": pinned === true,
Expand Down
20 changes: 18 additions & 2 deletions src/components/Aside/_aside.scss
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
@import "vanilla-framework/scss/vanilla";
@import "../../scss/breakpoints";

.l-aside {
// Scope the l-aside within l-application so that it can override the Vanilla styles.
.l-application .l-aside {
// Always fill the screen height.
height: 100vh;
// The aside is within the main element so any long content will make the
// aside scroll up with the page so this needs to be prevented.
position: fixed;

.loading {
align-items: center;
background-color: $color-x-light;
Expand Down Expand Up @@ -34,10 +41,19 @@
&:first-child {
border-right: 1px solid #ccc;
margin-right: 2rem;
padding-right: 2rem;
}
}
}
}
}

.p-panel {
// Panels inside the aside do not need to handle overflow as this is already
// handled by the aside, and doubling up prevents sticky from working in the children.
overflow: initial;

.p-panel__content {
overflow: initial;
}
}
}
4 changes: 3 additions & 1 deletion src/components/ConfirmationModal/ConfirmationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import "./_confirmation-modal.scss";
type Props = {
children?: ReactNode;
buttonRow: ReactNode;
onClose?: () => void;
};

export default function ConfirmationModal({
children,
buttonRow,
onClose,
}: Props): JSX.Element {
const modalRef = useRef<HTMLDivElement>(null);
const portalHost =
Expand All @@ -35,7 +37,7 @@ export default function ConfirmationModal({

return createPortal(
<div className="p-confirmation-modal" ref={modalRef}>
<Modal buttonRow={buttonRow}>
<Modal buttonRow={buttonRow} close={onClose}>
<div>{children}</div>
</Modal>
</div>,
Expand Down
142 changes: 142 additions & 0 deletions src/components/Panel/Panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";

import Panel from "./Panel";

describe("Panel", () => {
it("displays the title and content", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel panelClassName="test-panel" title="Test panel">
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(screen.getByText("Test panel")).toBeInTheDocument();
expect(screen.getByText("Test content")).toBeInTheDocument();
});

it("should give the panel a class name", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel panelClassName="test-panel" title="Test panel">
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(document.querySelector(".p-panel")).toHaveClass("test-panel");
});

it("should clear the search params when escape key is pressed", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel panelClassName="test-panel" title="Test panel">
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
await userEvent.type(window.document.documentElement, "{Escape}", {
skipClick: true,
});
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("");
});

it("should not close if another key is pressed", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel panelClassName="test-panel" title="Test panel">
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
await userEvent.type(window.document.documentElement, "{Enter}", {
skipClick: true,
});
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
});

it("should check if it can close when escape key is pressed", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel
checkCanClose={() => false}
panelClassName="test-panel"
title="Test panel"
>
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
await userEvent.type(window.document.documentElement, "{Escape}", {
skipClick: true,
});
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
});

it("should clear the search params when clicking outside the panel", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel panelClassName="test-panel" title="Test panel">
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
await userEvent.click(window.document.documentElement);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("");
});

it("should not clear the search params when clicking inside the panel", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel panelClassName="test-panel" title="Test panel">
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
await userEvent.click(screen.getByText("Test content"));
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
});

it("should check if it can close when clicking inside the panel", async () => {
window.history.pushState({}, "", "/foo?panel=share-model");
render(
<BrowserRouter>
<Panel
checkCanClose={() => false}
panelClassName="test-panel"
title="Test panel"
>
<>Test content</>
</Panel>
</BrowserRouter>
);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
await userEvent.click(window.document.documentElement);
expect(window.location.pathname).toBe("/foo");
expect(window.location.search).toBe("?panel=share-model");
});
});
92 changes: 92 additions & 0 deletions src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { PropsWithSpread } from "@canonical/react-components";
import { useListener } from "@canonical/react-components";
import classNames from "classnames";
import type { PropsWithChildren, ReactNode, MouseEvent } from "react";
import { forwardRef, useId } from "react";

import Aside from "components/Aside/Aside";
import type { Props as AsideProps } from "components/Aside/Aside";
import PanelHeader from "components/PanelHeader/PanelHeader";
import { useQueryParams } from "hooks/useQueryParams";

type Props = PropsWithSpread<
{
checkCanClose?: (e: KeyboardEvent | MouseEvent) => boolean;
panelClassName: string;
title: ReactNode;
},
PropsWithChildren & AsideProps
>;

export const close = {
// Close panel if Escape key is pressed when panel active
onEscape: (
e: KeyboardEvent,
queryStringSetter: (qs?: string | null) => void,
checkCanClose?: Props["checkCanClose"]
) => {
if (e.code === "Escape") {
if (checkCanClose && !checkCanClose?.(e)) {
return;
}
queryStringSetter(undefined);
}
},
onClickOutside: (
e: MouseEvent,
queryStringSetter: (qs?: string | null) => void,
checkCanClose?: Props["checkCanClose"]
) => {
if (checkCanClose && !checkCanClose?.(e)) {
return;
}
const target = e.target as HTMLElement;
if (!target.closest(".p-panel")) {
queryStringSetter(undefined);
}
},
};

export const Panel = forwardRef<HTMLDivElement, Props>(
(
{ checkCanClose, children, panelClassName, title, ...props }: Props,
ref
) => {
const [, setPanelQs] = useQueryParams<{ panel: string | null }>({
panel: null,
});

useListener(
window,
(e: KeyboardEvent) =>
close.onEscape(
e,
() => setPanelQs(null, { replace: true }),
checkCanClose
),
"keydown"
);
useListener(
window,
(e: MouseEvent) =>
close.onClickOutside(
e,
() => setPanelQs(null, { replace: true }),
checkCanClose
),
"click"
);

const titleId = useId();
return (
<Aside {...props} aria-labelledby={titleId} role="dialog">
<div className={classNames("p-panel", panelClassName)} ref={ref}>
<PanelHeader id={titleId} title={title} />
{children}
</div>
</Aside>
);
}
);

export default Panel;
1 change: 1 addition & 0 deletions src/components/Panel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./Panel";
4 changes: 2 additions & 2 deletions src/components/PanelHeader/PanelHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("PanelHeader", () => {
<MemoryRouter
initialEntries={["/models/user-eggman@external/new-search-aggregate"]}
>
<PanelHeader title={title} />
<PanelHeader id="123" title={title} />
</MemoryRouter>
);
expect(screen.getByText(title)).toHaveClass("p-panel__title");
Expand All @@ -21,7 +21,7 @@ describe("PanelHeader", () => {
window.history.pushState({}, "", "/models?model=cmr&panel=share-model");
render(
<BrowserRouter>
<PanelHeader title="Title" />
<PanelHeader id="123" title="Title" />
</BrowserRouter>
);
const searchParams = new URLSearchParams(window.location.search);
Expand Down
Loading

0 comments on commit 075cefe

Please sign in to comment.