fix(playlists): UAT round 1 — drag lag, an invisible default sort, dialog layout
- The dragged row trailed the cursor by 150ms. The row carried Tailwind's bare `transition` utility (copied from DataTable's <tr>), which animates TRANSFORM — exactly the property dnd-kit drives from the pointer. transition-colors now, and the row being dragged drops dnd-kit's own transition too. - The list looked sorted by Channel when it was really in its stored order that happens to be channel-grouped, and nothing said so. Sorting by "#" ascending IS the stored order, so it is now the neutral state: the header shows a real arrow for it, cycling off any column lands back on it, and it stays the state where drag is enabled and no save is offered. - A choice dialog with two or more actions stacks vertically. Side by side they wrapped raggedly, and the wrapped one read as a lesser second group when the two are peers. Cancel goes last, actions run least to most destructive.
This commit is contained in:
@@ -84,6 +84,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|||||||
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
|
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
|
||||||
// "Clear audit log" without the user ever looking at it.
|
// "Clear audit log" without the user ever looking at it.
|
||||||
const anyDanger = choices.some((c) => c.danger);
|
const anyDanger = choices.some((c) => c.danger);
|
||||||
|
const multi = choices.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChoiceContext.Provider value={ask}>
|
<ChoiceContext.Provider value={ask}>
|
||||||
@@ -95,11 +96,22 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|||||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||||
<div className="flex justify-end gap-2 mt-5 flex-wrap">
|
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
||||||
|
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
||||||
|
these are peers. Ordered least to most destructive, top to bottom. */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
multi
|
||||||
|
? "flex flex-col gap-2 mt-5"
|
||||||
|
: "flex justify-end gap-2 mt-5 flex-wrap"
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
autoFocus={anyDanger}
|
autoFocus={anyDanger}
|
||||||
onClick={() => close(null)}
|
onClick={() => close(null)}
|
||||||
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
|
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
|
||||||
|
multi ? "w-full order-last" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
@@ -109,6 +121,8 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|||||||
autoFocus={!anyDanger && i === choices.length - 1}
|
autoFocus={!anyDanger && i === choices.length - 1}
|
||||||
onClick={() => close(c.id)}
|
onClick={() => close(c.id)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||||
|
multi ? "w-full" : ""
|
||||||
|
} ${
|
||||||
c.danger
|
c.danger
|
||||||
? "bg-red-500 text-white hover:bg-red-600"
|
? "bg-red-500 text-white hover:bg-red-600"
|
||||||
: "bg-accent text-accent-fg hover:opacity-90"
|
: "bg-accent text-accent-fg hover:opacity-90"
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
|||||||
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
|
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
|
||||||
// its own filter/sort/page state and the stored order needs a single owner.
|
// its own filter/sort/page state and the stored order needs a single owner.
|
||||||
|
|
||||||
|
// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
|
||||||
|
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
|
||||||
|
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
|
||||||
|
const STORED_SORT: SortState = { key: "pos", dir: "asc" };
|
||||||
|
const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";
|
||||||
|
|
||||||
const idsKey = (items: Video[]) =>
|
const idsKey = (items: Video[]) =>
|
||||||
items
|
items
|
||||||
.map((v) => v.id)
|
.map((v) => v.id)
|
||||||
@@ -88,10 +94,14 @@ function Row({
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
// No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
|
||||||
|
// animating it makes the row trail the cursor. The others keep it for the reflow animation.
|
||||||
|
transition: isDragging ? undefined : transition,
|
||||||
opacity: isDragging ? 0.6 : 1,
|
opacity: isDragging ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
className="border-b border-border/50 hover:bg-card/40 transition"
|
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||||
|
// the same lag by another route (150ms behind the pointer, on every row).
|
||||||
|
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
@@ -171,7 +181,7 @@ export default function Playlists() {
|
|||||||
});
|
});
|
||||||
const items = order.value;
|
const items = order.value;
|
||||||
// VIEW state — never written to the server on its own.
|
// VIEW state — never written to the server on its own.
|
||||||
const [viewSort, setViewSort] = useState<SortState>(null);
|
const [viewSort, setViewSort] = useState<SortState>(STORED_SORT);
|
||||||
const [filters, setFilters] = useState<FilterMap>({});
|
const [filters, setFilters] = useState<FilterMap>({});
|
||||||
const [groupBy, setGroupBy] = useState(false);
|
const [groupBy, setGroupBy] = useState(false);
|
||||||
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
|
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
|
||||||
@@ -214,7 +224,7 @@ export default function Playlists() {
|
|||||||
|
|
||||||
// Reset the view when switching playlists (the stored order itself is per-playlist).
|
// Reset the view when switching playlists (the stored order itself is per-playlist).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewSort(null);
|
setViewSort(STORED_SORT);
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
@@ -256,19 +266,20 @@ export default function Playlists() {
|
|||||||
const shownRef = useRef(shown);
|
const shownRef = useRef(shown);
|
||||||
shownRef.current = shown;
|
shownRef.current = shown;
|
||||||
|
|
||||||
const viewActive = viewSort !== null || groupBy || filterActive;
|
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
||||||
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
||||||
// offers itself when the view is a pure reordering of the whole list.
|
// offers itself when the view is a pure reordering of the whole list.
|
||||||
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
||||||
|
|
||||||
function toggleSort(key: string) {
|
function toggleSort(key: string) {
|
||||||
setViewSort((s) => cycleSort(s, key));
|
// Cycling off any column lands back on the stored order rather than on a nameless "unsorted".
|
||||||
|
setViewSort((s) => cycleSort(s, key) ?? STORED_SORT);
|
||||||
}
|
}
|
||||||
function applyFilter(key: string, value: string | string[]) {
|
function applyFilter(key: string, value: string | string[]) {
|
||||||
setFilters((f) => ({ ...f, [key]: value }));
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
}
|
}
|
||||||
function clearView() {
|
function clearView() {
|
||||||
setViewSort(null);
|
setViewSort(STORED_SORT);
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,13 @@ export function playlistColumns({
|
|||||||
key: "pos",
|
key: "pos",
|
||||||
header: "#",
|
header: "#",
|
||||||
align: "right",
|
align: "right",
|
||||||
width: "3rem",
|
width: "4.5rem",
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
|
// Sorting by "#" ascending is the stored order itself — that is what makes it a truthful
|
||||||
|
// default for the header arrow, and a one-click way back from any other sort.
|
||||||
|
sortable: true,
|
||||||
|
sortValue: indexOf,
|
||||||
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
|
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user