fix: stabilize Toolbox navigation layout

This commit is contained in:
2026-07-23 01:10:18 +02:00
parent a058858b50
commit c296281f89
12 changed files with 750 additions and 251 deletions

View File

@@ -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": {

View File

@@ -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.

View File

@@ -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')\"",

View File

@@ -227,7 +227,6 @@ export function AppShell({
>
<ToolboxHeader
title={app.name}
iconUrl={safeHref(app.icon, appManifestUrl)}
subtitle={app.description}
theme={theme}
onThemeChange={setTheme}
@@ -235,11 +234,19 @@ export function AppShell({
sourceHref={sourceHref}
sourceLabel={`Source for ${app.name} on Gitea`}
homeHref={context?.catalog.homeUrl.href}
homeLabel={context?.catalog.catalog.theme.brand}
homeLabel="add·ideas Toolbox"
brandIconUrl={
context
? safeHref("favicon.svg", context.catalog.homeUrl.href)
: undefined
}
apps={switcherApps}
allAppsHref={context?.catalog.homeUrl.href}
leadingActions={
otherActions || appActions ? (
/>
<main className="toolbox-shell__main">{children}</main>
<footer className="toolbox-shell__footer">
<div className="toolbox-shell__footer-inner">
{otherActions?.length || appActions ? (
<div className="toolbox-shell__actions">
{otherActions?.map((action) => {
const href = safeHref(action.url, appManifestUrl);
@@ -251,10 +258,10 @@ export function AppShell({
})}
{appActions}
</div>
) : undefined
}
metadata={
<>
) : (
<span aria-hidden="true" />
)}
<div className="toolbox-shell__footer-meta">
<span aria-label={`Version ${app.version}`}>v{app.version}</span>
<span aria-hidden="true">·</span>
{privacyHref ? (
@@ -262,10 +269,9 @@ export function AppShell({
) : (
<span>{privacyText(app)}</span>
)}
</>
}
/>
<main className="toolbox-shell__main">{children}</main>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -79,49 +93,189 @@ function AppsIcon() {
);
}
function ToolboxMark() {
return (
<svg viewBox="0 0 48 48" aria-hidden="true">
<path
d="M7 14.5 24 5l17 9.5v19L24 43 7 33.5z"
fill="currentColor"
opacity=".12"
stroke="none"
/>
<path d="m8 15 16 9 16-9M24 24v18" />
<path d="m16 10.5 16 9v9L24 33l-8-4.5z" />
</svg>
);
}
function ToolboxBrandMark({ iconUrl }: { iconUrl?: string | undefined }) {
const [failedIconUrl, setFailedIconUrl] = useState<string>();
if (!iconUrl || failedIconUrl === iconUrl) return <ToolboxMark />;
return <img src={iconUrl} alt="" onError={() => 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<OpenPopover>(null);
const personalizeButtonRef = useRef<HTMLButtonElement | null>(null);
const appsButtonRef = useRef<HTMLButtonElement | null>(null);
const popoverRef = useRef<HTMLElement | null>(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<HTMLElement>(
'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<HTMLElement>) => {
if ((event.target as Element).closest?.("a")) setOpenPopover(null);
};
const brandContent = (
<>
<span className="toolbox-shell__brand-mark">
<ToolboxBrandMark iconUrl={brandIconUrl} />
</span>
<span className="toolbox-shell__brand-label">{homeLabel}</span>
</>
);
return (
<header
className={["toolbox-shell__header", className].filter(Boolean).join(" ")}
data-toolbox-theme={theme}
>
<div className="toolbox-shell__bar">
<div className="toolbox-shell__side toolbox-shell__side--left">
{homeHref ? (
<a className="toolbox-shell__home" href={homeHref}>
{homeLabel}
</a>
) : null}
{helpAction ? (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction.label ?? "Help"}
title={helpAction.title ?? helpAction.label ?? "Help"}
onClick={helpAction.onClick}
>
<HelpIcon />
</button>
) : null}
<div
className="toolbox-shell__controls"
role="group"
aria-label="Toolbox controls"
>
<button
ref={personalizeButtonRef}
className="toolbox-shell__menu-button"
type="button"
aria-label="Personalize"
title={
personalizeAvailable
? "Personalize"
: "Personalization unavailable"
}
aria-haspopup={hasPersonalizePopover ? "dialog" : undefined}
aria-expanded={
hasPersonalizePopover
? visiblePopover === "personalize"
: undefined
}
aria-controls={
hasPersonalizePopover && visiblePopover === "personalize"
? personalizeId
: undefined
}
disabled={!personalizeAvailable}
onClick={() => {
if (hasPersonalizePopover) {
setOpenPopover((current) =>
current === "personalize" ? null : "personalize",
);
} else {
onPersonalize?.();
}
}}
>
<PersonalizeIcon />
<span>Personalize</span>
</button>
<button
ref={appsButtonRef}
className="toolbox-shell__menu-button"
type="button"
aria-label="Apps"
title="Apps"
aria-expanded={visiblePopover === "apps"}
aria-controls={visiblePopover === "apps" ? appsId : undefined}
onClick={() =>
setOpenPopover((current) => (current === "apps" ? null : "apps"))
}
>
<AppsIcon />
<span>Apps</span>
</button>
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
@@ -130,74 +284,81 @@ export function ToolboxHeader({
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
onClick={() => setOpenPopover(null)}
>
<GiteaIcon />
</a>
) : null}
{leadingActions}
</div>
<div className="toolbox-shell__identity">
{iconUrl ? (
<img
className="toolbox-shell__icon"
src={iconUrl}
alt=""
width="32"
height="32"
/>
) : null}
<div>
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
</div>
<div className="toolbox-shell__side toolbox-shell__side--right">
{onPersonalize ? (
<button
className="toolbox-shell__menu-button"
type="button"
onClick={onPersonalize}
>
<PersonalizeIcon />
<span>Personalize</span>
</button>
) : (
<details className="toolbox-shell__menu toolbox-shell__personalize">
<summary className="toolbox-shell__menu-button">
<PersonalizeIcon />
<span>Personalize</span>
</summary>
<div className="toolbox-shell__popover">
<strong>Appearance</strong>
<div className="toolbox-shell__theme-options">
{THEMES.map((option) => (
<button
key={option}
type="button"
aria-pressed={theme === option}
onClick={() => onThemeChange?.(option)}
>
{option.slice(0, 1).toUpperCase() + option.slice(1)}
</button>
))}
</div>
<small>Saved only in this browser.</small>
</div>
</details>
<button
className="toolbox-shell__icon-button"
type="button"
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
<GiteaIcon />
</button>
)}
<details className="toolbox-shell__menu toolbox-shell__switcher">
<summary className="toolbox-shell__menu-button">
<AppsIcon />
<span>Apps</span>
</summary>
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction?.label ?? "Help"}
title={
helpAction?.title ??
helpAction?.label ??
(helpAction ? "Help" : "Help unavailable")
}
disabled={!helpAction}
onClick={() => {
setOpenPopover(null);
helpAction?.onClick();
}}
>
<HelpIcon />
</button>
{visiblePopover === "personalize" ? (
<section
ref={popoverRef}
id={personalizeId}
className="toolbox-shell__popover toolbox-shell__personalize-popover"
role="dialog"
aria-label="Personalize Toolbox"
tabIndex={-1}
onClick={closeOnLinkSelection}
>
{customPersonalizeContent ? (
personalizeContent
) : (
<>
<strong>Appearance</strong>
<div className="toolbox-shell__theme-options">
{THEMES.map((option) => (
<button
key={option}
type="button"
aria-pressed={theme === option}
onClick={() => onThemeChange?.(option)}
>
{option.slice(0, 1).toUpperCase() + option.slice(1)}
</button>
))}
</div>
<small>Saved only in this browser.</small>
</>
)}
</section>
) : null}
{visiblePopover === "apps" ? (
<nav
className="toolbox-shell__popover"
ref={popoverRef}
id={appsId}
className="toolbox-shell__popover toolbox-shell__switcher"
aria-label="Toolbox applications"
tabIndex={-1}
onClick={closeOnLinkSelection}
>
{allAppsHref ? (
<a className="toolbox-shell__all-apps" href={allAppsHref}>
@@ -224,10 +385,38 @@ export function ToolboxHeader({
<p>Open this app from Toolbox to switch tools here.</p>
)}
</nav>
</details>
) : null}
</div>
<div className="toolbox-shell__identity">
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
{homeHref ? (
<a
className="toolbox-shell__brand"
href={homeHref}
aria-label={homeLabel}
onClick={() => setOpenPopover(null)}
>
{brandContent}
</a>
) : (
<div className="toolbox-shell__brand">{brandContent}</div>
)}
</div>
{metadata ? <div className="toolbox-shell__meta">{metadata}</div> : null}
{leadingActions || metadata ? (
<div className="toolbox-shell__supplemental">
{leadingActions}
{metadata ? (
<div className="toolbox-shell__supplemental-meta">{metadata}</div>
) : null}
</div>
) : null}
</header>
);
}

View File

@@ -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);
}
}

View File

@@ -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", () => {
</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(
<AppShell
app={currentApp}
app={{
...currentApp,
actions: [
{
id: "source",
label: "Source",
url: "./source/",
},
],
}}
manifestUrl="./toolbox-app.json"
contextOptions={{
location: "https://tools.example.test/nested/pdf/index.html",
@@ -257,9 +307,122 @@ describe("AppShell", () => {
</AppShell>,
);
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(
<ToolboxHeader
title="PDF Workbench"
subtitle="Local PDF tools"
theme="system"
personalizeContent={<a href="#preferences">Full preferences</a>}
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(
<ToolboxHeader
title="PDF Workbench"
theme="system"
onPersonalize={onPersonalize}
onThemeChange={onThemeChange}
/>,
);
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(
<ToolboxHeader
title="PDF Workbench"
theme="system"
onThemeChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
expect(
screen.getByRole("dialog", { name: "Personalize Toolbox" }),
).toBeInTheDocument();
rerender(<ToolboxHeader title="PDF Workbench" theme="system" />);
expect(
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled();
});
});

View File

@@ -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')\"",