Skip to content

Commit

Permalink
BodyPortaled SidebarNav (#54)
Browse files Browse the repository at this point in the history
* change navbar to a <nav> element

* sidebarnav body portal component

* don't run animation if the value is unchanged

* update snapshots from zindex change

* more snapshot updates from zindex change

* fix some bugs with the sidebar, remove unused bodyportal changes

* bump version

* main probably doesn't need to have its own index

* bodyportal needs to take an id for accessiblilty use (aria-controls)

* it has to be passed to be used

* let the top navbar cast a shadow onto the sidebar

* improve the stories css

* fix backdrop styles and ensure toggle is refocused

* fix snapshots

* track and restore scroll state for body portal version

* fix react warning and some code coverage

* remove unnecessary wrapper

* fix some issues with re-renders

* split up SidebarNav

* this does not need to be exported externally
  • Loading branch information
jivey authored Sep 10, 2024
1 parent 35de9bc commit 637c585
Show file tree
Hide file tree
Showing 17 changed files with 886 additions and 330 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openstax/ui-components",
"version": "1.10.5",
"version": "1.10.6",
"license": "MIT",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
Expand Down
26 changes: 26 additions & 0 deletions src/components/BodyPortal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,32 @@ describe('BodyPortal', () => {
Footer stuff
</footer>
</body>
`);
});

it('takes an id and testid', () => {
render(
<BodyPortalSlotsContext.Provider value={['header', 'root']}>
<BodyPortal slot='header' tagName='header' id='orange' data-testid='blue'>
Now you're thinking with portals
</BodyPortal>
</BodyPortalSlotsContext.Provider>,
{ container: root }
);

expect(document.body).toMatchInlineSnapshot(`
<body>
<header
data-portal-slot="header"
data-testid="blue"
id="orange"
>
Now you're thinking with portals
</header>
<main
id="root"
/>
</body>
`);
});
});
12 changes: 11 additions & 1 deletion src/components/BodyPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ export type BodyPortalProps = React.PropsWithChildren<{
role?: string;
slot?: string;
tagName?: string;
id?: string;
'data-testid'?: string;
}>;

export const BodyPortal = React.forwardRef<HTMLElement, BodyPortalProps>((
{ children, className, role, slot, tagName }, ref?: React.ForwardedRef<HTMLElement>
{ children, className, role, slot, tagName, id, ...props }, ref?: React.ForwardedRef<HTMLElement>
) => {
const tag = tagName?.toUpperCase() ?? 'DIV';
const internalRef = React.useRef<HTMLElement>(document.createElement(tag));
Expand All @@ -51,6 +53,10 @@ export const BodyPortal = React.forwardRef<HTMLElement, BodyPortalProps>((

if (className) { element.classList.add(...className.split(' ')); }

if (id) { element.id = id; }

if (props['data-testid']) { element.dataset.testid = props['data-testid']; }

if (role) { element.setAttribute('role', role); }

if (slot) { element.dataset.portalSlot = slot; }
Expand All @@ -67,6 +73,10 @@ export const BodyPortal = React.forwardRef<HTMLElement, BodyPortalProps>((
if (role) { element.removeAttribute('role'); }

if (className) { element.classList.remove(...className.split(' ')); }

if (id) { element.id = ''; }

if (props['data-testid']) { delete element.dataset.testid; }
};
}, [bodyPortalOrderedRefs, className, role, slot, tag]);

Expand Down
2 changes: 1 addition & 1 deletion src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const NavBar = ({ logo = false, maxWidth, navDesktopHeight, navMobileHeig
const {alt = 'OpenStax Logo', ...anchorProps} = logoIsObject ? logo : {};
const logoComponent = logo ? <OpenstaxLogo alt={alt} /> : null;

return <BarWrapper role="toolbar" slot='nav' {...props}>
return <BarWrapper tagName='nav' slot='nav' {...props}>
<StyledNavBar
maxWidth={maxWidth}
navDesktopHeight={navDesktopHeight || Constants.navDesktopHeight}
Expand Down
200 changes: 187 additions & 13 deletions src/components/SidebarNav.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import {
fireEvent,
act,
waitFor,
cleanup,
} from "@testing-library/react";
import userEvent, { UserEvent } from "@testing-library/user-event";
import { SidebarNav } from "./SidebarNav";
import { BodyPortalSidebarNav, SidebarNav, SidebarNavBase } from "./SidebarNav";
import "@testing-library/jest-dom";
import { BodyPortalSlotsContext } from "./BodyPortalSlotsContext";

jest.useFakeTimers();

describe("SidebarNav", () => {
let user: UserEvent;
Expand Down Expand Up @@ -75,14 +79,20 @@ describe("SidebarNav", () => {
expect(component.asFragment()).toMatchSnapshot();

expect(screen.getByRole("navigation")).not.toHaveClass("collapsed");
expect(screen.getByRole("navigation")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("sidebarnav-toggle")).toHaveAttribute(
"aria-expanded",
"true",
);

act(() => {
fireEvent.click(screen.getByTestId("sidebarnav-toggle"));
});

expect(screen.getByRole("navigation")).toHaveClass("collapsed");
expect(screen.getByRole("navigation")).toHaveAttribute("aria-expanded", "false");
expect(screen.getByTestId("sidebarnav-toggle")).toHaveAttribute(
"aria-expanded",
"false",
);
expect(component.asFragment()).toMatchSnapshot();

act(() => {
Expand Down Expand Up @@ -120,11 +130,7 @@ describe("SidebarNav", () => {
});

it("collapses on outside click", async () => {
render(
<SidebarNav isMobile={true}>
Content
</SidebarNav>
);
render(<SidebarNav isMobile={true}>Content</SidebarNav>);

act(() => {
fireEvent.click(screen.getByTestId("sidebarnav-toggle"));
Expand All @@ -144,11 +150,7 @@ describe("SidebarNav", () => {
});

it("doesn't collapse on outside click when mobile is false", async () => {
render(
<SidebarNav isMobile={false}>
Content
</SidebarNav>
);
render(<SidebarNav isMobile={false}>Content</SidebarNav>);

await waitFor(() => {
expect(screen.getByTestId("sidebarnav")).not.toHaveClass("collapsed");
Expand All @@ -166,4 +168,176 @@ describe("SidebarNav", () => {
expect(screen.getByTestId("sidebarnav")).not.toHaveClass("collapsed");
});
});

describe("SidebarNavBase", () => {
it("outside clicks don't set nav to collapsed if the ref is null", async () => {
const setNavCollapsedFn = jest.fn();
render(
<SidebarNavBase
isMobile={true}
navIsCollapsed={false}
setNavIsCollapsed={setNavCollapsedFn}
>
Content
</SidebarNavBase>,
);
// setNavIsCollapsed fires on mount
expect(setNavCollapsedFn).toHaveBeenCalledTimes(1);
setNavCollapsedFn.mockReset();
fireEvent.mouseDown(document);

expect(setNavCollapsedFn).not.toHaveBeenCalled();

cleanup();

render(
<SidebarNavBase
isMobile={true}
navIsCollapsed={false}
setNavIsCollapsed={setNavCollapsedFn}
sidebarNavRef={{ current: null }}
>
Content
</SidebarNavBase>,
);

setNavCollapsedFn.mockReset();
fireEvent.mouseDown(document);

expect(setNavCollapsedFn).not.toHaveBeenCalled();
});
});

describe("BodyPortalSidebarNav", () => {
let root: HTMLElement;

beforeEach(() => {
root = document.createElement("main");
root.id = "root";
document.body.append(root);
});

it("uses a BodyPortal", async () => {
render(
<BodyPortalSlotsContext.Provider value={["sidebar", "root"]}>
<BodyPortalSidebarNav isMobile={false}>
Sidebar Nav
</BodyPortalSidebarNav>
Main
</BodyPortalSlotsContext.Provider>,
{ container: root },
);

expect(document.body).toMatchInlineSnapshot(`
<body>
<nav
class="sc-jSMfEi cTSawD"
data-portal-slot="sidebar"
data-testid="sidebarnav"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<button
aria-expanded="true"
aria-label="Collapse navigation"
class="sc-hKMtZM brWRpF"
data-testid="sidebarnav-toggle"
>
<svg
fill="none"
height="10"
viewBox="0 0 8 10"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.1266 5.38862L5.57702 9.83902C5.79166 10.0537 6.13964 10.0537 6.35426 9.83902L6.87333 9.31995C7.0876 9.10568 7.08801 8.75841 6.87424 8.54363L3.34721 4.99999L6.87424 1.45637C7.08801 1.24159 7.0876 0.89432 6.87333 0.680047L6.35426 0.160979C6.13962 -0.0536598 5.79164 -0.0536598 5.57702 0.160979L1.12662 4.61138C0.911981 4.826 0.911981 5.17398 1.1266 5.38862Z"
fill="#959595"
/>
</svg>
</button>
<div
class="sc-gsnTZi hSiqlK"
data-testid="nav-body"
>
Sidebar Nav
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</nav>
<main
id="root"
>
Main
</main>
</body>
`);
});

it("adds animation classes when opening and closing", () => {
// Start expanded
render(
<BodyPortalSlotsContext.Provider value={["sidebar", "root"]}>
<BodyPortalSidebarNav isMobile={false}>
Sidebar Nav
</BodyPortalSidebarNav>
Main
</BodyPortalSlotsContext.Provider>,
{ container: root },
);

expect(screen.getByRole("navigation")).not.toHaveClass("collapsing");
expect(screen.getByRole("navigation")).not.toHaveClass("expanding");

// collapse
act(() => {
fireEvent.click(screen.getByTestId("sidebarnav-toggle"));
});

expect(screen.getByRole("navigation")).toHaveClass("collapsing");
expect(screen.getByRole("navigation")).not.toHaveClass("expanding");

act(() => {
jest.advanceTimersByTime(300);
});

expect(screen.getByRole("navigation")).not.toHaveClass("collapsing");

// expand
act(() => {
fireEvent.click(screen.getByTestId("sidebarnav-toggle"));
});

expect(screen.getByRole("navigation")).not.toHaveClass("collapsing");
expect(screen.getByRole("navigation")).toHaveClass("expanding");

act(() => {
jest.advanceTimersByTime(300);
});

expect(screen.getByRole("navigation")).not.toHaveClass("expanding");
});
});

it("handles the onScroll event", async () => {
render(
<SidebarNav isMobile={false}>
{() => <div style={{ height: "200vh" }}>Content</div>}
</SidebarNav>,
);

const navBody = screen.getByTestId("nav-body");

act(() => {
fireEvent.scroll(navBody, { target: { scrollTop: 100 } });
});

await waitFor(() => {
expect(navBody.scrollTop).toBe(100);
});
});
});
Loading

0 comments on commit 637c585

Please sign in to comment.