From 72aabc0c4f64f765be45a0fc1d95016af042aad0 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 12:40:44 -0400 Subject: [PATCH 1/6] Add `wouter-preact` dependency --- package.json | 3 ++- yarn.lock | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 711cf10f1..819eb27a8 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "main": "./lib/index.js", "browserslist": "chrome 70, firefox 70, safari 11.1", "dependencies": { - "highlight.js": "^11.6.0" + "highlight.js": "^11.6.0", + "wouter-preact": "^2.10.0-alpha.1" } } diff --git a/yarn.lock b/yarn.lock index ac0b1038d..95c60d7d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8091,6 +8091,11 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== +wouter-preact@^2.10.0-alpha.1: + version "2.10.0-alpha.1" + resolved "https://registry.yarnpkg.com/wouter-preact/-/wouter-preact-2.10.0-alpha.1.tgz#213394a210d1566f8bd5bfc794909b10cafb09b2" + integrity sha512-V6K8YBIURy9PHbF1e2P0gCuhCy0Oe0GbD1EZ1VxEWLU/rtBZ7IwkTj+6QVJ236NFbCklDuwL68PiUg44sSgq/w== + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz" From ab5e79a5569c4220128aba95c8dd562c5f20166c Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 12:40:44 -0400 Subject: [PATCH 2/6] Use `wouter` routing in pattern library --- .../components/PlaygroundApp.tsx | 248 ++++++++++-------- src/pattern-library/routes.ts | 4 +- 2 files changed, 141 insertions(+), 111 deletions(-) diff --git a/src/pattern-library/components/PlaygroundApp.tsx b/src/pattern-library/components/PlaygroundApp.tsx index 8ee42f3f3..df749aa36 100644 --- a/src/pattern-library/components/PlaygroundApp.tsx +++ b/src/pattern-library/components/PlaygroundApp.tsx @@ -1,13 +1,83 @@ import classnames from 'classnames'; import type { ComponentChildren } from 'preact'; +import { + Route, + Router, + Switch, + Link as RouteLink, + useRoute, +} from 'wouter-preact'; import type { PlaygroundAppProps } from '../'; import { Link, LogoIcon } from '../../'; -import { useRoute } from '../router'; import { componentGroups, getRoutes } from '../routes'; import type { PlaygroundRoute } from '../routes'; import Library from './Library'; +/** + * Header for a primary section of nav + */ +function NavHeader({ children }: { children: ComponentChildren }) { + return

{children}

