Files
siftlode/frontend/src/components/ui/DragHandle.tsx
T
peter 86f467bd65 feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
2026-09-24 05:25:55 +02:00

41 lines
1.6 KiB
TypeScript

import { forwardRef } from "react";
import type { ButtonHTMLAttributes } from "react";
import clsx from "clsx";
import { GripVertical } from "lucide-react";
// The grip a sortable row is dragged by. The caller spreads its sortable `attributes` and `listeners`
// onto it. `label` names the row it moves: a screen reader that hears a bare "reorder" on every row
// cannot tell which one it is on.
//
// Unavailable (under a sort or a filter, say) it stays focusable, keeps its tooltip and lets the label
// say why: native `disabled` would drop it from the tab order and suppress the tooltip, so the reason
// would reach nobody. The caller also leaves its `listeners` off, so nothing can start a drag.
export const DragHandle = forwardRef<
HTMLButtonElement,
Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "disabled"> & {
label: string;
unavailable?: boolean;
}
>(function DragHandle({ label, unavailable = false, className, onClick, ...rest }, ref) {
return (
<button
ref={ref}
type="button"
{...rest}
aria-label={label}
title={label}
aria-disabled={unavailable || undefined}
onClick={(e) => (unavailable ? e.preventDefault() : onClick?.(e))}
className={clsx(
"inline-grid size-7 shrink-0 touch-none place-items-center rounded-lg text-muted transition touch-target [&>svg]:size-4",
unavailable
? "cursor-not-allowed opacity-30"
: "cursor-grab state-layer hover:text-fg active:cursor-grabbing",
className,
)}
>
<GripVertical />
</button>
);
});