mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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)
This commit is contained in:
committed by
GitHub
parent
cc04a0c93d
commit
40932d28e2
@@ -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.
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
return (
|
||||
<TracklistColumnPicker
|
||||
allColumns={COLUMNS}
|
||||
pickerRef={pickerRef}
|
||||
pickerOpen={open}
|
||||
setPickerOpen={setOpen}
|
||||
colVisible={new Set(['title', 'artist', 'album'])}
|
||||
toggleColumn={toggleColumn}
|
||||
resetColumns={resetColumns}
|
||||
t={passthroughT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TracklistColumnPicker', () => {
|
||||
it('opens the menu from the trigger and lists only non-required columns', async () => {
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
// 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(<Harness initialOpen toggleColumn={toggleColumn} />);
|
||||
|
||||
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(<Harness initialOpen resetColumns={resetColumns} />);
|
||||
|
||||
await userEvent.click(screen.getByText('albumDetail.resetColumns'));
|
||||
|
||||
expect(resetColumns).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
renderWithProviders(<Harness initialOpen />);
|
||||
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(<Harness initialOpen />);
|
||||
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(<Harness initialOpen />);
|
||||
|
||||
fireEvent.mouseDown(screen.getByText('albumDetail.artist'));
|
||||
|
||||
expect(screen.getByText('albumDetail.columns')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
// Start off-screen until measured so the portal never flashes at 0,0.
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({
|
||||
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 (
|
||||
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
|
||||
<div className="tracklist-col-picker">
|
||||
<button
|
||||
ref={btnRef}
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
{pickerOpen && createPortal(
|
||||
<div className="tracklist-col-picker-menu" ref={popRef} style={menuStyle}>
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{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({
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -135,15 +135,15 @@
|
||||
}
|
||||
|
||||
.tracklist-col-picker-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
/* Positioned inline (fixed) and portalled to <body> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user