feat(Tooltip): add floating animations for tooltip appearance based on placement

This commit is contained in:
alexsparkes
2026-02-01 14:48:47 +00:00
parent d937f8a82e
commit 99bd0a9d35
3 changed files with 89 additions and 25 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { useT } from 'contexts/TranslationContext';
import variables from 'config/variables';
import Tab from './Tab';
@@ -38,7 +38,6 @@ const Tabs = ({
};
const initial = getInitialSection();
const [currentTab, setCurrentTab] = useState(initial.label);
const [currentName, setCurrentName] = useState(initial.name);
const [showReminder, setShowReminder] = useState(localStorage.getItem('showReminder') === 'true');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
@@ -47,12 +46,24 @@ const Tabs = ({
const [searchQuery, setSearchQuery] = useState('');
const contentRef = useRef(null);
// Derive currentTab label from currentName - avoids setState in effects
const currentTab = (() => {
if (sections && currentName) {
const section = sections.find((s) => s.name === currentName);
if (section) {
return t(section.label);
}
}
// Fallback: find label from children
const child = children.find((c) => c.props.name === currentName);
return child?.props.label || children[0]?.props.label;
})();
const handleTabClick = (tab, name) => {
if (name !== currentName) {
variables.stats.postEvent('tab', `Opened ${name}`);
}
setCurrentTab(tab);
setCurrentName(name);
// Scroll content to top when changing tabs
@@ -73,24 +84,12 @@ const Tabs = ({
}
}, []);
// Update labels when language changes
useEffect(() => {
if (sections && currentName) {
const section = sections.find((s) => s.name === currentName);
if (section) {
const newLabel = t(section.label);
setCurrentTab(newLabel);
}
}
}, [t, sections, currentName]);
// Handle navigation trigger for settings sections (popstate)
useEffect(() => {
// useLayoutEffect is appropriate here for synchronous state updates before paint
useLayoutEffect(() => {
if (navigationTrigger?.type === 'settings-section' && sections) {
const section = sections.find((s) => s.name === navigationTrigger.data);
if (section) {
const label = t(section.label);
setCurrentTab(label);
setCurrentName(section.name);
// Scroll content to top when navigating via browser history
if (contentRef.current) {
@@ -98,12 +97,12 @@ const Tabs = ({
}
}
}
}, [navigationTrigger, sections, t]);
}, [navigationTrigger, sections]);
// Reset to first tab when requested
useEffect(() => {
// useLayoutEffect is appropriate here for synchronous state updates before paint
useLayoutEffect(() => {
if (resetToFirst) {
setCurrentTab(children[0]?.props.label);
setCurrentName(children[0]?.props.name);
// Scroll content to top when resetting to first tab
if (contentRef.current) {

View File

@@ -1,4 +1,4 @@
import { useState, memo, useRef } from 'react';
import { useState, memo, useRef, useId } from 'react';
import { useFloating, flip, offset, shift } from '@floating-ui/react-dom';
import './tooltip.scss';
@@ -6,7 +6,7 @@ function Tooltip({ children, title, style, placement, subtitle }) {
const [showTooltip, setShowTooltip] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [reference, setReference] = useState(null);
const tooltipId = useRef(`tooltip-${Math.random()}`);
const tooltipId = useId();
const closeTimeout = useRef(null);
const {
@@ -23,6 +23,8 @@ function Tooltip({ children, title, style, placement, subtitle }) {
},
});
const { setFloating } = refs;
const handleMouseEnter = () => {
// Clear any pending close timeout if mouse re-enters during exit
if (closeTimeout.current) {
@@ -76,13 +78,13 @@ function Tooltip({ children, title, style, placement, subtitle }) {
onFocus={handleFocus}
onBlur={handleBlur}
ref={setReference}
aria-describedby={tooltipId.current}
aria-describedby={tooltipId}
>
{children}
</div>
{(showTooltip || isClosing) && (
<span
ref={refs.setFloating}
ref={setFloating}
style={{
position: strategy,
top: y ?? '',

View File

@@ -5,6 +5,54 @@
display: grid;
}
@keyframes floatingFromTop {
0% {
transform: translateY(5px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes floatingFromBottom {
0% {
transform: translateY(-5px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes floatingFromLeft {
0% {
transform: translateX(5px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes floatingFromRight {
0% {
transform: translateX(-5px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes floating {
0% {
transform: translate(0, -5px);
@@ -46,9 +94,24 @@
&[data-status='open'] {
opacity: 1;
transform: translate(0, 0);
animation-name: floating;
animation-duration: 0.3s;
animation-timing-function: ease-in;
&[data-placement^='top'] {
animation-name: floatingFromTop;
}
&[data-placement^='bottom'] {
animation-name: floatingFromBottom;
}
&[data-placement^='left'] {
animation-name: floatingFromLeft;
}
&[data-placement^='right'] {
animation-name: floatingFromRight;
}
}
// Closing state - exit animation