diff --git a/.vscode/settings.json b/.vscode/settings.json index 00fe0693..6da0d31a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,7 @@ "effectful", "effectfully", "execroot", + "expando", "genfiles", "hydroactive", "inlines", diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 66570d43..183fe393 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -1,3 +1,4 @@ +load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") load( "//:index.bzl", "css_library", @@ -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", @@ -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"], diff --git a/docs/components/layout/BUILD.bazel b/docs/components/layout/BUILD.bazel index a6cce71f..c26847b4 100644 --- a/docs/components/layout/BUILD.bazel +++ b/docs/components/layout/BUILD.bazel @@ -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", ], diff --git a/docs/components/layout/layout.css b/docs/components/layout/layout.css index 990a347e..c522a2b6 100644 --- a/docs/components/layout/layout.css +++ b/docs/components/layout/layout.css @@ -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; } diff --git a/docs/components/layout/layout.tsx b/docs/components/layout/layout.tsx index 2e34d8b4..590bceec 100644 --- a/docs/components/layout/layout.tsx +++ b/docs/components/layout/layout.tsx @@ -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. @@ -13,12 +15,20 @@ import { Header } from '../../components/header/header.js'; * @param headChildren Children to render under the `` element. Callers * should *not* render `` 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> @@ -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>; diff --git a/docs/components/layout/layout_test.tsx b/docs/components/layout/layout_test.tsx index 431276f2..cf246a2b 100644 --- a/docs/components/layout/layout_test.tsx +++ b/docs/components/layout/layout_test.tsx @@ -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()', () => { @@ -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(); @@ -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(); + }); }); }); diff --git a/docs/components/nav_pane/BUILD.bazel b/docs/components/nav_pane/BUILD.bazel new file mode 100644 index 00000000..632691c7 --- /dev/null +++ b/docs/components/nav_pane/BUILD.bazel @@ -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"], +) diff --git a/docs/components/nav_pane/nav_pane.css b/docs/components/nav_pane/nav_pane.css new file mode 100644 index 00000000..97f82db2 --- /dev/null +++ b/docs/components/nav_pane/nav_pane.css @@ -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; +} diff --git a/docs/components/nav_pane/nav_pane.tsx b/docs/components/nav_pane/nav_pane.tsx new file mode 100644 index 00000000..925205f4 --- /dev/null +++ b/docs/components/nav_pane/nav_pane.tsx @@ -0,0 +1,59 @@ +import { polyfillDeclarativeShadowDom } from '@rules_prerender/declarative_shadow_dom/preact.mjs'; +import { Template, inlineStyle, includeScript } from '@rules_prerender/preact'; +import { VNode } from 'preact'; +import { Route } from '../../route.mjs'; + +/** Renders a navigation pane with the given routes. */ +export function NavPane({ routes }: { routes: readonly Route[] }): VNode { + return <rp-nav-pane> + <Template shadowrootmode="open"> + <nav> + <RouteList routes={routes} /> + </nav> + + {polyfillDeclarativeShadowDom()} + {includeScript('./nav_pane_script.mjs', import.meta)} + {inlineStyle('./nav_pane.css', import.meta)} + </Template> + </rp-nav-pane>; +} + +declare module 'preact' { + namespace JSX { + interface IntrinsicElements { + 'rp-nav-pane': JSX.HTMLAttributes<HTMLElement>; + } + } +} + +/** Recursively renders all the given routes to nested lists. */ +function RouteList({ routes, depth = 0 }: { + routes: readonly Route[], + depth?: number, +}): VNode { + if (routes.length === 0) { + throw new Error('Must provide at least one route to render.'); + } + + return <ul> + {routes.map(({ label, content }) => { + const hasSublist = typeof content !== 'string'; + const depthClass = `depth-${depth}`; + return <li class={hasSublist ? 'sublist' : undefined}> + {hasSublist + ? <> + {/* Expands and collapses the associated sublist. */} + <button data-list-toggle + class={`list-el ${depthClass}`}> + {label} + </button> + <RouteList routes={content} depth={depth + 1} /> + </> + : <a href={content} class={`list-el ${depthClass}`}> + {label} + </a> + } + </li>; + })} + </ul>; +} diff --git a/docs/components/nav_pane/nav_pane_script.mts b/docs/components/nav_pane/nav_pane_script.mts new file mode 100644 index 00000000..112b8ce4 --- /dev/null +++ b/docs/components/nav_pane/nav_pane_script.mts @@ -0,0 +1,43 @@ +import { component } from 'hydroactive'; + +/** Controls the navigation pane by expanding and collapsing list items. */ +export const NavPane = component('rp-nav-pane', ($) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + $.listen($.host.shadowRoot!, 'click', toggleNavItem); +}); + +declare global { + interface HTMLElementTagNameMap { + 'rp-nav-page': InstanceType<typeof NavPane>; + } +} + +/** Toggles the expanded state of the clicked navigation list item. */ +function toggleNavItem(evt: Event): void { + // Ignore clicks not coming from toggle buttons. + const target = (evt.target as HTMLElement); + if (!target.hasAttribute('data-list-toggle')) { + return; + } + + // Find the `<li>` tag containing the clicked button. + const parentListItem = target.closest('li'); + if (!parentListItem) { + throw new Error('Could not find list item for button'); + } + + // Toggle the sublist's visibility. + parentListItem.classList.toggle('expanded'); + + // Updated the `collapsed` class to be `!expanded`. This _feels_ unnecessary + // but actually isn't. On page load, no items have any classes. After + // expanding and collapsing an item, it has the `collapsed` class. This + // distinguishes "an element which has been closed" from "an element which + // has not been interacted with". This distinction is important for + // animations, which should only play in the former case. + if (parentListItem.classList.contains('expanded')) { + parentListItem.classList.remove('collapsed'); + } else { + parentListItem.classList.add('collapsed'); + } +} diff --git a/docs/components/nav_pane/nav_pane_script_test.mts b/docs/components/nav_pane/nav_pane_script_test.mts new file mode 100644 index 00000000..03f87a0e --- /dev/null +++ b/docs/components/nav_pane/nav_pane_script_test.mts @@ -0,0 +1,55 @@ +import { useDevserver } from '../../../common/testing/devserver.mjs'; +import { useWebDriver, webDriverTestTimeout } from '../../../common/testing/webdriver.mjs'; + +describe('NavPane', () => { + const devserver = useDevserver( + 'docs/components/nav_pane/scripts_test_cases_devserver.sh'); + const wd = useWebDriver(devserver); + + it('does nothing for a flat navigation', async () => { + const browser = wd.get(); + await browser.url('/flat.html'); + + const navPane = await browser.$('rp-nav-pane'); + const navItems = await navPane.shadow$$('.list-el'); + const labels = await Promise.all(navItems.map((el) => el.getText())); + + expect(labels).toEqual([ 'First', 'Second', 'Third' ]); + }, webDriverTestTimeout); + + it('expands a nested route', async () => { + const browser = wd.get(); + await browser.url('/nested.html'); + + const navPane = await browser.$('rp-nav-pane'); + + // Sublist should be hidden (height: 0) by default. + const subList = await navPane.shadow$('li ul'); + const initialSubListHeight = await browser.execute( + (el) => getComputedStyle(el).height, + subList as unknown as HTMLElement, + ); + expect(initialSubListHeight).toBe('0px'); + + // Click the root button, should expand children. + const rootItemBtn = await navPane.shadow$('[data-list-toggle]'); + await rootItemBtn.click(); + + // Check the height of the sublist again, it should be expanded now. + const expandedSubListHeight = await browser.execute( + (el) => getComputedStyle(el).height, + subList as unknown as HTMLElement, + ); + expect(expandedSubListHeight).not.toBe('0px'); + + // Click the root button again, should collapse children. + await rootItemBtn.click(); + + // Check the height of the sublist one more time, should be collapsed. + const collapsedSubListHeight = await browser.execute( + (el) => getComputedStyle(el).height, + subList as unknown as HTMLElement, + ); + expect(collapsedSubListHeight).toBe('0px'); + }, webDriverTestTimeout); +}); diff --git a/docs/components/nav_pane/nav_pane_script_test_cases.tsx b/docs/components/nav_pane/nav_pane_script_test_cases.tsx new file mode 100644 index 00000000..45e62220 --- /dev/null +++ b/docs/components/nav_pane/nav_pane_script_test_cases.tsx @@ -0,0 +1,42 @@ +import { PrerenderResource, renderToHtml } from '@rules_prerender/preact'; +import { NavPane } from './nav_pane.js'; + +/** Generates prerendered test cases of the navigation pane. */ +export default function* (): Generator<PrerenderResource, void, void> { + // Navigation pane with flat hierarchy. + yield PrerenderResource.fromHtml('/flat.html', renderToHtml( + <html> + <head> + <title>Flat + + + + + + )); + + // Navigation pane with a nested hierarchy. + yield PrerenderResource.fromHtml('/nested.html', renderToHtml( + + + Nested + + + + + + )); +} diff --git a/docs/components/nav_pane/nav_pane_test.tsx b/docs/components/nav_pane/nav_pane_test.tsx new file mode 100644 index 00000000..e2b6aee2 --- /dev/null +++ b/docs/components/nav_pane/nav_pane_test.tsx @@ -0,0 +1,64 @@ +import { render } from 'preact-render-to-string'; +import { HTMLElement, parse } from 'node-html-parser'; +import { NavPane } from './nav_pane.js'; +import { Route } from '../../route.mjs'; + +describe('nav_pane', () => { + describe('NavPane()', () => { + it('renders a flat navigation hierarchy', () => { + const routes = [ + { label: 'Home', content: '/' }, + { label: 'About', content: '/about/' }, + { label: 'Contact', content: '/contact/' }, + ] satisfies Route[]; + + const fragment = parse(render()); + + // Renders the custom element. + const root = fragment.firstChild as HTMLElement; + expect(root).not.toBeNull(); + expect(root.tagName).toBe('RP-NAV-PANE'); + + // Renders the list of routes in order. + const navItems = root.querySelectorAll('a'); + expect(navItems.map((el) => el.textContent)) + .toEqual([ 'Home', 'About', 'Contact' ]); + expect(navItems.map((el) => el.getAttribute('href'))) + .toEqual([ '/', '/about/', '/contact/' ]); + }); + + it('renders a nested hierarchy', () => { + const routes = [ + { + label: 'Root', + content: [ + { label: 'First', content: '/first/' }, + { label: 'Second', content: '/second/' }, + ], + }, + ] satisfies Route[]; + + const fragment = parse(render()); + expect(fragment).not.toBeNull(); + + // Renders the root element at depth 0. + const rootItems = fragment.querySelectorAll('li:has(.depth-0)'); + expect(rootItems.length).toBe(1); + const root = rootItems[0]!; + expect(root.querySelector('.list-el')!.textContent).toBe('Root'); + + // Renders first and second at depth 1. + const rootChildren = root.querySelectorAll('li:has(.depth-1)'); + expect(rootChildren.length).toBe(2); + const first = rootChildren[0]!; + expect(first.textContent).toBe('First'); + const second = rootChildren[1]!; + expect(second.textContent).toBe('Second'); + }); + + it('throws an error when given no routes', () => { + expect(() => render()) + .toThrowError(/Must provide at least one route/); + }); + }); +}); diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/docs/route.mts b/docs/route.mts new file mode 100644 index 00000000..c63f46a4 --- /dev/null +++ b/docs/route.mts @@ -0,0 +1,11 @@ +/** + * Recursive data type of route labels which map to either a URL to navigate to + * or a list of child routes to expand. + */ +export interface Route { + /** The text to display to the user. */ + readonly label: string; + + /** Either a link to a page, or a list of nested routes to expand. */ + readonly content: string | Route[]; +} diff --git a/docs/site.tsx b/docs/site.tsx index 21e9c5b3..99941e7f 100644 --- a/docs/site.tsx +++ b/docs/site.tsx @@ -1,5 +1,44 @@ -import { Layout } from './components/layout/layout.js'; import { PrerenderResource, inlineStyle, renderToHtml } from '@rules_prerender/preact'; +import { Layout } from './components/layout/layout.js'; +import { Route } from './route.mjs'; + +/** Docs site routes. */ +export const routes: readonly Route[] = [ + { + label: 'Home', + content: '/', + }, + { + label: 'Tutorials', + content: [ + { + label: 'Getting Started', + content: '/tutorials/getting-started/', + }, + { + label: 'Rendering Markdown', + content: '/tutorials/rendering-markdown/', + }, + ], + }, + { + label: 'Concepts', + content: [ + { + label: 'Components', + content: '/concepts/components/', + }, + { + label: 'Bundling', + content: '/concepts/bundling/', + }, + ], + }, + { + label: 'API Reference', + content: '/reference/', + }, +]; export default function*(): Generator { yield PrerenderResource.fromHtml('/index.html', renderToHtml( @@ -7,6 +46,7 @@ export default function*(): Generator { pageTitle="Documentation Home" headerTitle="rules_prerender" headChildren={inlineStyle('./site.css', import.meta)} + routes={routes} >
Hello World!
diff --git a/docs/theme.css b/docs/theme.css index 244c9168..f194e527 100644 --- a/docs/theme.css +++ b/docs/theme.css @@ -1,5 +1,6 @@ :host { --bazel-green: #0c713a; + --light-gray: #e8eaed; --light-text: white; --dark-text: black;