fix: stabilize Toolbox navigation layout

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

View File

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

View File

@@ -1,5 +1,12 @@
import type { ToolboxThemeMode } from "@add-ideas/toolbox-contract";
import type { ReactNode } from "react";
import {
useEffect,
useId,
useRef,
useState,
type MouseEvent,
type ReactNode,
} from "react";
export interface ToolboxHeaderAction {
label?: string | undefined;
@@ -17,23 +24,30 @@ export interface ToolboxHeaderApp {
export interface ToolboxHeaderProps {
title: string;
/** @deprecated The fixed header centers text without an application icon. */
iconUrl?: string | undefined;
subtitle?: string | undefined;
theme: ToolboxThemeMode;
onThemeChange?: ((theme: ToolboxThemeMode) => void) | undefined;
onPersonalize?: (() => void) | undefined;
personalizeContent?: ReactNode | undefined;
helpAction?: ToolboxHeaderAction | undefined;
sourceHref?: string | undefined;
sourceLabel?: string | undefined;
homeHref?: string | undefined;
homeLabel?: string | undefined;
brandIconUrl?: string | undefined;
apps?: readonly ToolboxHeaderApp[] | undefined;
allAppsHref?: string | undefined;
/** @deprecated Supplemental actions render outside the fixed top-bar row. */
leadingActions?: ReactNode | undefined;
/** @deprecated AppShell metadata now renders in its standard footer. */
metadata?: ReactNode | undefined;
className?: string | undefined;
}
type OpenPopover = "personalize" | "apps" | null;
function HelpIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -79,49 +93,189 @@ function AppsIcon() {
);
}
function ToolboxMark() {
return (
<svg viewBox="0 0 48 48" aria-hidden="true">
<path
d="M7 14.5 24 5l17 9.5v19L24 43 7 33.5z"
fill="currentColor"
opacity=".12"
stroke="none"
/>
<path d="m8 15 16 9 16-9M24 24v18" />
<path d="m16 10.5 16 9v9L24 33l-8-4.5z" />
</svg>
);
}
function ToolboxBrandMark({ iconUrl }: { iconUrl?: string | undefined }) {
const [failedIconUrl, setFailedIconUrl] = useState<string>();
if (!iconUrl || failedIconUrl === iconUrl) return <ToolboxMark />;
return <img src={iconUrl} alt="" onError={() => setFailedIconUrl(iconUrl)} />;
}
const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"];
export function ToolboxHeader({
title,
iconUrl,
subtitle,
theme,
onThemeChange,
onPersonalize,
personalizeContent,
helpAction,
sourceHref,
sourceLabel = "Source on Gitea",
homeHref,
homeLabel = "Toolbox",
homeLabel = "add·ideas Toolbox",
brandIconUrl,
apps = [],
allAppsHref,
leadingActions,
metadata,
className,
}: ToolboxHeaderProps) {
const [openPopover, setOpenPopover] = useState<OpenPopover>(null);
const personalizeButtonRef = useRef<HTMLButtonElement | null>(null);
const appsButtonRef = useRef<HTMLButtonElement | null>(null);
const popoverRef = useRef<HTMLElement | null>(null);
const personalizeId = useId();
const appsId = useId();
const customPersonalizeContent = personalizeContent != null;
const hasPersonalizePopover =
customPersonalizeContent ||
(onPersonalize === undefined && onThemeChange !== undefined);
const personalizeAvailable =
hasPersonalizePopover || onPersonalize !== undefined;
const visiblePopover =
openPopover === "personalize" && !hasPersonalizePopover
? null
: openPopover;
useEffect(() => {
if (visiblePopover === null) return;
const firstControl = popoverRef.current?.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
(firstControl ?? popoverRef.current)?.focus();
const closeFromOutside = (event: PointerEvent) => {
const trigger =
visiblePopover === "personalize"
? personalizeButtonRef.current
: appsButtonRef.current;
if (!(event.target instanceof Node)) return;
if (
trigger?.contains(event.target) ||
popoverRef.current?.contains(event.target)
) {
return;
}
setOpenPopover(null);
};
const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
const trigger =
visiblePopover === "personalize"
? personalizeButtonRef.current
: appsButtonRef.current;
setOpenPopover(null);
trigger?.focus();
};
document.addEventListener("pointerdown", closeFromOutside);
document.addEventListener("keydown", closeFromKeyboard);
return () => {
document.removeEventListener("pointerdown", closeFromOutside);
document.removeEventListener("keydown", closeFromKeyboard);
};
}, [visiblePopover]);
useEffect(() => {
if (openPopover !== "personalize" || hasPersonalizePopover) return;
const timeout = globalThis.setTimeout(() => setOpenPopover(null), 0);
return () => globalThis.clearTimeout(timeout);
}, [hasPersonalizePopover, openPopover]);
const closeOnLinkSelection = (event: MouseEvent<HTMLElement>) => {
if ((event.target as Element).closest?.("a")) setOpenPopover(null);
};
const brandContent = (
<>
<span className="toolbox-shell__brand-mark">
<ToolboxBrandMark iconUrl={brandIconUrl} />
</span>
<span className="toolbox-shell__brand-label">{homeLabel}</span>
</>
);
return (
<header
className={["toolbox-shell__header", className].filter(Boolean).join(" ")}
data-toolbox-theme={theme}
>
<div className="toolbox-shell__bar">
<div className="toolbox-shell__side toolbox-shell__side--left">
{homeHref ? (
<a className="toolbox-shell__home" href={homeHref}>
{homeLabel}
</a>
) : null}
{helpAction ? (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction.label ?? "Help"}
title={helpAction.title ?? helpAction.label ?? "Help"}
onClick={helpAction.onClick}
>
<HelpIcon />
</button>
) : null}
<div
className="toolbox-shell__controls"
role="group"
aria-label="Toolbox controls"
>
<button
ref={personalizeButtonRef}
className="toolbox-shell__menu-button"
type="button"
aria-label="Personalize"
title={
personalizeAvailable
? "Personalize"
: "Personalization unavailable"
}
aria-haspopup={hasPersonalizePopover ? "dialog" : undefined}
aria-expanded={
hasPersonalizePopover
? visiblePopover === "personalize"
: undefined
}
aria-controls={
hasPersonalizePopover && visiblePopover === "personalize"
? personalizeId
: undefined
}
disabled={!personalizeAvailable}
onClick={() => {
if (hasPersonalizePopover) {
setOpenPopover((current) =>
current === "personalize" ? null : "personalize",
);
} else {
onPersonalize?.();
}
}}
>
<PersonalizeIcon />
<span>Personalize</span>
</button>
<button
ref={appsButtonRef}
className="toolbox-shell__menu-button"
type="button"
aria-label="Apps"
title="Apps"
aria-expanded={visiblePopover === "apps"}
aria-controls={visiblePopover === "apps" ? appsId : undefined}
onClick={() =>
setOpenPopover((current) => (current === "apps" ? null : "apps"))
}
>
<AppsIcon />
<span>Apps</span>
</button>
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
@@ -130,74 +284,81 @@ export function ToolboxHeader({
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
onClick={() => setOpenPopover(null)}
>
<GiteaIcon />
</a>
) : null}
{leadingActions}
</div>
<div className="toolbox-shell__identity">
{iconUrl ? (
<img
className="toolbox-shell__icon"
src={iconUrl}
alt=""
width="32"
height="32"
/>
) : null}
<div>
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
</div>
<div className="toolbox-shell__side toolbox-shell__side--right">
{onPersonalize ? (
<button
className="toolbox-shell__menu-button"
type="button"
onClick={onPersonalize}
>
<PersonalizeIcon />
<span>Personalize</span>
</button>
) : (
<details className="toolbox-shell__menu toolbox-shell__personalize">
<summary className="toolbox-shell__menu-button">
<PersonalizeIcon />
<span>Personalize</span>
</summary>
<div className="toolbox-shell__popover">
<strong>Appearance</strong>
<div className="toolbox-shell__theme-options">
{THEMES.map((option) => (
<button
key={option}
type="button"
aria-pressed={theme === option}
onClick={() => onThemeChange?.(option)}
>
{option.slice(0, 1).toUpperCase() + option.slice(1)}
</button>
))}
</div>
<small>Saved only in this browser.</small>
</div>
</details>
<button
className="toolbox-shell__icon-button"
type="button"
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
<GiteaIcon />
</button>
)}
<details className="toolbox-shell__menu toolbox-shell__switcher">
<summary className="toolbox-shell__menu-button">
<AppsIcon />
<span>Apps</span>
</summary>
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction?.label ?? "Help"}
title={
helpAction?.title ??
helpAction?.label ??
(helpAction ? "Help" : "Help unavailable")
}
disabled={!helpAction}
onClick={() => {
setOpenPopover(null);
helpAction?.onClick();
}}
>
<HelpIcon />
</button>
{visiblePopover === "personalize" ? (
<section
ref={popoverRef}
id={personalizeId}
className="toolbox-shell__popover toolbox-shell__personalize-popover"
role="dialog"
aria-label="Personalize Toolbox"
tabIndex={-1}
onClick={closeOnLinkSelection}
>
{customPersonalizeContent ? (
personalizeContent
) : (
<>
<strong>Appearance</strong>
<div className="toolbox-shell__theme-options">
{THEMES.map((option) => (
<button
key={option}
type="button"
aria-pressed={theme === option}
onClick={() => onThemeChange?.(option)}
>
{option.slice(0, 1).toUpperCase() + option.slice(1)}
</button>
))}
</div>
<small>Saved only in this browser.</small>
</>
)}
</section>
) : null}
{visiblePopover === "apps" ? (
<nav
className="toolbox-shell__popover"
ref={popoverRef}
id={appsId}
className="toolbox-shell__popover toolbox-shell__switcher"
aria-label="Toolbox applications"
tabIndex={-1}
onClick={closeOnLinkSelection}
>
{allAppsHref ? (
<a className="toolbox-shell__all-apps" href={allAppsHref}>
@@ -224,10 +385,38 @@ export function ToolboxHeader({
<p>Open this app from Toolbox to switch tools here.</p>
)}
</nav>
</details>
) : null}
</div>
<div className="toolbox-shell__identity">
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
{homeHref ? (
<a
className="toolbox-shell__brand"
href={homeHref}
aria-label={homeLabel}
onClick={() => setOpenPopover(null)}
>
{brandContent}
</a>
) : (
<div className="toolbox-shell__brand">{brandContent}</div>
)}
</div>
{metadata ? <div className="toolbox-shell__meta">{metadata}</div> : null}
{leadingActions || metadata ? (
<div className="toolbox-shell__supplemental">
{leadingActions}
{metadata ? (
<div className="toolbox-shell__supplemental-meta">{metadata}</div>
) : null}
</div>
) : null}
</header>
);
}

