feat(housekeeping): finish accessible command deck
This commit is contained in:
1 parent
85ad0452d3
commit
a113e48880
55 files changed
+17064
-509
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route";
|
||||
@@ -40,11 +41,13 @@ export default async function HousekeepingPreviewRoutePage({
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
const translate = await getTranslations("pages.housekeeping");
|
||||
|
||||
return handler.render({
|
||||
context,
|
||||
match,
|
||||
surface: "preview",
|
||||
translate: (key) => translate(key as never),
|
||||
...(searchParams ? { searchParams: await searchParams } : {}),
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,9 @@ export default async function HousekeepingPreviewDomainLayout({
|
||||
command: translate("preview.commandDisabled"),
|
||||
preview: translate("preview.badge"),
|
||||
backToSite: translate("preview.backToSite"),
|
||||
operatorRank: translate("navigation.operatorRank", {
|
||||
rank: context.actor.rank,
|
||||
}),
|
||||
commandDeck: {
|
||||
placeholder: translate("commandDeck.placeholder"),
|
||||
navigation: translate("commandDeck.navigation"),
|
||||
|
||||
@@ -73,6 +73,7 @@ export default async function HousekeepingPreviewRootPage() {
|
||||
context,
|
||||
match,
|
||||
surface: "preview",
|
||||
translate: (key) => translate(key as never),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -87,6 +88,9 @@ export default async function HousekeepingPreviewRootPage() {
|
||||
command: translate("preview.commandDisabled"),
|
||||
preview: translate("preview.badge"),
|
||||
backToSite: translate("preview.backToSite"),
|
||||
operatorRank: translate("navigation.operatorRank", {
|
||||
rank: context.actor.rank,
|
||||
}),
|
||||
commandDeck: {
|
||||
placeholder: translate("commandDeck.placeholder"),
|
||||
navigation: translate("commandDeck.navigation"),
|
||||
|
||||
@@ -1618,6 +1618,42 @@ details[open] > summary .details-open\:rotate-180 {
|
||||
}
|
||||
}
|
||||
|
||||
/* Housekeeping command deck: keyboard focus, narrow screens, and motion safety. */
|
||||
[data-housekeeping-root] :is(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid var(--admin-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#housekeeping-content {
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#housekeeping-content table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
#housekeeping-content :is(pre, code, img, svg, canvas) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-housekeeping-root],
|
||||
[data-housekeeping-root] * {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
scroll-behavior: auto !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
animation-duration: 0.01ms !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
animation-iteration-count: 1 !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Premium effects ── */
|
||||
@keyframes float {
|
||||
0%,
|
||||
|
||||
@@ -14,6 +14,27 @@ import {
|
||||
WidgetSettings,
|
||||
} from "../../../foundation/shell/widget-settings";
|
||||
|
||||
export interface OperationsPersonalizationLabels {
|
||||
readonly favorites: {
|
||||
readonly title: string;
|
||||
readonly pin: string;
|
||||
readonly unpin: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
};
|
||||
readonly widgetSettings: {
|
||||
readonly title: string;
|
||||
readonly mandatory: string;
|
||||
readonly error: string;
|
||||
};
|
||||
readonly widgets: {
|
||||
readonly title: string;
|
||||
readonly partial: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
};
|
||||
}
|
||||
|
||||
const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => {
|
||||
const { saveHousekeepingPreferences } = await import(
|
||||
"@/actions/housekeeping-preferences"
|
||||
@@ -27,6 +48,9 @@ export function OperationsPersonalization({
|
||||
widgets,
|
||||
widgetResult,
|
||||
surface,
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
savePreferences = persistPreferences,
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
@@ -34,6 +58,9 @@ export function OperationsPersonalization({
|
||||
readonly widgets: readonly HousekeepingWidgetDefinition[];
|
||||
readonly widgetResult: HousekeepingWidgetLoadResult;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly labels: OperationsPersonalizationLabels;
|
||||
readonly routeTitles: Readonly<Record<string, string>>;
|
||||
readonly widgetTitles: Readonly<Record<string, string>>;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
}) {
|
||||
return (
|
||||
@@ -44,22 +71,15 @@ export function OperationsPersonalization({
|
||||
routes={routes}
|
||||
surface={surface}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Preferences could not be saved",
|
||||
}}
|
||||
labels={labels.favorites}
|
||||
titles={routeTitles}
|
||||
/>
|
||||
<WidgetSettings
|
||||
preferences={preferences}
|
||||
widgets={widgets}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Widget settings",
|
||||
mandatory: "Mandatory",
|
||||
error: "Preferences could not be saved",
|
||||
}}
|
||||
labels={labels.widgetSettings}
|
||||
titles={widgetTitles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,11 +87,8 @@ export function OperationsPersonalization({
|
||||
preferences={preferences}
|
||||
result={widgetResult}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Some widgets are unavailable",
|
||||
error: "Widget order could not be saved",
|
||||
}}
|
||||
labels={labels.widgets}
|
||||
titles={widgetTitles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -79,6 +79,45 @@ const result: OperationsWorkspaceLoadResult = {
|
||||
};
|
||||
|
||||
const savePreferences = vi.fn(async (value) => ok(value, "saved"));
|
||||
const labels = {
|
||||
title: "Operations workspace",
|
||||
description: "Capability-derived operational home",
|
||||
noDomainsTitle: "No operational domains are available",
|
||||
noDomainsDescription: "No authorized domain",
|
||||
partial: "Some workspace sections are unavailable",
|
||||
inbox: {
|
||||
title: "Operational inbox",
|
||||
domainFilter: "Filter by domain",
|
||||
stateFilter: "Filter by state",
|
||||
priorityFilter: "Filter by priority",
|
||||
all: "All",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Some sources are unavailable",
|
||||
open: "Open",
|
||||
},
|
||||
recent: { title: "Recent work", empty: "Nothing recent" },
|
||||
favorites: {
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Preferences could not be saved",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
},
|
||||
widgetSettings: {
|
||||
title: "Widget settings",
|
||||
mandatory: "Mandatory",
|
||||
error: "Preferences could not be saved",
|
||||
},
|
||||
widgets: {
|
||||
title: "Widgets",
|
||||
partial: "Some widgets are unavailable",
|
||||
error: "Widget order could not be saved",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
},
|
||||
} as const;
|
||||
const routeTitles = { Users: "Users" };
|
||||
const widgetTitles = { "people.queue": "People queue" };
|
||||
|
||||
describe("OperationsWorkspacePage", () => {
|
||||
it("renders capability-filtered sections, partial failures, and preview links", () => {
|
||||
@@ -88,6 +127,9 @@ describe("OperationsWorkspacePage", () => {
|
||||
routes={routes}
|
||||
widgetDefinitions={widgets}
|
||||
surface="preview"
|
||||
labels={labels}
|
||||
routeTitles={routeTitles}
|
||||
widgetTitles={widgetTitles}
|
||||
savePreferences={savePreferences}
|
||||
/>,
|
||||
);
|
||||
@@ -97,8 +139,8 @@ describe("OperationsWorkspacePage", () => {
|
||||
expect(html).toContain("Favorites");
|
||||
expect(html).toContain("Widget settings");
|
||||
expect(html).toContain("Some workspace sections are unavailable");
|
||||
expect(html).toContain("system.alerts");
|
||||
expect(html).toContain("system.health");
|
||||
expect(html).not.toContain("system.alerts");
|
||||
expect(html).not.toContain("system.health");
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
});
|
||||
|
||||
@@ -109,6 +151,9 @@ describe("OperationsWorkspacePage", () => {
|
||||
routes={[]}
|
||||
widgetDefinitions={[]}
|
||||
surface="preview"
|
||||
labels={labels}
|
||||
routeTitles={{}}
|
||||
widgetTitles={{}}
|
||||
savePreferences={savePreferences}
|
||||
/>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -16,10 +16,16 @@ export function HousekeepingPageShell({
|
||||
children,
|
||||
}: HousekeepingPageShellProps) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<section
|
||||
aria-labelledby="housekeeping-page-title"
|
||||
className="min-w-0 max-w-full space-y-6"
|
||||
>
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
|
||||
<h1
|
||||
id="housekeeping-page-title"
|
||||
className="text-xl font-semibold text-[var(--admin-text)]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
@@ -29,7 +35,7 @@ export function HousekeepingPageShell({
|
||||
</div>
|
||||
{primaryAction ? <div>{primaryAction}</div> : null}
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<div className="min-w-0 max-w-full">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ describe("HousekeepingPageState", () => {
|
||||
["empty", "status"],
|
||||
["partial", "status"],
|
||||
["error", "alert"],
|
||||
["forbidden", "alert"],
|
||||
["ready", "status"],
|
||||
] as const)("renders %s with the %s role", (state, role) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
@@ -34,6 +36,35 @@ describe("HousekeepingPageState", () => {
|
||||
expect(html).toContain(`>${state} description<`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", "polite"],
|
||||
["empty", "polite"],
|
||||
["partial", "polite"],
|
||||
["error", "assertive"],
|
||||
["forbidden", "assertive"],
|
||||
["ready", "polite"],
|
||||
] as const)("announces %s changes with %s priority", (state, live) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial"
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
) : (
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
);
|
||||
const html = renderToStaticMarkup(pageState);
|
||||
|
||||
expect(html).toContain(`data-page-state="${state}"`);
|
||||
expect(html).toContain(`aria-live="${live}"`);
|
||||
});
|
||||
|
||||
it("announces loading politely", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
|
||||
@@ -6,10 +6,21 @@ interface HousekeepingPageStateBaseProps {
|
||||
retryAction?: ReactNode;
|
||||
}
|
||||
|
||||
export type HousekeepingPageState =
|
||||
| "loading"
|
||||
| "empty"
|
||||
| "partial"
|
||||
| "error"
|
||||
| "forbidden"
|
||||
| "ready";
|
||||
|
||||
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
|
||||
(
|
||||
| { state: "partial"; partialLabel: string }
|
||||
| { state: "loading" | "empty" | "error"; partialLabel?: never }
|
||||
| {
|
||||
state: Exclude<HousekeepingPageState, "partial">;
|
||||
partialLabel?: never;
|
||||
}
|
||||
);
|
||||
|
||||
export function HousekeepingPageState({
|
||||
@@ -19,13 +30,14 @@ export function HousekeepingPageState({
|
||||
description,
|
||||
retryAction,
|
||||
}: HousekeepingPageStateProps) {
|
||||
const isError = state === "error";
|
||||
const isError = state === "error" || state === "forbidden";
|
||||
const isPartial = state === "partial";
|
||||
|
||||
return (
|
||||
<section
|
||||
role={isError ? "alert" : "status"}
|
||||
aria-live={state === "loading" ? "polite" : undefined}
|
||||
aria-live={isError ? "assertive" : "polite"}
|
||||
data-page-state={state}
|
||||
className={`rounded-lg border p-4 ${
|
||||
isError
|
||||
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
|
||||
|
||||
@@ -15,6 +15,7 @@ const routeMocks = vi.hoisted(() => {
|
||||
"navigation.skipToContent": "HK::skip-to-content",
|
||||
"navigation.primary": "HK::primary-navigation",
|
||||
"navigation.contextual": "HK::contextual-navigation",
|
||||
"navigation.operatorRank": "HK::operator-rank",
|
||||
"commandDeck.placeholder": "HK::command-placeholder",
|
||||
"commandDeck.navigation": "HK::command-navigation",
|
||||
"commandDeck.commands": "HK::command-commands",
|
||||
@@ -802,6 +803,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.users",
|
||||
domain: "people",
|
||||
@@ -809,7 +811,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
canonicalHref: "/ase/people/users",
|
||||
},
|
||||
});
|
||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps nested preview segments to canonical dynamic parameters", async () => {
|
||||
@@ -828,6 +830,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.user-detail",
|
||||
domain: "people",
|
||||
@@ -853,6 +856,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.literal-tool",
|
||||
domain: "people",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CommandDeck } from "./shell/command-deck";
|
||||
import { HousekeepingShell } from "./shell/housekeeping-shell";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
skipToContent: "Skip",
|
||||
primaryNavigation: "Primary",
|
||||
contextualNavigation: "Context",
|
||||
command: "Search",
|
||||
preview: "Preview",
|
||||
backToSite: "Back",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Partial",
|
||||
close: "Close",
|
||||
},
|
||||
};
|
||||
|
||||
describe("housekeeping responsive contract", () => {
|
||||
it("stacks both navigation levels below lg without removing capabilities", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId="operations"
|
||||
domains={[
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Operations",
|
||||
items: [{ id: "workspace", href: "/ase", label: "Workspace" }],
|
||||
},
|
||||
]}
|
||||
labels={labels}
|
||||
surface="canonical"
|
||||
>
|
||||
<div>Body</div>
|
||||
</HousekeepingShell>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-responsive-mode="stacked-below-lg"');
|
||||
expect(html).toContain('data-navigation-collapse="domain"');
|
||||
expect(html).toContain('data-navigation-collapse="context"');
|
||||
expect(html).toContain('href="/ase/operations"');
|
||||
expect(html).toContain('href="/ase"');
|
||||
expect(html).toContain("overflow-x-auto");
|
||||
expect(html).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("keeps the command deck bounded at a 320px viewport", () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/features/housekeeping/foundation/shell/command-deck.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const trigger = renderToStaticMarkup(
|
||||
<CommandDeck
|
||||
labels={{ ...labels.commandDeck, trigger: "Search" }}
|
||||
surface="canonical"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(trigger).toContain("min-w-0");
|
||||
expect(source).toContain("max-h-[calc(100dvh-2rem)]");
|
||||
expect(source).toContain("overflow-hidden");
|
||||
expect(source).toContain("p-2 sm:p-4");
|
||||
});
|
||||
|
||||
it("provides non-overflow and reduced-motion safeguards for housekeeping content", () => {
|
||||
const css = readFileSync(
|
||||
resolve(process.cwd(), "src/app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(css).toContain("[data-housekeeping-root]");
|
||||
expect(css).toContain("#housekeeping-content table");
|
||||
expect(css).toContain("overflow-x: auto");
|
||||
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
|
||||
expect(css).toContain("transition-duration: 0.01ms");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,13 @@ function matches(tokens: readonly string[], ...values: readonly string[]) {
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
function commandTitle(commandId: string): string {
|
||||
function commandTitle(
|
||||
commandId: string,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
const key = `pages.housekeeping.commands.${commandId}`;
|
||||
const translated = translate(key);
|
||||
if (translated !== key) return translated;
|
||||
return commandId
|
||||
.split(".")
|
||||
.slice(1)
|
||||
@@ -120,7 +126,7 @@ export function searchNavigationAndCommands({
|
||||
(candidate) => candidate.id === command.owner,
|
||||
);
|
||||
if (!domain || !satisfiesCapability(context, domain.capability)) continue;
|
||||
const title = commandTitle(command.id);
|
||||
const title = commandTitle(command.id, translate);
|
||||
if (!matches(tokens, command.id, command.owner, title)) continue;
|
||||
commandsFound.push({
|
||||
id: `command:${command.id}`,
|
||||
|
||||
@@ -276,7 +276,7 @@ export function CommandDeck({
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)]"
|
||||
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<span>{labels.trigger}</span>
|
||||
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
|
||||
@@ -286,14 +286,14 @@ export function CommandDeck({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.trigger}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-4 pt-[10vh]"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-2 sm:p-4 pt-4 sm:pt-[10vh]"
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
|
||||
<div className="max-h-[calc(100dvh-2rem)] min-w-0 w-full max-w-2xl overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
|
||||
<div className="flex justify-end border-b border-[var(--admin-border)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)]"
|
||||
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.close}
|
||||
</button>
|
||||
@@ -316,10 +316,17 @@ export function CommandDeck({
|
||||
placeholder={labels.placeholder}
|
||||
aria-label={labels.placeholder}
|
||||
/>
|
||||
<CommandList aria-busy={loading}>
|
||||
<CommandList
|
||||
aria-busy={loading}
|
||||
className="min-w-0 overflow-y-auto"
|
||||
>
|
||||
{loading ? <p role="status">{labels.loading}</p> : null}
|
||||
{response.errors.length > 0 ? (
|
||||
<p role="status" className="px-3 py-2 text-sm">
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-3 py-2 text-sm"
|
||||
>
|
||||
{labels.partial}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -7,13 +7,17 @@ interface ContextNavProps {
|
||||
|
||||
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="min-w-0">
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
data-housekeeping-landmark="context-navigation"
|
||||
className="min-w-0"
|
||||
>
|
||||
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="min-w-max">
|
||||
<Link
|
||||
href={item.href}
|
||||
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||
className="block min-h-11 rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
|
||||
@@ -27,14 +27,18 @@ export function DomainRail({
|
||||
ariaLabel,
|
||||
}: DomainRailProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="w-full lg:w-20">
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
data-housekeeping-landmark="primary-navigation"
|
||||
className="min-w-0 w-full lg:w-20"
|
||||
>
|
||||
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||
{domains.map((domain) => (
|
||||
<li key={domain.id} className="min-w-max lg:min-w-0">
|
||||
<Link
|
||||
href={domain.href}
|
||||
aria-current={domain.id === activeDomainId ? "page" : undefined}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
|
||||
className="flex min-h-11 items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)] lg:justify-center"
|
||||
>
|
||||
<span data-domain-icon={domain.iconId}>
|
||||
<DomainIcon iconId={domain.iconId} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,7 @@ const labels = {
|
||||
command: "Search housekeeping",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search navigation, commands, and entities",
|
||||
navigation: "Navigation",
|
||||
|
||||
@@ -8,6 +8,13 @@ import { ContextNav } from "./context-nav";
|
||||
import { DomainRail } from "./domain-rail";
|
||||
import { OperatorSummary } from "./operator-summary";
|
||||
|
||||
export const HOUSEKEEPING_LANDMARKS = [
|
||||
"banner",
|
||||
"primary-navigation",
|
||||
"context-navigation",
|
||||
"main",
|
||||
] as const;
|
||||
|
||||
interface HousekeepingShellProps {
|
||||
actor: HousekeepingActor;
|
||||
activeDomainId: HousekeepingDomainId;
|
||||
@@ -19,6 +26,7 @@ interface HousekeepingShellProps {
|
||||
command: string;
|
||||
preview: string;
|
||||
backToSite: string;
|
||||
operatorRank: string;
|
||||
commandDeck: {
|
||||
placeholder: string;
|
||||
navigation: string;
|
||||
@@ -45,21 +53,24 @@ export function HousekeepingShell({
|
||||
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||
<div
|
||||
data-housekeeping-root="true"
|
||||
className="min-h-screen min-w-0 bg-[var(--admin-canvas)] text-[var(--admin-text)]"
|
||||
>
|
||||
<a
|
||||
href="#housekeeping-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
|
||||
className="sr-only rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)] focus-visible:not-sr-only focus-visible:absolute focus-visible:left-4 focus-visible:top-4 focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.skipToContent}
|
||||
</a>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="flex min-w-0 flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{surface === "preview" ? (
|
||||
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||
{labels.preview}
|
||||
</span>
|
||||
) : null}
|
||||
<OperatorSummary actor={actor} />
|
||||
<OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandTrigger
|
||||
@@ -69,27 +80,40 @@ export function HousekeepingShell({
|
||||
/>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.backToSite}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
|
||||
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
|
||||
<div
|
||||
data-responsive-mode="stacked-below-lg"
|
||||
className="flex min-h-[calc(100vh-4rem)] min-w-0 flex-col lg:flex-row"
|
||||
>
|
||||
<aside
|
||||
data-navigation-collapse="domain"
|
||||
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r"
|
||||
>
|
||||
<DomainRail
|
||||
domains={domains}
|
||||
activeDomainId={activeDomainId}
|
||||
ariaLabel={labels.primaryNavigation}
|
||||
/>
|
||||
</aside>
|
||||
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
|
||||
<div
|
||||
data-navigation-collapse="context"
|
||||
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:shrink-0 lg:border-b-0 lg:border-r"
|
||||
>
|
||||
<ContextNav
|
||||
items={activeDomain?.items ?? []}
|
||||
ariaLabel={labels.contextualNavigation}
|
||||
/>
|
||||
</div>
|
||||
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
|
||||
<main
|
||||
id="housekeeping-content"
|
||||
tabIndex={-1}
|
||||
className="housekeeping-content min-w-0 max-w-full flex-1 p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--admin-accent)] lg:p-6"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,9 @@ describe("OperationalInbox", () => {
|
||||
expect(html).toContain('aria-label="Filter by state"');
|
||||
expect(html).toContain('aria-label="Filter by priority"');
|
||||
expect(html).toContain("Some sources are unavailable");
|
||||
expect(html).toContain("content.publication");
|
||||
expect(html).not.toContain("content.publication");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
expect(html).not.toContain("Dismiss");
|
||||
expect(html).not.toContain("Assign");
|
||||
|
||||
@@ -90,7 +90,10 @@ export function OperationalInbox({
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="operational-inbox-title" className="space-y-4">
|
||||
<section
|
||||
aria-labelledby="operational-inbox-title"
|
||||
className="min-w-0 space-y-4"
|
||||
>
|
||||
<h2 id="operational-inbox-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
@@ -102,7 +105,7 @@ export function OperationalInbox({
|
||||
aria-label={labels.domainFilter}
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{domains.map((value) => (
|
||||
@@ -115,7 +118,7 @@ export function OperationalInbox({
|
||||
aria-label={labels.stateFilter}
|
||||
value={state}
|
||||
onChange={(event) => setState(event.target.value)}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{states.map((value) => (
|
||||
@@ -128,7 +131,7 @@ export function OperationalInbox({
|
||||
aria-label={labels.priorityFilter}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{priorities.map((value) => (
|
||||
@@ -141,17 +144,11 @@ export function OperationalInbox({
|
||||
|
||||
{response.errors.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
|
||||
>
|
||||
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||
{response.errors.map((error) => (
|
||||
<li key={error.sourceId}>
|
||||
{error.sourceId}: {error.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -160,10 +157,10 @@ export function OperationalInbox({
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={`${item.sourceId}:${item.itemId}`}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="min-w-0 break-words">
|
||||
<p className="font-medium">
|
||||
{titles[item.titleKey] ?? item.titleKey}
|
||||
</p>
|
||||
@@ -176,7 +173,7 @@ export function OperationalInbox({
|
||||
</div>
|
||||
<Link
|
||||
href={projectOperationalInboxHref(item.href, surface)}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.open}
|
||||
</Link>
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { HousekeepingActor } from "../contracts";
|
||||
|
||||
interface OperatorSummaryProps {
|
||||
actor: HousekeepingActor;
|
||||
rankLabel: string;
|
||||
}
|
||||
|
||||
export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||
export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
|
||||
return (
|
||||
<div className="min-w-0 text-sm text-[var(--admin-text)]">
|
||||
<div className="truncate font-medium">{actor.username}</div>
|
||||
@@ -12,7 +13,8 @@ export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||
data-operator-rank={actor.rank}
|
||||
className="text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{actor.rank}
|
||||
<span className="sr-only">{rankLabel}</span>
|
||||
<span aria-hidden="true">{actor.rank}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("housekeeping personalization", () => {
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Save failed",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
}}
|
||||
/>
|
||||
<RecentWork
|
||||
@@ -190,6 +191,7 @@ describe("housekeeping personalization", () => {
|
||||
title: "Widgets",
|
||||
partial: "Some widgets failed",
|
||||
error: "Save failed",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
@@ -198,8 +200,7 @@ describe("housekeeping personalization", () => {
|
||||
expect(html).toContain("Required");
|
||||
expect(html).toContain('disabled=""');
|
||||
expect(html).toContain("Some widgets failed");
|
||||
expect(html).toContain("people.failing");
|
||||
expect(html).toContain("TIMEOUT");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
const gridSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
|
||||
@@ -20,7 +20,10 @@ export function RecentWork({
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby="housekeeping-recent-title" className="space-y-3">
|
||||
<section
|
||||
aria-labelledby="housekeeping-recent-title"
|
||||
className="min-w-0 space-y-3"
|
||||
>
|
||||
<h2 id="housekeeping-recent-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
@@ -29,10 +32,13 @@ export function RecentWork({
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.routeId}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||
>
|
||||
<Link href={toHousekeepingHref(item.canonicalHref, surface)}>
|
||||
{titles[item.labelKey] ?? item.labelKey}
|
||||
<Link
|
||||
href={toHousekeepingHref(item.canonicalHref, surface)}
|
||||
className="break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{titles[item.labelKey] ?? item.routeId}
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">
|
||||
<span>{item.source}</span>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -92,7 +92,11 @@ export function WidgetSettings({
|
||||
{labels.title}
|
||||
</h2>
|
||||
{error ? (
|
||||
<p role="alert" className="text-[var(--admin-danger)]">
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="text-[var(--admin-danger)]"
|
||||
>
|
||||
{error === labels.error ? error : labels.error}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -105,16 +109,19 @@ export function WidgetSettings({
|
||||
return (
|
||||
<li
|
||||
key={widget.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={mandatory || savingId !== null}
|
||||
onChange={() => void toggle(widget.id)}
|
||||
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
/>
|
||||
<span>{titles[widget.id] ?? widget.id}</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{titles[widget.id] ?? widget.id}
|
||||
</span>
|
||||
</label>
|
||||
{mandatory ? (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface HousekeepingPageInput {
|
||||
>;
|
||||
readonly surface?: HousekeepingSurface;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
readonly translate?: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HousekeepingRouteHandler {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+572
-100
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+561
-92
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user