feat: redesign Toolbox navigation and personalization

This commit is contained in:
2026-07-23 00:33:03 +02:00
parent 587aadb79b
commit 67ba5e3eca
25 changed files with 1075 additions and 427 deletions

View File

@@ -1,3 +1,5 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { ResolvedApp } from '../types';
interface AppCardProps {
@@ -5,11 +7,11 @@ interface AppCardProps {
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
first: boolean;
last: boolean;
activeTag: string;
onCategory: (category: string) => void;
onHide: () => void;
onMove: (direction: -1 | 1) => void;
onPin: () => void;
onTag: (tag: string) => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
}
@@ -55,21 +57,35 @@ export function AppCard({
catalogueUrl,
hidden,
pinned,
first,
last,
activeTag,
onCategory,
onHide,
onMove,
onPin,
onTag,
contextualUrl,
}: AppCardProps) {
const {
attributes,
isDragging,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id: app.id });
const launchUrl = app.isExternal
? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return (
<article
className={`app-card${hidden ? ' app-card--hidden' : ''}`}
ref={setNodeRef}
className={`app-card${hidden ? ' app-card--hidden' : ''}${
isDragging ? ' app-card--dragging' : ''
}`}
data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<div className="app-card__top">
<div className="app-card__icon" aria-hidden="true">
@@ -83,6 +99,17 @@ export function AppCard({
className="app-card__actions"
aria-label={`Personalize ${app.name}`}
>
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="icon-button drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<span aria-hidden="true"></span>
</button>
<button
className={`icon-button${pinned ? ' is-active' : ''}`}
type="button"
@@ -93,24 +120,6 @@ export function AppCard({
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={`Move ${app.name} earlier`}
disabled={first}
onClick={() => onMove(-1)}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={`Move ${app.name} later`}
disabled={last}
onClick={() => onMove(1)}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
@@ -123,7 +132,16 @@ export function AppCard({
</div>
<div className="app-card__body">
<div className="title-line">
<h2>{app.name}</h2>
<h3>
<a
className="app-card__launch-link"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{app.name}
</a>
</h3>
{pinned && <span className="status-chip">Pinned</span>}
{hidden && (
<span className="status-chip status-chip--muted">Hidden</span>
@@ -135,11 +153,33 @@ export function AppCard({
<p>{app.description}</p>
<PrivacySummary app={app} />
<RequirementSummary app={app} />
<div className="tag-row" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<span key={category}>{category}</span>
))}
</div>
{app.categories.length > 0 && (
<div className="category-row" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<button
type="button"
key={category}
onClick={() => onCategory(category)}
>
{category}
</button>
))}
</div>
)}
{app.tags.length > 0 && (
<div className="tag-row" aria-label="Tags">
{app.tags.map((tag) => (
<button
type="button"
key={tag}
aria-pressed={activeTag === tag}
onClick={() => onTag(tag)}
>
#{tag}
</button>
))}
</div>
)}
</div>
<footer className="app-card__footer">
<div className="app-card__meta">
@@ -157,15 +197,9 @@ export function AppCard({
</a>
)}
</div>
<a
className="launch-button"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{target ? 'Open in new tab' : 'Open tool'}{' '}
<span aria-hidden="true"></span>
</a>
<span className="open-hint">
Open tool <span aria-hidden="true"></span>
</span>
</footer>
</article>
);

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef } from 'react';
interface HelpPanelProps {
open: boolean;
onClose: () => void;
}
export function HelpPanel({ open, onClose }: HelpPanelProps) {
const panel = useRef<HTMLElement>(null);
useEffect(() => {
if (!open) return;
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusable = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)]
: [];
focusable()[0]?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusable();
const first = elements[0];
const last = elements.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
returnFocus?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="dialog-backdrop"
role="presentation"
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<section
ref={panel}
className="help-panel"
role="dialog"
aria-modal="true"
aria-labelledby="help-title"
tabIndex={-1}
>
<header>
<div>
<p className="eyebrow">Toolbox guide</p>
<h2 id="help-title">Choose and arrange your tools</h2>
</div>
<button
className="close-button"
type="button"
aria-label="Close help"
onClick={onClose}
>
×
</button>
</header>
<div className="help-steps">
<p>
<strong>Open:</strong> click anywhere on a tool tile that is not a
control.
</p>
<p>
<strong>Filter:</strong> search, choose a category, or click a tag.
Click the selected tag again to clear it.
</p>
<p>
<strong>Arrange:</strong> pin frequently used tools into their own
section, then drag the grip to reorder. The grip also supports
keyboard dragging with Space, arrow keys, and Escape.
</p>
<p>
<strong>Privacy:</strong> each tile reports its declared processing,
uploads, telemetry, and browser requirements before you open it.
</p>
</div>
</section>
</div>
);
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');