fix: reverse Toolbox header orientation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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')\"",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user