3 Commits
v0.2.0 ... main

20 changed files with 979 additions and 279 deletions

2
.npmrc
View File

@@ -1 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/

View File

@@ -3,6 +3,53 @@
All notable changes to the independently versioned Toolbox SDK packages are
recorded here. The packages currently share one release version.
## 0.2.3 — 2026-07-27
### Changed
- Moved the canonical repository, schema identifiers, examples, and source
metadata to the `lotobo` (LocalToolBox) Gitea organization.
- Moved publication of all three packages to the `lotobo` Gitea npm registry.
## 0.2.2 — 2026-07-23
### Added
- `ToolboxPersonalizePanel` for the portal-style personalization heading,
storage warning, appearance picker, and portal extension content.
### Changed
- The invariant top row now fixes the Toolbox brand/home at the far left and
Help, source, Apps, and Personalize at the far right in that order while the
text identity remains geometrically centered.
- Default app personalization now matches the central portal's panel styling;
portal-only catalogue counts and import/export actions remain extensible
content.
## 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

View File

@@ -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.3` contains three publish-ready packages:
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
discovery/loading, resolved URLs, and contextual link helpers.
@@ -36,7 +36,7 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
```json
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.pdf-tools",
"name": "PDF Workbench",
@@ -63,7 +63,7 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/zemion/pdf-tools",
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
"license": "AGPL-3.0-only"
}
}
@@ -167,13 +167,28 @@ import "@add-ideas/toolbox-shell-react/styles.css";
</AppShell>;
```
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 the Toolbox brand/home at the far left, a
geometrically centered text identity, and Help, source, Apps, and Personalize
controls at the far right in that order. 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. `ToolboxPersonalizePanel` provides the shared portal-style
heading, storage warning, and appearance picker, with a child slot for
portal-specific controls. `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

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.example-tool",
"name": "Example Tool",
@@ -26,7 +26,7 @@
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/zemion/toolbox-sdk",
"repository": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"license": "Apache-2.0"
}
}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.example-toolbox",
"name": "Example Toolbox",

View File

