admin: title-only information becomes visible text

the client address follows its name as visible muted text in the query tables, the config lock indicator prints its reason beside the tag except in table rows where a page-level note explains the lock instead, and the locked delete buttons describe themselves through that one visible note. the chart legend tooltip is deleted because a named client is deliberately not addressed in the chart, and the dead series address field went with it. titles that merely repeat visible copyable text stay.
This commit is contained in:
2026-08-29 13:03:30 +02:00
parent 207252acee
commit c65d92d8f8
9 changed files with 154 additions and 51 deletions
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
test("under file authority the lock names the file, in words a reader hears", async () => {
renderIndicator(MANAGED_FILE);
// The reason is visible text beside the tag, not a title and not a label: a
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
// text is the same failure pointed the other way.
const lock = await screen.findByText("Locked");
await waitFor(() =>
expect(lock.getAttribute("aria-label")).toBe(
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
),
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
);
expect(lock.getAttribute("title")).toBeNull();
expect(lock.getAttribute("aria-label")).toBeNull();
});
test("an unanswered status still locks, and says that is why", async () => {
renderIndicator("failed");
const lock = await screen.findByText("Locked");
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
await screen.findByText("Locked");
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
});
@@ -1,8 +1,20 @@
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
import { useAuthority, type Authority } from "./authority";
const styles = stylex.create({
row: {
display: "inline-flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "0.375rem",
},
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
reason: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tag: {
marginLeft: "0.5rem",
borderWidth: 1,
@@ -26,20 +38,34 @@ const styles = stylex.create({
* The word is real text, not colour or an icon, so a screen reader announces
* the reason the control will not answer.
*/
export default function ConfigLockIndicator() {
/**
* Why a configuration control will not answer. Exported because a page that
* shows the compact tag has to print this sentence itself, once, somewhere the
* tag can point at.
*/
export function lockReason(authority: Authority): string {
if (authority.state === "pending") return "Checking which configuration source this server obeys";
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
}
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
const authority = useAuthority();
if (authority.state === "resolved" && authority.status.authority === "database") return null;
const reason =
authority.state === "pending"
? "Checking which configuration source this server obeys"
: authority.state === "failed"
? "Configuration status unavailable, so edits are held back"
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
const reason = lockReason(authority);
// A compact caller has no room for the sentence and must print it once
// nearby instead: the Clients table would otherwise repeat it down every row.
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
// Everywhere else the reason is visible text rather than a `title` or an
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
// screen-reader-only text is the same failure pointed the other way.
return (
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
Locked
<span {...stylex.props(styles.row)}>
<span {...stylex.props(styles.tag)}>Locked</span>
<span {...stylex.props(styles.reason)}>{reason}</span>
</span>
);
}