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:
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=="