Skip to content

Commit

Permalink
Adds navigation panel to docs site.
Browse files Browse the repository at this point in the history
Refs #16.

This was an interesting test case. The panel itself is relatively straightforward, with a hierarchy and expand/collapse behavior. I spent more time than I'd like to admit on the CSS animations trying to get an arrow to rotate, though I did eventually figure it out. The arrow does get noticeably blurry when animated, which is very strange. This behavior applies for Chrome and Firefox. Searching the internet, I found countless hacks which claim to fix this, but none of them worked for me. For the time being, I'm calling this a browser bug.

I also tried to animate the reveal of a route's children, however I decided this is not possible as animating to `height: auto;` is generally not feasible today without compromises I wasn't comfotable making. https://css-tricks.com/using-css-transitions-auto-dimensions/

Beyond the CSS, I think `@rules_prerender` did a good job allowing me to shape the structure of how I solved this particular solution. I was able to decouple routes from the `NavPane` component which made for easier testing. HydroActive was also pretty smooth, though it only binds an event listener and not much more. On its own, that was mostly fine. However testing got complicated _fast_. How and what to assert in a `node-html-parser` test is complicated, verbose, and tedious. I can definitely understand the appeal of snapshot testing, even if I don't think that's a good general solution to this problem. Testing the client code is even more complicated due to a separate `test_cases.tsx` file, multiple build targets, and then the WebDriver tests themselves. User code would need to go out of their way to set up WebDriverIO and Jasmine in addition to all this complexity. This could definitely use some streamlining.

Also I wonder if there might be a better testing approach with in-browser testing vs WebDriver testing. Could we render `test_cases.tsx` at build time, then instrument a test runner which serves them with Jasmine loaded in. If it auto-deferred the root component and ran test code inside the browse, we could take advantage of HydroActive test APIs. Need to think more about how viable this is and where tests would be most useful.
  • Loading branch information
