Animate the tag follow toggle while the request settles
The chip toggle swaps its icon for a tiny current-color spinner from the click until the follows list reflects the new state, covering the refetch gap so '+' never flashes back before the checkmark.
This commit is contained in:
@@ -11,6 +11,8 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// What the chip should show once the request and refetch settle.
|
||||||
|
const [target, setTarget] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["follows-tags"],
|
queryKey: ["follows-tags"],
|
||||||
@@ -37,9 +39,17 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
||||||
},
|
},
|
||||||
onError: (err) => setError(errorMessage(err)),
|
onError: (err) => {
|
||||||
|
setTarget(null);
|
||||||
|
setError(errorMessage(err));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the spinner up until the refetched list reflects the new state.
|
||||||
|
// A stale target that matches `followed` is harmless: the next click
|
||||||
|
// recomputes it before mutating.
|
||||||
|
const busy = mutation.isPending || (target !== null && followed !== target);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
function handleClick(event: MouseEvent<HTMLButtonElement>) {
|
function handleClick(event: MouseEvent<HTMLButtonElement>) {
|
||||||
@@ -47,6 +57,8 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
// action and must not trigger them.
|
// action and must not trigger them.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (busy) return;
|
||||||
|
setTarget(!followed);
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,16 +66,21 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={mutation.isPending || query.isPending}
|
disabled={busy || query.isPending}
|
||||||
title={error ?? (followed ? `Unfollow ${tag}` : `Follow ${tag}`)}
|
title={error ?? (followed ? `Unfollow ${tag}` : `Follow ${tag}`)}
|
||||||
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
|
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"-mr-0.5 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm transition",
|
"-mr-0.5 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm transition",
|
||||||
error ? "text-ctp-red" : "opacity-70 hover:opacity-100",
|
error ? "text-ctp-red" : "opacity-70 hover:opacity-100",
|
||||||
mutation.isPending && "opacity-40",
|
query.isPending && "opacity-40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{followed ? (
|
{busy ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent"
|
||||||
|
/>
|
||||||
|
) : followed ? (
|
||||||
<Check className="h-3 w-3" strokeWidth={3} />
|
<Check className="h-3 w-3" strokeWidth={3} />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="h-3 w-3" strokeWidth={3} />
|
<Plus className="h-3 w-3" strokeWidth={3} />
|
||||||
|
|||||||
Reference in New Issue
Block a user