69 lines
2.2 KiB
TypeScript
69 lines
2.2 KiB
TypeScript
// Shared refresh affordance for list screens. The plain IconButton `loading`
|
|
// state hides the icon behind the generic loader, which reads as "the button
|
|
// vanished" rather than "the list is reloading" — so this keeps the refresh
|
|
// arrows and rotates them instead.
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { FeatherRefreshCw } from "@subframe/core";
|
|
import { IconButton } from "~/ui/components/IconButton";
|
|
|
|
/**
|
|
* Keeps a transient busy flag on for at least `minMs`. A revalidation that
|
|
* returns in 80ms would otherwise make the spinner flicker once, which reads as
|
|
* a glitch rather than as feedback.
|
|
*/
|
|
export function useMinimumDuration(active: boolean, minMs = 600): boolean {
|
|
const [held, setHeld] = useState(active);
|
|
const startedAt = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (active) {
|
|
if (!held) startedAt.current = Date.now();
|
|
setHeld(true);
|
|
return;
|
|
}
|
|
if (!held) return;
|
|
const remaining = Math.max(0, minMs - (Date.now() - startedAt.current));
|
|
if (remaining === 0) {
|
|
setHeld(false);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => setHeld(false), remaining);
|
|
return () => clearTimeout(timer);
|
|
}, [active, held, minMs]);
|
|
|
|
return held;
|
|
}
|
|
|
|
export type RefreshIconButtonProps = {
|
|
/** True while the underlying data is reloading (e.g. `revalidator.state !== "idle"`). */
|
|
readonly busy: boolean;
|
|
readonly onRefresh: () => void;
|
|
/** Translated label, used for both the accessible name and the tooltip. */
|
|
readonly label: string;
|
|
readonly dataUiId?: string;
|
|
};
|
|
|
|
export function RefreshIconButton({ busy, onRefresh, label, dataUiId }: RefreshIconButtonProps): React.JSX.Element {
|
|
const spinning = useMinimumDuration(busy);
|
|
return (
|
|
<IconButton
|
|
variant="ghost"
|
|
size="small"
|
|
icon={
|
|
// The hover tilt makes the button answer the pointer before the click;
|
|
// while reloading the arrows spin instead.
|
|
<FeatherRefreshCw
|
|
className={spinning ? "animate-spin" : "transition-transform duration-200 group-hover/af9405b1:rotate-45"}
|
|
/>
|
|
}
|
|
disabled={spinning}
|
|
data-busy={spinning ? "true" : undefined}
|
|
onClick={onRefresh}
|
|
aria-label={label}
|
|
title={label}
|
|
data-ui-id={dataUiId}
|
|
/>
|
|
);
|
|
}
|