mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
🐛 fix: improve sidebar title clarity and hover scrolling (#15644)
* fix: improve sidebar title clarity and hover scrolling * fix: address PR review bot findings Copilot: - Drive the title fade from CSS so autoprefixer emits -webkit-mask-size for WebKit; Web Animations silently drops prefixed keyframe properties. - Guard MediaQueryList subscription so legacy addListener/removeListener engines neither miss the change handler nor throw during cleanup.
This commit is contained in:
parent
a9da01fbe7
commit
1c115cdebf
2 changed files with 120 additions and 19 deletions
|
|
@ -48,6 +48,7 @@ function Conversation({
|
|||
const [titleInput, setTitleInput] = useState(title || '');
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
// Lazy-load ConvoOptions to avoid running heavy hooks for all conversations
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
|
||||
|
|
@ -222,6 +223,13 @@ function Conversation({
|
|||
? 'bg-surface-active-alt before:absolute before:bottom-1 before:left-0 before:top-1 before:w-0.5 before:rounded-full before:bg-text-primary'
|
||||
: 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
onPointerEnter={(event) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
setIsHovered(true);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
onPointerCancel={() => setIsHovered(false)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={handleMouseEnter}
|
||||
|
|
@ -249,6 +257,7 @@ function Conversation({
|
|||
<ConvoLink
|
||||
isActiveConvo={isActiveConvo}
|
||||
isPopoverActive={isPopoverActive}
|
||||
isHovered={isHovered}
|
||||
isSharedBadgeVisible={isSharedBadgeVisible}
|
||||
title={title}
|
||||
onRename={handleRename}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,39 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { TOptions } from 'i18next';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ConvoLinkProps {
|
||||
isActiveConvo: boolean;
|
||||
isPopoverActive: boolean;
|
||||
isHovered: boolean;
|
||||
isSharedBadgeVisible: boolean;
|
||||
title: string | null;
|
||||
onRename: () => void;
|
||||
isSmallScreen: boolean;
|
||||
localize: (key: any, options?: any) => string;
|
||||
localize: (key: TranslationKeys, options?: TOptions) => string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const TITLE_SPEED_PX_PER_SECOND = 30;
|
||||
const TITLE_START_DELAY_MS = 600;
|
||||
const TITLE_HOLD_MS = 2000;
|
||||
|
||||
/** Legacy engines expose only `addListener` on MediaQueryList. */
|
||||
const observeMediaQuery = (query: MediaQueryList, onChange: () => void) => {
|
||||
if (typeof query.addEventListener === 'function') {
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}
|
||||
|
||||
query.addListener(onChange);
|
||||
return () => query.removeListener(onChange);
|
||||
};
|
||||
|
||||
const ConvoLink: React.FC<ConvoLinkProps> = ({
|
||||
isActiveConvo,
|
||||
isPopoverActive,
|
||||
isHovered,
|
||||
isSharedBadgeVisible,
|
||||
title,
|
||||
onRename,
|
||||
|
|
@ -22,14 +41,90 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
|
|||
localize,
|
||||
children,
|
||||
}) => {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const titleRef = useRef<HTMLSpanElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const displayTitle = title || localize('com_ui_untitled');
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = titleRef.current;
|
||||
const text = textRef.current;
|
||||
if (!viewport || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let animation: Animation | undefined;
|
||||
let frame = 0;
|
||||
|
||||
const start = () => {
|
||||
animation?.cancel();
|
||||
cancelAnimationFrame(frame);
|
||||
delete viewport.dataset.titleRevealed;
|
||||
const overflow = text.getBoundingClientRect().width - viewport.getBoundingClientRect().width;
|
||||
setIsOverflowing(overflow > 0);
|
||||
if (!isHovered || isSmallScreen || reducedMotion.matches || overflow <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = getComputedStyle(viewport);
|
||||
const direction = style.direction === 'rtl' ? 1 : -1;
|
||||
const revealDuration = parseFloat(style.getPropertyValue('--convo-title-reveal-duration'));
|
||||
const travelDuration = (overflow / TITLE_SPEED_PX_PER_SECOND) * 1000;
|
||||
const duration = travelDuration + TITLE_HOLD_MS;
|
||||
const end = `translateX(${direction * overflow}px)`;
|
||||
|
||||
animation = text.animate(
|
||||
[
|
||||
{ transform: 'translateX(0)' },
|
||||
{ transform: end, offset: travelDuration / duration },
|
||||
{ transform: end },
|
||||
],
|
||||
{
|
||||
duration,
|
||||
delay: TITLE_START_DELAY_MS,
|
||||
iterations: Infinity,
|
||||
easing: 'linear',
|
||||
},
|
||||
);
|
||||
|
||||
/** The fade retreats as the ending arrives, so the last characters read
|
||||
* clearly without the text overscrolling past the available width. CSS owns
|
||||
* the mask because Web Animations cannot carry the `-webkit-` fallback that
|
||||
* older WebKit needs. */
|
||||
const revealFrom = Math.max(0, travelDuration - revealDuration);
|
||||
const tick = () => {
|
||||
const currentTime = typeof animation?.currentTime === 'number' ? animation.currentTime : 0;
|
||||
const elapsed = currentTime - TITLE_START_DELAY_MS;
|
||||
viewport.dataset.titleRevealed = String(elapsed > 0 && elapsed % duration >= revealFrom);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
tick();
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(start);
|
||||
observer.observe(viewport);
|
||||
observer.observe(text);
|
||||
const unobserveMediaQuery = observeMediaQuery(reducedMotion, start);
|
||||
start();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
unobserveMediaQuery();
|
||||
animation?.cancel();
|
||||
cancelAnimationFrame(frame);
|
||||
delete viewport.dataset.titleRevealed;
|
||||
};
|
||||
}, [isHovered, isSmallScreen, displayTitle]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-w-0 grow cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary',
|
||||
'flex w-full min-w-0 grow cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary',
|
||||
isActiveConvo || isPopoverActive ? 'bg-surface-active-alt' : '',
|
||||
)}
|
||||
title={title ?? undefined}
|
||||
aria-current={isActiveConvo ? 'page' : undefined}
|
||||
aria-label={
|
||||
isSharedBadgeVisible
|
||||
|
|
@ -40,12 +135,16 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
|
|||
title: title || localize('com_ui_untitled'),
|
||||
})
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className="relative flex-1 grow overflow-hidden whitespace-nowrap"
|
||||
style={{ textOverflow: 'clip' }}
|
||||
<span
|
||||
ref={titleRef}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 overflow-hidden whitespace-nowrap [--convo-title-fade-width:24px] [--convo-title-reveal-duration:800ms] [mask-position:left] [mask-repeat:no-repeat] [mask-size:100%_100%] [text-align:start] [transition-duration:0ms] [transition-property:mask-size] [transition-timing-function:linear] [&:dir(rtl)]:[mask-position:right]',
|
||||
isOverflowing &&
|
||||
'[mask-image:linear-gradient(to_right,currentColor_calc(100%_-_var(--convo-title-fade-width)),transparent)] [&:dir(rtl)]:[mask-image:linear-gradient(to_left,currentColor_calc(100%_-_var(--convo-title-fade-width)),transparent)]',
|
||||
'data-[title-revealed=true]:[mask-size:calc(100%_+_var(--convo-title-fade-width))_100%] data-[title-revealed=true]:[transition-duration:var(--convo-title-reveal-duration)]',
|
||||
)}
|
||||
onDoubleClick={(e) => {
|
||||
if (isSmallScreen) {
|
||||
return;
|
||||
|
|
@ -55,17 +154,10 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
|
|||
onRename();
|
||||
}}
|
||||
>
|
||||
{title || localize('com_ui_untitled')}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 right-0 top-0 w-20 bg-gradient-to-l',
|
||||
isActiveConvo || isPopoverActive
|
||||
? 'from-surface-active-alt'
|
||||
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-0%',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span ref={textRef} className="inline-block">
|
||||
{displayTitle}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue