From c296281f899608d5df7fae1d83d442675d1104a2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 23 Jul 2026 01:10:18 +0200 Subject: [PATCH] fix: stabilize Toolbox navigation layout --- CHANGELOG.md | 23 ++ README.md | 29 +- package-lock.json | 14 +- package.json | 2 +- packages/contract/package.json | 2 +- packages/shell-react/README.md | 14 +- packages/shell-react/package.json | 4 +- packages/shell-react/src/AppShell.tsx | 30 +- packages/shell-react/src/ToolboxHeader.tsx | 353 +++++++++++++++----- packages/shell-react/src/styles.css | 329 +++++++++++------- packages/shell-react/test/AppShell.test.tsx | 197 ++++++++++- packages/testkit/package.json | 4 +- 12 files changed, 750 insertions(+), 251 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 423db77..a80d270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to the independently versioned Toolbox SDK packages are recorded here. The packages currently share one release version. +## 0.2.1 — 2026-07-23 + +### Added + +- `ToolboxHeader.personalizeContent` for custom preferences inside the shared + controlled popover and `brandIconUrl` for a portal favicon with an inline + Toolbox cube fallback. +- Accessible, mutually exclusive Personalize and Apps popovers that close on + Escape, outside interaction, and link selection. + +### Changed + +- The invariant top row now orders Personalize, Apps, source, and Help on the + left, keeps the text-only app identity geometrically centered, and fixes the + Toolbox brand/home block at the far right. +- Personalize and Apps keep visible desktop labels, become compact icon controls + responsively without losing accessible names, and source and Help remain + present at every supported width. +- `AppShell` now renders version, privacy, manifest actions, and legacy app + actions in a standard footer instead of changing the top-row geometry. +- `iconUrl`, `leadingActions`, and `metadata` remain accepted for source + compatibility but are deprecated for the fixed header design. + ## 0.2.0 — 2026-07-22 ### Added diff --git a/README.md b/README.md index 9261fe4..e0f6c61 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ tools. An app always works by itself. When it receives a trusted same-origin catalog URL, the same app gains a toolbox home link and an app switcher without becoming coupled to a portal or client-side router. -Version `0.2.0` contains three publish-ready packages: +Version `0.2.1` contains three publish-ready packages: - `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context discovery/loading, resolved URLs, and contextual link helpers. @@ -167,13 +167,26 @@ import "@add-ideas/toolbox-shell-react/styles.css"; ; ``` -The shell always renders a centered app identity, version, derived privacy -facts, standard Help and source controls, Personalize, Apps, manifest actions, -and legacy `appActions`. With valid context it additionally renders the catalog -brand/home and available app destinations. Destinations are ordinary links, so -switching performs full-page navigation. On missing, cross-origin, invalid, or -unavailable context the shell quietly remains usable in standalone mode; -`onContextError` is optional observability. +The shell's fixed top row renders Personalize, Apps, source, and Help controls +in that order, a geometrically centered text identity, and the Toolbox +brand/home at the far right. Personalize and Apps have labeled desktop controls +and retain their accessible names when compact. The mutually exclusive popovers +close on Escape, outside interaction, and link selection. + +`AppShell` moves version, derived privacy facts, manifest actions, and legacy +`appActions` into its standard footer, keeping the top row stable in connected +and standalone modes. With valid context it supplies the catalog home, favicon, +and available app destinations. Destinations are ordinary links, so switching +performs full-page navigation. On missing, cross-origin, invalid, or unavailable +context the shell quietly remains usable in standalone mode; `onContextError` is +optional observability. + +For a custom portal-style header, `ToolboxHeader` accepts `personalizeContent` +for content hosted inside the shared controlled popover and `brandIconUrl` for +the Toolbox mark. `onPersonalize` remains available for callback-only +integrations. The deprecated `iconUrl`, `leadingActions`, and `metadata` props +are retained for source compatibility; the centered app icon is intentionally no +longer rendered. CSS custom properties prefixed with `--toolbox-` are the v1 theme surface. The user's `light`, `dark`, or `system` preference is shared with same-origin apps diff --git a/package-lock.json b/package-lock.json index c0c7947..0d77dbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -3946,7 +3946,7 @@ }, "packages/contract": { "name": "@add-ideas/toolbox-contract", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "engines": { "node": ">=20" @@ -3954,10 +3954,10 @@ }, "packages/shell-react": { "name": "@add-ideas/toolbox-shell-react", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.0" + "@add-ideas/toolbox-contract": "0.2.1" }, "peerDependencies": { "react": ">=18 <20", @@ -3966,10 +3966,10 @@ }, "packages/testkit": { "name": "@add-ideas/toolbox-testkit", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.0" + "@add-ideas/toolbox-contract": "0.2.1" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index c82c966..cc5e57c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "A small, framework-neutral toolbox contract with a React application shell.", "license": "Apache-2.0", diff --git a/packages/contract/package.json b/packages/contract/package.json index 9ca0324..c86e49d 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-contract", - "version": "0.2.0", + "version": "0.2.1", "description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.", "license": "Apache-2.0", "repository": { diff --git a/packages/shell-react/README.md b/packages/shell-react/README.md index f437779..0c838a7 100644 --- a/packages/shell-react/README.md +++ b/packages/shell-react/README.md @@ -13,6 +13,14 @@ import "@add-ideas/toolbox-shell-react/styles.css"; standalone mode if context is absent, unavailable, cross-origin, or invalid. Its app switcher is exposed as the accessible navigation landmark `Toolbox applications` and all destinations use ordinary document navigation. -The shared top bar also exposes Help, Gitea source, light/dark/system -personalization, and Apps controls. `ToolboxHeader` is exported separately for -custom shells. +The fixed top row exposes Personalize, Apps, Gitea source, and Help in that +order, a centered title/subtitle, and a Toolbox home/brand block. Personalize +offers light/dark/system mode by default. Version, privacy, and application +actions render in the standard footer so connected and standalone headers keep +the same geometry. + +`ToolboxHeader` is exported separately for custom shells. Its +`personalizeContent` prop puts custom preferences inside the shared controlled +popover, while `brandIconUrl` supplies a portal favicon; an inline Toolbox cube +is the fallback. Personalize and Apps popovers are mutually exclusive and close +on Escape, outside interaction, or link selection. diff --git a/packages/shell-react/package.json b/packages/shell-react/package.json index 89212b8..1cb11f5 100644 --- a/packages/shell-react/package.json +++ b/packages/shell-react/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-shell-react", - "version": "0.2.0", + "version": "0.2.1", "description": "A lightweight React application shell for toolbox-compatible browser applications.", "license": "Apache-2.0", "repository": { @@ -38,7 +38,7 @@ "react-dom": ">=18 <20" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.0" + "@add-ideas/toolbox-contract": "0.2.1" }, "scripts": { "build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.copyFileSync('src/styles.css','dist/styles.css');fs.copyFileSync('../../LICENSE','LICENSE')\"", diff --git a/packages/shell-react/src/AppShell.tsx b/packages/shell-react/src/AppShell.tsx index 5d9d69c..b17f602 100644 --- a/packages/shell-react/src/AppShell.tsx +++ b/packages/shell-react/src/AppShell.tsx @@ -227,7 +227,6 @@ export function AppShell({ > +
{children}
+
+
+ {otherActions?.length || appActions ? (
{otherActions?.map((action) => { const href = safeHref(action.url, appManifestUrl); @@ -251,10 +258,10 @@ export function AppShell({ })} {appActions}
- ) : undefined - } - metadata={ - <> + ) : ( +
+
); } diff --git a/packages/shell-react/src/ToolboxHeader.tsx b/packages/shell-react/src/ToolboxHeader.tsx index 89ac4db..befe5e7 100644 --- a/packages/shell-react/src/ToolboxHeader.tsx +++ b/packages/shell-react/src/ToolboxHeader.tsx @@ -1,5 +1,12 @@ import type { ToolboxThemeMode } from "@add-ideas/toolbox-contract"; -import type { ReactNode } from "react"; +import { + useEffect, + useId, + useRef, + useState, + type MouseEvent, + type ReactNode, +} from "react"; export interface ToolboxHeaderAction { label?: string | undefined; @@ -17,23 +24,30 @@ export interface ToolboxHeaderApp { export interface ToolboxHeaderProps { title: string; + /** @deprecated The fixed header centers text without an application icon. */ iconUrl?: string | undefined; subtitle?: string | undefined; theme: ToolboxThemeMode; onThemeChange?: ((theme: ToolboxThemeMode) => void) | undefined; onPersonalize?: (() => void) | undefined; + personalizeContent?: ReactNode | undefined; helpAction?: ToolboxHeaderAction | undefined; sourceHref?: string | undefined; sourceLabel?: string | undefined; homeHref?: string | undefined; homeLabel?: string | undefined; + brandIconUrl?: string | undefined; apps?: readonly ToolboxHeaderApp[] | undefined; allAppsHref?: string | undefined; + /** @deprecated Supplemental actions render outside the fixed top-bar row. */ leadingActions?: ReactNode | undefined; + /** @deprecated AppShell metadata now renders in its standard footer. */ metadata?: ReactNode | undefined; className?: string | undefined; } +type OpenPopover = "personalize" | "apps" | null; + function HelpIcon() { return ( setFailedIconUrl(iconUrl)} />; +} + const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"]; export function ToolboxHeader({ title, - iconUrl, subtitle, theme, onThemeChange, onPersonalize, + personalizeContent, helpAction, sourceHref, sourceLabel = "Source on Gitea", homeHref, - homeLabel = "Toolbox", + homeLabel = "add·ideas Toolbox", + brandIconUrl, apps = [], allAppsHref, leadingActions, metadata, className, }: ToolboxHeaderProps) { + const [openPopover, setOpenPopover] = useState(null); + const personalizeButtonRef = useRef(null); + const appsButtonRef = useRef(null); + const popoverRef = useRef(null); + const personalizeId = useId(); + const appsId = useId(); + const customPersonalizeContent = personalizeContent != null; + const hasPersonalizePopover = + customPersonalizeContent || + (onPersonalize === undefined && onThemeChange !== undefined); + const personalizeAvailable = + hasPersonalizePopover || onPersonalize !== undefined; + const visiblePopover = + openPopover === "personalize" && !hasPersonalizePopover + ? null + : openPopover; + + useEffect(() => { + if (visiblePopover === null) return; + + const firstControl = popoverRef.current?.querySelector( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + (firstControl ?? popoverRef.current)?.focus(); + + const closeFromOutside = (event: PointerEvent) => { + const trigger = + visiblePopover === "personalize" + ? personalizeButtonRef.current + : appsButtonRef.current; + if (!(event.target instanceof Node)) return; + if ( + trigger?.contains(event.target) || + popoverRef.current?.contains(event.target) + ) { + return; + } + setOpenPopover(null); + }; + const closeFromKeyboard = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + const trigger = + visiblePopover === "personalize" + ? personalizeButtonRef.current + : appsButtonRef.current; + setOpenPopover(null); + trigger?.focus(); + }; + + document.addEventListener("pointerdown", closeFromOutside); + document.addEventListener("keydown", closeFromKeyboard); + return () => { + document.removeEventListener("pointerdown", closeFromOutside); + document.removeEventListener("keydown", closeFromKeyboard); + }; + }, [visiblePopover]); + + useEffect(() => { + if (openPopover !== "personalize" || hasPersonalizePopover) return; + const timeout = globalThis.setTimeout(() => setOpenPopover(null), 0); + return () => globalThis.clearTimeout(timeout); + }, [hasPersonalizePopover, openPopover]); + + const closeOnLinkSelection = (event: MouseEvent) => { + if ((event.target as Element).closest?.("a")) setOpenPopover(null); + }; + + const brandContent = ( + <> + + + + {homeLabel} + + ); + return (
-
- {homeHref ? ( - - {homeLabel} - - ) : null} - {helpAction ? ( - - ) : null} +
+ + + + {sourceHref ? ( setOpenPopover(null)} > - ) : null} - {leadingActions} -
- -
- {iconUrl ? ( - - ) : null} -
-

{title}

- {subtitle ? ( - {subtitle} - ) : null} -
-
- -
- {onPersonalize ? ( - ) : ( -
- - - Personalize - -
- Appearance -
- {THEMES.map((option) => ( - - ))} -
- Saved only in this browser. -
-
+ )} -
- - - Apps - + + + {visiblePopover === "personalize" ? ( + + ) : null} + + {visiblePopover === "apps" ? ( -
+ ) : null}
+ +
+

{title}

+ {subtitle ? ( + {subtitle} + ) : null} +
+ + {homeHref ? ( +
setOpenPopover(null)} + > + {brandContent} + + ) : ( +
{brandContent}
+ )}
- {metadata ?
{metadata}
: null} + + {leadingActions || metadata ? ( +
+ {leadingActions} + {metadata ? ( +
{metadata}
+ ) : null} +
+ ) : null}
); } diff --git a/packages/shell-react/src/styles.css b/packages/shell-react/src/styles.css index 91cd6df..c037f04 100644 --- a/packages/shell-react/src/styles.css +++ b/packages/shell-react/src/styles.css @@ -57,6 +57,8 @@ .toolbox-shell { min-height: 100vh; + display: flex; + flex-direction: column; color: var(--toolbox-text); background: var(--toolbox-background); font-family: var(--toolbox-font); @@ -86,24 +88,25 @@ .toolbox-shell__bar { width: min(100%, 90rem); - min-height: 4.35rem; + height: 4.5rem; display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-columns: minmax(18.75rem, 1fr) minmax(0, 31rem) minmax( + 18.75rem, + 1fr + ); align-items: center; gap: 1rem; margin: 0 auto; padding: 0.65rem 1rem; } -.toolbox-shell__side { +.toolbox-shell__controls { + position: relative; min-width: 0; display: flex; align-items: center; - gap: 0.45rem; -} - -.toolbox-shell__side--right { - justify-content: flex-end; + gap: 0.4rem; + justify-self: start; } .toolbox-shell__header a { @@ -115,70 +118,15 @@ color: var(--toolbox-accent-hover); } -.toolbox-shell__home { - max-width: 9rem; - margin-right: 0.15rem; - overflow: hidden; - font-weight: 750; - text-overflow: ellipsis; - white-space: nowrap; -} - -.toolbox-shell__identity { - min-width: 0; - display: flex; - align-items: center; - justify-content: center; - gap: 0.65rem; - text-align: center; -} - -.toolbox-shell__identity > div { - min-width: 0; - display: grid; - gap: 0.08rem; -} - -.toolbox-shell__icon { - width: 2rem; - height: 2rem; - flex: none; - border-radius: 0.45rem; - object-fit: contain; -} - -.toolbox-shell__name { - margin: 0; - overflow: hidden; - color: var(--toolbox-text); - font-family: var(--toolbox-font); - font-size: 1rem; - font-weight: 750; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.toolbox-shell__description { - display: block; - max-width: 27rem; - overflow: hidden; - color: var(--toolbox-muted); - font-size: 0.73rem; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; -} - .toolbox-shell__icon-button, .toolbox-shell__menu-button, -.toolbox-shell__actions a { - min-height: 2.35rem; +.toolbox-shell__supplemental a { + min-height: 2.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; - padding: 0.45rem 0.65rem; + padding: 0.45rem 0.68rem; border: 1px solid var(--toolbox-border); border-radius: var(--toolbox-radius); background: var(--toolbox-surface); @@ -192,14 +140,15 @@ } .toolbox-shell__icon-button { - width: 2.35rem; + width: 2.5rem; + min-width: 2.5rem; + height: 2.5rem; padding: 0.45rem; - border-radius: 50%; } .toolbox-shell__icon-button:hover, .toolbox-shell__menu-button:hover, -.toolbox-shell__actions a:hover { +.toolbox-shell__supplemental a:hover { border-color: color-mix( in srgb, var(--toolbox-accent) 45%, @@ -210,10 +159,17 @@ text-decoration: none; } +.toolbox-shell__icon-button:disabled, +.toolbox-shell__menu-button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + .toolbox-shell__icon-button:focus-visible, .toolbox-shell__menu-button:focus-visible, .toolbox-shell__popover a:focus-visible, -.toolbox-shell__popover button:focus-visible { +.toolbox-shell__popover button:focus-visible, +.toolbox-shell__brand:focus-visible { outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 72%, var(--toolbox-accent)); outline-offset: 2px; @@ -242,32 +198,92 @@ color: #609926 !important; } -.toolbox-shell__actions { +.toolbox-shell__identity { min-width: 0; - display: flex; + display: grid; + gap: 0.12rem; + justify-items: center; + text-align: center; +} + +.toolbox-shell__name { + width: 100%; + margin: 0; + overflow: hidden; + color: var(--toolbox-text); + font-family: var(--toolbox-font); + font-size: 1rem; + font-weight: 750; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbox-shell__description { + width: 100%; + overflow: hidden; + color: var(--toolbox-muted); + font-size: 0.72rem; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbox-shell__brand { + min-width: 0; + display: inline-flex; align-items: center; - gap: 0.4rem; + justify-self: end; + gap: 0.58rem; + padding: 0.28rem 0.35rem; + border-radius: var(--toolbox-radius); + color: var(--toolbox-text) !important; + font-size: 0.82rem; + font-weight: 750; + line-height: 1.1; + white-space: nowrap; } -.toolbox-shell__menu { - position: relative; +a.toolbox-shell__brand:hover { + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent) !important; } -.toolbox-shell__menu > summary { - list-style: none; +.toolbox-shell__brand-mark { + width: 2rem; + height: 2rem; + display: inline-grid; + flex: none; + place-items: center; + color: var(--toolbox-accent); } -.toolbox-shell__menu > summary::-webkit-details-marker { - display: none; +.toolbox-shell__brand-mark img, +.toolbox-shell__brand-mark svg { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.toolbox-shell__brand-mark svg { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; } .toolbox-shell__popover { position: absolute; - top: calc(100% + 0.55rem); - right: 0; + z-index: 1; + top: calc(100% + 0.7rem); + left: 0; width: max-content; min-width: 13rem; max-width: min(22rem, calc(100vw - 1.5rem)); + max-height: calc(100vh - 5.5rem); + overflow: auto; padding: 0.55rem; border: 1px solid var(--toolbox-border); border-radius: 0.85rem; @@ -352,76 +368,157 @@ font-weight: 750; } -.toolbox-shell__meta { - min-height: 1.65rem; +.toolbox-shell__supplemental { + min-height: 2rem; display: flex; align-items: center; justify-content: center; - gap: 0.45rem; - padding: 0.25rem 1rem 0.45rem; + gap: 0.65rem; + padding: 0.3rem 1rem 0.45rem; color: var(--toolbox-muted); - font-size: 0.7rem; - text-align: center; + font-size: 0.72rem; +} + +.toolbox-shell__supplemental-meta { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.toolbox-shell__actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; } .toolbox-shell__main { width: min(100%, 90rem); + display: block; + flex: 1 0 auto; margin: 0 auto; padding: 1rem; } -@media (max-width: 48rem) { - .toolbox-shell__bar { - min-height: 3.8rem; - gap: 0.5rem; - padding-inline: 0.7rem; - } +.toolbox-shell__footer { + width: min(100%, 90rem); + margin: auto auto 0; + padding: 0.35rem 1rem 1.25rem; + color: var(--toolbox-muted); + font-size: 0.72rem; +} - .toolbox-shell__home, - .toolbox-shell__description, - .toolbox-shell__menu-button > span { - display: none; +.toolbox-shell__footer-inner { + min-height: 2.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.25rem; + padding-top: 0.7rem; + border-top: 1px solid var(--toolbox-border); +} + +.toolbox-shell__footer-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; + text-align: right; +} + +.toolbox-shell__footer a { + color: var(--toolbox-accent); +} + +@media (max-width: 72rem) { + .toolbox-shell__bar { + grid-template-columns: + minmax(0, 1fr) minmax(0, min(31rem, calc(100% - 24rem))) + minmax(0, 1fr); + gap: 0.7rem; } .toolbox-shell__menu-button { - width: 2.35rem; + width: 2.5rem; + min-width: 2.5rem; + height: 2.5rem; padding: 0.45rem; - border-radius: 50%; } - .toolbox-shell__side { - gap: 0.3rem; + .toolbox-shell__menu-button > span, + .toolbox-shell__description { + display: none; + } +} + +@media (max-width: 40rem) { + .toolbox-shell__bar { + gap: 0.45rem; + padding-inline: 0.65rem; } - .toolbox-shell__identity { - gap: 0.4rem; - } - - .toolbox-shell__icon { - width: 1.7rem; - height: 1.7rem; + .toolbox-shell__controls { + gap: 0.28rem; } .toolbox-shell__name { - max-width: 36vw; - font-size: 0.92rem; + font-size: 0.9rem; } - .toolbox-shell__meta { - display: none; + .toolbox-shell__footer-inner { + align-items: flex-start; + flex-direction: column; } - .toolbox-shell__actions { + .toolbox-shell__footer-meta { + justify-content: flex-start; + text-align: left; + } +} + +@media (max-width: 32rem) { + .toolbox-shell__brand-label { display: none; } } @media (max-width: 27rem) { - .toolbox-shell__source { - display: none; + .toolbox-shell__bar { + height: 4.1rem; + grid-template-columns: + minmax(0, 1fr) minmax(0, calc(100vw - 17.9rem)) + minmax(0, 1fr); + gap: 0.2rem; + padding-inline: 0.5rem; + } + + .toolbox-shell__controls { + gap: 0.1rem; + } + + .toolbox-shell__icon-button, + .toolbox-shell__menu-button { + width: 2rem; + min-width: 2rem; + height: 2rem; + min-height: 2rem; + padding: 0.3rem; + } + + .toolbox-shell__brand { + padding-inline: 0.1rem; + } + + .toolbox-shell__brand-mark { + width: 1.75rem; + height: 1.75rem; } .toolbox-shell__name { - max-width: 30vw; + font-size: 0.82rem; + } + + .toolbox-shell__popover { + max-width: calc(100vw - 1rem); } } diff --git a/packages/shell-react/test/AppShell.test.tsx b/packages/shell-react/test/AppShell.test.tsx index b040eba..ff1d9c0 100644 --- a/packages/shell-react/test/AppShell.test.tsx +++ b/packages/shell-react/test/AppShell.test.tsx @@ -1,11 +1,17 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { TOOLBOX_PREFERENCES_KEY, type ToolboxAppManifest, } from "@add-ideas/toolbox-contract"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AppShell } from "../src/index.js"; +import { AppShell, ToolboxHeader } from "../src/index.js"; const currentApp: ToolboxAppManifest = { schemaVersion: 1, @@ -112,16 +118,40 @@ describe("AppShell", () => { expect( document.querySelector("[data-toolbox-context='standalone']"), ).toBeInTheDocument(); + const controls = screen.getByLabelText("Toolbox controls"); expect( - screen.queryByRole("navigation", { name: "Toolbox applications" }), + Array.from(controls.children, (element) => + element.getAttribute("aria-label"), + ), + ).toEqual([ + "Personalize", + "Apps", + "Source for PDF Workbench on Gitea", + "Help", + ]); + fireEvent.click(screen.getByRole("button", { name: "Apps" })); + expect( + screen.getByRole("navigation", { name: "Toolbox applications" }), ).toHaveTextContent("Open this app from Toolbox"); expect( screen.getByRole("link", { name: /Source for PDF Workbench/u }), ).toHaveAttribute("href", "https://git.example.test/pdf-tools"); fireEvent.click(screen.getByRole("button", { name: "Help" })); expect(onHelp).toHaveBeenCalledOnce(); - expect(screen.getByText("Personalize")).toBeInTheDocument(); - expect(screen.getByText("Apps")).toBeInTheDocument(); + expect( + screen.queryByRole("navigation", { name: "Toolbox applications" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Personalize" }), + ).toHaveTextContent("Personalize"); + expect(screen.getByRole("button", { name: "Apps" })).toHaveTextContent( + "Apps", + ); + expect(document.querySelector(".toolbox-shell__icon")).toBeNull(); + expect(screen.getByText("add·ideas Toolbox").closest("a")).toBeNull(); + expect(document.querySelector(".toolbox-shell__footer")).toContainElement( + screen.getByLabelText("Version 1.2.3"), + ); }); it("loads same-origin context and creates full-page contextual switch links", async () => { @@ -140,7 +170,13 @@ describe("AppShell", () => { , ); - const navigation = await screen.findByRole("navigation", { + await waitFor(() => + expect( + document.querySelector("[data-toolbox-context='connected']"), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByRole("button", { name: "Apps" })); + const navigation = screen.getByRole("navigation", { name: "Toolbox applications", }); expect(navigation).toBeInTheDocument(); @@ -152,10 +188,14 @@ describe("AppShell", () => { expect(destination.searchParams.get("toolbox")).toBe( "https://tools.example.test/toolbox.catalog.json", ); - expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute( - "href", - "https://tools.example.test/", - ); + expect( + screen.getByRole("link", { name: "add·ideas Toolbox" }), + ).toHaveAttribute("href", "https://tools.example.test/"); + expect( + screen + .getByRole("link", { name: "add·ideas Toolbox" }) + .querySelector("img"), + ).toHaveAttribute("src", "https://tools.example.test/favicon.svg"); expect( document.querySelector("[data-toolbox-context='connected']"), ).toBeInTheDocument(); @@ -183,8 +223,9 @@ describe("AppShell", () => { code: "cross-origin", }); expect(fetch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Apps" })); expect( - screen.queryByRole("navigation", { name: "Toolbox applications" }), + screen.getByRole("navigation", { name: "Toolbox applications" }), ).toHaveTextContent("Open this app from Toolbox"); }); @@ -236,7 +277,7 @@ describe("AppShell", () => { const shell = document.querySelector(".toolbox-shell"); expect(shell).toHaveAttribute("data-toolbox-theme", "dark"); - fireEvent.click(screen.getByText("Personalize")); + fireEvent.click(screen.getByRole("button", { name: "Personalize" })); fireEvent.click(screen.getByRole("button", { name: "Light" })); expect(shell).toHaveAttribute("data-toolbox-theme", "light"); expect( @@ -244,10 +285,19 @@ describe("AppShell", () => { ).toMatchObject({ theme: "light" }); }); - it("resolves a relative explicit manifest URL against the deployed page", async () => { + it("resolves manifest-relative header links without rendering an app icon", async () => { render( { , ); - expect(document.querySelector(".toolbox-shell__icon")).toHaveAttribute( - "src", - "https://tools.example.test/nested/pdf/icon.svg", + expect( + screen.getByRole("link", { name: "Source for PDF Workbench on Gitea" }), + ).toHaveAttribute("href", "https://tools.example.test/nested/pdf/source/"); + expect(document.querySelector(".toolbox-shell__icon")).toBeNull(); + }); + + it("uses one accessible controlled popover for Personalize and Apps", () => { + const onHelp = vi.fn(); + render( + Full preferences} + helpAction={{ onClick: onHelp }} + sourceHref="https://git.example.test/pdf-tools" + apps={[ + { + id: "xslt", + name: "XSLT Workbench", + href: "#xslt", + }, + ]} + />, ); + + const personalize = screen.getByRole("button", { name: "Personalize" }); + const apps = screen.getByRole("button", { name: "Apps" }); + expect( + screen.getByRole("group", { name: "Toolbox controls" }), + ).toBeInTheDocument(); + expect(personalize).toHaveAttribute("aria-label", "Personalize"); + expect(apps).toHaveAttribute("aria-label", "Apps"); + expect(apps).not.toHaveAttribute("aria-haspopup"); + + fireEvent.click(personalize); + expect( + screen.getByRole("dialog", { name: "Personalize Toolbox" }), + ).toHaveTextContent("Full preferences"); + expect(personalize).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByRole("link", { name: "Full preferences" }), + ).toHaveFocus(); + + fireEvent.click(apps); + expect( + screen.queryByRole("dialog", { name: "Personalize Toolbox" }), + ).not.toBeInTheDocument(); + const navigation = screen.getByRole("navigation", { + name: "Toolbox applications", + }); + expect(navigation).toBeInTheDocument(); + expect( + within(navigation).getByRole("link", { name: "XSLT Workbench" }), + ).toHaveFocus(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect( + screen.queryByRole("navigation", { name: "Toolbox applications" }), + ).not.toBeInTheDocument(); + expect(apps).toHaveFocus(); + + fireEvent.click(personalize); + fireEvent.pointerDown(document.body); + expect( + screen.queryByRole("dialog", { name: "Personalize Toolbox" }), + ).not.toBeInTheDocument(); + + fireEvent.click(apps); + fireEvent.click( + within( + screen.getByRole("navigation", { name: "Toolbox applications" }), + ).getByRole("link", { name: "XSLT Workbench" }), + ); + expect( + screen.queryByRole("navigation", { name: "Toolbox applications" }), + ).not.toBeInTheDocument(); + }); + + it("keeps the legacy onPersonalize callback when no popover content exists", () => { + const onPersonalize = vi.fn(); + const onThemeChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Personalize" })); + expect(onPersonalize).toHaveBeenCalledOnce(); + expect( + screen.queryByRole("dialog", { name: "Personalize Toolbox" }), + ).not.toBeInTheDocument(); + }); + + it("closes Personalize if its content becomes unavailable", () => { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Personalize" })); + expect( + screen.getByRole("dialog", { name: "Personalize Toolbox" }), + ).toBeInTheDocument(); + + rerender(); + expect( + screen.queryByRole("dialog", { name: "Personalize Toolbox" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled(); }); }); diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 48a108e..41d8580 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-testkit", - "version": "0.2.0", + "version": "0.2.1", "description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.", "license": "Apache-2.0", "repository": { @@ -37,7 +37,7 @@ }, "types": "./dist/index.d.ts", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.0" + "@add-ideas/toolbox-contract": "0.2.1" }, "scripts": { "build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",