Polish subscription and server picker animations
Build and Deploy Gateway / build-and-push (push) Successful in 28s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-11 07:55:41 +03:00
parent 9a8191dc91
commit 9ad0307333
8 changed files with 247 additions and 48 deletions
@@ -66,8 +66,10 @@ export function useSubscriptionFeature({
}: SubscriptionFeatureOptions) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>([]);
const [closing, setClosing] = useState<string[]>([]);
const [visited, setVisited] = useState<string[]>([]);
const [adding, setAdding] = useState(profiles.length === 0);
const [addClosing, setAddClosing] = useState(false);
const [url, setUrl] = useState('');
const [deleteId, setDeleteId] = useState('');
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
@@ -76,6 +78,9 @@ export function useSubscriptionFeature({
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
const addFormRef = useRef<HTMLFormElement>(null);
const collapseTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const addCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const deleteIdRef = useRef(deleteId);
const previousProfileCountRef = useRef(profiles.length);
deleteIdRef.current = deleteId;
@@ -90,9 +95,11 @@ export function useSubscriptionFeature({
useEffect(() => {
const ids = new Set(profiles.map((profile) => profile.id));
setExpanded((current) => current.filter((id) => ids.has(id)));
setClosing((current) => current.filter((id) => ids.has(id)));
setVisited((current) => current.filter((id) => ids.has(id)));
if (profiles.length === 0) {
setAdding(true);
setAddClosing(false);
if (previousProfileCountRef.current > 0) setOpen(false);
}
if (previousProfileCountRef.current === 0 && profiles.length > 0) {
@@ -135,12 +142,56 @@ export function useSubscriptionFeature({
};
}, [open]);
function resetAdd() {
setUrl('');
if (profiles.length) setAdding(false);
if (profiles.length) requestAnimationFrame(() => (
document.getElementById('client-profile-add-trigger')?.focus()
));
useEffect(() => {
if (!adding || profiles.length === 0) return undefined;
const closeAdd = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
const target = event.target as Node | null;
if (event.type !== 'keydown' && addFormRef.current?.contains(target)) return;
resetAdd(Boolean(target && panelRef.current?.contains(target)));
};
document.addEventListener('pointerdown', closeAdd);
document.addEventListener('keydown', closeAdd);
return () => {
document.removeEventListener('pointerdown', closeAdd);
document.removeEventListener('keydown', closeAdd);
};
}, [adding, profiles.length]);
useEffect(() => () => {
for (const timer of collapseTimersRef.current.values()) clearTimeout(timer);
if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current);
}, []);
function resetAdd(restoreFocus = true) {
if (!profiles.length) {
setUrl('');
return;
}
setAdding(false);
const finish = () => {
setAddClosing(false);
setUrl('');
addCloseTimerRef.current = null;
if (restoreFocus) requestAnimationFrame(() => (
document.getElementById('client-profile-add-trigger')?.focus()
));
};
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
finish();
return;
}
setAddClosing(true);
if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current);
addCloseTimerRef.current = setTimeout(finish, 320);
}
function showAdd() {
if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current);
addCloseTimerRef.current = null;
setAddClosing(false);
setAdding(true);
onDismissError();
}
async function addProfile() {
@@ -180,10 +231,22 @@ export function useSubscriptionFeature({
}
function toggleProfile(profileId: string) {
const activeTimer = collapseTimersRef.current.get(profileId);
if (activeTimer) clearTimeout(activeTimer);
collapseTimersRef.current.delete(profileId);
setVisited((current) => current.includes(profileId) ? current : [...current, profileId]);
setExpanded((current) => current.includes(profileId)
? current.filter((id) => id !== profileId)
: [...current, profileId]);
if (!expanded.includes(profileId)) {
setClosing((current) => current.filter((id) => id !== profileId));
setExpanded((current) => current.includes(profileId) ? current : [...current, profileId]);
return;
}
setExpanded((current) => current.filter((id) => id !== profileId));
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
setClosing((current) => current.includes(profileId) ? current : [...current, profileId]);
collapseTimersRef.current.set(profileId, setTimeout(() => {
setClosing((current) => current.filter((id) => id !== profileId));
collapseTimersRef.current.delete(profileId);
}, 950));
}
return {
@@ -196,8 +259,10 @@ export function useSubscriptionFeature({
error,
open,
expanded,
closing,
visited,
adding,
addClosing,
url,
validationStatus,
addError,
@@ -209,17 +274,15 @@ export function useSubscriptionFeature({
toggleRef,
closeRef,
labelRef,
addFormRef,
addBlocked: operationBlocked(operations, 'profileAdd'),
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
deleteBlocked: operationBlocked(operations, 'profileDelete'),
activateBlocked: operationBlocked(operations, 'profileActivate'),
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
showAdd: () => {
setAdding(true);
onDismissError();
},
cancelAdd: resetAdd,
showAdd,
cancelAdd: () => resetAdd(),
setUrl: (value: string) => {
setUrl(value);
onDismissError();
@@ -264,8 +327,11 @@ function AddProfileForm({ feature }: { feature: SubscriptionFeatureController })
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
: feature.addError?.message || '';
return <form
className="client-profile-add"
ref={feature.addFormRef}
className={`client-profile-add${feature.addClosing ? ' is-closing' : ''}`}
autoComplete="off"
aria-hidden={feature.addClosing}
inert={feature.addClosing ? true : undefined}
onSubmit={(event) => {
event.preventDefault();
feature.addProfile();
@@ -316,6 +382,7 @@ function ProfileGroup({
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
}) {
const expanded = feature.expanded.includes(profile.id);
const closing = feature.closing.includes(profile.id);
const visited = feature.visited.includes(profile.id);
const applied = feature.connected
&& !feature.gatewayDirect
@@ -397,7 +464,7 @@ function ProfileGroup({
{localStatus && <div className="client-profile-local-status" role="status">{localStatus}</div>}
{!expanded && visibleServer && <button
{!expanded && !closing && visibleServer && <button
className="client-profile-selected-server"
type="button"
onClick={() => feature.toggleProfile(profile.id)}
@@ -409,16 +476,18 @@ function ProfileGroup({
{visited && <div
id={`client-profile-${profile.id}`}
className="client-profile-body"
className={`client-profile-body${expanded ? ' is-open' : ''}${closing ? ' is-closing' : ''}`}
aria-hidden={!expanded}
inert={!expanded ? true : undefined}
>
{!profile.desiredServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
{renderServerPicker(profile, {
disabled: controlsBlocked,
leaving: false,
revealVersion: feature.revealVersions[profile.id] || 0,
})}
<div className="client-profile-body-inner">
{!profile.desiredServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
{renderServerPicker(profile, {
disabled: controlsBlocked,
leaving: false,
revealVersion: feature.revealVersions[profile.id] || 0,
})}
</div>
</div>}
</section>;
}
@@ -471,7 +540,7 @@ export function SubscriptionPanel({
renderServerPicker={renderServerPicker}
/>)}
</div>
{feature.adding && feature.profiles.length > 0
{(feature.adding || feature.addClosing) && feature.profiles.length > 0
? <AddProfileForm feature={feature} />
: <button
id="client-profile-add-trigger"