refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
// Shared MATCH / segmented primitive — a rounded-full pill track where exactly ONE option is active
|
||||
// (radio-like). Backs the feed any/all + or/and "Match" toggles and the feed Mine/Library scope bar,
|
||||
// and the Plex any/all combine toggles. The active paint comes entirely from the
|
||||
// `.pill[aria-pressed="true"]` recipe (index.css) — do NOT add bg/text/border-colour utilities on the
|
||||
// buttons here or a utility-layer class would beat the pressed fill.
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
title?: string; // optional per-option tooltip
|
||||
}
|
||||
|
||||
export default function SegmentedToggle<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
title,
|
||||
fullWidth = false,
|
||||
size = "sm",
|
||||
className,
|
||||
}: {
|
||||
options: readonly SegmentedOption<T>[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
ariaLabel: string;
|
||||
// Optional left-hand caption; when set the control renders as a justify-between row (the feed/Plex
|
||||
// "Match" toggles). Omit for a standalone bar (the Mine/Library scope switch).
|
||||
label?: ReactNode;
|
||||
title?: string; // tooltip on the whole row (caption mode only)
|
||||
// Full-width track with flex-1 buttons (the scope bar); default is intrinsic width.
|
||||
fullWidth?: boolean;
|
||||
// sm = text-3xs, compact padding (the captioned match toggles); md = text-xs (the scope bar).
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}) {
|
||||
const track = (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border border-border bg-card p-0.5 ${
|
||||
size === "md" ? "text-xs" : "text-3xs"
|
||||
}${fullWidth ? " w-full" : ""}`}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
title={o.title}
|
||||
aria-pressed={value === o.value}
|
||||
className={`pill inline-flex items-center justify-center gap-1 rounded-full ${
|
||||
size === "md" ? "px-2.5 py-1" : "px-2 py-0.5"
|
||||
}${fullWidth ? " flex-1" : ""}`}
|
||||
>
|
||||
{o.icon}
|
||||
<span>{o.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (label == null) {
|
||||
return className ? <div className={className}>{track}</div> : track;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center justify-between ${className ?? "mb-1.5"}`} title={title}>
|
||||
<span className="text-3xs uppercase tracking-wide text-muted">{label}</span>
|
||||
{track}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user