refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, closeThenAct, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
|
||||
@@ -10,7 +10,8 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
import { PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
|
||||
@@ -1,45 +1,22 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset as offsetMiddleware,
|
||||
flip,
|
||||
shift,
|
||||
size,
|
||||
hide,
|
||||
useClick,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useListNavigation,
|
||||
useTypeahead,
|
||||
useInteractions,
|
||||
useFloatingNodeId,
|
||||
useListItem,
|
||||
safePolygon,
|
||||
FloatingNode,
|
||||
FloatingList,
|
||||
FloatingPortal,
|
||||
FloatingFocusManager,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
|
||||
import type { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||
@@ -79,280 +56,12 @@ import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
|
||||
// target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference")
|
||||
// and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input
|
||||
// field). The universal <Combobox> component wraps all of this — call sites use it, not this mode raw.
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
offset?: number;
|
||||
/** Hover mode only: open/close intent delays, ms. */
|
||||
hoverDelay?: number | { open?: number; close?: number };
|
||||
/** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
|
||||
role?: UseRoleProps["role"];
|
||||
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
|
||||
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
|
||||
fitHeight?: boolean;
|
||||
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
|
||||
* contain a free-text input, where character keys belong to the text field. */
|
||||
typeahead?: boolean;
|
||||
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
|
||||
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
|
||||
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
|
||||
hostsInput?: boolean;
|
||||
/** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom —
|
||||
* a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and
|
||||
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
||||
* hair before the click handler toggles it shut again. */
|
||||
hoverAlso?: boolean;
|
||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
selectedIndex?: number | null;
|
||||
/** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored
|
||||
* to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point
|
||||
* rather than an element — a right-click menu, which otherwise stays parked over whatever row
|
||||
* scrolls under it while its actions still target the row you opened it on (E10 review round 1).
|
||||
* This is also what a native context menu does. */
|
||||
dismissOnScroll?: boolean;
|
||||
}
|
||||
//
|
||||
// The hook itself (`useAnchoredPopover`) and `closeThenAct` live in anchoredPopoverHooks.ts.
|
||||
|
||||
// Internal: call sites use the inferred hook return, they don't name the type.
|
||||
type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
const isCombobox = mode === "combobox";
|
||||
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
|
||||
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
|
||||
const isNav = mode === "menu" || mode === "listbox";
|
||||
// Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND
|
||||
// the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept
|
||||
// out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here.
|
||||
const roves = isNav || isCombobox;
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
|
||||
const elementsRef = useRef<Array<HTMLElement | null>>([]);
|
||||
const labelsRef = useRef<Array<string | null>>([]);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
// Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation
|
||||
// syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input
|
||||
// (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never
|
||||
// follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that
|
||||
// tracks the pointer when hovering and the keyboard otherwise.
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
|
||||
nodeId,
|
||||
open: opts.open,
|
||||
onOpenChange: opts.onOpenChange,
|
||||
placement: opts.placement ?? "bottom-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offsetMiddleware(opts.offset ?? 4),
|
||||
flip({ padding: 8 }),
|
||||
shift({ padding: 8 }),
|
||||
...(opts.fitHeight
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
// Cap the positioned element, AND publish the available space as a custom property so
|
||||
// the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on
|
||||
// the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))`
|
||||
// keeps a caller's design cap while still shrinking to fit at high zoom / short viewports.
|
||||
// Capping only the outer element would leave the inner overflow-y-auto box at its fixed
|
||||
// height, spilling its top off-screen (the whole point of fitHeight).
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner
|
||||
// scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input
|
||||
// field box (the caller mounts it via setPositionReference), not the bare input.
|
||||
...(isCombobox
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
hide({ padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
// A stale active item would highlight (and steal the next open's initial focus) the second time a
|
||||
// menu opens; clear it whenever the surface closes.
|
||||
useEffect(() => {
|
||||
if (!opts.open) {
|
||||
setActiveIndex(null);
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}, [opts.open]);
|
||||
|
||||
// A combobox opens from the field's own focus/typing (wired by <Combobox>), never a click-to-toggle —
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover || !!opts.hoverAlso,
|
||||
move: false,
|
||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
||||
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
||||
mouseOnly: !isHover,
|
||||
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
||||
// menu, so the "did they mean it" question is mostly answered.
|
||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const focus = useFocus(context, { enabled: isHover });
|
||||
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
|
||||
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
|
||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, {
|
||||
escapeKey: false,
|
||||
ancestorScroll: opts.dismissOnScroll ?? false,
|
||||
});
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
// `listbox` aria-role internally.) So <Combobox> sets NO aria-* by hand.
|
||||
const role = useRole(context, {
|
||||
role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"),
|
||||
});
|
||||
const listNav = useListNavigation(context, {
|
||||
enabled: roves,
|
||||
// Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an
|
||||
// `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the
|
||||
// input). A menu/listbox uses real roving focus instead.
|
||||
virtual: isCombobox,
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
|
||||
// a stale pointer-hover position.
|
||||
onNavigate: (i) => {
|
||||
setActiveIndex(i);
|
||||
setHoverIndex(null);
|
||||
},
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
|
||||
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a
|
||||
// pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
|
||||
// `isListIndexDisabled`.) No explicit index list needed.
|
||||
loop: true,
|
||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all). A
|
||||
// combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
|
||||
// option; auto-highlighting item 0 on open would fight the typed query.
|
||||
focusItemOnOpen: isCombobox ? false : true,
|
||||
// A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
|
||||
// the pointer merely passes over it (the library default) — that blurs the input mid-typing and
|
||||
// mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex
|
||||
// instead (see PopoverItem), so the pointer still highlights without stealing the caret.
|
||||
focusItemOnHover: isCombobox ? false : !opts.hostsInput,
|
||||
});
|
||||
const typeahead = useTypeahead(context, {
|
||||
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
|
||||
listRef: labelsRef,
|
||||
activeIndex,
|
||||
onMatch: (i) => {
|
||||
setActiveIndex(i);
|
||||
setHoverIndex(null);
|
||||
},
|
||||
});
|
||||
|
||||
// PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's
|
||||
// list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's
|
||||
// channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a
|
||||
// menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has
|
||||
// already switched to keyboard modality — which is what makes it scroll the new row into view. A
|
||||
// nested menu's surface bubbles through its parent's in the React tree; the inner one handles the
|
||||
// key first and marks it, so the outer one stands down.
|
||||
const onPageKey = (e: React.KeyboardEvent) => {
|
||||
if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return;
|
||||
const rows = elementsRef.current;
|
||||
const live = rows.filter((el): el is HTMLElement => el != null);
|
||||
// No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning.
|
||||
if (!live.length) return;
|
||||
e.preventDefault();
|
||||
const step = pageStep(scrollParent(live[0]!), live);
|
||||
const to = pageTarget(
|
||||
activeIndex ?? 0,
|
||||
e.key === "PageDown" ? step : -step,
|
||||
rows.length,
|
||||
(i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"),
|
||||
);
|
||||
if (to === null) return;
|
||||
setActiveIndex(to);
|
||||
setHoverIndex(null);
|
||||
};
|
||||
const pageKeyRef = useRef(onPageKey);
|
||||
pageKeyRef.current = onPageKey;
|
||||
const paging = useMemo(() => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e);
|
||||
return { reference: { onKeyDown }, floating: { onKeyDown } };
|
||||
}, []);
|
||||
|
||||
const interactions = useInteractions([
|
||||
click,
|
||||
hover,
|
||||
focus,
|
||||
dismiss,
|
||||
role,
|
||||
listNav,
|
||||
typeahead,
|
||||
paging,
|
||||
]);
|
||||
|
||||
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
|
||||
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
|
||||
const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
|
||||
|
||||
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
|
||||
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
|
||||
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
|
||||
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
|
||||
// an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer:
|
||||
// it does not own the keyboard, so the page's own keys keep working under a resting pointer.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover });
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
isPositioned,
|
||||
referenceHidden,
|
||||
elementsRef,
|
||||
labelsRef,
|
||||
activeIndex,
|
||||
highlightIndex: hoverIndex ?? activeIndex,
|
||||
setHoverIndex,
|
||||
...interactions,
|
||||
};
|
||||
}
|
||||
|
||||
// Carries the active index + item-props getter down to <PopoverItem>. FloatingList (in PopoverSurface)
|
||||
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
|
||||
interface PopoverListContextValue {
|
||||
@@ -394,18 +103,6 @@ const HIGHLIGHT_SELECTED =
|
||||
const HIGHLIGHT_SELECTED_RING_ONLY =
|
||||
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
|
||||
|
||||
/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it
|
||||
* has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as
|
||||
* the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in
|
||||
* Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
|
||||
* 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that
|
||||
* microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element
|
||||
* to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */
|
||||
export function closeThenAct(close: () => void, act: () => void): void {
|
||||
close();
|
||||
setTimeout(act, 0);
|
||||
}
|
||||
|
||||
export function PopoverItem({
|
||||
children,
|
||||
onSelect,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
|
||||
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
|
||||
import Pager, { hasPagerContent } from "./Pager";
|
||||
import Pager from "./Pager";
|
||||
import { hasPagerContent } from "./pagerContent";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { ColumnHead, alignClass } from "./tableHeader";
|
||||
import { ColumnHead } from "./tableHeader";
|
||||
import { alignClass } from "./tableAlign";
|
||||
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
|
||||
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
|
||||
|
||||
@@ -7,7 +7,8 @@ import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
import { trapTab, useDialogChrome } from "./dialogFocus";
|
||||
import { Button } from "./Button";
|
||||
import Toaster, { useOverlayOrder } from "./Toaster";
|
||||
import Toaster from "./Toaster";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
|
||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
@@ -11,12 +10,7 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import PageScroller from "./PageScroller";
|
||||
|
||||
// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its
|
||||
// fixed band as the default target; a module can override it with its own <PageToolbarSlot.Provider>
|
||||
// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the
|
||||
// toolbar rides with the tabs instead of the shell's top band.
|
||||
export const PageToolbarSlot = createContext<HTMLElement | null>(null);
|
||||
import { PageToolbarSlot } from "./pageToolbarSlot";
|
||||
|
||||
// A module renders its per-module fixed band (filter chips / sort / counts) through this. The
|
||||
// content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { hasPagerContent, pageCount } from "./pagerContent";
|
||||
|
||||
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
|
||||
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
|
||||
// size and slices its own rows; this only renders the controls and reports changes. `sizeLabel` lets
|
||||
// the caller say "Rows per page" or "Cards per page". Returns null when there's nothing to page.
|
||||
|
||||
/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */
|
||||
function pageCount(total: number, size: number): number {
|
||||
return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size));
|
||||
}
|
||||
|
||||
/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager
|
||||
* in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what
|
||||
* left empty control bands behind on tables with nothing to page. */
|
||||
export function hasPagerContent(total: number, size: number): boolean {
|
||||
return pageCount(total, size) > 1 || total > 0;
|
||||
}
|
||||
export default function Pager({
|
||||
page,
|
||||
size,
|
||||
|
||||
@@ -33,13 +33,13 @@ domain-free.
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster`, `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader`, `Pager`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
| area | components |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
|
||||
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
|
||||
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { useAccountPersistedState } from "../../lib/storage";
|
||||
import CountBadge from "./CountBadge";
|
||||
|
||||
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
||||
@@ -7,8 +6,8 @@ import CountBadge from "./CountBadge";
|
||||
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
|
||||
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
||||
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
||||
// it is a horizontal row that scrolls. The active tab is persisted to localStorage so a reload keeps
|
||||
// the user where they were.
|
||||
// it is a horizontal row that scrolls. Callers persist the active tab with lib/storage's
|
||||
// useAccountPersistedState (account-scoped), so a reload keeps the user where they were.
|
||||
|
||||
export interface TabDef {
|
||||
id: string;
|
||||
@@ -18,10 +17,6 @@ export interface TabDef {
|
||||
testId?: string; // optional data-testid, to preserve existing e2e hooks
|
||||
}
|
||||
|
||||
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
|
||||
* carry into another in the same browser) — re-exported under its original name for call sites. */
|
||||
export const usePersistedTab = useAccountPersistedState;
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
active,
|
||||
|
||||
@@ -21,7 +21,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import Toaster, { useOverlayOrder } from "./Toaster";
|
||||
import Toaster from "./Toaster";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications";
|
||||
import { X } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
||||
import { createStore } from "../../lib/store";
|
||||
|
||||
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
|
||||
info: { icon: Info, color: "text-info", bar: "bg-info" },
|
||||
success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" },
|
||||
warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" },
|
||||
error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" },
|
||||
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
|
||||
};
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { LEVEL_STYLE } from "./toastLevels";
|
||||
|
||||
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
|
||||
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
|
||||
@@ -25,14 +19,7 @@ export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string;
|
||||
// PlayerModal's moves into the fullscreen stage and back — and ranked by mount time it came back on
|
||||
// top of a dialog opened over the player (the download dialog after leaving true fullscreen), so the
|
||||
// toasts were drawn under that dialog's scrim again (deep-review round 4). So the OVERLAY takes its
|
||||
// rank once, when it opens (`useOverlayOrder`), and hands it to every host it renders.
|
||||
let overlaySeq = 0;
|
||||
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
|
||||
* itself, never in a component that can remount while the overlay stays open. */
|
||||
export function useOverlayOrder(): number {
|
||||
const [order] = useState(() => ++overlaySeq);
|
||||
return order;
|
||||
}
|
||||
// rank once, when it opens (`useOverlayOrder`, in overlayOrder.ts), and hands it to every host it renders.
|
||||
type Host = { id: string; order: number };
|
||||
const overlayHosts = createStore<readonly Host[]>([]);
|
||||
const topHost = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { hintsEnabled, subscribeHints } from "../../lib/hints";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset as offsetMiddleware,
|
||||
flip,
|
||||
shift,
|
||||
size,
|
||||
hide,
|
||||
useClick,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useListNavigation,
|
||||
useTypeahead,
|
||||
useInteractions,
|
||||
useFloatingNodeId,
|
||||
safePolygon,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
|
||||
|
||||
// The non-component half of the R8 popover platform: the `useAnchoredPopover` engine hook and the
|
||||
// `closeThenAct` row helper. The surfaces it drives (PopoverSurface / PopoverItem) and the platform's
|
||||
// design notes live in AnchoredPopover.tsx; the split keeps that file components-only (Fast Refresh).
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
offset?: number;
|
||||
/** Hover mode only: open/close intent delays, ms. */
|
||||
hoverDelay?: number | { open?: number; close?: number };
|
||||
/** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
|
||||
role?: UseRoleProps["role"];
|
||||
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
|
||||
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
|
||||
fitHeight?: boolean;
|
||||
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
|
||||
* contain a free-text input, where character keys belong to the text field. */
|
||||
typeahead?: boolean;
|
||||
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
|
||||
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
|
||||
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
|
||||
hostsInput?: boolean;
|
||||
/** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom —
|
||||
* a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and
|
||||
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
||||
* hair before the click handler toggles it shut again. */
|
||||
hoverAlso?: boolean;
|
||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
selectedIndex?: number | null;
|
||||
/** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored
|
||||
* to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point
|
||||
* rather than an element — a right-click menu, which otherwise stays parked over whatever row
|
||||
* scrolls under it while its actions still target the row you opened it on (E10 review round 1).
|
||||
* This is also what a native context menu does. */
|
||||
dismissOnScroll?: boolean;
|
||||
}
|
||||
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
const isCombobox = mode === "combobox";
|
||||
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
|
||||
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
|
||||
const isNav = mode === "menu" || mode === "listbox";
|
||||
// Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND
|
||||
// the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept
|
||||
// out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here.
|
||||
const roves = isNav || isCombobox;
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
|
||||
const elementsRef = useRef<Array<HTMLElement | null>>([]);
|
||||
const labelsRef = useRef<Array<string | null>>([]);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
// Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation
|
||||
// syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input
|
||||
// (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never
|
||||
// follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that
|
||||
// tracks the pointer when hovering and the keyboard otherwise.
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
|
||||
nodeId,
|
||||
open: opts.open,
|
||||
onOpenChange: opts.onOpenChange,
|
||||
placement: opts.placement ?? "bottom-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offsetMiddleware(opts.offset ?? 4),
|
||||
flip({ padding: 8 }),
|
||||
shift({ padding: 8 }),
|
||||
...(opts.fitHeight
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
// Cap the positioned element, AND publish the available space as a custom property so
|
||||
// the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on
|
||||
// the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))`
|
||||
// keeps a caller's design cap while still shrinking to fit at high zoom / short viewports.
|
||||
// Capping only the outer element would leave the inner overflow-y-auto box at its fixed
|
||||
// height, spilling its top off-screen (the whole point of fitHeight).
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner
|
||||
// scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input
|
||||
// field box (the caller mounts it via setPositionReference), not the bare input.
|
||||
...(isCombobox
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
hide({ padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
// A stale active item would highlight (and steal the next open's initial focus) the second time a
|
||||
// menu opens; clear it whenever the surface closes.
|
||||
useEffect(() => {
|
||||
if (!opts.open) {
|
||||
setActiveIndex(null);
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}, [opts.open]);
|
||||
|
||||
// A combobox opens from the field's own focus/typing (wired by <Combobox>), never a click-to-toggle —
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover || !!opts.hoverAlso,
|
||||
move: false,
|
||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
||||
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
||||
mouseOnly: !isHover,
|
||||
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
||||
// menu, so the "did they mean it" question is mostly answered.
|
||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const focus = useFocus(context, { enabled: isHover });
|
||||
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
|
||||
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
|
||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, {
|
||||
escapeKey: false,
|
||||
ancestorScroll: opts.dismissOnScroll ?? false,
|
||||
});
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
// `listbox` aria-role internally.) So <Combobox> sets NO aria-* by hand.
|
||||
const role = useRole(context, {
|
||||
role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"),
|
||||
});
|
||||
const listNav = useListNavigation(context, {
|
||||
enabled: roves,
|
||||
// Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an
|
||||
// `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the
|
||||
// input). A menu/listbox uses real roving focus instead.
|
||||
virtual: isCombobox,
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
|
||||
// a stale pointer-hover position.
|
||||
onNavigate: (i) => {
|
||||
setActiveIndex(i);
|
||||
setHoverIndex(null);
|
||||
},
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
|
||||
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a
|
||||
// pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
|
||||
// `isListIndexDisabled`.) No explicit index list needed.
|
||||
loop: true,
|
||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all). A
|
||||
// combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
|
||||
// option; auto-highlighting item 0 on open would fight the typed query.
|
||||
focusItemOnOpen: isCombobox ? false : true,
|
||||
// A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
|
||||
// the pointer merely passes over it (the library default) — that blurs the input mid-typing and
|
||||
// mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex
|
||||
// instead (see PopoverItem), so the pointer still highlights without stealing the caret.
|
||||
focusItemOnHover: isCombobox ? false : !opts.hostsInput,
|
||||
});
|
||||
const typeahead = useTypeahead(context, {
|
||||
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
|
||||
listRef: labelsRef,
|
||||
activeIndex,
|
||||
onMatch: (i) => {
|
||||
setActiveIndex(i);
|
||||
setHoverIndex(null);
|
||||
},
|
||||
});
|
||||
|
||||
// PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's
|
||||
// list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's
|
||||
// channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a
|
||||
// menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has
|
||||
// already switched to keyboard modality — which is what makes it scroll the new row into view. A
|
||||
// nested menu's surface bubbles through its parent's in the React tree; the inner one handles the
|
||||
// key first and marks it, so the outer one stands down.
|
||||
const onPageKey = (e: React.KeyboardEvent) => {
|
||||
if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return;
|
||||
const rows = elementsRef.current;
|
||||
const live = rows.filter((el): el is HTMLElement => el != null);
|
||||
// No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning.
|
||||
if (!live.length) return;
|
||||
e.preventDefault();
|
||||
const step = pageStep(scrollParent(live[0]!), live);
|
||||
const to = pageTarget(
|
||||
activeIndex ?? 0,
|
||||
e.key === "PageDown" ? step : -step,
|
||||
rows.length,
|
||||
(i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"),
|
||||
);
|
||||
if (to === null) return;
|
||||
setActiveIndex(to);
|
||||
setHoverIndex(null);
|
||||
};
|
||||
const pageKeyRef = useRef(onPageKey);
|
||||
pageKeyRef.current = onPageKey;
|
||||
const paging = useMemo(() => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e);
|
||||
return { reference: { onKeyDown }, floating: { onKeyDown } };
|
||||
}, []);
|
||||
|
||||
const interactions = useInteractions([
|
||||
click,
|
||||
hover,
|
||||
focus,
|
||||
dismiss,
|
||||
role,
|
||||
listNav,
|
||||
typeahead,
|
||||
paging,
|
||||
]);
|
||||
|
||||
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
|
||||
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
|
||||
const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
|
||||
|
||||
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
|
||||
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
|
||||
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
|
||||
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
|
||||
// an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer:
|
||||
// it does not own the keyboard, so the page's own keys keep working under a resting pointer.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover });
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
isPositioned,
|
||||
referenceHidden,
|
||||
elementsRef,
|
||||
labelsRef,
|
||||
activeIndex,
|
||||
highlightIndex: hoverIndex ?? activeIndex,
|
||||
setHoverIndex,
|
||||
...interactions,
|
||||
};
|
||||
}
|
||||
|
||||
/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it
|
||||
* has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as
|
||||
* the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in
|
||||
* Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
|
||||
* 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that
|
||||
* microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element
|
||||
* to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */
|
||||
export function closeThenAct(close: () => void, act: () => void): void {
|
||||
close();
|
||||
setTimeout(act, 0);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useState } from "react";
|
||||
|
||||
// An overlay's toast-host rank (see the overlay-hosts note in Toaster). Its own module so Toaster exports
|
||||
// only components (Fast Refresh); the counter is module state, so there is exactly one sequence.
|
||||
let overlaySeq = 0;
|
||||
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
|
||||
* itself, never in a component that can remount while the overlay stays open. */
|
||||
export function useOverlayOrder(): number {
|
||||
const [order] = useState(() => ++overlaySeq);
|
||||
return order;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its
|
||||
// fixed band as the default target; a module can override it with its own <PageToolbarSlot.Provider>
|
||||
// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the
|
||||
// toolbar rides with the tabs instead of the shell's top band. Its own module so PageShell exports only
|
||||
// components (Fast Refresh).
|
||||
export const PageToolbarSlot = createContext<HTMLElement | null>(null);
|
||||
@@ -0,0 +1,14 @@
|
||||
// The pager's page arithmetic, split out of Pager so that file exports only components (Fast Refresh)
|
||||
// and table chrome can gate on it without importing the control.
|
||||
|
||||
/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */
|
||||
export function pageCount(total: number, size: number): number {
|
||||
return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size));
|
||||
}
|
||||
|
||||
/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager
|
||||
* in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what
|
||||
* left empty control bands behind on tables with nothing to page. */
|
||||
export function hasPagerContent(total: number, size: number): boolean {
|
||||
return pageCount(total, size) > 1 || total > 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A column's `align` as its text-alignment class, shared by the header row (tableHeader) and the table
|
||||
// bodies that render under it. Its own module so tableHeader exports only components (Fast Refresh).
|
||||
export const alignClass = (a?: "left" | "right" | "center") =>
|
||||
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { type SortState } from "../../lib/columnSort";
|
||||
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
import { alignClass } from "./tableAlign";
|
||||
|
||||
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
|
||||
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
|
||||
@@ -17,9 +19,6 @@ import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
// playlist keep its stored order and still get real column headers. Same move as E4 S3's
|
||||
// ChannelLayoutGrid, which builds a card view straight from the `Column<T>` render closures.
|
||||
|
||||
export const alignClass = (a?: "left" | "right" | "center") =>
|
||||
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
|
||||
|
||||
// The multi-select filter's option list, capped and scrollable. Its own component because it owns a
|
||||
// hook — see the FilterPopover note below for why nesting either of these would remount them.
|
||||
function FilterOptionList({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
|
||||
import type { NotifLevel } from "../../lib/notifications";
|
||||
|
||||
// Each notification level's icon and colours, shared by the toasts and the notifications inbox. Its own
|
||||
// module so Toaster exports only components (Fast Refresh).
|
||||
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
|
||||
info: { icon: Info, color: "text-info", bar: "bg-info" },
|
||||
success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" },
|
||||
warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" },
|
||||
error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" },
|
||||
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
import { matchesShortcut, useKeyScope } from "../../lib/keymap";
|
||||
import { useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
|
||||
// E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they
|
||||
// belong to. The surface itself is `<ContextMenu>` (ContextMenu.tsx); they are two files only so
|
||||
|
||||
Reference in New Issue
Block a user