Change hover cards to not appear until the mouse stops in web UI (#30850)
parent
20fa9ce484
commit
b728c0e8ce
|
@ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
export const useTimeout = () => {
|
export const useTimeout = () => {
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const callbackRef = useRef<() => void>();
|
||||||
|
|
||||||
const set = useCallback((callback: () => void, delay: number) => {
|
const set = useCallback((callback: () => void, delay: number) => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
callbackRef.current = callback;
|
||||||
timeoutRef.current = setTimeout(callback, delay);
|
timeoutRef.current = setTimeout(callback, delay);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const delay = useCallback((delay: number) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!callbackRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(callbackRef.current, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
timeoutRef.current = undefined;
|
timeoutRef.current = undefined;
|
||||||
|
callbackRef.current = undefined;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -25,5 +40,5 @@ export const useTimeout = () => {
|
||||||
[cancel],
|
[cancel],
|
||||||
);
|
);
|
||||||
|
|
||||||
return [set, cancel] as const;
|
return [set, cancel, delay] as const;
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,7 +27,7 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FollowButton: React.FC<{
|
export const FollowButton: React.FC<{
|
||||||
accountId: string;
|
accountId?: string;
|
||||||
}> = ({ accountId }) => {
|
}> = ({ accountId }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -36,7 +36,7 @@ export const FollowButton: React.FC<{
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
);
|
);
|
||||||
const relationship = useAppSelector((state) =>
|
const relationship = useAppSelector((state) =>
|
||||||
state.relationships.get(accountId),
|
accountId ? state.relationships.get(accountId) : undefined,
|
||||||
);
|
);
|
||||||
const following = relationship?.following || relationship?.requested;
|
const following = relationship?.following || relationship?.requested;
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
export const HoverCardAccount = forwardRef<
|
export const HoverCardAccount = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{ accountId: string }
|
{ accountId?: string }
|
||||||
>(({ accountId }, ref) => {
|
>(({ accountId }, ref) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,8 @@ import { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||||
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
|
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
|
||||||
|
|
||||||
const offset = [-12, 4] as OffsetValue;
|
const offset = [-12, 4] as OffsetValue;
|
||||||
const enterDelay = 650;
|
const enterDelay = 750;
|
||||||
const leaveDelay = 250;
|
const leaveDelay = 150;
|
||||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
const isHoverCardAnchor = (element: HTMLElement) =>
|
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||||
|
@ -23,50 +23,12 @@ export const HoverCardController: React.FC = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [accountId, setAccountId] = useState<string | undefined>();
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||||
const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
|
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||||
|
const [setScrollTimeout] = useTimeout();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const handleAnchorMouseEnter = useCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
const { target } = e;
|
|
||||||
|
|
||||||
if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
|
|
||||||
cancelLeaveTimeout();
|
|
||||||
|
|
||||||
setEnterTimeout(() => {
|
|
||||||
target.setAttribute('aria-describedby', 'hover-card');
|
|
||||||
setAnchor(target);
|
|
||||||
setOpen(true);
|
|
||||||
setAccountId(
|
|
||||||
target.getAttribute('data-hover-card-account') ?? undefined,
|
|
||||||
);
|
|
||||||
}, enterDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target === cardRef.current?.parentNode) {
|
|
||||||
cancelLeaveTimeout();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAnchorMouseLeave = useCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
if (e.target === anchor || e.target === cardRef.current?.parentNode) {
|
|
||||||
cancelEnterTimeout();
|
|
||||||
|
|
||||||
setLeaveTimeout(() => {
|
|
||||||
anchor?.removeAttribute('aria-describedby');
|
|
||||||
setOpen(false);
|
|
||||||
setAnchor(null);
|
|
||||||
}, leaveDelay);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
cancelEnterTimeout();
|
cancelEnterTimeout();
|
||||||
cancelLeaveTimeout();
|
cancelLeaveTimeout();
|
||||||
|
@ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => {
|
||||||
}, [handleClose, location]);
|
}, [handleClose, location]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
|
let isScrolling = false;
|
||||||
|
let currentAnchor: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const open = (target: HTMLElement) => {
|
||||||
|
target.setAttribute('aria-describedby', 'hover-card');
|
||||||
|
setOpen(true);
|
||||||
|
setAnchor(target);
|
||||||
|
setAccountId(target.getAttribute('data-hover-card-account') ?? undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
currentAnchor?.removeAttribute('aria-describedby');
|
||||||
|
currentAnchor = null;
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
setAccountId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (e: MouseEvent) => {
|
||||||
|
const { target } = e;
|
||||||
|
|
||||||
|
// We've exited the window
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've entered an anchor
|
||||||
|
if (!isScrolling && isHoverCardAnchor(target)) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
|
||||||
|
currentAnchor?.removeAttribute('aria-describedby');
|
||||||
|
currentAnchor = target;
|
||||||
|
|
||||||
|
setEnterTimeout(() => {
|
||||||
|
open(target);
|
||||||
|
}, enterDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've entered the hover card
|
||||||
|
if (
|
||||||
|
!isScrolling &&
|
||||||
|
(target === currentAnchor || target === cardRef.current)
|
||||||
|
) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (e: MouseEvent) => {
|
||||||
|
if (!currentAnchor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target === currentAnchor || e.target === cardRef.current) {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
|
||||||
|
setLeaveTimeout(() => {
|
||||||
|
close();
|
||||||
|
}, leaveDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollEnd = () => {
|
||||||
|
isScrolling = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
isScrolling = true;
|
||||||
|
cancelEnterTimeout();
|
||||||
|
setScrollTimeout(handleScrollEnd, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
delayEnterTimeout(enterDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.addEventListener('mouseenter', handleMouseEnter, {
|
||||||
passive: true,
|
passive: true,
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
|
|
||||||
|
document.body.addEventListener('mousemove', handleMouseMove, {
|
||||||
|
passive: true,
|
||||||
|
capture: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('mouseleave', handleMouseLeave, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('scroll', handleScroll, {
|
||||||
passive: true,
|
passive: true,
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
|
document.body.removeEventListener('mouseenter', handleMouseEnter);
|
||||||
document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
|
document.body.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.body.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
document.removeEventListener('scroll', handleScroll);
|
||||||
};
|
};
|
||||||
}, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
|
}, [
|
||||||
|
setEnterTimeout,
|
||||||
if (!accountId) return null;
|
setLeaveTimeout,
|
||||||
|
setScrollTimeout,
|
||||||
|
cancelEnterTimeout,
|
||||||
|
cancelLeaveTimeout,
|
||||||
|
delayEnterTimeout,
|
||||||
|
setOpen,
|
||||||
|
setAccountId,
|
||||||
|
setAnchor,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay
|
<Overlay
|
||||||
|
|
|
@ -163,7 +163,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
||||||
|
|
||||||
const names = accounts.map(a => (
|
const names = accounts.map(a => (
|
||||||
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
|
<Link to={`/@${a.get('acct')}`} key={a.get('id')} data-hover-card-account={a.get('id')}>
|
||||||
<bdi>
|
<bdi>
|
||||||
<strong
|
<strong
|
||||||
className='display-name__html'
|
className='display-name__html'
|
||||||
|
|
|
@ -10468,12 +10468,14 @@ noscript {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
text-align: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.verified {
|
&.verified {
|
||||||
dd {
|
dd {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
Loading…
Reference in New Issue