From 30fbdc52b2eb7bb9161eb6f2f3e8095a79860faa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 18:14:18 +0200 Subject: [PATCH] fix(ui): a focused, disabled FocusSafeButton no longer lights up on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/eslint.config.js | 12 +++++++++ .../src/components/PersonalViewsModal.tsx | 4 +-- .../src/components/PlaylistLimitsHint.tsx | 7 ++++- .../src/components/playerModalControls.tsx | 4 +-- frontend/src/components/ui/UndoToolbar.tsx | 2 +- .../src/lib/controlRules.contract.test.ts | 26 ++++++++++++++++--- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f134ed21..a8678c72 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -221,6 +221,18 @@ export default tseslint.config( // 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 // 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']", message: diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index e87d6b1b..e0520207 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -240,7 +240,7 @@ export default function PersonalViewsModal() { disabled={!nameChanged} title={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" > @@ -302,7 +302,7 @@ export default function PersonalViewsModal() { title={ 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" > {t("views.saveCurrent")} diff --git a/frontend/src/components/PlaylistLimitsHint.tsx b/frontend/src/components/PlaylistLimitsHint.tsx index e9682ce3..64ca4cfb 100644 --- a/frontend/src/components/PlaylistLimitsHint.tsx +++ b/frontend/src/components/PlaylistLimitsHint.tsx @@ -21,7 +21,12 @@ export default function PlaylistLimitsHint() { offset: 8, }); return ( -
+ // 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). +