diff --git a/AGENTS.md b/AGENTS.md index 943f16bde..8928d81a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ Env vars are validated with zod in `apps/web/lib/env.ts` and loaded in `apps/rec - **Fake secrets stay in test paths.** Push protection is on. Test fixtures that look like real credentials (Stripe-style `whsec_` secrets, signed vectors) belong under the paths in `.github/secret_scanning.yml`, which scanning skips; add a path there rather than bypassing a block, and never put a fixture like that in production code. - **A release label and a changelog file on every PR; the web version is given at deploy.** Every pull request carries one label: `release: patch` (fixes and small features), `release: minor` (significant features), `release: major` (reserved for 1.0), or `release: none` (nothing shipped to the web app, receiver or MX host: SDK-only, CI, tooling). Unless it is `none`, it adds its own changelog file to `apps/web/changelog/web/` (a title and items, never a version; see the README there). The required `Release label` check enforces both. Never write a web version in a PR or a commit title: a shared version line made every pair of open PRs conflict. The ops repository's Deploy workflow runs `apps/web/scripts/release.mjs plan`, which takes the newest `web-v*` tag the deployed commit contains, applies the highest bump among the PRs merged since, and writes `apps/web/lib/changelog-release.json` for the build (in the repository it holds no version, so local and CI builds report `dev`; never commit a generated copy); a production deploy then tags the commit `web-vX.Y.Z` with the release GitHub App. A dry run is `X.Y.Z-dryrun` and is not tagged, deploying a tag again keeps its version, and an untagged commit behind the newest tag is refused (roll back by deploying a tag). Tag rulesets let only admins and the release app (`webhooks-cc-release`) create `web-v*` tags, and only admins move or delete them. Web entries up to 0.65.1 were numbered by hand and stay in `changelog.ts`. CLI, SDK, and MCP releases bump `CLI_VERSION`, `SDK_VERSION`, or `MCP_VERSION` and add an entry on their own track when the tag is cut (`v*`, `sdk-v*`, `mcp-v*`). A unit test keeps the SDK and MCP constants in sync with the package versions. The Python SDK's version lives in `packages/sdk-python/pyproject.toml` and `src/webhooks_cc/__init__.py`; a `sdk-python-v*` tag publishes it to PyPI through trusted publishing (no token), and the workflow refuses a tag that does not match the version. The pytest plugin's version lives in `packages/pytest-webhooks-cc/pyproject.toml` and `src/pytest_webhooks_cc/__init__.py`, and a `pytest-plugin-v*` tag publishes it the same way; it depends on the SDK from PyPI, so publish the SDK first when the plugin needs something new from it. The Go SDK's version is `Version` in `packages/sdk-go/doc.go`; Go modules are published by their tags, so a release is the tag `packages/sdk-go/v` (the module sits in a subdirectory), pushed after the web release whose `/go` route serves the `go-import` tag; the release workflow checks the tag against `Version` and has the module proxy fetch it. - **Tests live next to the code they cover.** Unit tests as `*.test.ts` beside the source, web integration suites in `apps/web/tests/integration/`, Rust tests in each crate. Scratch verification scripts stay out of the repo. -- **Design system.** One look: paper and ink (a cool paper canvas, white surfaces edged by hairlines rather than drop shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), in light and dark mode. Every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page context passed as children. Colours are CSS variables in `apps/web/app/globals.css` (values in the base layer, mapped to Tailwind in `@theme`); write plain Tailwind against them (`text-primary`, `bg-selected`, `text-destructive-ink`). A plain `border` already takes the hairline colour; add `border-border` only to override a primitive's border colour through `cn()`. Shadows are `shadow-surface` at most on a resting surface and `shadow-float` or `shadow-float-lg` on floating layers, all tinted to the ink. The shared looks are components in `components/ui`: `Card` (`interactive` darkens its hairline on hover, as a card that is a link does), `Button` (control variants for forms and dialogs, `raised` for their lifted actions, and the padded surface variants `primary`, `surface`, `hairline` and `soft` used across the dashboard and site; `surface` is not the control `outline`), `CodeBox` and `InlineCode`, `FieldControl` (the field look on an input, or any control through `render`), and `field` on `Input`, `Textarea` and `SelectTrigger` (which keeps the control look and only adds the field's transition); `CodeInput` is the one-time code field (input-otp) and toasts go through Sonner's `toast.custom` with the app's own look (its Toaster is mounted only in `app/dashboard/layout.tsx`, so mount it where a new toast is needed). `Card`, `CodeBox`, `FieldControl` and `Button` take a `render` element for another tag. `CodeInput` draws its digits over input-otp's invisible input: a click puts the caret after the last digit, or selects the last digit once the code is complete, and a single digit cannot be clicked into. Their `ui-*` rules stay in `globals.css`'s components layer so a call site's utilities always win over them, and `lib/retired-classes.test.ts` fails on a `ui-*` look class outside `components/ui` (and on the classic style's old utilities). The primitives in `components/ui` wrap Base UI (`@base-ui/react`): dialogs, alert dialogs, menus, selects and the slider are its parts with our classes; tabs, collapsibles and the accordion are its unstyled parts (a tab bar draws its tabs with `data-active:` classes, a chevron turns with `group-data-panel-open:`), so build a new tab bar or show-and-hide panel on them, not by hand; and `Button` takes base-ui's `render` prop to draw as a link (` + ); })} - + ); diff --git a/apps/web/app/changelog/page.tsx b/apps/web/app/changelog/page.tsx index c540819e5..dc243e50a 100644 --- a/apps/web/app/changelog/page.tsx +++ b/apps/web/app/changelog/page.tsx @@ -12,6 +12,7 @@ import { TRACK_LABELS, type ChangelogTrack, } from "@/lib/changelog"; +import { Radio, RadioGroup } from "@/components/ui/radio-group"; const TRACKS: (ChangelogTrack | "all")[] = ["all", "web", "cli", "sdk", "python", "go", "mcp"]; @@ -59,21 +60,26 @@ export default function ChangelogPage() { -
+ setActiveTrack(next as typeof activeTrack)} + aria-label="Releases to show" + className="flex gap-2 mb-8 flex-wrap" + > {TRACKS.map((track) => ( - + ))} -
+
{filtered.map((entry) => ( diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 8f63fa55d..865788c8c 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -1713,6 +1713,10 @@ export default function DashboardPage() { // Don't intercept when typing in inputs if (isInput) return; + // An open menu or dialog has the keyboard: a letter there picks an item + // (a menu's type-ahead) or types, and must not act on the page behind it. + if ((e.target as HTMLElement)?.closest?.("[role=menu], [role=dialog], [role=alertdialog]")) + return; // Don't intercept when modifiers are held (allow browser shortcuts) if (e.ctrlKey || e.metaKey || e.altKey) return; diff --git a/apps/web/changelog/web/base-ui-menus-and-choices.json b/apps/web/changelog/web/base-ui-menus-and-choices.json new file mode 100644 index 000000000..75acd245a --- /dev/null +++ b/apps/web/changelog/web/base-ui-menus-and-choices.json @@ -0,0 +1,10 @@ +{ + "title": "Menus, choices and meters on Base UI", + "items": [ + "The request list's menu (right click, the keyboard's menu key or a long press) and the body's Copy menu work from the keyboard: arrow keys move through them, Enter picks, Escape closes, and the dashboard's shortcut keys leave an open menu alone. Like the browser's own, the right-click menu closes on a click outside without acting on it", + "Pick-one choices (filters, body and preview views, theme, plan, ticket category and rating) are one stop for Tab, and the arrow keys move the choice", + "The dashboard guide's sections are tabs: the arrow keys move between them", + "On phones, the docs contents keep focus inside while open, and once closed no longer hold links Tab could reach", + "Usage bars are announced as meters" + ] +} diff --git a/apps/web/components/account/shares/shares-page.tsx b/apps/web/components/account/shares/shares-page.tsx index be8daf618..7055bf644 100644 --- a/apps/web/components/account/shares/shares-page.tsx +++ b/apps/web/components/account/shares/shares-page.tsx @@ -50,6 +50,7 @@ import { CommandBlock } from "./command-block"; import { NamesPanel } from "./names-panel"; import { QrDialog } from "./qr-dialog"; import { RequestLog } from "./request-log"; +import { Meter } from "@/components/ui/meter"; const REFRESH_MS = 10_000; const INSTALL = "curl -fsSL https://webhooks.cc/install.sh | sh"; @@ -507,11 +508,8 @@ function DataMeter({

Resets {longDate(resetsAt)}

-
@@ -519,7 +517,7 @@ function DataMeter({ className={cn("h-full", near ? "bg-destructive" : "bg-primary")} style={{ width: `${Math.max(percent, bytes > 0 ? 1 : 0)}%` }} /> -
+ ); } diff --git a/apps/web/components/dashboard/dashboard-guide-dialog.tsx b/apps/web/components/dashboard/dashboard-guide-dialog.tsx index 4bde264b6..c5f1e959d 100644 --- a/apps/web/components/dashboard/dashboard-guide-dialog.tsx +++ b/apps/web/components/dashboard/dashboard-guide-dialog.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { cn } from "@/lib/utils"; import { Dialog, DialogContent, @@ -32,6 +31,7 @@ import { import Link from "next/link"; import { WEB_VERIFICATION_PROVIDER_OPTIONS } from "@/lib/provider-catalog"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs"; interface Section { id: string; @@ -544,40 +544,40 @@ export function DashboardGuideDialog({ )} {/* As tall as the longer of the two columns, up to 85vh; each column scrolls past that. */} -
+ setActiveSection(next as string)} + orientation="vertical" + className="flex max-h-[85vh] min-h-0" + > {/* Sidebar */} - + +
{/* Content */} -
+

{current.icon} {current.title}

{current.content}
-
- + +
); diff --git a/apps/web/components/dashboard/email-detail.tsx b/apps/web/components/dashboard/email-detail.tsx index ef155cb84..6c8d6ed79 100644 --- a/apps/web/components/dashboard/email-detail.tsx +++ b/apps/web/components/dashboard/email-detail.tsx @@ -53,6 +53,7 @@ import { ParsedResult } from "./layouts-section"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { CodeBox } from "@/components/ui/code"; +import { Radio, RadioGroup } from "@/components/ui/radio-group"; type EmailTab = | "parsed" @@ -973,12 +974,18 @@ function EmailJsonPane({ )}

-
+ setView(next as typeof view)} + aria-label="JSON view" + // A view switch keeps the light hairline it had as plain buttons, + // not the darker control edge globals.css gives a radio group. + className="flex overflow-hidden rounded-sm border border-border!" + > {(["formatted", "tree"] as const).map((mode) => ( - + ))} -
+ + ))} -
+ {email.truncated.html && (

@@ -1669,9 +1676,11 @@ export function EmailDetail({

{reply && (
-
setTextView(next as typeof textView)} + // A view switch keeps the light hairline it had as plain buttons. + className="flex overflow-hidden rounded-sm border border-border!" aria-label="Text to show" > {( @@ -1680,11 +1689,9 @@ export function EmailDetail({ ["reply", "Reply only"], ] as const ).map(([mode, label]) => ( - + ))} -
+
)} {email.textFromHtml && ( diff --git a/apps/web/components/dashboard/request-detail.tsx b/apps/web/components/dashboard/request-detail.tsx index 0b248e2cd..1bd2eef6b 100644 --- a/apps/web/components/dashboard/request-detail.tsx +++ b/apps/web/components/dashboard/request-detail.tsx @@ -36,7 +36,14 @@ import { ResponseAnswer } from "./response-answer"; import { LiveResponseView } from "./live-response"; import { readLiveResponse } from "@/lib/live-response"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { CodeBox } from "@/components/ui/code"; +import { Radio, RadioGroup } from "@/components/ui/radio-group"; /** Any request shape that has the fields needed for display. */ export type DisplayableRequest = Request | ClickHouseRequest; @@ -359,13 +366,18 @@ export function RequestDetail({ {getFormatLabel(bodyFormat)} {bodyViews.length > 1 && ( -
+ setBodyView(next as typeof bodyView)} + aria-label="Body view" + // A view switch keeps the light hairline it had as plain buttons, + // not the darker control edge globals.css gives a radio group. + className="flex items-center overflow-hidden rounded-sm border border-border!" + > {bodyViews.map((view, i) => ( - + ))} -
+ )}
{request.body && bodyView !== "schema" && !bodyBytes && ( @@ -569,27 +581,6 @@ function BodyCopyDropdown({ onCopy: (text: string, key: string) => void; copied: string | null; }) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - } - function handleEscape(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [open]); - const csvBody = useMemo(() => jsonToCsvValue(body), [body]); const isCopied = copied === "body" || copied === "body-formatted" || copied === "ts" || copied === "csv"; @@ -609,35 +600,34 @@ function BodyCopyDropdown({ }, [body, formattedBody, tsInterface, csvBody]); return ( -
- - {open && ( -
+
+ {/* Not modal: a click elsewhere closes it and still lands, as before. */} + + + } + > + {isCopied ? : } + {isCopied ? "Copied!" : "Copy"} + + + {options.map((opt, i) => ( - + ))} -
- )} + +
); } diff --git a/apps/web/components/dashboard/request-list.tsx b/apps/web/components/dashboard/request-list.tsx index d086291a7..0c9c24e5b 100644 --- a/apps/web/components/dashboard/request-list.tsx +++ b/apps/web/components/dashboard/request-list.tsx @@ -20,6 +20,13 @@ import { Clock, } from "lucide-react"; import { copyToClipboard } from "@/lib/clipboard"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Radio, RadioGroup } from "@/components/ui/radio-group"; import { Select, SelectContent, @@ -171,33 +178,21 @@ export function RequestList({ const internalSearchRef = useRef(null); const inputRef = searchInputRef ?? internalSearchRef; - // Context menu - const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; id: string } | null>(null); - const ctxRef = useRef(null); - useEffect(() => { - if (!ctxMenu) return; - function close(e: MouseEvent) { - if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) setCtxMenu(null); + // Context menu: it acts on the row that was pressed (by a right click, the + // keyboard's menu key or a long press). Pressed anywhere else in the list, + // such as a group heading, it offers only what needs no row. + const [ctxId, setCtxId] = useState(null); + const [ctxOpen, setCtxOpen] = useState(false); + const handleCtxOpenChange = useCallback((open: boolean, details: { event?: Event }) => { + if (open) { + const target = details.event?.target; + setCtxId( + (target instanceof Element + ? target.closest("[data-request-id]")?.dataset.requestId + : undefined) ?? null + ); } - function esc(e: KeyboardEvent) { - if (e.key === "Escape") setCtxMenu(null); - } - document.addEventListener("mousedown", close); - document.addEventListener("keydown", esc); - return () => { - document.removeEventListener("mousedown", close); - document.removeEventListener("keydown", esc); - }; - }, [ctxMenu]); - - const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { - e.preventDefault(); - // Clamp to viewport so the menu doesn't overflow off-screen - const menuW = 180; - const menuH = 160; - const x = Math.min(e.clientX, window.innerWidth - menuW - 8); - const y = Math.min(e.clientY, window.innerHeight - menuH - 8); - setCtxMenu({ x: Math.max(0, x), y: Math.max(0, y), id }); + setCtxOpen(open); }, []); // Pin/unpin split @@ -324,7 +319,6 @@ export function RequestList({ tabIndex: tabStopId === id ? 0 : -1, "data-request-id": id, onClick: (e: React.MouseEvent) => handleRowClick(e, id), - onContextMenu: (e: React.MouseEvent) => handleContextMenu(e, id), onKeyDown: (e: React.KeyboardEvent) => handleRowKeyDown(e, id), className: rowClass(id, isComparing), }); @@ -353,318 +347,272 @@ export function RequestList({ ) : null; return ( -
- {/* Toolbar */} -
- {onKindFilterChange ? ( -
- {kinds.map((kind, index) => ( + +
+ {/* Toolbar */} +
+ {onKindFilterChange ? ( + onKindFilterChange(next as KindFilter)} + aria-label="Show" + className="flex min-w-0 overflow-hidden rounded-md border" + > + {kinds.map((kind, index) => ( + 0 && "border-l border-border", + kindFilter === kind.id + ? "bg-selected text-selected-foreground" + : "bg-background hover:bg-muted" + )} + > + {kind.label} + {/* The count of what is shown, from the server, so only the picked kind has one. */} + {kindFilter === kind.id && ( + + {displayCount} + + )} + + ))} + + ) : ( + + {displayCount} request{displayCount !== 1 ? "s" : ""} + + )} +
+ {onViewModeChange && ( - ))} -
- ) : ( - - {displayCount} request{displayCount !== 1 ? "s" : ""} - - )} -
- {onViewModeChange && ( + )} - )} - - -
-
- - {/* Filter bar */} -
- {kindFilter !== "email" && ( - - )} -
- - onSearchQueryChange(e.target.value)} - aria-label="Search requests" - placeholder={onKindFilterChange ? "Search" : "Search..."} - className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground font-mono min-w-0" - /> - {searchQuery && ( - - )} +
-
- - {/* New requests banner */} - {/* Screen readers hear the count change as requests arrive. */} -

- {displayCount} {displayCount === 1 ? itemNoun.slice(0, -1) : itemNoun} -

- {!liveMode && (newCount ?? 0) > 0 && onJumpToNew && ( - - )} - {/* Compare hint */} - {compareId && ( -
- Shift-click another request to compare + {/* Filter bar */} +
+ {kindFilter !== "email" && ( + + )} +
+ + onSearchQueryChange(e.target.value)} + aria-label="Search requests" + placeholder={onKindFilterChange ? "Search" : "Search..."} + className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground font-mono min-w-0" + /> + {searchQuery && ( + + )} +
- )} - {/* Request rows */} -
- {viewMode === "timeline" && timelineSlot ? ( - <> - {timelineSlot} - {/* The timeline pages like the list, so older matches stay reachable. */} - {loadMore} - - ) : searchLoading ? ( -
- - Searching... + {/* New requests banner */} + {/* Screen readers hear the count change as requests arrive. */} +

+ {displayCount} {displayCount === 1 ? itemNoun.slice(0, -1) : itemNoun} +

+ {!liveMode && (newCount ?? 0) > 0 && onJumpToNew && ( + + )} + + {/* Compare hint */} + {compareId && ( +
+ Shift-click another request to compare
- ) : pinned.length === 0 && - unpinned.length === 0 && - talksEmail && - emailAddress && - kindFilter !== "http" && - !searchQuery && - methodFilter === "ALL" && - !hasMore && - !searchError ? ( - // Nothing filtered away: no email has arrived yet. - - ) : pinned.length === 0 && unpinned.length === 0 ? ( - <> -
- {searchError - ? "Search unavailable" - : hasMore - ? `None in the newest ${itemNoun}` - : searchQuery - ? // The detail pane explains an empty search and offers to clear it. - "0 results" - : `No matching ${itemNoun}`} + )} + + {/* Request rows */} +
+ {viewMode === "timeline" && timelineSlot ? ( + <> + {timelineSlot} + {/* The timeline pages like the list, so older matches stay reachable. */} + {loadMore} + + ) : searchLoading ? ( +
+ + Searching...
- {/* A filter can match nothing loaded so far and still match older pages. */} - {loadMore} - - ) : ( - <> -
- {pinned.length > 0 ? ( - <> -
- - {pinned.map((request) => renderRow(request))} -
- {unpinned.length > 0 && ( -
+ ) : pinned.length === 0 && + unpinned.length === 0 && + talksEmail && + emailAddress && + kindFilter !== "http" && + !searchQuery && + methodFilter === "ALL" && + !hasMore && + !searchError ? ( + // Nothing filtered away: no email has arrived yet. + + ) : pinned.length === 0 && unpinned.length === 0 ? ( + <> +
+ {searchError + ? "Search unavailable" + : hasMore + ? `None in the newest ${itemNoun}` + : searchQuery + ? // The detail pane explains an empty search and offers to clear it. + "0 results" + : `No matching ${itemNoun}`} +
+ {/* A filter can match nothing loaded so far and still match older pages. */} + {loadMore} + + ) : ( + <> + + {pinned.length > 0 ? ( + <> +
- {unpinned.map((request) => renderRow(request))} + {pinned.map((request) => renderRow(request))}
- )} - - ) : ( - unpinned.map((request) => renderRow(request)) - )} -
+ {unpinned.length > 0 && ( +
+ + {unpinned.map((request) => renderRow(request))} +
+ )} + + ) : ( + unpinned.map((request) => renderRow(request)) + )} + - {loadMore} - - )} -
+ {loadMore} + + )} +
- {/* Context menu */} - {ctxMenu && ( -
- {onTogglePin && ( - + + {ctxId && onTogglePin && ( + onTogglePin(ctxId)}> + + {pinnedIds?.has(ctxId) ? "Unpin" : "Pin"} + )} - {onCompareSelect && ( - + )} - - -
- )} -
+ + +
+ ); function renderRow(request: AnyRequestSummary) { diff --git a/apps/web/components/dashboard/settings-primitives.tsx b/apps/web/components/dashboard/settings-primitives.tsx index 372674835..e285f814f 100644 --- a/apps/web/components/dashboard/settings-primitives.tsx +++ b/apps/web/components/dashboard/settings-primitives.tsx @@ -1,12 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { useAuth } from "@/components/providers/supabase-auth-provider"; import { emitDashboardEndpointsChanged, updateDashboardEndpoint } from "@/lib/dashboard-api"; import { SectionIdentityContext, useReportDirty } from "./dirty-sections"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Radio, RadioGroup } from "@/components/ui/radio-group"; +import { SwitchRoot, SwitchThumb } from "@/components/ui/switch"; /** * The building blocks every section of the endpoint settings shares: the @@ -252,15 +254,15 @@ export function Switch({ describedBy?: string; }) { return ( - + ); } @@ -327,7 +329,7 @@ export interface SegmentedOption { /** * The segmented control (the JSON pane's Formatted | Tree pattern) as a - * radio group: one tab stop, arrow keys move the choice, Home and End jump. + * radio group: one tab stop, and the arrow keys move the choice. */ export function Segmented({ name, @@ -344,29 +346,13 @@ export function Segmented({ size?: "sm" | "xs"; className?: string; }) { - const onKeyDown = (event: KeyboardEvent, index: number) => { - let next: number | null = null; - if (event.key === "ArrowRight" || event.key === "ArrowDown") { - next = (index + 1) % options.length; - } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { - next = (index - 1 + options.length) % options.length; - } else if (event.key === "Home") { - next = 0; - } else if (event.key === "End") { - next = options.length - 1; - } - if (next === null) return; - event.preventDefault(); - onChange(options[next].value); - const group = event.currentTarget.parentElement; - group?.querySelectorAll("[role=radio]")[next]?.focus(); - }; // More than three choices do not fit a phone's width side by side, and a // scrolling strip hides some of them, so they stack there instead. const stack = options.length > 3; return ( -
onChange(next as T)} aria-label={name} className={cn( "inline-flex max-w-full overflow-x-auto [scrollbar-width:none] rounded-sm border bg-background", @@ -377,14 +363,12 @@ export function Segmented({ {options.map((option, index) => { const checked = option.value === value; return ( - + ); })} -
+ ); } diff --git a/apps/web/components/docs/mdx/tabs.tsx b/apps/web/components/docs/mdx/tabs.tsx index 29f0ddb54..f59359d52 100644 --- a/apps/web/components/docs/mdx/tabs.tsx +++ b/apps/web/components/docs/mdx/tabs.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, Children, isValidElement, type ReactElement } from "react"; -import { cn } from "@/lib/utils"; +import { Tabs as TabsRoot, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs"; export function Tabs({ items, children }: { items?: string[]; children: React.ReactNode }) { const [active, setActive] = useState(0); @@ -13,28 +13,29 @@ export function Tabs({ items, children }: { items?: string[]; children: React.Re const labels = items ?? tabs.map((tab, i) => tab.props.label ?? `Tab ${i + 1}`); return ( -
-
+ setActive(next as number)} + className="my-6 rounded-lg border border-border shadow-surface overflow-hidden" + > + {labels.map((label, i) => ( - + ))} -
-
+ + {tabs[active]} -
-
+ + ); } diff --git a/apps/web/components/docs/search-modal.tsx b/apps/web/components/docs/search-modal.tsx index 83eb0ac2d..4350e095c 100644 --- a/apps/web/components/docs/search-modal.tsx +++ b/apps/web/components/docs/search-modal.tsx @@ -2,7 +2,13 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { + Dialog, + DialogBackdrop, + DialogPopup, + DialogPortal, + DialogTitle, +} from "@/components/ui/dialog"; import { Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { isDocsSearchUrl, normalizePagefindUrl } from "@/lib/docs-search"; @@ -137,14 +143,14 @@ export function SearchModal() { } return ( - - - - + + + - Search documentation + Search documentation {/* Input */}
@@ -228,8 +234,8 @@ export function SearchModal() { close
-
-
-
+ + + ); } diff --git a/apps/web/components/docs/sidebar.tsx b/apps/web/components/docs/sidebar.tsx index c4a514135..7472964cc 100644 --- a/apps/web/components/docs/sidebar.tsx +++ b/apps/web/components/docs/sidebar.tsx @@ -1,10 +1,18 @@ "use client"; -import { useState, useEffect, useCallback, useSyncExternalStore } from "react"; +import { useState, useCallback, useEffect, useSyncExternalStore } from "react"; import { SiteLink } from "@/components/nav/site-link"; import { useSitePathname } from "@/lib/site-pathname"; import { cn } from "@/lib/utils"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Dialog, + DialogBackdrop, + DialogClose, + DialogPopup, + DialogPortal, + DialogTrigger, +} from "@/components/ui/dialog"; import { BookOpen, X, ChevronDown, Search } from "lucide-react"; import { NAV_SECTIONS, type NavSection } from "@/lib/docs-nav"; import { SITE_HEADER_HEIGHT, siteHeaderTop } from "@/lib/announcements"; @@ -138,13 +146,17 @@ export function DocsSidebar() { const [mobileOpen, setMobileOpen] = useState(false); const s = useSidebarStyles(); + // The contents are a phone's: once the page is wide enough for the sidebar + // (a rotated tablet, a widened window), close them so the dialog lets go. useEffect(() => { if (!mobileOpen) return; - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") setMobileOpen(false); + const wide = window.matchMedia("(min-width: 768px)"); + const closeIfWide = () => { + if (wide.matches) setMobileOpen(false); }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); + closeIfWide(); + wide.addEventListener("change", closeIfWide); + return () => wide.removeEventListener("change", closeIfWide); }, [mobileOpen]); const handleSearchClick = useCallback(() => { @@ -154,42 +166,39 @@ export function DocsSidebar() { return ( <> - {/* Mobile toggle */} - - - {/* Mobile overlay */} - {mobileOpen && ( -
setMobileOpen(false)} - /> - )} - - {/* Mobile sidebar */} - + {/* Phone: the contents slide in as a dialog under the header. */} + + + {/* A word, not a second hamburger under the site menu's. */} + {mobileOpen ? : } + Contents + + + + + setMobileOpen(false)} + onSearchClick={handleSearchClick} + /> + {/* The Contents button sits outside the dialog, out of reach of a + touch screen reader inside it; this one is seen only when focused. */} + + Close docs contents + + + + {/* Desktop sidebar */}