fix: reverse Toolbox header orientation

This commit is contained in:
2026-07-23 01:47:55 +02:00
parent c296281f89
commit 53c40a61ba
12 changed files with 331 additions and 138 deletions

View File

@@ -13,14 +13,17 @@ 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 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.
The fixed top row exposes a Toolbox home/brand block at the far left, a centered
title/subtitle, and Help, Gitea source, Apps, and Personalize at the far right
in that order. Personalize uses the portal-style panel and 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.
is the fallback. `ToolboxPersonalizePanel` shares the portal heading, optional
storage warning, and appearance picker with custom preference content.
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.1",
"version": "0.2.2",
"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.1"
"@add-ideas/toolbox-contract": "0.2.2"
},
"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

@@ -46,6 +46,13 @@ export interface ToolboxHeaderProps {
className?: string | undefined;
}
export interface ToolboxPersonalizePanelProps {
theme: ToolboxThemeMode;
onThemeChange: (theme: ToolboxThemeMode) => void;
storageAvailable?: boolean | undefined;
children?: ReactNode | undefined;
}
type OpenPopover = "personalize" | "apps" | null;
function HelpIcon() {
@@ -117,6 +124,57 @@ function ToolboxBrandMark({ iconUrl }: { iconUrl?: string | undefined }) {
const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"];
export function ToolboxPersonalizePanel({
theme,
onThemeChange,
storageAvailable,
children,
}: ToolboxPersonalizePanelProps) {
const headingId = useId();
return (
<section
className="toolbox-shell__preferences-content"
aria-labelledby={headingId}
>
<div className="toolbox-shell__preferences-heading">
<p className="toolbox-shell__preferences-kicker">On this device</p>
<h2 id={headingId}>Personalize your toolbox</h2>
</div>
{storageAvailable === false ? (
<p className="toolbox-shell__preferences-warning" role="status">
Browser storage is unavailable. Changes will last only until this page
closes.
</p>
) : null}
<fieldset className="toolbox-shell__theme-choice">
<legend>Appearance</legend>
<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>
</fieldset>
{children ?? (
<p className="toolbox-shell__preferences-copy">
The Toolbox stores your appearance choice in this browser and does not
transmit it.
</p>
)}
</section>
);
}
export function ToolboxHeader({
title,
subtitle,
@@ -219,11 +277,89 @@ export function ToolboxHeader({
data-toolbox-theme={theme}
>
<div className="toolbox-shell__bar">
{homeHref ? (
<a
className="toolbox-shell__brand"
href={homeHref}
aria-label={homeLabel}
onClick={() => setOpenPopover(null)}
>
{brandContent}
</a>
) : (
<div className="toolbox-shell__brand">{brandContent}</div>
)}
<div className="toolbox-shell__identity">
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
<div
className="toolbox-shell__controls"
role="group"
aria-label="Toolbox controls"
>
<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>
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
href={sourceHref}
target="_blank"
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
onClick={() => setOpenPopover(null)}
>
<GiteaIcon />
</a>
) : (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
<GiteaIcon />
</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>
<button
ref={personalizeButtonRef}
className="toolbox-shell__menu-button"
@@ -260,64 +396,6 @@ export function ToolboxHeader({
<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"
href={sourceHref}
target="_blank"
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
onClick={() => setOpenPopover(null)}
>
<GiteaIcon />
</a>
) : (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
<GiteaIcon />
</button>
)}
<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}
@@ -331,22 +409,10 @@ export function ToolboxHeader({
{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>
</>
<ToolboxPersonalizePanel
theme={theme}
onThemeChange={(option) => onThemeChange?.(option)}
/>
)}
</section>
) : null}
@@ -387,26 +453,6 @@ export function ToolboxHeader({
</nav>
) : 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>
{leadingActions || metadata ? (

View File

@@ -1,8 +1,9 @@
export { AppShell } from "./AppShell.js";
export type { AppShellProps } from "./AppShell.js";
export { ToolboxHeader } from "./ToolboxHeader.js";
export { ToolboxHeader, ToolboxPersonalizePanel } from "./ToolboxHeader.js";
export type {
ToolboxHeaderAction,
ToolboxHeaderApp,
ToolboxHeaderProps,
ToolboxPersonalizePanelProps,
} from "./ToolboxHeader.js";

View File

@@ -13,6 +13,7 @@
--toolbox-accent-soft: #e9edff;
--toolbox-accent-contrast: #ffffff;
--toolbox-focus: #63cdbc;
--toolbox-danger: #b4233a;
--toolbox-radius: 0.7rem;
--toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%);
}
@@ -34,6 +35,7 @@
--toolbox-accent-soft: #252f55;
--toolbox-accent-contrast: #11182a;
--toolbox-focus: #66cdbd;
--toolbox-danger: #ff8fa3;
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
@@ -51,6 +53,7 @@
--toolbox-accent-soft: #252f55;
--toolbox-accent-contrast: #11182a;
--toolbox-focus: #66cdbd;
--toolbox-danger: #ff8fa3;
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
}
@@ -106,7 +109,7 @@
display: flex;
align-items: center;
gap: 0.4rem;
justify-self: start;
justify-self: end;
}
.toolbox-shell__header a {
@@ -233,7 +236,7 @@
min-width: 0;
display: inline-flex;
align-items: center;
justify-self: end;
justify-self: start;
gap: 0.58rem;
padding: 0.28rem 0.35rem;
border-radius: var(--toolbox-radius);
@@ -278,7 +281,8 @@ a.toolbox-shell__brand:hover {
position: absolute;
z-index: 1;
top: calc(100% + 0.7rem);
left: 0;
right: 0;
left: auto;
width: max-content;
min-width: 13rem;
max-width: min(22rem, calc(100vw - 1.5rem));
@@ -292,7 +296,12 @@ a.toolbox-shell__brand:hover {
color: var(--toolbox-text);
}
.toolbox-shell__popover strong {
.toolbox-shell__personalize-popover {
width: min(26rem, calc(100vw - 1.5rem));
max-width: min(26rem, calc(100vw - 1.5rem));
}
.toolbox-shell__popover > strong {
display: block;
padding: 0.35rem 0.4rem 0.5rem;
font-size: 0.75rem;
@@ -300,8 +309,8 @@ a.toolbox-shell__brand:hover {
letter-spacing: 0.08em;
}
.toolbox-shell__popover small,
.toolbox-shell__popover p {
.toolbox-shell__popover > small,
.toolbox-shell__switcher > p {
display: block;
margin: 0;
padding: 0.55rem 0.4rem 0.25rem;
@@ -310,17 +319,59 @@ a.toolbox-shell__brand:hover {
line-height: 1.4;
}
.toolbox-shell__preferences-content {
padding: 0.35rem;
color: var(--toolbox-text);
}
.toolbox-shell__preferences-heading {
margin-bottom: 0.9rem;
}
.toolbox-shell__preferences-content h2 {
margin: 0;
color: var(--toolbox-text);
font-family: var(--toolbox-font);
font-size: 1rem;
letter-spacing: -0.025em;
}
.toolbox-shell__preferences-content .toolbox-shell__preferences-kicker {
margin: 0 0 0.3rem;
padding: 0;
color: var(--toolbox-accent);
font-size: 0.68rem;
font-weight: 750;
letter-spacing: 0.1em;
line-height: 1.4;
text-transform: uppercase;
}
.toolbox-shell__theme-choice {
margin: 1rem 0;
padding: 0;
border: 0;
}
.toolbox-shell__theme-choice legend {
margin-bottom: 0.6rem;
color: var(--toolbox-muted);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.toolbox-shell__theme-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.25rem;
padding: 0.25rem;
border-radius: 0.65rem;
gap: 0.45rem;
padding: 0.35rem;
border-radius: 0.75rem;
background: var(--toolbox-surface-soft);
}
.toolbox-shell__theme-options button {
padding: 0.5rem 0.45rem;
padding: 0.62rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
@@ -333,8 +384,30 @@ a.toolbox-shell__brand:hover {
.toolbox-shell__theme-options button[aria-pressed="true"] {
background: var(--toolbox-surface);
color: var(--toolbox-accent);
box-shadow: 0 1px 5px rgb(24 34 68 / 12%);
font-weight: 750;
box-shadow: 0 2px 8px rgb(20 30 60 / 9%);
font-weight: 700;
}
.toolbox-shell__preferences-content .toolbox-shell__preferences-copy {
margin: 0.9rem 0 0;
padding: 0;
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.55;
}
.toolbox-shell__preferences-content .toolbox-shell__preferences-warning {
margin: 0 0 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: 0.6rem;
background: color-mix(
in srgb,
var(--toolbox-danger) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
font-size: 0.82rem;
line-height: 1.45;
}
.toolbox-shell__switcher ul {

View File

@@ -11,7 +11,11 @@ import {
} from "@add-ideas/toolbox-contract";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppShell, ToolboxHeader } from "../src/index.js";
import {
AppShell,
ToolboxHeader,
ToolboxPersonalizePanel,
} from "../src/index.js";
const currentApp: ToolboxAppManifest = {
schemaVersion: 1,
@@ -124,10 +128,20 @@ describe("AppShell", () => {
element.getAttribute("aria-label"),
),
).toEqual([
"Personalize",
"Apps",
"Source for PDF Workbench on Gitea",
"Help",
"Source for PDF Workbench on Gitea",
"Apps",
"Personalize",
]);
expect(
Array.from(
document.querySelector(".toolbox-shell__bar")?.children ?? [],
(element) => element.className,
),
).toEqual([
"toolbox-shell__brand",
"toolbox-shell__identity",
"toolbox-shell__controls",
]);
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
expect(
@@ -278,6 +292,21 @@ describe("AppShell", () => {
const shell = document.querySelector(".toolbox-shell");
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
expect(
screen.getByRole("heading", { name: "Personalize your toolbox" }),
).toBeInTheDocument();
expect(screen.getByText("On this device")).toBeInTheDocument();
expect(
screen.getByRole("group", { name: "Appearance" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.queryByText("Pinned")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Export JSON" }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Light" }));
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
expect(
@@ -285,6 +314,29 @@ describe("AppShell", () => {
).toMatchObject({ theme: "light" });
});
it("shares the portal-style personalization frame and extension slot", () => {
const onThemeChange = vi.fn();
render(
<ToolboxPersonalizePanel
theme="system"
onThemeChange={onThemeChange}
storageAvailable={false}
>
<p>Portal preference actions</p>
</ToolboxPersonalizePanel>,
);
expect(
screen.getByRole("heading", { name: "Personalize your toolbox" }),
).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Browser storage is unavailable",
);
expect(screen.getByText("Portal preference actions")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dark" }));
expect(onThemeChange).toHaveBeenCalledWith("dark");
});
it("resolves manifest-relative header links without rendering an app icon", async () => {
render(
<AppShell