Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c296281f89 | |||
| a058858b50 |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -3,6 +3,47 @@
|
|||||||
All notable changes to the independently versioned Toolbox SDK packages are
|
All notable changes to the independently versioned Toolbox SDK packages are
|
||||||
recorded here. The packages currently share one release version.
|
recorded here. The packages currently share one release version.
|
||||||
|
|
||||||
|
## 0.2.1 — 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ToolboxHeader.personalizeContent` for custom preferences inside the shared
|
||||||
|
controlled popover and `brandIconUrl` for a portal favicon with an inline
|
||||||
|
Toolbox cube fallback.
|
||||||
|
- Accessible, mutually exclusive Personalize and Apps popovers that close on
|
||||||
|
Escape, outside interaction, and link selection.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The invariant top row now orders Personalize, Apps, source, and Help on the
|
||||||
|
left, keeps the text-only app identity geometrically centered, and fixes the
|
||||||
|
Toolbox brand/home block at the far right.
|
||||||
|
- Personalize and Apps keep visible desktop labels, become compact icon controls
|
||||||
|
responsively without losing accessible names, and source and Help remain
|
||||||
|
present at every supported width.
|
||||||
|
- `AppShell` now renders version, privacy, manifest actions, and legacy app
|
||||||
|
actions in a standard footer instead of changing the top-row geometry.
|
||||||
|
- `iconUrl`, `leadingActions`, and `metadata` remain accepted for source
|
||||||
|
compatibility but are deprecated for the fixed header design.
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A reusable, genuinely centered `ToolboxHeader` with standard Help, Gitea
|
||||||
|
source, Personalize, and Apps controls.
|
||||||
|
- Shared versioned browser preferences in `@add-ideas/toolbox-contract`, using
|
||||||
|
the portal's existing storage key so light, dark, and system mode follow the
|
||||||
|
user between same-origin Toolbox apps.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `AppShell` now uses the common top bar in both connected and standalone mode,
|
||||||
|
derives its source link from manifest metadata, and offers app switching from
|
||||||
|
a consistent Apps menu.
|
||||||
|
- Explicit relative `manifestUrl` values now resolve against the deployed page
|
||||||
|
URL, including nested-path deployments.
|
||||||
|
|
||||||
## 0.1.1 — 2026-07-20
|
## 0.1.1 — 2026-07-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -5,7 +5,7 @@ tools. An app always works by itself. When it receives a trusted same-origin
|
|||||||
catalog URL, the same app gains a toolbox home link and an app switcher without
|
catalog URL, the same app gains a toolbox home link and an app switcher without
|
||||||
becoming coupled to a portal or client-side router.
|
becoming coupled to a portal or client-side router.
|
||||||
|
|
||||||
Version `0.1.0` contains three publish-ready packages:
|
Version `0.2.1` contains three publish-ready packages:
|
||||||
|
|
||||||
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
|
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
|
||||||
discovery/loading, resolved URLs, and contextual link helpers.
|
discovery/loading, resolved URLs, and contextual link helpers.
|
||||||
@@ -160,23 +160,39 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
|||||||
<AppShell
|
<AppShell
|
||||||
app={manifest}
|
app={manifest}
|
||||||
manifestUrl="/toolbox-app.json"
|
manifestUrl="/toolbox-app.json"
|
||||||
appActions={<a href="./help/">Help</a>}
|
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||||
onContextError={(error) => console.warn(error)}
|
onContextError={(error) => console.warn(error)}
|
||||||
>
|
>
|
||||||
<Application />
|
<Application />
|
||||||
</AppShell>;
|
</AppShell>;
|
||||||
```
|
```
|
||||||
|
|
||||||
The shell always renders app identity as an `h1`, version, derived privacy
|
The shell's fixed top row renders Personalize, Apps, source, and Help controls
|
||||||
facts, manifest actions, and `appActions`. With valid context it additionally
|
in that order, a geometrically centered text identity, and the Toolbox
|
||||||
renders the catalog brand/home and a switcher at the accessible navigation
|
brand/home at the far right. Personalize and Apps have labeled desktop controls
|
||||||
landmark `Toolbox applications`. Destinations are ordinary links, so switching
|
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
|
performs full-page navigation. On missing, cross-origin, invalid, or unavailable
|
||||||
context the shell quietly remains usable in standalone mode; `onContextError` is
|
context the shell quietly remains usable in standalone mode; `onContextError` is
|
||||||
optional observability.
|
optional observability.
|
||||||
|
|
||||||
|
For a custom portal-style header, `ToolboxHeader` accepts `personalizeContent`
|
||||||
|
for content hosted inside the shared controlled popover and `brandIconUrl` for
|
||||||
|
the Toolbox mark. `onPersonalize` remains available for callback-only
|
||||||
|
integrations. The deprecated `iconUrl`, `leadingActions`, and `metadata` props
|
||||||
|
are retained for source compatibility; the centered app icon is intentionally no
|
||||||
|
longer rendered.
|
||||||
|
|
||||||
CSS custom properties prefixed with `--toolbox-` are the v1 theme surface. The
|
CSS custom properties prefixed with `--toolbox-` are the v1 theme surface. The
|
||||||
catalog `light`, `dark`, or `system` mode selects the built-in palette.
|
user's `light`, `dark`, or `system` preference is shared with same-origin apps
|
||||||
|
under `TOOLBOX_PREFERENCES_KEY`; the catalog mode is used until a preference is
|
||||||
|
saved. `ToolboxHeader` is also exported for portal-like pages that do not need
|
||||||
|
the complete application shell.
|
||||||
|
|
||||||
## Validate a build
|
## Validate a build
|
||||||
|
|
||||||
@@ -210,6 +226,8 @@ The main exports are:
|
|||||||
`fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`.
|
`fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`.
|
||||||
- URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and
|
- URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and
|
||||||
`contextualizeToolboxLink()`.
|
`contextualizeToolboxLink()`.
|
||||||
|
- Preferences: `parseToolboxPreferences()`, `readToolboxPreferences()`,
|
||||||
|
`writeToolboxPreferences()`, and `TOOLBOX_PREFERENCES_KEY`.
|
||||||
|
|
||||||
See each emitted `.d.ts` file for the complete signatures.
|
See each emitted `.d.ts` file for the complete signatures.
|
||||||
|
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -3946,7 +3946,7 @@
|
|||||||
},
|
},
|
||||||
"packages/contract": {
|
"packages/contract": {
|
||||||
"name": "@add-ideas/toolbox-contract",
|
"name": "@add-ideas/toolbox-contract",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -3954,10 +3954,10 @@
|
|||||||
},
|
},
|
||||||
"packages/shell-react": {
|
"packages/shell-react": {
|
||||||
"name": "@add-ideas/toolbox-shell-react",
|
"name": "@add-ideas/toolbox-shell-react",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1"
|
"@add-ideas/toolbox-contract": "0.2.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -3966,10 +3966,10 @@
|
|||||||
},
|
},
|
||||||
"packages/testkit": {
|
"packages/testkit": {
|
||||||
"name": "@add-ideas/toolbox-testkit",
|
"name": "@add-ideas/toolbox-testkit",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1"
|
"@add-ideas/toolbox-contract": "0.2.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A small, framework-neutral toolbox contract with a React application shell.",
|
"description": "A small, framework-neutral toolbox contract with a React application shell.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const app = parseToolboxApp(await response.json());
|
|||||||
const result = await loadToolboxContext();
|
const result = await loadToolboxContext();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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/zemion/toolbox-sdk#readme)
|
||||||
for the v1 document formats and full API.
|
for the v1 document formats and full API.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-contract",
|
"name": "@add-ideas/toolbox-contract",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
|
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ export {
|
|||||||
loadToolboxCatalog,
|
loadToolboxCatalog,
|
||||||
loadToolboxContext,
|
loadToolboxContext,
|
||||||
} from "./load.js";
|
} from "./load.js";
|
||||||
|
export {
|
||||||
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
|
defaultToolboxPreferences,
|
||||||
|
parseToolboxPreferences,
|
||||||
|
readToolboxPreferences,
|
||||||
|
writeToolboxPreferences,
|
||||||
|
} from "./preferences.js";
|
||||||
|
export type {
|
||||||
|
ToolboxPreferences,
|
||||||
|
ToolboxPreferenceStorage,
|
||||||
|
ToolboxThemeMode,
|
||||||
|
} from "./preferences.js";
|
||||||
export type {
|
export type {
|
||||||
ToolboxContextLoadOptions,
|
ToolboxContextLoadOptions,
|
||||||
ToolboxFetch,
|
ToolboxFetch,
|
||||||
|
|||||||
94
packages/contract/src/preferences.ts
Normal file
94
packages/contract/src/preferences.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ToolboxCatalogTheme } from "./types.js";
|
||||||
|
|
||||||
|
export const TOOLBOX_PREFERENCES_KEY =
|
||||||
|
"@add-ideas/toolbox-portal:v1:preferences" as const;
|
||||||
|
|
||||||
|
export type ToolboxThemeMode = ToolboxCatalogTheme["mode"];
|
||||||
|
|
||||||
|
export interface ToolboxPreferences {
|
||||||
|
version: 1;
|
||||||
|
pinned: string[];
|
||||||
|
order: string[];
|
||||||
|
hidden: string[];
|
||||||
|
theme: ToolboxThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxPreferenceStorage {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultToolboxPreferences: Readonly<ToolboxPreferences> = {
|
||||||
|
version: 1,
|
||||||
|
pinned: [],
|
||||||
|
order: [],
|
||||||
|
hidden: [],
|
||||||
|
theme: "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
function freshDefaults(): ToolboxPreferences {
|
||||||
|
return {
|
||||||
|
...defaultToolboxPreferences,
|
||||||
|
pinned: [],
|
||||||
|
order: [],
|
||||||
|
hidden: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(value: unknown, field: string): string[] {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.some((item) => typeof item !== "string" || item.length === 0)
|
||||||
|
) {
|
||||||
|
throw new Error(`${field} must be an array of non-empty strings.`);
|
||||||
|
}
|
||||||
|
return [...new Set(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolboxPreferences(value: unknown): ToolboxPreferences {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error("Preferences must be a JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
if (candidate.version !== 1) {
|
||||||
|
throw new Error("Unsupported preferences version.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = candidate.theme;
|
||||||
|
if (theme !== "light" && theme !== "dark" && theme !== "system") {
|
||||||
|
throw new Error("Invalid theme preference.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
pinned: uniqueStrings(candidate.pinned, "pinned"),
|
||||||
|
order: uniqueStrings(candidate.order, "order"),
|
||||||
|
hidden: uniqueStrings(candidate.hidden, "hidden"),
|
||||||
|
theme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readToolboxPreferences(
|
||||||
|
storage?: ToolboxPreferenceStorage,
|
||||||
|
): ToolboxPreferences {
|
||||||
|
if (!storage) return freshDefaults();
|
||||||
|
const value = storage.getItem(TOOLBOX_PREFERENCES_KEY);
|
||||||
|
if (!value) return freshDefaults();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseToolboxPreferences(JSON.parse(value));
|
||||||
|
} catch {
|
||||||
|
return freshDefaults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeToolboxPreferences(
|
||||||
|
preferences: ToolboxPreferences,
|
||||||
|
storage: ToolboxPreferenceStorage,
|
||||||
|
): void {
|
||||||
|
storage.setItem(
|
||||||
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
|
JSON.stringify(parseToolboxPreferences(preferences)),
|
||||||
|
);
|
||||||
|
}
|
||||||
63
packages/contract/test/preferences.test.ts
Normal file
63
packages/contract/test/preferences.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
|
defaultToolboxPreferences,
|
||||||
|
parseToolboxPreferences,
|
||||||
|
readToolboxPreferences,
|
||||||
|
writeToolboxPreferences,
|
||||||
|
} from "../src/index.js";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
function memoryStorage(initial?: string) {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
if (initial !== undefined) values.set(TOOLBOX_PREFERENCES_KEY, initial);
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shared toolbox preferences", () => {
|
||||||
|
it("parses version 1 and removes duplicate ids", () => {
|
||||||
|
expect(
|
||||||
|
parseToolboxPreferences({
|
||||||
|
version: 1,
|
||||||
|
pinned: ["pdf", "pdf"],
|
||||||
|
order: ["pdf", "xslt"],
|
||||||
|
hidden: [],
|
||||||
|
theme: "dark",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
version: 1,
|
||||||
|
pinned: ["pdf"],
|
||||||
|
order: ["pdf", "xslt"],
|
||||||
|
hidden: [],
|
||||||
|
theme: "dark",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns independent defaults for missing or corrupt storage", () => {
|
||||||
|
const missing = readToolboxPreferences(memoryStorage());
|
||||||
|
const corrupt = readToolboxPreferences(memoryStorage("not json"));
|
||||||
|
missing.pinned.push("pdf");
|
||||||
|
expect(corrupt).toEqual(defaultToolboxPreferences);
|
||||||
|
expect(corrupt.pinned).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes validated preferences under the compatible portal key", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
writeToolboxPreferences(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
pinned: ["pdf"],
|
||||||
|
order: ["pdf"],
|
||||||
|
hidden: [],
|
||||||
|
theme: "light",
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
expect(readToolboxPreferences(storage)).toMatchObject({
|
||||||
|
pinned: ["pdf"],
|
||||||
|
theme: "light",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||||
|
|
||||||
<AppShell app={manifest} appActions={<a href="/help">Help</a>}>
|
<AppShell app={manifest} helpAction={{ onClick: () => setHelpOpen(true) }}>
|
||||||
<App />
|
<App />
|
||||||
</AppShell>;
|
</AppShell>;
|
||||||
```
|
```
|
||||||
@@ -13,3 +13,14 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
|||||||
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
|
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
|
||||||
app switcher is exposed as the accessible navigation landmark
|
app switcher is exposed as the accessible navigation landmark
|
||||||
`Toolbox applications` and all destinations use ordinary document navigation.
|
`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.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-shell-react",
|
"name": "@add-ideas/toolbox-shell-react",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
|
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"react-dom": ">=18 <20"
|
"react-dom": ">=18 <20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1"
|
"@add-ideas/toolbox-contract": "0.2.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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')\"",
|
"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')\"",
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
contextualizeToolboxLink,
|
contextualizeToolboxLink,
|
||||||
loadToolboxContext,
|
loadToolboxContext,
|
||||||
|
readToolboxPreferences,
|
||||||
resolveWebUrl,
|
resolveWebUrl,
|
||||||
|
writeToolboxPreferences,
|
||||||
type ToolboxAppManifest,
|
type ToolboxAppManifest,
|
||||||
type ToolboxContext,
|
type ToolboxContext,
|
||||||
type ToolboxContextLoadOptions,
|
type ToolboxContextLoadOptions,
|
||||||
type ToolboxError,
|
type ToolboxError,
|
||||||
|
type ToolboxThemeMode,
|
||||||
} from "@add-ideas/toolbox-contract";
|
} from "@add-ideas/toolbox-contract";
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ToolboxHeader,
|
||||||
|
type ToolboxHeaderAction,
|
||||||
|
type ToolboxHeaderApp,
|
||||||
|
} from "./ToolboxHeader.js";
|
||||||
|
|
||||||
export interface AppShellProps {
|
export interface AppShellProps {
|
||||||
app: ToolboxAppManifest;
|
app: ToolboxAppManifest;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
helpAction?: ToolboxHeaderAction;
|
||||||
appActions?: ReactNode;
|
appActions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
manifestUrl?: string | URL;
|
manifestUrl?: string | URL;
|
||||||
@@ -25,9 +36,31 @@ function currentHref(): string {
|
|||||||
: globalThis.location.href;
|
: globalThis.location.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeHref(reference: string, base: string | URL): string | undefined {
|
function safeHref(
|
||||||
|
reference: string | URL,
|
||||||
|
base: string | URL,
|
||||||
|
): string | undefined {
|
||||||
try {
|
try {
|
||||||
return resolveWebUrl(reference, base).href;
|
return resolveWebUrl(String(reference), base).href;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserStorage(): Storage | undefined {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialStoredTheme(): ToolboxThemeMode | undefined {
|
||||||
|
const storage = browserStorage();
|
||||||
|
if (!storage) return undefined;
|
||||||
|
try {
|
||||||
|
if (storage.getItem(TOOLBOX_PREFERENCES_KEY) === null) return undefined;
|
||||||
|
return readToolboxPreferences(storage).theme;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -51,6 +84,7 @@ function privacyText(app: ToolboxAppManifest): string {
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
app,
|
app,
|
||||||
children,
|
children,
|
||||||
|
helpAction,
|
||||||
appActions,
|
appActions,
|
||||||
className,
|
className,
|
||||||
manifestUrl,
|
manifestUrl,
|
||||||
@@ -61,12 +95,29 @@ export function AppShell({
|
|||||||
const [contextState, setContextState] = useState<"standalone" | "connected">(
|
const [contextState, setContextState] = useState<"standalone" | "connected">(
|
||||||
"standalone",
|
"standalone",
|
||||||
);
|
);
|
||||||
|
const [storedTheme, setStoredTheme] = useState<ToolboxThemeMode | undefined>(
|
||||||
|
initialStoredTheme,
|
||||||
|
);
|
||||||
const errorHandler = useRef(onContextError);
|
const errorHandler = useRef(onContextError);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
errorHandler.current = onContextError;
|
errorHandler.current = onContextError;
|
||||||
}, [onContextError]);
|
}, [onContextError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== TOOLBOX_PREFERENCES_KEY) return;
|
||||||
|
if (event.newValue === null) {
|
||||||
|
setStoredTheme(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storage = browserStorage();
|
||||||
|
if (storage) setStoredTheme(readToolboxPreferences(storage).theme);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener?.("storage", handleStorage);
|
||||||
|
return () => globalThis.removeEventListener?.("storage", handleStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const contextCatalog = contextOptions?.catalogUrl;
|
const contextCatalog = contextOptions?.catalogUrl;
|
||||||
const contextDocument = contextOptions?.document;
|
const contextDocument = contextOptions?.document;
|
||||||
const contextFetch = contextOptions?.fetch;
|
const contextFetch = contextOptions?.fetch;
|
||||||
@@ -97,9 +148,7 @@ export function AppShell({
|
|||||||
if (result.status === "error") errorHandler.current?.(result.error);
|
if (result.status === "error") errorHandler.current?.(result.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => controller.abort();
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
contextCatalog,
|
contextCatalog,
|
||||||
contextDocument,
|
contextDocument,
|
||||||
@@ -112,7 +161,7 @@ export function AppShell({
|
|||||||
const pageUrl = contextLocation ?? currentHref();
|
const pageUrl = contextLocation ?? currentHref();
|
||||||
const appManifestUrl = useMemo(
|
const appManifestUrl = useMemo(
|
||||||
() =>
|
() =>
|
||||||
manifestUrl ??
|
(manifestUrl ? safeHref(manifestUrl, pageUrl) : undefined) ??
|
||||||
safeHref("toolbox-app.json", pageUrl) ??
|
safeHref("toolbox-app.json", pageUrl) ??
|
||||||
"http://localhost/toolbox-app.json",
|
"http://localhost/toolbox-app.json",
|
||||||
[manifestUrl, pageUrl],
|
[manifestUrl, pageUrl],
|
||||||
@@ -120,46 +169,86 @@ export function AppShell({
|
|||||||
const privacyHref = app.privacy.url
|
const privacyHref = app.privacy.url
|
||||||
? safeHref(app.privacy.url, appManifestUrl)
|
? safeHref(app.privacy.url, appManifestUrl)
|
||||||
: undefined;
|
: undefined;
|
||||||
const enabledApps =
|
const sourceAction = app.actions?.find((action) => action.id === "source");
|
||||||
context?.catalog.apps.filter((entry) => entry.enabled) ?? [];
|
const sourceHref = sourceAction
|
||||||
const theme = context?.catalog.catalog.theme.mode ?? "system";
|
? safeHref(sourceAction.url, appManifestUrl)
|
||||||
|
: app.source?.repository;
|
||||||
|
const otherActions = app.actions?.filter((action) => action.id !== "source");
|
||||||
|
const theme = storedTheme ?? context?.catalog.catalog.theme.mode ?? "system";
|
||||||
const rootClassName = ["toolbox-shell", className].filter(Boolean).join(" ");
|
const rootClassName = ["toolbox-shell", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const switcherApps: ToolboxHeaderApp[] =
|
||||||
|
context?.catalog.apps.flatMap<ToolboxHeaderApp>((entry) => {
|
||||||
|
if (!entry.enabled) return [];
|
||||||
|
if (entry.kind === "external") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `external:${entry.name}:${entry.entryUrl.href}`,
|
||||||
|
name: entry.name,
|
||||||
|
href: entry.entryUrl.href,
|
||||||
|
newTab: entry.launch === "new-tab",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const manifest = entry.app.manifest;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: manifest.id,
|
||||||
|
name: manifest.name,
|
||||||
|
href: contextualizeToolboxLink(
|
||||||
|
entry.app.entryUrl,
|
||||||
|
context.catalog.catalogUrl,
|
||||||
|
{ location: pageUrl },
|
||||||
|
),
|
||||||
|
current: manifest.id === app.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
function setTheme(themeChoice: ToolboxThemeMode) {
|
||||||
|
setStoredTheme(themeChoice);
|
||||||
|
const storage = browserStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
writeToolboxPreferences(
|
||||||
|
{ ...readToolboxPreferences(storage), theme: themeChoice },
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory theme if storage is blocked or full.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={rootClassName}
|
className={rootClassName}
|
||||||
data-toolbox-context={contextState}
|
data-toolbox-context={contextState}
|
||||||
data-toolbox-theme={theme}
|
data-toolbox-theme={theme}
|
||||||
>
|
>
|
||||||
<header className="toolbox-shell__header">
|
<ToolboxHeader
|
||||||
<div className="toolbox-shell__bar">
|
title={app.name}
|
||||||
{context ? (
|
subtitle={app.description}
|
||||||
<a
|
theme={theme}
|
||||||
className="toolbox-shell__home"
|
onThemeChange={setTheme}
|
||||||
href={context.catalog.homeUrl.href}
|
helpAction={helpAction}
|
||||||
>
|
sourceHref={sourceHref}
|
||||||
{context.catalog.catalog.theme.brand}
|
sourceLabel={`Source for ${app.name} on Gitea`}
|
||||||
</a>
|
homeHref={context?.catalog.homeUrl.href}
|
||||||
) : null}
|
homeLabel="add·ideas Toolbox"
|
||||||
|
brandIconUrl={
|
||||||
<div className="toolbox-shell__identity">
|
context
|
||||||
<img
|
? safeHref("favicon.svg", context.catalog.homeUrl.href)
|
||||||
className="toolbox-shell__icon"
|
: undefined
|
||||||
src={safeHref(app.icon, appManifestUrl)}
|
}
|
||||||
alt=""
|
apps={switcherApps}
|
||||||
width="32"
|
allAppsHref={context?.catalog.homeUrl.href}
|
||||||
height="32"
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<main className="toolbox-shell__main">{children}</main>
|
||||||
<h1 className="toolbox-shell__name">{app.name}</h1>
|
<footer className="toolbox-shell__footer">
|
||||||
<span className="toolbox-shell__description">
|
<div className="toolbox-shell__footer-inner">
|
||||||
{app.description}
|
{otherActions?.length || appActions ? (
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbox-shell__actions">
|
<div className="toolbox-shell__actions">
|
||||||
{app.actions?.map((action) => {
|
{otherActions?.map((action) => {
|
||||||
const href = safeHref(action.url, appManifestUrl);
|
const href = safeHref(action.url, appManifestUrl);
|
||||||
return href ? (
|
return href ? (
|
||||||
<a key={action.id} href={href}>
|
<a key={action.id} href={href}>
|
||||||
@@ -169,63 +258,20 @@ export function AppShell({
|
|||||||
})}
|
})}
|
||||||
{appActions}
|
{appActions}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="toolbox-shell__meta">
|
<span aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<div className="toolbox-shell__footer-meta">
|
||||||
<span aria-label={`Version ${app.version}`}>v{app.version}</span>
|
<span aria-label={`Version ${app.version}`}>v{app.version}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
{privacyHref ? (
|
{privacyHref ? (
|
||||||
<a href={privacyHref}>{privacyText(app)}</a>
|
<a href={privacyHref}>{privacyText(app)}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{privacyText(app)}</span>
|
<span>{privacyText(app)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{context && enabledApps.length > 0 ? (
|
|
||||||
<details className="toolbox-shell__switcher">
|
|
||||||
<summary>Apps</summary>
|
|
||||||
<nav aria-label="Toolbox applications">
|
|
||||||
<ul>
|
|
||||||
{enabledApps.map((entry) => {
|
|
||||||
if (entry.kind === "external") {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={`external:${entry.name}:${entry.entryUrl.href}`}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={entry.entryUrl.href}
|
|
||||||
{...(entry.launch === "new-tab"
|
|
||||||
? { target: "_blank", rel: "noreferrer" }
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
{entry.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const manifest = entry.app.manifest;
|
|
||||||
return (
|
|
||||||
<li key={manifest.id}>
|
|
||||||
<a
|
|
||||||
href={contextualizeToolboxLink(
|
|
||||||
entry.app.entryUrl,
|
|
||||||
context.catalog.catalogUrl,
|
|
||||||
{ location: pageUrl },
|
|
||||||
)}
|
|
||||||
aria-current={
|
|
||||||
manifest.id === app.id ? "page" : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{manifest.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</footer>
|
||||||
<main className="toolbox-shell__main">{children}</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
422
packages/shell-react/src/ToolboxHeader.tsx
Normal file
422
packages/shell-react/src/ToolboxHeader.tsx
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import type { ToolboxThemeMode } from "@add-ideas/toolbox-contract";
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export interface ToolboxHeaderAction {
|
||||||
|
label?: string | undefined;
|
||||||
|
onClick: () => void;
|
||||||
|
title?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxHeaderApp {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
current?: boolean | undefined;
|
||||||
|
newTab?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M9.8 9a2.35 2.35 0 1 1 3.7 1.92c-.93.65-1.5 1.04-1.5 2.08" />
|
||||||
|
<path d="M12 16.5h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiteaIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 640 640" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2l-16.6-7.9-.1-109.2c-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6ZM125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1Zm300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c8.1-3.8 18.2 1.3 18.2 1.3L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c5.8.4 9.9-4.1 9.9-4.1 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3 5.1 3 11.6 8.7 8.3 20.4-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 7-13.8 19.1-41.7 19.1-41.7.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3l14.1-29-12.2-6.1-14.5 29.5c-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalizeIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppsIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
subtitle,
|
||||||
|
theme,
|
||||||
|
onThemeChange,
|
||||||
|
onPersonalize,
|
||||||
|
personalizeContent,
|
||||||
|
helpAction,
|
||||||
|
sourceHref,
|
||||||
|
sourceLabel = "Source on Gitea",
|
||||||
|
homeHref,
|
||||||
|
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__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"
|
||||||
|
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}
|
||||||
|
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
|
||||||
|
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}>
|
||||||
|
All apps
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{apps.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{apps.map((entry) => (
|
||||||
|
<li key={entry.id}>
|
||||||
|
<a
|
||||||
|
href={entry.href}
|
||||||
|
aria-current={entry.current ? "page" : undefined}
|
||||||
|
{...(entry.newTab
|
||||||
|
? { target: "_blank", rel: "noreferrer" }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p>Open this app from Toolbox to switch tools here.</p>
|
||||||
|
)}
|
||||||
|
</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 ? (
|
||||||
|
<div className="toolbox-shell__supplemental">
|
||||||
|
{leadingActions}
|
||||||
|
{metadata ? (
|
||||||
|
<div className="toolbox-shell__supplemental-meta">{metadata}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
export { AppShell } from "./AppShell.js";
|
export { AppShell } from "./AppShell.js";
|
||||||
export type { AppShellProps } from "./AppShell.js";
|
export type { AppShellProps } from "./AppShell.js";
|
||||||
|
export { ToolboxHeader } from "./ToolboxHeader.js";
|
||||||
|
export type {
|
||||||
|
ToolboxHeaderAction,
|
||||||
|
ToolboxHeaderApp,
|
||||||
|
ToolboxHeaderProps,
|
||||||
|
} from "./ToolboxHeader.js";
|
||||||
|
|||||||
@@ -1,42 +1,64 @@
|
|||||||
:root {
|
:root {
|
||||||
--toolbox-font:
|
--toolbox-font:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
Inter, Avenir, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
"Segoe UI", sans-serif;
|
"Segoe UI", sans-serif;
|
||||||
--toolbox-background: #f5f7fa;
|
--toolbox-background: #f6f7fb;
|
||||||
--toolbox-surface: #ffffff;
|
--toolbox-surface: #ffffff;
|
||||||
--toolbox-text: #172033;
|
--toolbox-surface-soft: #eff2f8;
|
||||||
--toolbox-muted: #657086;
|
--toolbox-text: #17203b;
|
||||||
--toolbox-border: #dce2ea;
|
--toolbox-muted: #667085;
|
||||||
--toolbox-accent: #3156d3;
|
--toolbox-border: #dfe3ec;
|
||||||
|
--toolbox-accent: #28366d;
|
||||||
|
--toolbox-accent-hover: #1c2858;
|
||||||
|
--toolbox-accent-soft: #e9edff;
|
||||||
--toolbox-accent-contrast: #ffffff;
|
--toolbox-accent-contrast: #ffffff;
|
||||||
--toolbox-radius: 0.65rem;
|
--toolbox-focus: #63cdbc;
|
||||||
--toolbox-shadow: 0 1px 2px rgb(18 28 45 / 8%);
|
--toolbox-radius: 0.7rem;
|
||||||
|
--toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-toolbox-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-toolbox-theme="dark"] {
|
[data-toolbox-theme="dark"] {
|
||||||
--toolbox-background: #111722;
|
color-scheme: dark;
|
||||||
--toolbox-surface: #192130;
|
--toolbox-background: #0f1422;
|
||||||
--toolbox-text: #edf1f7;
|
--toolbox-surface: #171d2d;
|
||||||
--toolbox-muted: #aab4c5;
|
--toolbox-surface-soft: #20283a;
|
||||||
--toolbox-border: #303b4d;
|
--toolbox-text: #edf1fb;
|
||||||
--toolbox-accent: #9db2ff;
|
--toolbox-muted: #a9b3ca;
|
||||||
--toolbox-accent-contrast: #111722;
|
--toolbox-border: #303a50;
|
||||||
|
--toolbox-accent: #a9b7ff;
|
||||||
|
--toolbox-accent-hover: #c3ccff;
|
||||||
|
--toolbox-accent-soft: #252f55;
|
||||||
|
--toolbox-accent-contrast: #11182a;
|
||||||
|
--toolbox-focus: #66cdbd;
|
||||||
|
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
[data-toolbox-theme="system"] {
|
[data-toolbox-theme="system"] {
|
||||||
--toolbox-background: #111722;
|
color-scheme: dark;
|
||||||
--toolbox-surface: #192130;
|
--toolbox-background: #0f1422;
|
||||||
--toolbox-text: #edf1f7;
|
--toolbox-surface: #171d2d;
|
||||||
--toolbox-muted: #aab4c5;
|
--toolbox-surface-soft: #20283a;
|
||||||
--toolbox-border: #303b4d;
|
--toolbox-text: #edf1fb;
|
||||||
--toolbox-accent: #9db2ff;
|
--toolbox-muted: #a9b3ca;
|
||||||
--toolbox-accent-contrast: #111722;
|
--toolbox-border: #303a50;
|
||||||
|
--toolbox-accent: #a9b7ff;
|
||||||
|
--toolbox-accent-hover: #c3ccff;
|
||||||
|
--toolbox-accent-soft: #252f55;
|
||||||
|
--toolbox-accent-contrast: #11182a;
|
||||||
|
--toolbox-focus: #66cdbd;
|
||||||
|
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell {
|
.toolbox-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
color: var(--toolbox-text);
|
color: var(--toolbox-text);
|
||||||
background: var(--toolbox-background);
|
background: var(--toolbox-background);
|
||||||
font-family: var(--toolbox-font);
|
font-family: var(--toolbox-font);
|
||||||
@@ -44,116 +66,275 @@
|
|||||||
|
|
||||||
.toolbox-shell *,
|
.toolbox-shell *,
|
||||||
.toolbox-shell *::before,
|
.toolbox-shell *::before,
|
||||||
.toolbox-shell *::after {
|
.toolbox-shell *::after,
|
||||||
|
.toolbox-shell__header *,
|
||||||
|
.toolbox-shell__header *::before,
|
||||||
|
.toolbox-shell__header *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__header {
|
.toolbox-shell__header {
|
||||||
position: relative;
|
position: sticky;
|
||||||
z-index: 10;
|
z-index: 40;
|
||||||
border-bottom: 1px solid var(--toolbox-border);
|
top: 0;
|
||||||
background: var(--toolbox-surface);
|
color: var(--toolbox-text);
|
||||||
box-shadow: var(--toolbox-shadow);
|
border-bottom: 1px solid
|
||||||
|
color-mix(in srgb, var(--toolbox-border) 75%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--toolbox-surface) 92%, transparent);
|
||||||
|
box-shadow: 0 1px 0 rgb(24 34 68 / 3%);
|
||||||
|
font-family: var(--toolbox-font);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__bar {
|
.toolbox-shell__bar {
|
||||||
display: flex;
|
width: min(100%, 90rem);
|
||||||
min-height: 4.25rem;
|
height: 4.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(18.75rem, 1fr) minmax(0, 31rem) minmax(
|
||||||
|
18.75rem,
|
||||||
|
1fr
|
||||||
|
);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
max-width: 90rem;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell a {
|
.toolbox-shell__controls {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__header a {
|
||||||
color: var(--toolbox-accent);
|
color: var(--toolbox-accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell a:hover {
|
.toolbox-shell__header a:hover {
|
||||||
text-decoration: underline;
|
color: var(--toolbox-accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__home {
|
.toolbox-shell__icon-button,
|
||||||
flex: none;
|
.toolbox-shell__menu-button,
|
||||||
font-weight: 700;
|
.toolbox-shell__supplemental a {
|
||||||
}
|
min-height: 2.5rem;
|
||||||
|
display: inline-flex;
|
||||||
.toolbox-shell__identity {
|
|
||||||
display: flex;
|
|
||||||
min-width: 12rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
justify-content: center;
|
||||||
}
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem 0.68rem;
|
||||||
.toolbox-shell__identity > div {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__icon {
|
|
||||||
flex: none;
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__description {
|
|
||||||
color: var(--toolbox-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__meta {
|
|
||||||
display: grid;
|
|
||||||
flex: none;
|
|
||||||
gap: 0.15rem;
|
|
||||||
color: var(--toolbox-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__switcher {
|
|
||||||
position: relative;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__switcher summary {
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
border: 1px solid var(--toolbox-border);
|
|
||||||
border-radius: var(--toolbox-radius);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__switcher summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__switcher nav {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
right: 0;
|
|
||||||
width: max-content;
|
|
||||||
min-width: 12rem;
|
|
||||||
padding: 0.4rem;
|
|
||||||
border: 1px solid var(--toolbox-border);
|
border: 1px solid var(--toolbox-border);
|
||||||
border-radius: var(--toolbox-radius);
|
border-radius: var(--toolbox-radius);
|
||||||
background: var(--toolbox-surface);
|
background: var(--toolbox-surface);
|
||||||
box-shadow: 0 0.75rem 2rem rgb(18 28 45 / 14%);
|
color: var(--toolbox-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__icon-button {
|
||||||
|
width: 2.5rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__icon-button:hover,
|
||||||
|
.toolbox-shell__menu-button:hover,
|
||||||
|
.toolbox-shell__supplemental a:hover {
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--toolbox-accent) 45%,
|
||||||
|
var(--toolbox-border)
|
||||||
|
);
|
||||||
|
background: var(--toolbox-accent-soft);
|
||||||
|
color: var(--toolbox-accent);
|
||||||
|
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__brand:focus-visible {
|
||||||
|
outline: 3px solid
|
||||||
|
color-mix(in srgb, var(--toolbox-focus) 72%, var(--toolbox-accent));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__icon-button svg,
|
||||||
|
.toolbox-shell__menu-button svg {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__source svg {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__source {
|
||||||
|
color: #609926 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__identity {
|
||||||
|
min-width: 0;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.toolbox-shell__brand:hover {
|
||||||
|
background: var(--toolbox-accent-soft);
|
||||||
|
color: var(--toolbox-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__brand-mark {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: inline-grid;
|
||||||
|
flex: none;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--toolbox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
background: var(--toolbox-surface);
|
||||||
|
box-shadow: var(--toolbox-shadow);
|
||||||
|
color: var(--toolbox-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__popover strong {
|
||||||
|
display: block;
|
||||||
|
padding: 0.35rem 0.4rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__popover small,
|
||||||
|
.toolbox-shell__popover p {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.55rem 0.4rem 0.25rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.73rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__theme-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: var(--toolbox-surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__theme-options button {
|
||||||
|
padding: 0.5rem 0.45rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--toolbox-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__switcher ul {
|
.toolbox-shell__switcher ul {
|
||||||
@@ -166,8 +347,13 @@
|
|||||||
|
|
||||||
.toolbox-shell__switcher a {
|
.toolbox-shell__switcher a {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.55rem 0.65rem;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__switcher a:hover {
|
||||||
|
background: var(--toolbox-accent-soft);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__switcher a[aria-current="page"] {
|
.toolbox-shell__switcher a[aria-current="page"] {
|
||||||
@@ -175,28 +361,164 @@
|
|||||||
background: var(--toolbox-accent);
|
background: var(--toolbox-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__all-apps {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--toolbox-border);
|
||||||
|
border-radius: 0 !important;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__supplemental {
|
||||||
|
min-height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.3rem 1rem 0.45rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
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 {
|
.toolbox-shell__main {
|
||||||
width: min(100%, 90rem);
|
width: min(100%, 90rem);
|
||||||
|
display: block;
|
||||||
|
flex: 1 0 auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 52rem) {
|
.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__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 {
|
.toolbox-shell__bar {
|
||||||
flex-wrap: wrap;
|
grid-template-columns:
|
||||||
|
minmax(0, 1fr) minmax(0, min(31rem, calc(100% - 24rem)))
|
||||||
|
minmax(0, 1fr);
|
||||||
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__identity {
|
.toolbox-shell__menu-button {
|
||||||
flex: 1;
|
width: 2.5rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__actions {
|
.toolbox-shell__menu-button > span,
|
||||||
order: 3;
|
.toolbox-shell__description {
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-shell__meta {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.toolbox-shell__bar {
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding-inline: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__controls {
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__footer-inner {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__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 {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__popover {
|
||||||
|
max-width: calc(100vw - 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
import type { ToolboxAppManifest } from "@add-ideas/toolbox-contract";
|
fireEvent,
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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 = {
|
const currentApp: ToolboxAppManifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -76,11 +85,15 @@ function catalogFetch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AppShell", () => {
|
describe("AppShell", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
it("renders identity, privacy, version, actions, and content standalone", async () => {
|
it("renders identity, privacy, version, actions, and content standalone", async () => {
|
||||||
|
const onHelp = vi.fn();
|
||||||
const fetch = vi.fn();
|
const fetch = vi.fn();
|
||||||
render(
|
render(
|
||||||
<AppShell
|
<AppShell
|
||||||
app={currentApp}
|
app={currentApp}
|
||||||
|
helpAction={{ onClick: onHelp }}
|
||||||
appActions={<a href="/help">Help</a>}
|
appActions={<a href="/help">Help</a>}
|
||||||
contextOptions={{
|
contextOptions={{
|
||||||
location: "https://tools.example.test/pdf/",
|
location: "https://tools.example.test/pdf/",
|
||||||
@@ -105,9 +118,40 @@ describe("AppShell", () => {
|
|||||||
expect(
|
expect(
|
||||||
document.querySelector("[data-toolbox-context='standalone']"),
|
document.querySelector("[data-toolbox-context='standalone']"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
const controls = screen.getByLabelText("Toolbox controls");
|
||||||
|
expect(
|
||||||
|
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(
|
expect(
|
||||||
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
||||||
).not.toBeInTheDocument();
|
).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 () => {
|
it("loads same-origin context and creates full-page contextual switch links", async () => {
|
||||||
@@ -126,7 +170,13 @@ describe("AppShell", () => {
|
|||||||
</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",
|
name: "Toolbox applications",
|
||||||
});
|
});
|
||||||
expect(navigation).toBeInTheDocument();
|
expect(navigation).toBeInTheDocument();
|
||||||
@@ -138,10 +188,14 @@ describe("AppShell", () => {
|
|||||||
expect(destination.searchParams.get("toolbox")).toBe(
|
expect(destination.searchParams.get("toolbox")).toBe(
|
||||||
"https://tools.example.test/toolbox.catalog.json",
|
"https://tools.example.test/toolbox.catalog.json",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute(
|
expect(
|
||||||
"href",
|
screen.getByRole("link", { name: "add·ideas Toolbox" }),
|
||||||
"https://tools.example.test/",
|
).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(
|
expect(
|
||||||
document.querySelector("[data-toolbox-context='connected']"),
|
document.querySelector("[data-toolbox-context='connected']"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -169,9 +223,10 @@ describe("AppShell", () => {
|
|||||||
code: "cross-origin",
|
code: "cross-origin",
|
||||||
});
|
});
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
screen.getByRole("navigation", { name: "Toolbox applications" }),
|
||||||
).not.toBeInTheDocument();
|
).toHaveTextContent("Open this app from Toolbox");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -199,4 +254,175 @@ describe("AppShell", () => {
|
|||||||
document.querySelector("[data-toolbox-context='standalone']"),
|
document.querySelector("[data-toolbox-context='standalone']"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses and updates the shared light, dark, and system preference", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
pinned: [],
|
||||||
|
order: [],
|
||||||
|
hidden: [],
|
||||||
|
theme: "dark",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AppShell
|
||||||
|
app={currentApp}
|
||||||
|
contextOptions={{ location: "https://tools.example.test/pdf/" }}
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shell = document.querySelector(".toolbox-shell");
|
||||||
|
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Light" }));
|
||||||
|
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
|
||||||
|
expect(
|
||||||
|
JSON.parse(localStorage.getItem(TOOLBOX_PREFERENCES_KEY) ?? "{}"),
|
||||||
|
).toMatchObject({ theme: "light" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves manifest-relative header links without rendering an app icon", async () => {
|
||||||
|
render(
|
||||||
|
<AppShell
|
||||||
|
app={{
|
||||||
|
...currentApp,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
label: "Source",
|
||||||
|
url: "./source/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
manifestUrl="./toolbox-app.json"
|
||||||
|
contextOptions={{
|
||||||
|
location: "https://tools.example.test/nested/pdf/index.html",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-testkit",
|
"name": "@add-ideas/toolbox-testkit",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
|
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1"
|
"@add-ideas/toolbox-contract": "0.2.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
||||||
|
|||||||
Reference in New Issue
Block a user