Files
Psychotoxical-psysonic/src/utils/useTracklistColumns.ts
T
Frank Stellmacher 40932d28e2 UI/CSS fixes: focus rings, search fields, column dropdown, theme accordion (#954)
* fix(focus): keyboard focus ring no longer clipped by overflow or cover

The global :focus-visible ring used a positive outline-offset, so it was
drawn outside the element and clipped by any ancestor with overflow:hidden
or a scroll container (cards, rails, player bar, queue strip). Draw it inset
instead via shared --focus-ring-* variables (single source). Cards draw the
ring as an overlay above the cover, since a cover's own stacking context
(transform/contain for render stability) would otherwise paint over an inset
outline. Dracula now only sets --focus-ring-color.

* refactor(focus): fold scattered focus-ring overrides onto shared knob

Six components declared their own :focus-visible outline (mostly an exact copy
of the old global ring). Remove the redundant ones so they inherit the global
inset ring; keep the custom-coloured ones (genre pill, playback-delay modal)
but source width/offset from --focus-ring-*; move the because-card ring to the
central card focus ring (it has a cover and needs the lifted treatment).

* fix(settings): round theme accordion inner box to match its section

The theme picker's inner accordion had square corners, so the first (open)
group header ran flush into the rounded Theme card. Give .theme-accordion a
border-radius + overflow:hidden so its top/bottom corners continue the parent
card's rounding, matching the other settings sections.

* fix(search): unify search fields to one look

Live-search, Help and Settings search differed (pill vs rounded-rect, glow vs
plain border-change, mismatched backgrounds). Align them on the canonical input
look: radius-md, ctp-base background, accent border + soft accent-dim focus
glow. Drop the live-search pill radius, and suppress the input's own inset ring
so only the outer cluster glow shows (no double ring).

* fix(tracklist): column picker menu no longer clipped on short lists

The column-visibility dropdown was an absolutely-positioned menu inside the
tracklist, so a short list (e.g. a one-song Favorites view) clipped it via the
ancestor's overflow box. Render the menu in a portal to <body> with fixed
positioning anchored to the trigger (flips above when there's no room below),
following on scroll/resize. Outside-click + Escape close now live in the shared
TracklistColumnPicker (the menu is portalled out of the wrapper, so the old
wrapper-only check would have closed it on every in-menu click). Fixes albums,
playlists and favorites in one shared place. Adds a behaviour test.

* docs(changelog): UI/CSS fixes pass (#954)
2026-06-02 21:43:09 +02:00

204 lines
7.6 KiB
TypeScript

import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
export interface ColDef {
readonly key: string;
readonly i18nKey?: string | null;
readonly minWidth: number;
readonly defaultWidth: number;
readonly required: boolean;
/** If true the column uses minmax(minWidth, 1fr) instead of a fixed px width. */
readonly flex?: boolean;
}
function loadPrefs(
storageKey: string,
columns: readonly ColDef[],
): { widths: Record<string, number>; visible: Set<string> } {
const defaultWidths: Record<string, number> = Object.fromEntries(
columns.map(c => [c.key, c.defaultWidth]),
);
const defaultVisible = new Set<string>(columns.map(c => c.key));
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[]; known?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).forEach(c => visible.add(c.key));
// Auto-show columns that are new since prefs were last saved.
// "known" tracks every column seen at save time; absent = newly added column → default to visible.
if (parsed.known) {
const known = new Set<string>(parsed.known);
columns.filter(c => !c.required && !known.has(c.key)).forEach(c => visible.add(c.key));
}
const widths = { ...defaultWidths, ...(parsed.widths ?? {}) };
const durationCol = columns.find(c => c.key === 'duration');
if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) {
widths.duration = defaultWidths.duration;
}
return { widths, visible };
} catch {
return { widths: defaultWidths, visible: defaultVisible };
}
}
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
const known = Object.keys(widths);
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible], known }));
}
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
const [colWidths, setColWidths] = useState<Record<string, number>>(
() => loadPrefs(storageKey, columns).widths,
);
const [colVisible, setColVisible] = useState<Set<string>>(
() => loadPrefs(storageKey, columns).visible,
);
const [pickerOpen, setPickerOpen] = useState(false);
const tracklistRef = useRef<HTMLDivElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
// Refs to avoid stale closures in drag/save handlers
const colWidthsRef = useRef(colWidths);
const colVisibleRef = useRef(colVisible);
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
const visibleCols = useMemo(
() => columns.filter(c => colVisible.has(c.key)),
[columns, colVisible],
);
const gridTemplate = useMemo(
() =>
visibleCols
.map(c => {
if (c.flex) return `minmax(${c.minWidth}px, 1fr)`;
// Defensive fallback: a column added since the last persist would have
// no saved width, leaving the grid template with `undefinedpx` and
// collapsing the row visually until the user resets defaults.
const w = colWidths[c.key];
return `${typeof w === 'number' && w > 0 ? w : c.defaultWidth}px`;
})
.join(' '),
[visibleCols, colWidths],
);
// Minimum total width so the grid never squishes below its current column sizes.
// When .tracklist is narrower, overflow-x: auto triggers a scrollbar.
// Formula (box-sizing: border-box): colSum + gaps + left/right padding (12px each = 24px)
const gridMinWidth = useMemo(() => {
const gapPx = 12; // --space-3
const boxPaddingH = 24; // var(--space-3) * 2
const colSum = visibleCols.reduce<number>(
(s, c) => {
if (c.flex) return s + c.minWidth;
const w = colWidths[c.key];
return s + (typeof w === 'number' && w > 0 ? w : c.defaultWidth);
},
0,
);
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
return colSum + gaps + boxPaddingH;
}, [visibleCols, colWidths]);
const gridStyle = useMemo(
() => ({ gridTemplateColumns: gridTemplate, minWidth: `${gridMinWidth}px` }),
[gridTemplate, gridMinWidth],
);
// Excel-style column resize:
// direction = 1 → right-edge handle: drag right → column grows, 1fr title shrinks
// direction = -1 → left-edge handle : drag right → next px col shrinks, 1fr title grows
const startResize = useCallback(
(e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => {
e.preventDefault();
e.stopPropagation();
const visCols = visibleCols; // stable for the drag duration
const colDef = visCols[colIndex];
const colKey = colDef.key;
const colMin = columns.find(c => c.key === colKey)!.minWidth;
const startX = e.clientX;
const startW = colWidths[colKey];
let maxW = Infinity;
const el = tracklistRef.current;
if (el) {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl
? parseFloat(getComputedStyle(headerEl).columnGap) || 12
: 12;
const totalGaps = (visCols.length - 1) * gapPx;
const otherFixed = visCols
.filter((_, i) => i !== colIndex)
.reduce<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
maxW = Math.max(colMin, containerW - totalGaps - otherFixed);
}
const onMove = (me: MouseEvent) => {
const delta = me.clientX - startX;
const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW);
setColWidths(prev => ({ ...prev, [colKey]: newW }));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[columns, visibleCols, colWidths, storageKey],
);
const toggleColumn = useCallback(
(key: string) => {
const def = columns.find(c => c.key === key)!;
if (def.required) return;
setColVisible(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
savePrefs(storageKey, colWidthsRef.current, next);
return next;
});
},
[columns, storageKey],
);
const resetColumns = useCallback(() => {
const defaultWidths = Object.fromEntries(columns.map(c => [c.key, c.defaultWidth]));
const defaultVisible = new Set(columns.map(c => c.key));
setColWidths(defaultWidths);
setColVisible(defaultVisible);
localStorage.removeItem(storageKey);
}, [columns, storageKey]);
// 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,
colVisible,
visibleCols,
gridStyle,
startResize,
toggleColumn,
resetColumns,
pickerOpen,
setPickerOpen,
pickerRef,
tracklistRef,
};
}