Polish subscription and server picker animations
This commit is contained in:
@@ -127,14 +127,19 @@ function ServerRow({
|
||||
}) {
|
||||
const health = ping?.checking ? 'Проверяем пинг' : serverHealthText(ping);
|
||||
|
||||
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
|
||||
return <div
|
||||
className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}
|
||||
style={{
|
||||
'--server-index': Math.min(index, 7),
|
||||
'--server-exit-delay': `${90 + Math.max(0, 4 - Math.min(index, 4)) * 45}ms`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<button
|
||||
className={`client-server${selected ? ' is-selected' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
|
||||
style={{ '--server-index': Math.min(index, 7) } as CSSProperties}
|
||||
onClick={() => onSelect(server.id)}
|
||||
>
|
||||
<strong>{server.label}</strong>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -638,6 +638,54 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.client-profile-body.is-open .client-server-row {
|
||||
animation: client-profile-server-in 420ms calc(var(--server-index) * 70ms) cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-profile-body.is-open .client-server-toolbar {
|
||||
animation: client-profile-ping-in 300ms 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-profile-body.is-closing .client-server-toolbar {
|
||||
animation: client-profile-ping-out 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
.client-profile-body.is-closing .client-server-row {
|
||||
animation: client-profile-server-out 300ms var(--server-exit-delay) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes client-profile-server-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes client-profile-server-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes client-profile-ping-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes client-profile-ping-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.client-profile-body .client-server-row .client-server::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
|
||||
@@ -223,11 +223,40 @@
|
||||
}
|
||||
|
||||
.client-profile-body {
|
||||
padding: 0 0 4px;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transition:
|
||||
grid-template-rows 360ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 260ms ease,
|
||||
filter 420ms ease,
|
||||
visibility 0s 930ms;
|
||||
}
|
||||
|
||||
.client-profile-body[aria-hidden='true'] {
|
||||
display: none;
|
||||
.client-profile-body.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transition:
|
||||
grid-template-rows 620ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 260ms ease,
|
||||
filter 420ms ease,
|
||||
visibility 0s;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.client-profile-body.is-closing {
|
||||
visibility: visible;
|
||||
transition-delay: 570ms, 570ms, 570ms, 0s;
|
||||
}
|
||||
|
||||
.client-profile-body-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.client-profile-body .client-servers {
|
||||
@@ -249,6 +278,15 @@
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
animation: client-profile-selected-in 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes client-profile-selected-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(3px);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.client-profile-selected-server > span {
|
||||
@@ -299,6 +337,27 @@
|
||||
|
||||
.client-subscription-sheet > .client-profile-add {
|
||||
margin-top: 8px;
|
||||
animation: client-profile-add-open 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-subscription-sheet > .client-profile-add.is-closing {
|
||||
animation: client-profile-add-close 320ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes client-profile-add-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes client-profile-add-close {
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
}
|
||||
|
||||
.client-profile-activate {
|
||||
@@ -314,6 +373,11 @@
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
text-align: left;
|
||||
animation: client-profile-add-trigger-in 240ms ease both;
|
||||
}
|
||||
|
||||
@keyframes client-profile-add-trigger-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
.client-profile-server-hint,
|
||||
@@ -433,6 +497,11 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.client-profile-chevron,
|
||||
.client-profile-body,
|
||||
.client-profile-body *,
|
||||
.client-profile-selected-server,
|
||||
.client-profile-add,
|
||||
.client-profile-add-trigger,
|
||||
.client-profile-refresh,
|
||||
.client-profile-refresh svg,
|
||||
.client-profile-delete-lid,
|
||||
|
||||
Reference in New Issue
Block a user