dgp1130 committed Aug 20, 2023
1 parent 6f2db4f commit 4ce3227
Show file tree
Hide file tree
Showing 17 changed files with 614 additions and 8 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"effectful",
"effectfully",
"execroot",
"expando",
"genfiles",
"hydroactive",
"inlines",
Expand Down
16 changes: 16 additions & 0 deletions docs/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin")
load(
"//:index.bzl",
"css_library",
Expand All @@ -6,6 +7,12 @@ load(
)
load("//tools/typescript:defs.bzl", "ts_project")

copy_to_bin(
name = "package",
srcs = ["package.json"],
visibility = [":__subpackages__"],
)

prerender_pages(
name = "site",
entry_point = "./site.js",
Expand All @@ -16,12 +23,21 @@ prerender_pages(
ts_project(
name = "prerender",
srcs = ["site.tsx"],
# Need `"type": "module"` to load `*.js` files output by `*.tsx` compilation.
data = [":package"],
deps = [
":route",
"//docs/components/layout:layout_prerender",
"//:node_modules/@rules_prerender/preact",
],
)

ts_project(
name = "route",
srcs = ["route.mts"],
visibility = [":__subpackages__"],
)

css_library(
name = "styles",
srcs = ["site.css"],
Expand Down
2 changes: 2 additions & 0 deletions docs/components/layout/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ ts_project(
name = "prerender",
srcs = ["layout.tsx"],
deps = [
"//docs:route",
"//docs/components/footer:footer_prerender",
"//docs/components/header:header_prerender",
"//docs/components/nav_pane:nav_pane_prerender",
"//:node_modules/@rules_prerender/preact",
"//:node_modules/preact",
],
Expand Down
13 changes: 12 additions & 1 deletion docs/components/layout/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ body {
min-height: 100vh;
}

body > main {
body > .layout-middle {
flex-grow: 1;

display: flex;
}

body > .layout-middle > rp-nav-pane {
max-width: 300px;
box-shadow: 2px 0 5px 0 gray;
}

body > .layout-middle > main {
flex-grow: 1;
}
24 changes: 20 additions & 4 deletions docs/components/layout/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { inlineStyle } from '@rules_prerender/preact';
import { VNode, ComponentChildren } from 'preact';
import { Footer } from '../../components/footer/footer.js';
import { Header } from '../../components/header/header.js';
import { Footer } from '../footer/footer.js';
import { Header } from '../header/header.js';
import { NavPane } from '../nav_pane/nav_pane.js';
import { Route } from '../../route.mjs';

/**
* Renders the base layout for documentation. Most pages should use this.
Expand All @@ -13,12 +15,20 @@ import { Header } from '../../components/header/header.js';
* @param headChildren Children to render under the `<head>` element. Callers
* should *not* render `<title>` or `<meta charset="utf8">`, `Layout` will
* do that automatically.
* @param routes List of routes to render in the navigation pane.
*/
export function Layout({ pageTitle, headerTitle, children, headChildren }: {
export function Layout({
pageTitle,
headerTitle,
children,
headChildren,
routes = [],
}: {
pageTitle: string,
headerTitle?: string,
children: ComponentChildren,
headChildren?: ComponentChildren,
routes?: readonly Route[],
}): VNode {
return <html lang="en">
<head>
Expand All @@ -29,7 +39,13 @@ export function Layout({ pageTitle, headerTitle, children, headChildren }: {
</head>
<body>
<Header title={headerTitle} />
<main>{children}</main>
<div class="layout-middle">
{routes.length
? <NavPane routes={routes} />
: undefined
}
<main>{children}</main>
</div>
<Footer />
</body>
</html>;
Expand Down
44 changes: 42 additions & 2 deletions docs/components/layout/layout_test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render } from 'preact-render-to-string';
import { HTMLElement, parse } from 'node-html-parser';
import { Layout } from './layout.js';
import { Route } from 'docs/route.mjs';

describe('layout', () => {
describe('Layout()', () => {
Expand Down Expand Up @@ -35,7 +36,7 @@ describe('layout', () => {
expect(footer).not.toBeNull();

// Renders main content.
const main = html.querySelector('body > main');
const main = html.querySelector('body main');
expect(main).not.toBeNull();
const content = main!.firstChild as HTMLElement;
expect(content).not.toBeNull();
Expand Down Expand Up @@ -63,10 +64,49 @@ describe('layout', () => {
}>
</Layout>
));
expect(document).toBeDefined();
expect(document).not.toBeNull();

const meta = document.querySelector('head > meta[content="test"]');
expect(meta).not.toBeNull();
});

it('renders provided routes', () => {
const routes = [
{ label: 'Home', content: '/' },
] satisfies Route[];

const document = parse(render(
<Layout pageTitle="Title" routes={routes}>
</Layout>
));
expect(document).not.toBeNull();

const navPane = document.querySelector('rp-nav-pane');
expect(navPane).not.toBeNull();

expect(navPane!.textContent).toContain('Home');
});

it('does *not* render navigation pane when there are no routes', () => {
const document = parse(render(
<Layout pageTitle="Title">
</Layout>
));
expect(document).not.toBeNull();

const navPane = document.querySelector('rp-nav-pane');
expect(navPane).toBeNull();
});

it('does *not* render navigation pane when routes are empty', () => {
const document = parse(render(
<Layout pageTitle="Title" routes={[]}>
</Layout>
));
expect(document).not.toBeNull();

const navPane = document.querySelector('rp-nav-pane');
expect(navPane).toBeNull();
});
});
});
101 changes: 101 additions & 0 deletions docs/components/nav_pane/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
load(
"//:index.bzl",
"css_library",
"prerender_component",
"prerender_pages",
"web_resources_devserver",
)
load("//tools/jasmine:defs.bzl", "jasmine_node_test", "jasmine_web_test_suite")
load("//tools/typescript:defs.bzl", "ts_project")

prerender_component(
name = "nav_pane",
prerender = ":prerender",
scripts = ":scripts",
styles = ":styles",
visibility = ["//docs:__subpackages__"],
)

ts_project(
name = "prerender",
srcs = ["nav_pane.tsx"],
deps = [
"//docs:route",
"//:node_modules/@rules_prerender/preact",
"//:node_modules/preact",
"//:prerender_components/@rules_prerender/declarative_shadow_dom_prerender",
],
)

ts_project(
name = "prerender_test_lib",
srcs = ["nav_pane_test.tsx"],
testonly = True,
deps = [
":prerender",
"//:node_modules/@types/jasmine",
"//:node_modules/node-html-parser",
"//:node_modules/preact",
"//:node_modules/preact-render-to-string",
],
)

jasmine_node_test(
name = "prerender_test",
deps = [":prerender_test_lib"],
)

ts_project(
name = "scripts",
srcs = ["nav_pane_script.mts"],
tsconfig = "//:tsconfig_client",
deps = ["//:node_modules/hydroactive"],
)

ts_project(
name = "scripts_test_cases_lib",
srcs = ["nav_pane_script_test_cases.tsx"],
data = ["//docs:package"],
testonly = True,
deps = [
":nav_pane_prerender",
"//:node_modules/@rules_prerender/preact",
"//:node_modules/preact",
],
)

prerender_pages(
name = "scripts_test_cases",
entry_point = "./nav_pane_script_test_cases.js",
prerender = ":scripts_test_cases_lib",
testonly = True,
)

web_resources_devserver(
name = "scripts_test_cases_devserver",
resources = ":scripts_test_cases",
testonly = True,
)

ts_project(
name = "scripts_test_lib",
srcs = ["nav_pane_script_test.mts"],
testonly = True,
data = [":scripts_test_cases_devserver"],
deps = [
"//common/testing:devserver",
"//common/testing:webdriver",
"//:node_modules/@types/jasmine",
],
)

jasmine_web_test_suite(
name = "scripts_test",
deps = [":scripts_test_lib"],
)

css_library(
name = "styles",
srcs = ["nav_pane.css"],
deps = ["//docs:theme"],
)
101 changes: 101 additions & 0 deletions docs/components/nav_pane/nav_pane.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
@import '../../theme.css';

:host {
font-size: 1.5rem;
background-color: var(--light-gray);

--rp-nav-pane--indentation: 1.5rem;
}

/* Root `<ul>` element. */
nav > ul {
padding: 0.5rem 0;
}

/* Remove user agent styles from all `<ul>` elements. */
ul {
list-style: none;
margin-block: 0;
margin-inline: 0;
padding-inline: 0;
}

li ul {
/*
* Hide sublists by default. Cannot use `display: none;` for two reasons:
* 1. Changing to `display: block;` triggers nested animations to play
* immediately, causing sublist `>` characters to rotate when revealed.
* 2. `display: none;` implicitly sets `width: 0; height: 0;`. We actually
* want to preserve `width: auto;` so the width of the navigation pane
* is dictated by the longest text in it, even when that text is hidden.
*/
height: 0;
overflow: hidden;
}

/* Show sublists when the `expanded` class is applied. */
li.expanded > ul {
height: auto;
}

/* Apply `>` to any expandable lists. */
li.sublist > button::after {
content: '>';
float: right;
font-family: monospace;
will-change: animation-name transform;

animation-duration: 0.25s;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}

/* Rotate `>` when list is expanded. */
li.sublist.expanded > button::after {
animation-name: rotateExpando;
}

@keyframes rotateExpando {
to { transform: rotate(90deg); }
}

/* Un-rotate `>` when list is collapsed. */
li.sublist.collapsed > button::after {
/* It should probably be possible to use `rotateExpando` backwards instead
of defining a second animation, but attempting to do so breaks both
animations for some unknown reason. */
animation-name: unrotateExpando;
}

@keyframes unrotateExpando {
from { transform: rotate(90deg); }
}

.list-el {
display: block;
padding: 0.25rem 0.75rem;
}

.list-el:hover {
background-color: lightgray;
}

/* Indent each level of the nested lists. */
.list-el.depth-1 { padding-left: var(--rp-nav-pane--indentation); }
.list-el.depth-2 { padding-left: calc(2 * var(--rp-nav-pane--indentation)); }

/* Clear built-in button styles to align with anchor tags. */
button {
background: none;
border: none;
font-size: 1.5rem;
width: 100%;
text-align: left;
font: inherit;
}

/* Clear built-in anchor styles to align with button tags. */
a {
color: inherit;
text-decoration: none;
}
Loading

0 comments on commit 4ce3227

Please sign in to comment.