View File

@@ -57,6 +57,8 @@
.toolbox-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--toolbox-text);
background: var(--toolbox-background);
font-family: var(--toolbox-font);
@@ -86,24 +88,25 @@
.toolbox-shell__bar {
width: min(100%, 90rem);
min-height: 4.35rem;
height: 4.5rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
grid-template-columns: minmax(18.75rem, 1fr) minmax(0, 31rem) minmax(
18.75rem,
1fr
);
align-items: center;
gap: 1rem;
margin: 0 auto;
padding: 0.65rem 1rem;
}
.toolbox-shell__side {
.toolbox-shell__controls {
position: relative;
min-width: 0;
display: flex;
align-items: center;
gap: 0.45rem;
}
.toolbox-shell__side--right {
justify-content: flex-end;
gap: 0.4rem;
justify-self: start;
}
.toolbox-shell__header a {
@@ -115,70 +118,15 @@
color: var(--toolbox-accent-hover);
}
.toolbox-shell__home {
max-width: 9rem;
margin-right: 0.15rem;
overflow: hidden;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__identity {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.65rem;
text-align: center;
}
.toolbox-shell__identity > div {
min-width: 0;
display: grid;
gap: 0.08rem;
}
.toolbox-shell__icon {
width: 2rem;
height: 2rem;
flex: none;
border-radius: 0.45rem;
object-fit: contain;
}
.toolbox-shell__name {
margin: 0;
overflow: hidden;
color: var(--toolbox-text);
font-family: var(--toolbox-font);
font-size: 1rem;
font-weight: 750;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__description {
display: block;
max-width: 27rem;
overflow: hidden;
color: var(--toolbox-muted);
font-size: 0.73rem;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__icon-button,
.toolbox-shell__menu-button,
.toolbox-shell__actions a {
min-height: 2.35rem;
.toolbox-shell__supplemental a {
min-height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.45rem 0.65rem;
padding: 0.45rem 0.68rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: var(--toolbox-surface);
@@ -192,14 +140,15 @@
}
.toolbox-shell__icon-button {
width: 2.35rem;
width: 2.5rem;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.45rem;
border-radius: 50%;
}
.toolbox-shell__icon-button:hover,
.toolbox-shell__menu-button:hover,
.toolbox-shell__actions a:hover {
.toolbox-shell__supplemental a:hover {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 45%,
@@ -210,10 +159,17 @@
text-decoration: none;
}
.toolbox-shell__icon-button:disabled,
.toolbox-shell__menu-button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.toolbox-shell__icon-button:focus-visible,
.toolbox-shell__menu-button:focus-visible,
.toolbox-shell__popover a:focus-visible,
.toolbox-shell__popover button:focus-visible {
.toolbox-shell__popover button:focus-visible,
.toolbox-shell__brand:focus-visible {
outline: 3px solid
color-mix(in srgb, var(--toolbox-focus) 72%, var(--toolbox-accent));
outline-offset: 2px;
@@ -242,32 +198,92 @@
color: #609926 !important;
}
.toolbox-shell__actions {
.toolbox-shell__identity {
min-width: 0;
display: flex;
display: grid;
gap: 0.12rem;
justify-items: center;
text-align: center;
}
.toolbox-shell__name {
width: 100%;
margin: 0;
overflow: hidden;
color: var(--toolbox-text);
font-family: var(--toolbox-font);
font-size: 1rem;
font-weight: 750;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__description {
width: 100%;
overflow: hidden;
color: var(--toolbox-muted);
font-size: 0.72rem;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__brand {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
justify-self: end;
gap: 0.58rem;
padding: 0.28rem 0.35rem;
border-radius: var(--toolbox-radius);
color: var(--toolbox-text) !important;
font-size: 0.82rem;
font-weight: 750;
line-height: 1.1;
white-space: nowrap;
}
.toolbox-shell__menu {
position: relative;
a.toolbox-shell__brand:hover {
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent) !important;
}
.toolbox-shell__menu > summary {
list-style: none;
.toolbox-shell__brand-mark {
width: 2rem;
height: 2rem;
display: inline-grid;
flex: none;
place-items: center;
color: var(--toolbox-accent);
}
.toolbox-shell__menu > summary::-webkit-details-marker {
display: none;
.toolbox-shell__brand-mark img,
.toolbox-shell__brand-mark svg {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.toolbox-shell__brand-mark svg {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.toolbox-shell__popover {
position: absolute;
top: calc(100% + 0.55rem);
right: 0;
z-index: 1;
top: calc(100% + 0.7rem);
left: 0;
width: max-content;
min-width: 13rem;
max-width: min(22rem, calc(100vw - 1.5rem));
max-height: calc(100vh - 5.5rem);
overflow: auto;
padding: 0.55rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.85rem;
@@ -352,76 +368,157 @@
font-weight: 750;
}
.toolbox-shell__meta {
min-height: 1.65rem;
.toolbox-shell__supplemental {
min-height: 2rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.25rem 1rem 0.45rem;
gap: 0.65rem;
padding: 0.3rem 1rem 0.45rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
text-align: center;
font-size: 0.72rem;
}
.toolbox-shell__supplemental-meta {
display: flex;
align-items: center;
gap: 0.4rem;
}
.toolbox-shell__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.toolbox-shell__main {
width: min(100%, 90rem);
display: block;
flex: 1 0 auto;
margin: 0 auto;
padding: 1rem;
}
@media (max-width: 48rem) {
.toolbox-shell__bar {
min-height: 3.8rem;
gap: 0.5rem;
padding-inline: 0.7rem;
}
.toolbox-shell__footer {
width: min(100%, 90rem);
margin: auto auto 0;
padding: 0.35rem 1rem 1.25rem;
color: var(--toolbox-muted);
font-size: 0.72rem;
}
.toolbox-shell__home,
.toolbox-shell__description,
.toolbox-shell__menu-button > span {
display: none;
.toolbox-shell__footer-inner {
min-height: 2.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.25rem;
padding-top: 0.7rem;
border-top: 1px solid var(--toolbox-border);
}
.toolbox-shell__footer-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
text-align: right;
}
.toolbox-shell__footer a {
color: var(--toolbox-accent);
}
@media (max-width: 72rem) {
.toolbox-shell__bar {
grid-template-columns:
minmax(0, 1fr) minmax(0, min(31rem, calc(100% - 24rem)))
minmax(0, 1fr);
gap: 0.7rem;
}
.toolbox-shell__menu-button {
width: 2.35rem;
width: 2.5rem;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.45rem;
border-radius: 50%;
}
.toolbox-shell__side {
gap: 0.3rem;
.toolbox-shell__menu-button > span,
.toolbox-shell__description {
display: none;
}
}
@media (max-width: 40rem) {
.toolbox-shell__bar {
gap: 0.45rem;
padding-inline: 0.65rem;
}
.toolbox-shell__identity {
gap: 0.4rem;
}
.toolbox-shell__icon {
width: 1.7rem;
height: 1.7rem;
.toolbox-shell__controls {
gap: 0.28rem;
}
.toolbox-shell__name {
max-width: 36vw;
font-size: 0.92rem;
font-size: 0.9rem;
}
.toolbox-shell__meta {
display: none;
.toolbox-shell__footer-inner {
align-items: flex-start;
flex-direction: column;
}
.toolbox-shell__actions {
.toolbox-shell__footer-meta {
justify-content: flex-start;
text-align: left;
}
}
@media (max-width: 32rem) {
.toolbox-shell__brand-label {
display: none;
}
}
@media (max-width: 27rem) {
.toolbox-shell__source {
display: none;
.toolbox-shell__bar {
height: 4.1rem;
grid-template-columns:
minmax(0, 1fr) minmax(0, calc(100vw - 17.9rem))
minmax(0, 1fr);
gap: 0.2rem;
padding-inline: 0.5rem;
}
.toolbox-shell__controls {
gap: 0.1rem;
}
.toolbox-shell__icon-button,
.toolbox-shell__menu-button {
width: 2rem;
min-width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0.3rem;
}
.toolbox-shell__brand {
padding-inline: 0.1rem;
}
.toolbox-shell__brand-mark {
width: 1.75rem;
height: 1.75rem;
}
.toolbox-shell__name {
max-width: 30vw;
font-size: 0.82rem;
}
.toolbox-shell__popover {
max-width: calc(100vw - 1rem);
}
}