fix(ui): a focused, disabled FocusSafeButton no longer lights up on hover
A FocusSafeButton that holds the focus is disabled by aria-disabled, not the native attribute, so every hover gated on `:disabled` / `enabled:` alone still fired on it — the glass lift, the primary / danger / warn dim, and eight `enabled:hover:` chains (saved views, the player's list buttons, the undo toolbar). Measured on localdev: the Pager's Next, focused at the last page, now keeps its resting border and shadow under the pointer. The CSS selectors and the chains also exclude `[aria-disabled="true"]`, and a lint rule refuses a bare `enabled:hover:` from now on.
This commit is contained in:
@@ -221,6 +221,18 @@ export default tseslint.config(
|
|||||||
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
||||||
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
||||||
// 2026-09 debt burn replaced them, and this keeps the next one out.
|
// 2026-09 debt burn replaced them, and this keeps the next one out.
|
||||||
|
// `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is
|
||||||
|
// disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked.
|
||||||
|
{
|
||||||
|
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
||||||
|
message:
|
||||||
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
||||||
|
message:
|
||||||
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
selector: "JSXOpeningElement[name.name='select']",
|
selector: "JSXOpeningElement[name.name='select']",
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default function PersonalViewsModal() {
|
|||||||
disabled={!nameChanged}
|
disabled={!nameChanged}
|
||||||
title={t("views.rename")}
|
title={t("views.rename")}
|
||||||
aria-label={t("views.rename")}
|
aria-label={t("views.rename")}
|
||||||
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-30"
|
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</FocusSafeButton>
|
</FocusSafeButton>
|
||||||
@@ -302,7 +302,7 @@ export default function PersonalViewsModal() {
|
|||||||
title={
|
title={
|
||||||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||||||
}
|
}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-40"
|
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-3.5 w-3.5" />
|
||||||
{t("views.saveCurrent")}
|
{t("views.saveCurrent")}
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ export default function PlaylistLimitsHint() {
|
|||||||
offset: 8,
|
offset: 8,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 px-1 pt-2 text-2xs text-muted">
|
// The panel is placed off the whole row, not the "Why?" button: off the button it opened
|
||||||
|
// over the right third of the rail and covered the lists (measured on localdev).
|
||||||
|
<div
|
||||||
|
ref={pop.refs.setPositionReference}
|
||||||
|
className="mt-2 flex items-center gap-1.5 border-t border-border/60 px-1 pt-2 text-2xs text-muted"
|
||||||
|
>
|
||||||
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
<span>{t("playlists.limits.hint")}</span>
|
<span>{t("playlists.limits.hint")}</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export function PlayerTransportControls({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const docked = variant === "docked";
|
const docked = variant === "docked";
|
||||||
const navBtn = docked
|
const navBtn = docked
|
||||||
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition"
|
||||||
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
: "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition";
|
||||||
const navLabel = docked ? "hidden sm:inline" : "";
|
const navLabel = docked ? "hidden sm:inline" : "";
|
||||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function UndoToolbar({
|
|||||||
}, [canUndo, canRedo, onUndo, onRedo]);
|
}, [canUndo, canRedo, onUndo, onRedo]);
|
||||||
|
|
||||||
const btn =
|
const btn =
|
||||||
"p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition";
|
"p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition";
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
<FocusSafeButton
|
<FocusSafeButton
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { ESLint } from "eslint";
|
import { ESLint } from "eslint";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
// The canonical "FE control" row (CLAUDE.md) as a lint rule: no native <select>. Six had drifted in
|
// Two lint rules about controls: no native <select> (the canonical "FE control" row in CLAUDE.md;
|
||||||
// before the rule existed. A selector that matches nothing looks exactly like a clean codebase, so
|
// six had drifted in before the rule existed), and no hover gated on `enabled:` alone. A selector
|
||||||
// this pins that it fires.
|
// that matches nothing looks exactly like a clean codebase, so this pins that each fires.
|
||||||
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||||
|
|
||||||
async function refused(source: string): Promise<number> {
|
async function refused(source: string): Promise<number> {
|
||||||
@@ -20,3 +20,23 @@ describe("a native select", () => {
|
|||||||
expect(await refused("export const B = () => <SegmentedToggle />;\n")).toBe(0);
|
expect(await refused("export const B = () => <SegmentedToggle />;\n")).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `enabled:hover:` is blind to a focused FocusSafeButton (aria-disabled, not the native attribute),
|
||||||
|
// which then lit up on hover as if a click would work.
|
||||||
|
describe("a hover gated on `enabled:` alone", () => {
|
||||||
|
it("is refused; the aria-aware gate is not", async () => {
|
||||||
|
const eslint = new ESLint({ cwd: FRONTEND });
|
||||||
|
const lint = async (cls: string) =>
|
||||||
|
(
|
||||||
|
await eslint.lintText(
|
||||||
|
`export const c = "p-1 ${cls}:text-fg";
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
filePath: "src/components/__probe.tsx",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)[0]?.messages.filter((m) => m.message.startsWith("`enabled:hover:`")).length;
|
||||||
|
expect(await lint("enabled:hover")).toBe(1);
|
||||||
|
expect(await lint("enabled:not-aria-disabled:hover")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user