@@ -27,7 +27,7 @@ export const toolboxApp = defineToolboxApp({
telemetry: false,
},
source: {
repository: "https://git.add-ideas.de/zemion/toolbox-sdk",
repository: "https://git.add-ideas.de/lotobo/toolbox-sdk",
license: "Apache-2.0",
},
});

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.2.0",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.2.0",
"version": "0.2.3",
"license": "Apache-2.0",
"workspaces": [
"packages/*"
@@ -3946,7 +3946,7 @@
},
"packages/contract": {
"name": "@add-ideas/toolbox-contract",
"version": "0.2.0",
"version": "0.2.3",
"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.3",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.0"
"@add-ideas/toolbox-contract": "0.2.3"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -3966,10 +3966,10 @@
},
"packages/testkit": {
"name": "@add-ideas/toolbox-testkit",
"version": "0.2.0",
"version": "0.2.3",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.0"
"@add-ideas/toolbox-contract": "0.2.3"
},
"bin": {
"toolbox-check": "dist/cli.js"

View File

@@ -1,12 +1,12 @@
{
"name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.2.0",
"version": "0.2.3",
"private": true,
"description": "A small, framework-neutral toolbox contract with a React application shell.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git"
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git"
},
"type": "module",
"workspaces": [

View File

@@ -17,7 +17,7 @@ The package also owns the versioned same-origin browser preference contract. Use
`readToolboxPreferences()` and `writeToolboxPreferences()` to share pinned apps,
ordering, visibility, and light/dark/system mode with the Toolbox portal.
See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme)
See the workspace [README](https://git.add-ideas.de/lotobo/toolbox-sdk#readme)
for the v1 document formats and full API.
The canonical schemas are also published at

View File

@@ -1,14 +1,14 @@
{
"name": "@add-ideas/toolbox-contract",
"version": "0.2.0",
"version": "0.2.3",
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
"directory": "packages/contract"
},
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"keywords": [
"toolbox",
"manifest",
@@ -43,6 +43,6 @@
},
"publishConfig": {
"access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
}
}

View File

@@ -13,6 +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 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 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. `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,14 +1,14 @@
{
"name": "@add-ideas/toolbox-shell-react",
"version": "0.2.0",
"version": "0.2.3",
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
"directory": "packages/shell-react"
},
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"keywords": [
"toolbox",
"react",
@@ -38,7 +38,7 @@
"react-dom": ">=18 <20"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.0"
"@add-ideas/toolbox-contract": "0.2.3"
},
"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')\"",
@@ -48,6 +48,6 @@
},
"publishConfig": {
"access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
}
}

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,37 @@ 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;
}
export interface ToolboxPersonalizePanelProps {
theme: ToolboxThemeMode;
onThemeChange: (theme: ToolboxThemeMode) => void;
storageAvailable?: boolean | undefined;
children?: ReactNode | undefined;
}
type OpenPopover = "personalize" | "apps" | null;
function HelpIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -79,49 +100,226 @@ 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 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,
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
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}
{helpAction ? (
</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 ?? "Help"}
onClick={helpAction.onClick}
aria-label={helpAction?.label ?? "Help"}
title={
helpAction?.title ??
helpAction?.label ??
(helpAction ? "Help" : "Help unavailable")
}
disabled={!helpAction}
onClick={() => {
setOpenPopover(null);
helpAction?.onClick();
}}
>
<HelpIcon />
</button>
) : null}
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
@@ -130,74 +328,103 @@ 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}
className="toolbox-shell__icon-button"
type="button"
aria-pressed={theme === option}
onClick={() => onThemeChange?.(option)}
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
{option.slice(0, 1).toUpperCase() + option.slice(1)}
<GiteaIcon />
</button>
))}
</div>
<small>Saved only in this browser.</small>
</div>
</details>
)}
<details className="toolbox-shell__menu toolbox-shell__switcher">
<summary className="toolbox-shell__menu-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>
</summary>
</button>
<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>
{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
) : (
<ToolboxPersonalizePanel
theme={theme}
onThemeChange={(option) => onThemeChange?.(option)}
/>
)}
</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 +451,18 @@ export function ToolboxHeader({
<p>Open this app from Toolbox to switch tools here.</p>
)}
</nav>
</details>
) : null}
</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

@@ -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,12 +53,15 @@
--toolbox-accent-soft: #252f55;
--toolbox-accent-contrast: #11182a;
--toolbox-focus: #66cdbd;
--toolbox-danger: #ff8fa3;
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
}
.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 +91,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: end;
}
.toolbox-shell__header a {
@@ -115,70 +121,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 +143,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 +162,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 +201,93 @@
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: start;
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);
z-index: 1;
top: calc(100% + 0.7rem);
right: 0;
left: auto;
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;
@@ -276,7 +296,12 @@
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;
@@ -284,8 +309,8 @@
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;
@@ -294,17 +319,59 @@
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;
@@ -317,8 +384,30 @@
.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 {
@@ -352,76 +441,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,21 @@
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,
ToolboxPersonalizePanel,
} from "../src/index.js";
const currentApp: ToolboxAppManifest = {
schemaVersion: 1,
@@ -112,16 +122,50 @@ 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([
"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(
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 +184,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 +202,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 +237,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 +291,22 @@ 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" }));
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(
@@ -244,10 +314,42 @@ describe("AppShell", () => {
).toMatchObject({ theme: "light" });
});
it("resolves a relative explicit manifest URL against the deployed page", async () => {
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
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 +359,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,14 +1,14 @@
{
"name": "@add-ideas/toolbox-testkit",
"version": "0.2.0",
"version": "0.2.3",
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
"directory": "packages/testkit"
},
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"keywords": [
"toolbox",
"manifest",
@@ -37,7 +37,7 @@
},
"types": "./dist/index.d.ts",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.0"
"@add-ideas/toolbox-contract": "0.2.3"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
@@ -47,6 +47,6 @@
},
"publishConfig": {
"access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
}
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$id": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"title": "Toolbox application manifest v1",
"type": "object",
"required": [

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"$id": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"title": "Toolbox catalog v1",
"type": "object",
"required": ["schemaVersion", "id", "name", "home", "theme", "apps"],