; +} + +/** + * Secondary section of navigation, with header + */ +function NavSection({ + title, + children, +}: { + title: string; + children: ComponentChildren; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +/** + * Render a list of navigation items + */ +function NavList({ children }: { children: ComponentChildren }) { + return ( + + ); +} + +/** + * A single navigation link + */ +function NavLink({ route }: { route: PlaygroundRoute }) { + const [isActive] = useRoute(route.route ?? ''); + return ( +
  • + {route.route && ( + + + {route.title} + + + )} + {!route.route && ( +
    {route.title}
    + )} +
  • + ); +} + /** * Render web content for the playground application. This includes the "frame" * around the page and a navigation channel, as well as the content rendered @@ -25,124 +95,84 @@ export default function PlaygroundApp({ return { ...route, group: 'custom' }; }); const allRoutes = routes.concat(customRoutes); - const [activeRoute] = useRoute(baseURL, allRoutes); - const content = - activeRoute && activeRoute.component ? ( - - ) : ( - -

    Page not found

    -
    - ); - - /** - * Header for a primary section of nav - */ - function NavHeader({ children }: { children: ComponentChildren }) { - return ( -

    {children}

    - ); - } - - /** - * A single navigation link - */ - function NavLink({ route }: { route: PlaygroundRoute }) { - return ( -
  • - {route.route && ( - - {route.title} - - )} - {!route.route && ( -
    - {route.title} -
    - )} -
  • - ); - } - /** - * Render a list of navigation items - */ - function NavList({ routes }: { routes: PlaygroundRoute[] }) { - return ( -
      - {routes.map(route => ( - + const pageRoutes = ( + <> + {allRoutes + .filter(route => !!route.route) + .map(aRoute => ( + + {aRoute.component ?? aRoute.title} + ))} -
    - ); - } + + ); - /** - * Secondary section of navigation, with header - */ - function NavSection({ - title, - children, - }: { - title: string; - children: ComponentChildren; - }) { - return ( -
    -

    {title}

    - {children} -
    - ); - } const groupKeys = Object.keys(componentGroups) as Array< keyof typeof componentGroups >; return ( -
    -
    -
    -
    -

    - - - Component Library - -

    -
    - +
    +
    + + {pageRoutes} + + +

    Page not found

    +
    +
    +
    +
    -
    {content}
    - -
    + + ); } diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts index 3fafa6e39..650aaafb0 100644 --- a/src/pattern-library/routes.ts +++ b/src/pattern-library/routes.ts @@ -53,7 +53,7 @@ export type PlaygroundRoute = { * not provided, a placeholder entry for this route will be added to the * navigation, with no link. */ - route?: RegExp | string; + route?: string; title: string; component?: FunctionComponent; group: PlaygroundRouteGroup; @@ -63,7 +63,7 @@ export type CustomPlaygroundRoute = Omit; const routes: PlaygroundRoute[] = [ { - route: /^\/?$/, + route: '/', title: 'Home', component: LibraryHome, group: 'home', From 69c25eb6362f8fafd69f823949407972617c0b2d Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 13:07:06 -0400 Subject: [PATCH 3/6] Restore scrolling on navigation and handle fragment navigation Ensure that when navigation to a new page occurs, scroll is reset to the top of the page, unless there is a fragment in the URL. When there is a fragment, scroll the element indicated by the fragment to the top of the page. --- src/pattern-library/components/Library.tsx | 5 ++- .../components/PlaygroundApp.tsx | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/pattern-library/components/Library.tsx b/src/pattern-library/components/Library.tsx index e2150e2f2..65dc12dfd 100644 --- a/src/pattern-library/components/Library.tsx +++ b/src/pattern-library/components/Library.tsx @@ -48,7 +48,10 @@ export type LibraryPageProps = { function Page({ children, intro, title }: LibraryPageProps) { return (
    -
    + {intro && ( diff --git a/src/pattern-library/components/PlaygroundApp.tsx b/src/pattern-library/components/PlaygroundApp.tsx index df749aa36..b217f6c7a 100644 --- a/src/pattern-library/components/PlaygroundApp.tsx +++ b/src/pattern-library/components/PlaygroundApp.tsx @@ -1,10 +1,12 @@ import classnames from 'classnames'; import type { ComponentChildren } from 'preact'; +import { useEffect } from 'preact/hooks'; import { Route, Router, Switch, Link as RouteLink, + useLocation, useRoute, } from 'wouter-preact'; @@ -89,6 +91,41 @@ export default function PlaygroundApp({ extraRoutesTitle = 'Playground', }: PlaygroundAppProps) { const routes = getRoutes(); + const [pathname] = useLocation(); + + useEffect( + /** + * Support hash-based navigation and reset scroll when `wouter` path + * changes. + * - For locations without hash, reset scroll to top of page + * - For locations with hash, scroll to top of fragment-indicated element, + * and ensure it's not obscured by the sticky `#page-header` element. + */ + () => { + const hash = window.location.hash.replace(/^#/, ''); + if (hash) { + const fragElement = document.getElementById(hash); + if (fragElement) { + fragElement.scrollIntoView(); + const pageHeaderElement = document.getElementById('page-header'); + // Height taken up by sticky header on page. Add 8 pixels to give + // some visual padding. + const headerOffset = pageHeaderElement + ? pageHeaderElement.getBoundingClientRect().height + 8 + : 0; + const fragTop = fragElement.getBoundingClientRect().top; + if (fragTop <= headerOffset) { + // Adjustment to accommodate sticky header (only if fragment is at or + // near top of viewport) + window.scrollBy({ top: -1 * (headerOffset - fragTop) }); + } + } + } else { + window.scrollTo(0, 0); + } + }, + [pathname] + ); // Put all of the custom routes into the "custom" group const customRoutes = extraRoutes.map((route): PlaygroundRoute => { From e9dae4b6d4def390c06af056816de3cbfc967820 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 12:40:44 -0400 Subject: [PATCH 4/6] Add `LibraryLink` Add a `LibraryLink` component that wraps the package's `Link` component for linking internally to other `wouter`-routed pattern-library pages. --- src/pattern-library/components/Library.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pattern-library/components/Library.tsx b/src/pattern-library/components/Library.tsx index 65dc12dfd..9d5320eb5 100644 --- a/src/pattern-library/components/Library.tsx +++ b/src/pattern-library/components/Library.tsx @@ -2,8 +2,9 @@ import classnames from 'classnames'; import { toChildArray } from 'preact'; import type { ComponentChildren, JSX } from 'preact'; import { useMemo, useState } from 'preact/hooks'; +import { Link as RouteLink } from 'wouter-preact'; -import { Scroll, ScrollContainer } from '../../'; +import { Link as UILink, Scroll, ScrollContainer } from '../../'; import { jsxToHTML } from '../util/jsx-to-string'; /** @@ -368,12 +369,30 @@ function Usage({ componentName, size = 'md' }: LibraryUsageProps) { ); } +export type LinkProps = { + children: ComponentChildren; + href: string; +}; + +/** + * Render an internal link to another pattern-library page. + * TODO: Support external links + */ +function Link({ children, href }: LinkProps) { + return ( + + {children} + + ); +} + export default { Changelog, ChangelogItem, Code, Demo, Example, + Link, Page, Pattern, Section, From 8fbe6c78cd27a4b4be3a34f6acdbde7d62cedbac Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 13:58:55 -0400 Subject: [PATCH 5/6] Add example of hash-linking --- .../components/patterns/feedback/DialogPage.tsx | 2 ++ .../components/patterns/feedback/ModalPage.tsx | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pattern-library/components/patterns/feedback/DialogPage.tsx b/src/pattern-library/components/patterns/feedback/DialogPage.tsx index 27fbf5cdb..d442e9f29 100644 --- a/src/pattern-library/components/patterns/feedback/DialogPage.tsx +++ b/src/pattern-library/components/patterns/feedback/DialogPage.tsx @@ -165,6 +165,7 @@ export default function DialogPage() { } > @@ -391,6 +392,7 @@ export default function DialogPage() { diff --git a/src/pattern-library/components/patterns/feedback/ModalPage.tsx b/src/pattern-library/components/patterns/feedback/ModalPage.tsx index 484aea2c2..b4818e781 100644 --- a/src/pattern-library/components/patterns/feedback/ModalPage.tsx +++ b/src/pattern-library/components/patterns/feedback/ModalPage.tsx @@ -111,8 +111,14 @@ export default function ModalPage() { Modal {' '} is deprecated. Use - ModalDialog or Dialog instead, which - provide a similar API and enhanced accessibility. + + ModalDialog + {' '} + or{' '} + + Dialog + {' '} + instead, which provide a similar API and enhanced accessibility. From b701a0be053d7a1a787c663ae1d2a4747ef13e38 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 14:25:59 -0400 Subject: [PATCH 6/6] Remove custom router --- src/pattern-library/router.tsx | 157 --------------------------------- 1 file changed, 157 deletions(-) delete mode 100644 src/pattern-library/router.tsx diff --git a/src/pattern-library/router.tsx b/src/pattern-library/router.tsx deleted file mode 100644 index 18c617016..000000000 --- a/src/pattern-library/router.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; - -import type { PlaygroundRoute } from './routes'; - -function routeFromCurrentURL(baseURL: string) { - return location.pathname.slice(baseURL.length); -} - -function isAbsolute(url: string) { - try { - new URL(url); - return true; - } catch { - // URL constructor throws if passed a relative URL - return false; - } -} - -/** - * Scroll page to target of fragment identifier. - * - * @param hash - Fragment including the leading `#` - */ -function scrollToFragment(hash: string) { - const fragmentId = decodeURIComponent(hash.substring(1)); - const fragElement = document.getElementById(fragmentId); - // Vertical offset (px) that should be added to scroll to ensure - // content is not obscured by sticky header on page - const headerOffset = 72; - if (fragElement) { - fragElement.scrollIntoView(); - const fragTop = fragElement.getBoundingClientRect().top; - if (fragTop <= headerOffset) { - // Adjustment to accommodate sticky header (only if fragment is at or - // near top of viewport) - window.scrollBy({ top: -1 * (headerOffset - fragTop) }); - } - } -} - -/** - * Hook that sets up the router for the component library and returns the - * current route. - * - * Clicks on links in the current page to URLs that are under `baseURL` - * are automatically intercepted and handled. - * - * @return - Returns a two-item array with the current route's data and a - * `navigate` function to manually trigger a client-side navigation to another - * route. - */ -export function useRoute( - baseURL: string, - routes: PlaygroundRoute[] -): [PlaygroundRoute | undefined, (e: Event, url: string) => void] { - const [route, setRoute] = useState(() => routeFromCurrentURL(baseURL)); - - // Data associated with the currently-applied route - const routeData = useMemo(() => { - return routes.find(r => { - if (!r.route) { - return false; - } - if (typeof r.route === 'string') { - return r.route === route; - } - return r.route && route.match(r.route); - }); - }, [route, routes]); - const title = `${ - routeData?.title ?? 'Page not found' - }: Hypothesis Component Library`; - - useEffect(() => { - document.title = title; - }, [title]); - - useEffect(() => { - // Reset scrolling after navigation - const hash = location.hash; - - if (!hash) { - window.scrollTo({ top: 0 }); - return; - } - - scrollToFragment(hash); - }, [route]); - - useEffect(() => { - const hashChangeListener = (e: HashChangeEvent) => { - try { - const hash = new URL(e.newURL).hash; - scrollToFragment(hash); - } catch (e) { - // no op - } - }; - const popstateListener = () => { - setRoute(routeFromCurrentURL(baseURL)); - }; - window.addEventListener('hashchange', hashChangeListener); - window.addEventListener('popstate', popstateListener); - return () => { - window.removeEventListener('hashchange', hashChangeListener); - window.removeEventListener('popstate', popstateListener); - }; - }, [baseURL]); - - const navigate = useCallback( - /** - * @param url - Relative or absolute URL. If relative, it is assumed to be - * relative to {@link baseURL} - */ - (event: Event, url: string) => { - if (!isAbsolute(url)) { - url = baseURL + url; - } - const routeURL = new URL(url, location.href); - - event.preventDefault(); - history.pushState({}, '' /* unused */, routeURL); - - setRoute(routeURL.pathname.slice(baseURL.length)); - }, - [baseURL] - ); - - // Intercept clicks on links and trigger navigation to a route within the - // app if appropriate. - useEffect(() => { - const clickListener = (event: Event) => { - const link = (event.target as HTMLElement).closest('a'); - if (!link) { - return; - } - - // Don't handle links that point outside this app or links that open in a - // new tab. - if ( - link.origin !== location.origin || - !link.pathname.startsWith(baseURL) || - link.target !== '' - ) { - return; - } - - navigate(event, link.href); - }; - window.addEventListener('click', clickListener); - return () => { - window.removeEventListener('click', clickListener); - }; - }, [baseURL, navigate]); - - return [routeData, navigate]; -}