- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title and hint (a label pointed at the switch). - F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge); labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows. - F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP) refuses the swap, contract-tested. - F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>. - F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the Config tests say why while dirty (testDirty, HU/EN). - F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle, Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest. - F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips. - F15: the "+N" / "More..." open state worded as it renders.
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
import { type ReactNode, type CSSProperties } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { useSortableItem } from "./sortableA11y";
|
|
import { DragHandle } from "./DragHandle";
|
|
import { IconButton } from "./IconButton";
|
|
import { Pressable } from "./Pressable";
|
|
import { SURFACE } from "./surfaces";
|
|
|
|
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
|
|
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
|
|
// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
|
|
// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
|
|
// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
|
|
export default function PanelGroup({
|
|
id,
|
|
title,
|
|
editing,
|
|
collapsible = true,
|
|
collapsed,
|
|
hidden,
|
|
onToggleCollapse,
|
|
onToggleHidden,
|
|
onTitleClick,
|
|
titleActionLabel,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
title: string;
|
|
editing: boolean;
|
|
collapsible?: boolean;
|
|
collapsed: boolean;
|
|
hidden: boolean;
|
|
onToggleCollapse: () => void;
|
|
onToggleHidden: () => void;
|
|
onTitleClick?: () => void;
|
|
titleActionLabel?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
|
id,
|
|
disabled: !editing,
|
|
});
|
|
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
|
// Non-collapsible groups are always open (outside edit mode); collapsible ones honour `collapsed`.
|
|
const showBody = !editing && (!collapsible || !collapsed);
|
|
// A non-collapsible group's title acts as a trigger when one is provided.
|
|
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
|
|
|
|
// min-h-6 + touch-target: when the title is a control (a collapse, a trigger) it is a 24 / 44 target.
|
|
const titleCls =
|
|
"flex-1 min-w-0 min-h-6 flex items-center gap-1 select-none text-sm uppercase tracking-wide touch-target";
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`${SURFACE.card} ${
|
|
isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : ""
|
|
} ${hidden && editing ? "opacity-50" : ""}`}
|
|
>
|
|
<div className="flex items-center gap-1.5 px-3 py-2">
|
|
{editing && (
|
|
<DragHandle
|
|
{...attributes}
|
|
{...listeners}
|
|
label={t("sidebar.dragToReorder")}
|
|
className="-ml-1"
|
|
/>
|
|
)}
|
|
{collapsible && !editing ? (
|
|
<Pressable
|
|
onClick={onToggleCollapse}
|
|
aria-expanded={!collapsed}
|
|
className={`${titleCls} text-muted hover:text-fg transition`}
|
|
>
|
|
<span className="truncate">{title}</span>
|
|
</Pressable>
|
|
) : titleIsTrigger ? (
|
|
<Pressable
|
|
onClick={onTitleClick}
|
|
title={titleActionLabel}
|
|
className={`${titleCls} group text-muted hover:text-accent transition`}
|
|
>
|
|
<span className="truncate">{title}</span>
|
|
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
|
</Pressable>
|
|
) : (
|
|
<span className={`${titleCls} text-muted`}>
|
|
<span className="truncate">{title}</span>
|
|
</span>
|
|
)}
|
|
{editing ? (
|
|
<IconButton
|
|
onClick={onToggleHidden}
|
|
label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
|
>
|
|
{hidden ? <EyeOff /> : <Eye />}
|
|
</IconButton>
|
|
) : (
|
|
collapsible && (
|
|
<IconButton
|
|
onClick={onToggleCollapse}
|
|
aria-expanded={!collapsed}
|
|
label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
|
>
|
|
<ChevronDown
|
|
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
|
/>
|
|
</IconButton>
|
|
)
|
|
)}
|
|
</div>
|
|
{showBody && <div className="px-3 pb-3">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|