admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog

the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
This commit is contained in:
2026-08-29 11:55:24 +02:00
parent 79578e1d2a
commit 317d5dd4f8
11 changed files with 709 additions and 375 deletions
+80 -40
View File
@@ -10,6 +10,7 @@
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
@@ -53,6 +54,13 @@ const styles = stylex.create({
fontWeight: 500,
cursor: "pointer",
},
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
modeFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
modeIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
@@ -76,8 +84,30 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
panel: {
outlineStyle: "none",
},
/**
* A panel with nothing tabbable in it — an empty or loading history — is given
* a tabindex by RAC so the reader can still reach its content, so it has to be
* able to show that it holds the focus.
*/
panelFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
tabsRoot: {
display: "block",
},
});
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
}
export default function ActivityPage() {
const search = useSearch({ from: "/shell/activity" });
const navigate = useNavigate({ from: "/activity" });
@@ -97,56 +127,66 @@ export default function ActivityPage() {
return (
<section>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
{MODES.map((option) => {
const selected = option.mode === search.mode;
return (
<button
{/*
* The selected tab is the URL's `mode` and nothing else. RAC would hold
* the selection itself, but a second copy of it would fight the back
* button, so the search parameter stays the only state there is.
*/}
<Tabs
selectedKey={search.mode}
onSelectionChange={(key) => selectMode(key as ActivityMode)}
className={() => stylex.props(styles.tabsRoot).className ?? ""}
>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<TabList
aria-label="Activity mode"
className={() => stylex.props(styles.switch).className ?? ""}
>
{MODES.map((option) => (
<Tab
key={option.mode}
type="button"
aria-pressed={selected}
onClick={() => selectMode(option.mode)}
{...stylex.props(
styles.modeButton,
selected ? styles.modeSelected : styles.modeIdle,
shared.focusRing,
)}
id={option.mode}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.modeButton,
isSelected ? styles.modeSelected : styles.modeIdle,
isFocusVisible && styles.modeFocusVisible,
).className ?? ""
}
>
{option.label}
</button>
);
})}
</Tab>
))}
</TabList>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div>
{/*
* Remounted whenever the applied search changes, which is what makes
* the back button work: the draft is derived state, and the browser
* moving the URL under it has to move the form with it.
*/}
<ActivityFilters
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
applied={search}
isDisabled={live}
onApply={apply}
onClear={() => apply(NO_FILTERS)}
/>
{/*
* Remounted whenever the applied search changes, which is what makes
* the back button work: the draft is derived state, and the browser
* moving the URL under it has to move the form with it.
*/}
<ActivityFilters
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
applied={search}
isDisabled={live}
onApply={apply}
onClear={() => apply(NO_FILTERS)}
/>
{live ? (
<>
<TabPanel id="history" className={panelClass}>
<HistoryActivity search={search} />
</TabPanel>
<TabPanel id="live" className={panelClass}>
<p {...stylex.props(styles.liveNote)}>
The stream carries every query the server answers; these filters apply to history only.
</p>
<LiveActivity origin={search} />
</>
) : (
<HistoryActivity search={search} />
)}
</TabPanel>
</Tabs>
</section>
);
}