selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
/**
|
|
* Activity: one surface over the queries nxdns answered, in two modes.
|
|
*
|
|
* History reads the persisted log and Live reads the stream, but they are the
|
|
* same seven columns over the same filters, and the reader moves between them
|
|
* without losing the question they were asking. The mode lives in the URL with
|
|
* the filters, so an investigation is one link — including which half of it the
|
|
* recipient should be looking at.
|
|
*/
|
|
|
|
import { useCallback } from "react";
|
|
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";
|
|
import HistoryActivity from "./HistoryActivity";
|
|
import LiveActivity from "./LiveActivity";
|
|
import type { ActivityMode } from "./search";
|
|
|
|
const MODES: ReadonlyArray<{ mode: ActivityMode; label: string }> = [
|
|
{ mode: "history", label: "History" },
|
|
{ mode: "live", label: "Live" },
|
|
];
|
|
|
|
const styles = stylex.create({
|
|
header: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
},
|
|
heading: {
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 600,
|
|
textWrap: "balance",
|
|
},
|
|
switch: {
|
|
display: "flex",
|
|
gap: "0.25rem",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
padding: "0.125rem",
|
|
},
|
|
modeButton: {
|
|
borderStyle: "none",
|
|
borderRadius: "0.1875rem",
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
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 },
|
|
},
|
|
/** The selected mode reads as a filled chip, the same weight the nav uses. */
|
|
modeSelected: {
|
|
backgroundColor: colors.primary,
|
|
color: colors.primaryText,
|
|
},
|
|
/** The one way into the simulation from here, so it cannot sit behind a row. */
|
|
simulationLink: {
|
|
marginInlineStart: "auto",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.primaryOnSurface,
|
|
textDecorationLine: "none",
|
|
},
|
|
liveNote: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
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" });
|
|
// The functional form, not a replacement object: the filters are retained
|
|
// across a mode switch on purpose, and spelling out a new search here would
|
|
// drop every one of them on the way to Live and back.
|
|
function selectMode(mode: ActivityMode) {
|
|
if (mode === search.mode) return;
|
|
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
|
}
|
|
|
|
// A patch, merged into whatever the URL already says: a segment click must not
|
|
// spell out the four filters it is not about. `replace` is the debounced text
|
|
// commits, so the back button steps between investigations, not keystrokes.
|
|
const apply = useCallback(
|
|
(patch: Partial<AppliedFilters>, replace = false) => {
|
|
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
const clear = useCallback(() => {
|
|
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
|
|
}, [navigate]);
|
|
|
|
return (
|
|
<section>
|
|
{/*
|
|
* 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}
|
|
id={option.mode}
|
|
className={({ isSelected, isFocusVisible }) =>
|
|
stylex.props(
|
|
styles.modeButton,
|
|
isSelected ? styles.modeSelected : styles.modeIdle,
|
|
isFocusVisible && styles.modeFocusVisible,
|
|
).className ?? ""
|
|
}
|
|
>
|
|
{option.label}
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
|
Current policy simulation
|
|
</Link>
|
|
</div>
|
|
|
|
{/*
|
|
* The toolbar belongs to History alone. It is not remounted on a search
|
|
* change: it resyncs its draft from the URL instead, because a remount
|
|
* mid-debounce would take the focus out of the input being typed in.
|
|
*/}
|
|
<TabPanel id="history" className={panelClass}>
|
|
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
|
<HistoryActivity search={search} onClear={clear} />
|
|
</TabPanel>
|
|
<TabPanel id="live" className={panelClass}>
|
|
<p {...stylex.props(styles.liveNote)}>
|
|
The stream carries every query the server answers; the History filters apply to history only.
|
|
</p>
|
|
<LiveActivity origin={search} />
|
|
</TabPanel>
|
|
</Tabs>
|
|
</section>
|
|
);
|
|
}
|