taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 1fa9854ee9c4394c9b16044c52a53d6b461b1082
parent c901ff42295811d3b5a91cf5cea1a870db7007b5
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 00:07:07 +0200

web-util: make stories browser mobile-friendly

Diffstat:
Apackages/web-util/src/stories-utils.test.tsx | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/stories-utils.tsx | 714++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/web-util/src/stories.html | 1+
3 files changed, 770 insertions(+), 154 deletions(-)

diff --git a/packages/web-util/src/stories-utils.test.tsx b/packages/web-util/src/stories-utils.test.tsx @@ -0,0 +1,209 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { h, render } from "preact"; +import { act } from "preact/test-utils"; +import { renderStories } from "./stories-utils.js"; + +function installDom(mobile: boolean): Window { + const window = new Window({ url: "https://stories.example/examples" }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string): MediaQueryList => + ({ + matches: mobile, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, + }) as MediaQueryList, + }); + + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + HTMLSelectElement: window.HTMLSelectElement, + location: window.location, + history: window.history, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + document.body.innerHTML = '<div id="container"></div>'; + return window; +} + +function mountStories(): void { + renderStories( + { + forms: { + ExampleForm: { + default: { title: "Example form" }, + Basic: () => <div>Mobile-compatible story</div>, + }, + }, + }, + { strings: { en: {} } }, + ); +} + +async function settle(): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function eventually(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt++) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await settle(); + } + } + throw lastError; +} + +async function unmount(window: Window): Promise<void> { + await act(() => render(null, document.getElementById("container")!)); + await window.happyDOM.abort(); +} + +test("mobile navigation behaves as an accessible slide-over", async () => { + const window = installDom(true); + await act(() => mountStories()); + + const menuButton = document.querySelector<HTMLButtonElement>( + 'button[aria-label="Open stories navigation"]', + )!; + const sidebar = document.getElementById("taler-stories-sidebar")!; + assert.equal(menuButton.getAttribute("aria-expanded"), "false"); + assert.equal(sidebar.getAttribute("aria-hidden"), "true"); + + menuButton.focus(); + await act(() => menuButton.click()); + await eventually(() => assert.equal(document.body.style.overflow, "hidden")); + assert.equal(menuButton.getAttribute("aria-expanded"), "true"); + assert.equal(sidebar.getAttribute("role"), "dialog"); + assert.equal(sidebar.getAttribute("aria-modal"), "true"); + assert.equal(document.body.style.overflow, "hidden"); + assert.equal( + document.activeElement?.getAttribute("aria-label"), + "Close stories navigation", + ); + + const groupButton = document.querySelector<HTMLButtonElement>( + 'button[aria-controls="story-group-forms"]', + )!; + await act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + }), + ); + }); + assert.equal(document.activeElement, groupButton); + + await act(() => groupButton.click()); + assert.equal(groupButton.getAttribute("aria-expanded"), "true"); + await act(() => + document + .querySelector<HTMLAnchorElement>( + 'a[href="#forms-Example%20form-Basic"]', + )! + .click(), + ); + await eventually(() => assert.equal(document.body.style.overflow, "")); + + assert.match( + document.querySelector("main")!.textContent, + /Mobile-compatible/, + ); + assert.equal(window.location.hash, "#forms-Example%20form-Basic"); + assert.equal(menuButton.getAttribute("aria-expanded"), "false"); + assert.equal(document.body.style.overflow, ""); + assert.equal(document.activeElement, menuButton); + + await act(() => menuButton.click()); + await eventually(() => assert.equal(document.body.style.overflow, "hidden")); + await act(() => + document.querySelector<HTMLElement>(".taler-stories-backdrop")!.click(), + ); + await eventually(() => + assert.equal(menuButton.getAttribute("aria-expanded"), "false"), + ); + assert.equal(menuButton.getAttribute("aria-expanded"), "false"); + + await act(() => menuButton.click()); + await eventually(() => assert.equal(document.body.style.overflow, "hidden")); + await act(() => + document + .querySelector<HTMLButtonElement>( + 'button[aria-label="Close stories navigation"]', + )! + .click(), + ); + await eventually(() => + assert.equal(menuButton.getAttribute("aria-expanded"), "false"), + ); + + await act(() => menuButton.click()); + await eventually(() => assert.equal(document.body.style.overflow, "hidden")); + await act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + await eventually(() => + assert.equal(menuButton.getAttribute("aria-expanded"), "false"), + ); + assert.equal(menuButton.getAttribute("aria-expanded"), "false"); + + await unmount(window); +}); + +test("desktop navigation remains persistently available", async () => { + const window = installDom(false); + await act(() => mountStories()); + + const sidebar = document.getElementById("taler-stories-sidebar")!; + assert.equal(sidebar.getAttribute("role"), null); + assert.equal(sidebar.getAttribute("aria-modal"), null); + assert.equal(sidebar.getAttribute("aria-hidden"), null); + assert.equal(document.querySelector("main")!.inert, false); + + await unmount(window); +}); diff --git a/packages/web-util/src/stories-utils.tsx b/packages/web-util/src/stories-utils.tsx @@ -29,54 +29,309 @@ import { render, VNode, } from "preact"; -import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { useEffect, useErrorBoundary, useRef, useState } from "preact/hooks"; import { ExampleItemSetup } from "./tests/hook.js"; -const Page: FunctionalComponent = ({ children }): VNode => { - return ( - <div - style={{ - fontFamily: "Arial, Helvetica, sans-serif", - width: "100%", - display: "flex", - flexDirection: "row", - }} - > - {children} - </div> - ); -}; +const MOBILE_MEDIA_QUERY = "(max-width: 767px)"; -const SideBar: FunctionalComponent<{ width: number }> = ({ - width, - children, -}): VNode => { - return ( - <div - style={{ - minWidth: width, - height: "calc(100vh - 20px)", - overflowX: "hidden", - overflowY: "visible", - scrollBehavior: "smooth", - }} - > - {children} - </div> - ); -}; +const STORY_BROWSER_STYLES = ` + html, body { + margin: 0; + min-height: 100%; + } -const Content: FunctionalComponent = ({ children }): VNode => { - return ( - <div - style={{ - width: "100%", - padding: 20, - }} - > - {children} - </div> - ); + .taler-stories-page, + .taler-stories-toolbar *, + .taler-stories-sidebar *, + .taler-stories-content { + box-sizing: border-box; + } + + .taler-stories-page { + min-height: 100vh; + min-height: 100dvh; + width: 100%; + display: flex; + align-items: flex-start; + color: #172033; + background: #f7f8fa; + font-family: Arial, Helvetica, sans-serif; + } + + .taler-stories-toolbar { + display: none; + } + + .taler-stories-sidebar { + position: sticky; + top: 0; + z-index: 20; + flex: 0 0 260px; + width: 260px; + height: 100vh; + height: 100dvh; + overflow-x: hidden; + overflow-y: auto; + padding: 16px 12px 24px; + border-right: 1px solid #d9dee8; + background: #ffffff; + box-shadow: 2px 0 8px rgba(23, 32, 51, 0.04); + scroll-behavior: smooth; + } + + .taler-stories-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 44px; + margin-bottom: 8px; + } + + .taler-stories-sidebar-title { + margin: 0; + font-size: 18px; + line-height: 1.25; + } + + .taler-stories-close-button, + .taler-stories-menu-button, + .taler-stories-group-button { + border: 0; + font: inherit; + cursor: pointer; + } + + .taler-stories-close-button { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 8px; + color: #3b455a; + background: transparent; + font-size: 24px; + } + + .taler-stories-close-button:hover, + .taler-stories-close-button:focus-visible, + .taler-stories-menu-button:hover, + .taler-stories-menu-button:focus-visible { + background: #e9edf4; + outline: 2px solid transparent; + } + + .taler-stories-language { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 44px; + margin-bottom: 12px; + font-size: 14px; + font-weight: 600; + } + + .taler-stories-language select { + min-height: 36px; + max-width: 150px; + border: 1px solid #b8c0cf; + border-radius: 6px; + padding: 4px 8px; + background: #ffffff; + } + + .taler-stories-navigation { + display: flex; + flex-direction: column; + gap: 8px; + } + + .taler-stories-group { + overflow: hidden; + border: 1px solid #d9dee8; + border-radius: 8px; + background: #ffffff; + } + + .taler-stories-group-button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + min-height: 44px; + padding: 8px 10px; + color: #172033; + background: #f1f3f7; + text-align: left; + font-weight: 700; + } + + .taler-stories-group-button:hover, + .taler-stories-group-button:focus-visible { + background: #e3e8f0; + outline: 2px solid #526fbd; + outline-offset: -2px; + } + + .taler-stories-group-chevron { + transition: transform 150ms ease; + } + + .taler-stories-group-button[aria-expanded="true"] .taler-stories-group-chevron { + transform: rotate(90deg); + } + + .taler-stories-components, + .taler-stories-examples { + margin: 0; + padding: 0; + list-style: none; + } + + .taler-stories-components { + padding: 8px; + } + + .taler-stories-component-name { + display: block; + padding: 6px 8px 4px; + color: #596579; + font-size: 13px; + font-weight: 700; + overflow-wrap: anywhere; + } + + .taler-stories-example-link { + display: flex; + align-items: center; + min-height: 44px; + margin-bottom: 3px; + padding: 8px; + border-radius: 6px; + color: #263248; + text-decoration: none; + overflow-wrap: anywhere; + } + + .taler-stories-example-link:hover, + .taler-stories-example-link:focus-visible { + background: #edf1f7; + outline: 2px solid #526fbd; + outline-offset: -2px; + } + + .taler-stories-example-link[aria-current="page"] { + color: #ffffff; + background: #3156a3; + font-weight: 700; + } + + .taler-stories-content { + flex: 1 1 auto; + min-width: 0; + padding: clamp(16px, 3vw, 32px); + } + + .taler-stories-backdrop { + display: none; + } + + @media (max-width: 767px) { + .taler-stories-page { + display: block; + } + + .taler-stories-toolbar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + width: 100%; + padding: 6px 10px; + border-bottom: 1px solid #d9dee8; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 2px 8px rgba(23, 32, 51, 0.08); + } + + .taler-stories-menu-button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 8px; + color: #263248; + background: transparent; + font-size: 24px; + } + + .taler-stories-current-story { + min-width: 0; + overflow: hidden; + color: #3b455a; + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .taler-stories-sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 50; + width: min(20rem, 88vw); + height: 100vh; + height: 100dvh; + padding-top: 8px; + transform: translateX(-105%); + visibility: hidden; + box-shadow: 8px 0 24px rgba(23, 32, 51, 0.24); + transition: + transform 180ms ease, + visibility 180ms ease; + } + + .taler-stories-sidebar.is-open { + transform: translateX(0); + visibility: visible; + } + + .taler-stories-close-button { + display: inline-flex; + } + + .taler-stories-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: block; + border: 0; + background: rgba(15, 23, 42, 0.5); + } + + .taler-stories-content { + width: 100%; + min-width: 0; + padding: 12px; + } + } + + @media (prefers-reduced-motion: reduce) { + .taler-stories-sidebar, + .taler-stories-group-chevron { + transition: none; + } + } +`; + +const Page: FunctionalComponent = ({ children }): VNode => { + return <div class="taler-stories-page">{children}</div>; }; function findByGroupComponentName( @@ -136,21 +391,35 @@ function ExampleList({ selected: ExampleItem | undefined; onSelectStory: (i: ExampleItem, id: string) => void; }): VNode { - const [isOpen, setOpen] = useState(selected && selected.group === name); + const [isOpen, setOpen] = useState(selected?.group === name); + const groupId = `story-group-${encodeURIComponent(name)}`; + + useEffect(() => { + if (selected?.group === name) { + setOpen(true); + } + }, [name, selected?.group]); + return ( - <ol style={{ padding: 4, margin: 0 }}> - <div - style={{ backgroundColor: "lightcoral", cursor: "pointer" }} + <section class="taler-stories-group"> + <button + type="button" + class="taler-stories-group-button" + aria-expanded={isOpen} + aria-controls={groupId} onClick={() => setOpen(!isOpen)} > - {name} - </div> - <div style={{ display: isOpen ? undefined : "none" }}> + <span>{name}</span> + <span class="taler-stories-group-chevron" aria-hidden="true"> + › + </span> + </button> + <ul id={groupId} class="taler-stories-components" hidden={!isOpen}> {list.map((k) => ( <li key={k.name}> - <dl style={{ margin: 0 }}> - <dt>{k.name}</dt> - {k.examples.map((r, i) => { + <span class="taler-stories-component-name">{k.name}</span> + <ul class="taler-stories-examples"> + {k.examples.map((r) => { const e = encodeURIComponent; const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; const isSelected = @@ -159,48 +428,27 @@ function ExampleList({ selected.group === r.group && selected.name === r.name; return ( - <dd - id={eId} - key={r.name} - style={{ - backgroundColor: isSelected - ? "green" - : i % 2 - ? "lightgray" - : "lightblue", - marginLeft: "1em", - padding: 4, - cursor: "pointer", - borderRadius: 4, - marginBottom: 4, - }} - onClick={(e) => { - e.preventDefault(); - location.hash = `#${eId}`; - onSelectStory(r, eId); - history.pushState({}, "", `#${eId}`); - }} - > + <li id={eId} key={r.name}> <a + class="taler-stories-example-link" href={`#${eId}`} - style={{ color: "black" }} + aria-current={isSelected ? "page" : undefined} onClick={(e) => { e.preventDefault(); - location.hash = `#${eId}`; - onSelectStory(r, eId); history.pushState({}, "", `#${eId}`); + onSelectStory(r, eId); }} > {r.name} </a> - </dd> + </li> ); })} - </dl> + </ul> </li> ))} - </div> - </ol> + </ul> + </section> ); } @@ -214,25 +462,32 @@ function PreventLinkNavigation({ }: { children: ComponentChildren; }): VNode { - return ( - <div - onClick={(e) => { - let t: any = e.target; - do { - if (t.localName === "a" && t.getAttribute("href")) { - alert(`should navigate to: ${t.attributes.href.value}`); - e.stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - return false; - } - } while ((t = t.parentNode)); - return true; - }} - > - {children} - </div> - ); + const containerRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + const preventNavigation = (event: MouseEvent): void => { + if (!(event.target instanceof Element)) { + return; + } + const link = event.target.closest<HTMLAnchorElement>("a[href]"); + const href = link?.getAttribute("href"); + if (!link || !href || !container.contains(link)) { + return; + } + alert(`should navigate to: ${href}`); + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + }; + container.addEventListener("click", preventNavigation); + return () => container.removeEventListener("click", preventNavigation); + }, []); + + return <div ref={containerRef}>{children}</div>; } function ErrorReport({ @@ -389,7 +644,7 @@ function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { typeof value.default.title === "string" ? value.default.title : undefined; - } catch (e) { + } catch { throw Error( `Could not defined if it is component or folder ${groupName}: ${JSON.stringify( value, @@ -413,6 +668,37 @@ interface Props { langs: Record<string, object>; } +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState( + typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia(query).matches + : false, + ); + + useEffect(() => { + const mediaQuery = window.matchMedia(query); + const updateMatches = (): void => setMatches(mediaQuery.matches); + updateMatches(); + mediaQuery.addEventListener("change", updateMatches); + return () => mediaQuery.removeEventListener("change", updateMatches); + }, [query]); + + return matches; +} + +function focusableElements(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll<HTMLElement>( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter( + (element) => + !element.hidden && + !element.closest("[hidden]") && + !element.closest('[aria-hidden="true"]'), + ); +} + function Application({ langs, examplesInGroups, @@ -434,10 +720,16 @@ function Application({ const [selected, updateSelected] = useState<ExampleItem | undefined>( initialSelection, ); - const [sidebarWidth, setSidebarWidth] = useState(200); + const [navigationOpen, setNavigationOpen] = useState(false); + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); + const initialHashRef = useRef(url.hash); + const menuButtonRef = useRef<HTMLButtonElement>(null); + const sidebarRef = useRef<HTMLElement>(null); + const contentRef = useRef<HTMLElement>(null); + useEffect(() => { - if (url.hash) { - const hash = url.hash.substring(1); + if (initialHashRef.current) { + const hash = initialHashRef.current.substring(1); const found = document.getElementById(hash); if (found) { setTimeout(() => { @@ -449,60 +741,174 @@ function Application({ } }, []); + useEffect(() => { + if (contentRef.current) { + contentRef.current.inert = isMobile && navigationOpen; + } + }, [isMobile, navigationOpen]); + + useEffect(() => { + if (!isMobile) { + setNavigationOpen(false); + } + }, [isMobile]); + + useEffect(() => { + if (!isMobile || !navigationOpen) { + return; + } + + const previousFocus = document.activeElement as HTMLElement | null; + const menuButton = menuButtonRef.current; + const previousBodyOverflow = document.body.style.overflow; + const sidebar = sidebarRef.current; + document.body.style.overflow = "hidden"; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + setNavigationOpen(false); + return; + } + if (event.key !== "Tab" || !sidebar) { + return; + } + const focusable = focusableElements(sidebar); + if (focusable.length === 0) { + event.preventDefault(); + sidebar.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + queueMicrotask(() => { + const currentSidebar = sidebarRef.current; + if (currentSidebar) { + focusableElements(currentSidebar)[0]?.focus(); + } + }); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousBodyOverflow; + (previousFocus ?? menuButton)?.focus(); + }; + }, [isMobile, navigationOpen]); + const GroupWrapper = getWrapperForGroup(selected?.group || "default"); const ExampleContent = getContentForExample(selected, examplesInGroups); + const selectedLabel = selected + ? `${selected.component} / ${selected.name}` + : "Select a story"; - //style={{ "--with-size": `${sidebarWidth}px` }} return ( - <Page> - {/* <LiveReload /> */} - <SideBar width={sidebarWidth}> - <div> - Language: - <select - value={currentLang} - onChange={(e) => { - const url = new URL(window.location.href); - url.searchParams.set("lang", e.currentTarget.value); - window.location.href = url.href; - }} + <Fragment> + <style>{STORY_BROWSER_STYLES}</style> + <Page> + <header class="taler-stories-toolbar"> + <button + ref={menuButtonRef} + type="button" + class="taler-stories-menu-button" + aria-label="Open stories navigation" + aria-controls="taler-stories-sidebar" + aria-expanded={navigationOpen} + onClick={() => setNavigationOpen(true)} > - {Object.keys(langs).map((l) => ( - <option key={l}>{l}</option> - ))} - </select> - </div> - {examplesInGroups.map((group) => ( - <ExampleList - key={group.title} - name={group.title} - list={group.list} - selected={selected} - onSelectStory={(item, htmlId) => { - document.getElementById(htmlId)?.scrollIntoView({ - block: "center", - }); - updateSelected(item); - }} + ☰ + </button> + <span class="taler-stories-current-story" title={selectedLabel}> + {selectedLabel} + </span> + </header> + {isMobile && navigationOpen && ( + <button + type="button" + class="taler-stories-backdrop" + aria-hidden="true" + tabIndex={-1} + onClick={() => setNavigationOpen(false)} /> - ))} - <hr /> - </SideBar> - {/* <ResizeHandle - onUpdate={(x) => { - setSidebarWidth((s) => s + x); - }} - /> */} - <Content> - <ErrorReport selected={selected}> - <PreventLinkNavigation> - <GroupWrapper> - <ExampleContent /> - </GroupWrapper> - </PreventLinkNavigation> - </ErrorReport> - </Content> - </Page> + )} + <aside + ref={sidebarRef} + id="taler-stories-sidebar" + class={`taler-stories-sidebar${navigationOpen ? " is-open" : ""}`} + role={isMobile ? "dialog" : undefined} + aria-modal={isMobile ? "true" : undefined} + aria-label="Stories navigation" + aria-hidden={isMobile && !navigationOpen ? "true" : undefined} + tabIndex={isMobile ? -1 : undefined} + > + <div class="taler-stories-sidebar-header"> + <h1 class="taler-stories-sidebar-title">Stories</h1> + <button + type="button" + class="taler-stories-close-button" + aria-label="Close stories navigation" + onClick={() => setNavigationOpen(false)} + > + × + </button> + </div> + <label class="taler-stories-language"> + <span>Language</span> + <select + value={currentLang} + onChange={(e) => { + const url = new URL(window.location.href); + url.searchParams.set("lang", e.currentTarget.value); + window.location.href = url.href; + }} + > + {Object.keys(langs).map((l) => ( + <option key={l}>{l}</option> + ))} + </select> + </label> + <nav class="taler-stories-navigation" aria-label="Stories"> + {examplesInGroups.map((group) => ( + <ExampleList + key={group.title} + name={group.title} + list={group.list} + selected={selected} + onSelectStory={(item, htmlId) => { + document.getElementById(htmlId)?.scrollIntoView({ + block: "center", + }); + updateSelected(item); + setNavigationOpen(false); + }} + /> + ))} + </nav> + </aside> + <main + ref={contentRef} + class="taler-stories-content" + aria-hidden={isMobile && navigationOpen ? "true" : undefined} + > + <ErrorReport selected={selected}> + <PreventLinkNavigation> + <GroupWrapper> + <ExampleContent /> + </GroupWrapper> + </PreventLinkNavigation> + </ErrorReport> + </main> + </Page> + </Fragment> ); } diff --git a/packages/web-util/src/stories.html b/packages/web-util/src/stories.html @@ -3,6 +3,7 @@ <head> <title>WebUtils: Stories</title> <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="