diff --git a/CHANGELOG.md b/CHANGELOG.md index 936790fc..5e8538ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -710,6 +710,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * On a dual-address server, library cover backfill was configured once with a snapshot of the connect URL and never followed the smart LAN↔public switch. Starting already off the LAN — or moving off it mid-session — (internet up, playback already on the public address) left backfill hammering the now-unreachable local address and flooding the log with `error sending request` failures. * The backfill worklist no longer carries a URL: each cover fetch now reads the current reachable address live, so a LAN↔public flip is honoured even by the pass already in flight (its remaining covers download against the new endpoint). The connect cache is observable and pushes the resolved URL to the native worker on every flip; a real change clears the stale `.fetch-failed` backoff and runs a forced pass so the handful of covers attempted against the old address retry on the reachable one. This also covers the boot case where the initial pass starts on the primary URL before the first reachability probe resolves. On-demand UI / playback covers already followed the switch. +### UI polish — focus rings, search fields, column menus, settings + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#954](https://github.com/Psychotoxical/psysonic/pull/954)** + +* A pass of UI/CSS fixes: keyboard focus rings now sit inside the focused element, so they're no longer clipped at the edge of cards, rails, the player bar, queue tabs or search fields; the page, Help and Settings search fields share one consistent shape and focus highlight; the column-visibility dropdown on track tables no longer gets cut off on short lists (e.g. a single favorited song); and the Theme settings list rounds its corners to match its section. + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src/components/albumTrackList/TracklistColumnPicker.test.tsx b/src/components/albumTrackList/TracklistColumnPicker.test.tsx new file mode 100644 index 00000000..540c8455 --- /dev/null +++ b/src/components/albumTrackList/TracklistColumnPicker.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest'; +import React, { useRef, useState } from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { TFunction } from 'i18next'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { TracklistColumnPicker } from './TracklistColumnPicker'; +import type { ColDef } from '@/utils/useTracklistColumns'; + +const COLUMNS: readonly ColDef[] = [ + { key: 'title', i18nKey: 'title', minWidth: 100, defaultWidth: 200, required: true }, + { key: 'artist', i18nKey: 'artist', minWidth: 80, defaultWidth: 150, required: false }, + { key: 'album', i18nKey: 'album', minWidth: 80, defaultWidth: 150, required: false }, +]; + +// Passthrough translator: returns the key so assertions are translation-agnostic. +const passthroughT = ((k: string) => k) as unknown as TFunction; + +function Harness({ + initialOpen = false, + toggleColumn = vi.fn(), + resetColumns = vi.fn(), +}: { + initialOpen?: boolean; + toggleColumn?: (key: string) => void; + resetColumns?: () => void; +}) { + const [open, setOpen] = useState(initialOpen); + const pickerRef = useRef(null); + return ( + + ); +} + +describe('TracklistColumnPicker', () => { + it('opens the menu from the trigger and lists only non-required columns', async () => { + renderWithProviders(); + + // Closed: only the trigger button exists. + await userEvent.click(screen.getByRole('button')); + + expect(screen.getByText('albumDetail.columns')).toBeInTheDocument(); + expect(screen.getByText('albumDetail.artist')).toBeInTheDocument(); + expect(screen.getByText('albumDetail.album')).toBeInTheDocument(); + // Required columns are not offered in the menu. + expect(screen.queryByText('albumDetail.title')).not.toBeInTheDocument(); + }); + + it('toggles a column without closing the menu', async () => { + const toggleColumn = vi.fn(); + renderWithProviders(); + + await userEvent.click(screen.getByText('albumDetail.artist')); + + expect(toggleColumn).toHaveBeenCalledWith('artist'); + // Menu stays open so several columns can be toggled in one pass. + expect(screen.getByText('albumDetail.columns')).toBeInTheDocument(); + }); + + it('calls resetColumns from the reset action', async () => { + const resetColumns = vi.fn(); + renderWithProviders(); + + await userEvent.click(screen.getByText('albumDetail.resetColumns')); + + expect(resetColumns).toHaveBeenCalledOnce(); + }); + + it('closes on Escape', () => { + renderWithProviders(); + expect(screen.getByText('albumDetail.columns')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByText('albumDetail.columns')).not.toBeInTheDocument(); + }); + + it('closes on an outside mousedown', () => { + renderWithProviders(); + expect(screen.getByText('albumDetail.columns')).toBeInTheDocument(); + + fireEvent.mouseDown(document.body); + + expect(screen.queryByText('albumDetail.columns')).not.toBeInTheDocument(); + }); + + it('stays open on a mousedown inside the menu', () => { + renderWithProviders(); + + fireEvent.mouseDown(screen.getByText('albumDetail.artist')); + + expect(screen.getByText('albumDetail.columns')).toBeInTheDocument(); + }); +}); diff --git a/src/components/albumTrackList/TracklistColumnPicker.tsx b/src/components/albumTrackList/TracklistColumnPicker.tsx index dcd0e709..092c2f59 100644 --- a/src/components/albumTrackList/TracklistColumnPicker.tsx +++ b/src/components/albumTrackList/TracklistColumnPicker.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { Check, ChevronDown, RotateCcw } from 'lucide-react'; import type { TFunction } from 'i18next'; import type { ColDef } from '../../utils/useTracklistColumns'; @@ -16,10 +17,12 @@ interface Props { } /** - * The column visibility dropdown that sits outside `.tracklist` so the - * popover menu can grow without being clipped by the tracklist's overflow - * box. Lists every non-required column and offers a reset-to-defaults - * button. + * The column-visibility dropdown for tracklists. The menu is rendered in a + * portal with fixed positioning anchored to the trigger button, so it can never + * be clipped by an ancestor's overflow box — the reason short lists (e.g. a + * one-song Favorites list) used to cut it off. Shared by every tracklist + * (albums, playlists, favorites); all popover behaviour lives here, so the + * pages only wire up column state. */ export function TracklistColumnPicker({ allColumns, @@ -31,18 +34,76 @@ export function TracklistColumnPicker({ resetColumns, t, }: Props) { + const btnRef = useRef(null); + const popRef = useRef(null); + // Start off-screen until measured so the portal never flashes at 0,0. + const [menuStyle, setMenuStyle] = useState({ + position: 'fixed', top: -9999, left: -9999, + }); + + const updatePos = () => { + const btn = btnRef.current; + if (!btn) return; + const MARGIN = 8; + const r = btn.getBoundingClientRect(); + const menuH = popRef.current?.offsetHeight ?? 240; + const spaceBelow = window.innerHeight - r.bottom; + // Flip above the trigger when there isn't room below and there is above. + const openUp = spaceBelow < menuH + MARGIN && r.top > spaceBelow; + setMenuStyle({ + position: 'fixed', + right: Math.max(MARGIN, window.innerWidth - r.right), + zIndex: 10050, + ...(openUp + ? { bottom: window.innerHeight - r.top + 4 } + : { top: r.bottom + 4 }), + }); + }; + + // Position before paint so the menu appears in place. + useLayoutEffect(() => { + if (pickerOpen) updatePos(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pickerOpen]); + + // Keep anchored on scroll/resize, and close on outside-click / Escape. + useEffect(() => { + if (!pickerOpen) return; + const reposition = () => updatePos(); + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (btnRef.current?.contains(target) || popRef.current?.contains(target)) return; + setPickerOpen(() => false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setPickerOpen(() => false); + }; + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition, true); + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('resize', reposition); + window.removeEventListener('scroll', reposition, true); + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pickerOpen]); + return (
- {pickerOpen && ( -
+ {pickerOpen && createPortal( +
{t('albumDetail.columns')}
{allColumns.filter(c => !c.required).map(c => { const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key; @@ -65,7 +126,8 @@ export function TracklistColumnPicker({ {t('albumDetail.resetColumns')} -
+
, + document.body, )}
diff --git a/src/styles/components/custom-select.css b/src/styles/components/custom-select.css index f8d8442e..7617028c 100644 --- a/src/styles/components/custom-select.css +++ b/src/styles/components/custom-select.css @@ -19,10 +19,6 @@ border-color: var(--accent); background: var(--bg-hover); } -.custom-select-trigger:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} .custom-select-label { flex: 1; overflow: hidden; diff --git a/src/styles/components/filter-quick-clear.css b/src/styles/components/filter-quick-clear.css index c4be7feb..36c8a44b 100644 --- a/src/styles/components/filter-quick-clear.css +++ b/src/styles/components/filter-quick-clear.css @@ -19,8 +19,3 @@ .filter-quick-clear--on-active-chip:hover { background: color-mix(in srgb, var(--ctp-crust) 22%, transparent); } - -.filter-quick-clear:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} diff --git a/src/styles/components/genre-tag-cloud.css b/src/styles/components/genre-tag-cloud.css index cf0ee3e6..9ee6786d 100644 --- a/src/styles/components/genre-tag-cloud.css +++ b/src/styles/components/genre-tag-cloud.css @@ -31,7 +31,7 @@ } .genre-pill:focus-visible { - outline: 2px solid var(--genre-color, var(--accent)); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--genre-color, var(--focus-ring-color)); + outline-offset: var(--focus-ring-offset); } diff --git a/src/styles/components/help-page.css b/src/styles/components/help-page.css index 21c21ca3..4dc44d65 100644 --- a/src/styles/components/help-page.css +++ b/src/styles/components/help-page.css @@ -15,16 +15,17 @@ align-items: center; gap: 8px; padding: 6px 10px; - background: var(--bg-card); - border: 1px solid var(--border-subtle); + background: var(--ctp-base); + border: 1px solid var(--ctp-overlay0); border-radius: var(--radius-md); width: 280px; max-width: 100%; - transition: border-color var(--transition-fast); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } .help-search:focus-within { border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); } .help-search-icon { @@ -77,6 +78,10 @@ .theme-accordion { display: flex; flex-direction: column; + /* Round the inner box so the first header / last content corners continue the + parent card's rounding instead of running square into it. */ + border-radius: var(--radius-md); + overflow: hidden; } .theme-accordion-item { diff --git a/src/styles/components/hero.css b/src/styles/components/hero.css index 4a576533..67d4d9f9 100644 --- a/src/styles/components/hero.css +++ b/src/styles/components/hero.css @@ -89,11 +89,6 @@ transform: scale(1.05); } -.hero-nav-arrow:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - .hero-overlay { position: absolute; inset: 0; diff --git a/src/styles/components/live-search.css b/src/styles/components/live-search.css index 7795136b..127a9edf 100644 --- a/src/styles/components/live-search.css +++ b/src/styles/components/live-search.css @@ -95,7 +95,7 @@ min-width: 0; background: var(--ctp-base); border: 1px solid var(--ctp-overlay0); - border-radius: var(--radius-full); + border-radius: var(--radius-md); transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); } @@ -149,7 +149,7 @@ border: none !important; background: transparent !important; box-shadow: none !important; - border-radius: var(--radius-full) !important; + border-radius: var(--radius-md) !important; flex: 1; min-width: 0; } @@ -157,6 +157,9 @@ .live-search-field:focus { border-color: transparent !important; box-shadow: none !important; + /* The cluster shows the focus ring (:focus-within); suppress the input's own + ring so there isn't a second inner pill inside it. */ + outline: none; } .live-search-adv-btn { diff --git a/src/styles/components/orbit-session-top-strip.css b/src/styles/components/orbit-session-top-strip.css index 88d2c319..3a07f5d5 100644 --- a/src/styles/components/orbit-session-top-strip.css +++ b/src/styles/components/orbit-session-top-strip.css @@ -1735,10 +1735,7 @@ background: var(--bg-hover, var(--bg-card)); border-color: var(--accent, var(--border-subtle)); } -.because-card:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} +/* Focus ring handled centrally in card.css (lifted above the cover). */ .because-card-cover-wrap { position: relative; flex: 0 0 auto; diff --git a/src/styles/components/playback-delay-sleep-delayed-start-modal.css b/src/styles/components/playback-delay-sleep-delayed-start-modal.css index c2a602d1..e307f337 100644 --- a/src/styles/components/playback-delay-sleep-delayed-start-modal.css +++ b/src/styles/components/playback-delay-sleep-delayed-start-modal.css @@ -60,8 +60,8 @@ } .playback-delay-modal > .modal-close:focus-visible { - outline: 2px solid color-mix(in srgb, var(--pd-accent, var(--accent)) 60%, transparent); - outline-offset: 2px; + outline: var(--focus-ring-width) solid color-mix(in srgb, var(--pd-accent, var(--accent)) 60%, transparent); + outline-offset: var(--focus-ring-offset); } .playback-delay-modal > .modal-close:active { diff --git a/src/styles/components/tracklist.css b/src/styles/components/tracklist.css index 1f7d06be..abc93b53 100644 --- a/src/styles/components/tracklist.css +++ b/src/styles/components/tracklist.css @@ -135,15 +135,15 @@ } .tracklist-col-picker-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; + /* Positioned inline (fixed) and portalled to so no ancestor overflow + can clip it; see TracklistColumnPicker. */ background: var(--bg-card); border: 1px solid var(--ctp-surface1); border-radius: var(--radius-md); padding: 6px; min-width: 160px; - z-index: 200; + max-height: calc(100vh - 16px); + overflow-y: auto; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); } diff --git a/src/styles/themes/card.css b/src/styles/themes/card.css index 6002270b..93065273 100644 --- a/src/styles/themes/card.css +++ b/src/styles/themes/card.css @@ -1,5 +1,6 @@ /* ─── Card ─── */ .card { + position: relative; background: var(--bg-card); border-radius: var(--radius-lg); overflow: hidden; @@ -11,3 +12,32 @@ box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md); } +/* Card focus ring. A card's cover forms its own stacking context (transform/ + contain for render stability), so the inset global :focus-visible outline gets + painted UNDER the cover. Cards instead draw the ring as a lifted overlay above + the cover, using the same --focus-ring-* knob as everything else. */ +.card:focus-visible, +.because-card:focus-visible { + outline: none; +} + +.card:focus-visible::after, +.because-card:focus-visible::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 var(--focus-ring-width) var(--focus-ring-color); + z-index: 10; + pointer-events: none; +} + +@media (forced-colors: active) { + .card:focus-visible::after, + .because-card:focus-visible::after { + box-shadow: none; + outline: var(--focus-ring-width) solid Highlight; + outline-offset: calc(-1 * var(--focus-ring-width)); + } +} + diff --git a/src/styles/themes/dracula-component-level-overrides.css b/src/styles/themes/dracula-component-level-overrides.css index b6a263c7..85b1dce1 100644 --- a/src/styles/themes/dracula-component-level-overrides.css +++ b/src/styles/themes/dracula-component-level-overrides.css @@ -7,10 +7,10 @@ width: 2px; } -/* Focus ring — Dracula Purple for keyboard nav */ -[data-theme='dracula'] *:focus-visible { - outline: 2px solid #bd93f9; - outline-offset: 2px; +/* Focus ring — Dracula only tunes the colour; width/offset/placement come from + the shared :focus-visible rule and card focus ring (all var-driven). */ +[data-theme='dracula'] { + --focus-ring-color: #bd93f9; } /* Scrollbar thumb — Comment colour (Dracula-authentic) */ diff --git a/src/styles/themes/focus.css b/src/styles/themes/focus.css index 752adada..01315349 100644 --- a/src/styles/themes/focus.css +++ b/src/styles/themes/focus.css @@ -1,7 +1,17 @@ /* ─── Focus ─── */ -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: var(--radius-sm); +/* Single source of truth for keyboard focus rings. Inset (negative offset) so the + ring is drawn INSIDE the element and is never clipped by an ancestor's + overflow:hidden / scroll container (cards, rails, player bar, queue strip). + Outline (not box-shadow) keeps it visible in forced-colors / high-contrast mode. + The outline follows each element's own border-radius natively. */ +:root { + --focus-ring-width: 2px; + --focus-ring-color: var(--accent); + --focus-ring-offset: -2px; +} + +:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); } diff --git a/src/utils/useTracklistColumns.ts b/src/utils/useTracklistColumns.ts index 0e5b372c..292f7a0b 100644 --- a/src/utils/useTracklistColumns.ts +++ b/src/utils/useTracklistColumns.ts @@ -183,14 +183,9 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri localStorage.removeItem(storageKey); }, [columns, storageKey]); - useEffect(() => { - if (!pickerOpen) return; - const handler = (e: MouseEvent) => { - if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [pickerOpen]); + // Note: outside-click / Escape close is handled inside TracklistColumnPicker, + // because its menu is portalled out of `pickerRef` and a wrapper-only check + // would close it on every in-menu click. return { colWidths,