Improve tab scrolling with arrows

This commit is contained in:
2025-12-22 19:50:55 +01:00
parent 69c0fd7506
commit 1f52680721
18 changed files with 378 additions and 37 deletions

View File

@@ -0,0 +1,233 @@
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type PointerEvent,
type MouseEvent
} from 'react';
type TabsScrollerProps = {
className: string;
children: ReactNode;
scrollStep?: number;
ariaLabelLeft?: string;
ariaLabelRight?: string;
};
const SCROLL_EDGE_THRESHOLD = 8;
const DRAG_START_DISTANCE = 6;
const DEFAULT_SCROLL_RATIO = 0.65;
const MIN_SCROLL_STEP = 180;
const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
(
{
className,
children,
scrollStep,
ariaLabelLeft = 'Scroll tabs left',
ariaLabelRight = 'Scroll tabs right'
},
ref
) => {
const sliderRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef(0);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(false);
const dragRef = useRef({
pointerId: null as number | null,
startX: 0,
startY: 0,
startScrollLeft: 0,
isDragging: false,
didDrag: false
});
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
sliderRef.current = node;
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
ref.current = node;
}
},
[ref]
);
const updateOverflow = useCallback(() => {
const node = sliderRef.current;
if (!node) return;
const maxScroll = node.scrollWidth - node.clientWidth;
setShowLeft(node.scrollLeft > SCROLL_EDGE_THRESHOLD);
setShowRight(node.scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD);
}, []);
const scheduleOverflowUpdate = useCallback(() => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
updateOverflow();
});
}, [updateOverflow]);
useLayoutEffect(() => {
updateOverflow();
const node = sliderRef.current;
if (!node) return undefined;
const handleScroll = () => scheduleOverflowUpdate();
node.addEventListener('scroll', handleScroll, { passive: true });
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => scheduleOverflowUpdate());
resizeObserver.observe(node);
} else if (typeof window !== 'undefined') {
window.addEventListener('resize', scheduleOverflowUpdate);
}
return () => {
node.removeEventListener('scroll', handleScroll);
resizeObserver?.disconnect();
if (typeof window !== 'undefined') {
window.removeEventListener('resize', scheduleOverflowUpdate);
}
};
}, [scheduleOverflowUpdate, updateOverflow]);
useEffect(() => {
updateOverflow();
}, [children, updateOverflow]);
useEffect(() => {
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
const scrollByAmount = useCallback(
(direction: 'left' | 'right') => {
const node = sliderRef.current;
if (!node) return;
const width = node.clientWidth || 0;
const step = scrollStep ?? Math.max(MIN_SCROLL_STEP, Math.round(width * DEFAULT_SCROLL_RATIO));
node.scrollBy({
left: direction === 'left' ? -step : step,
behavior: 'smooth'
});
},
[scrollStep]
);
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'mouse') return;
if (event.button !== 0) return;
const node = sliderRef.current;
if (!node) return;
dragRef.current.pointerId = event.pointerId;
dragRef.current.startX = event.clientX;
dragRef.current.startY = event.clientY;
dragRef.current.startScrollLeft = node.scrollLeft;
dragRef.current.isDragging = false;
dragRef.current.didDrag = false;
}, []);
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const node = sliderRef.current;
if (!node) return;
const state = dragRef.current;
if (state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
const dy = event.clientY - state.startY;
if (!state.isDragging) {
if (Math.abs(dx) < DRAG_START_DISTANCE || Math.abs(dx) < Math.abs(dy)) {
return;
}
state.isDragging = true;
state.didDrag = true;
node.classList.add('tabs-scroll-slider--dragging');
if (event.currentTarget.setPointerCapture) {
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Ignore pointer capture errors.
}
}
}
if (state.isDragging) {
node.scrollLeft = state.startScrollLeft - dx;
scheduleOverflowUpdate();
if (event.cancelable) {
event.preventDefault();
}
}
}, [scheduleOverflowUpdate]);
const endPointerDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
const node = sliderRef.current;
if (!node) return;
const state = dragRef.current;
if (state.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
state.pointerId = null;
state.isDragging = false;
node.classList.remove('tabs-scroll-slider--dragging');
if (state.didDrag && typeof window !== 'undefined') {
window.setTimeout(() => {
dragRef.current.didDrag = false;
}, 0);
}
}, []);
const handleClickCapture = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (dragRef.current.didDrag) {
event.preventDefault();
event.stopPropagation();
dragRef.current.didDrag = false;
}
}, []);
return (
<div className="tabs-scroll-shell">
<button
className={`tabs-scroll-arrow left ${showLeft ? 'visible' : ''}`}
type="button"
aria-label={ariaLabelLeft}
onClick={() => scrollByAmount('left')}
>
<span className="material-symbols-outlined">chevron_left</span>
</button>
<div
ref={setRefs}
className={`${className} tabs-scroll-slider`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endPointerDrag}
onPointerCancel={endPointerDrag}
onClickCapture={handleClickCapture}
>
{children}
</div>
<button
className={`tabs-scroll-arrow right ${showRight ? 'visible' : ''}`}
type="button"
aria-label={ariaLabelRight}
onClick={() => scrollByAmount('right')}
>
<span className="material-symbols-outlined">chevron_right</span>
</button>
</div>
);
}
);
TabsScroller.displayName = 'TabsScroller';
export default TabsScroller;

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import { apiKeysAPI } from '../api/client';
import type { ApiKeyItem } from '../api/client';
import '../styles/APIKeys.css';
@@ -89,14 +90,14 @@ export default function APIKeys() {
return (
<main className="main-content api-keys-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.apiKeysPage.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">
@@ -185,4 +186,3 @@ export default function APIKeys() {
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import GeneralTab from '../components/admin/GeneralTab';
import UsersTab from '../components/admin/UsersTab';
import { SwipeTabs } from '../components/SwipeTabs';
@@ -24,7 +25,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
return (
<main className="main-content admin-panel-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
@@ -46,7 +47,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
<span className="material-symbols-outlined">group</span>
<span>{t.admin.usersTab}</span>
</button>
</div>
</TabsScroller>
</div>
<SwipeTabs

View File

@@ -1,5 +1,6 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
export default function Dashboard() {
const { t } = useTranslation();
@@ -8,14 +9,14 @@ export default function Dashboard() {
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.dashboard.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">

View File

@@ -1,5 +1,6 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import '../styles/AdminPanel.css';
export default function Feature1() {
@@ -9,14 +10,14 @@ export default function Feature1() {
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.feature1.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">

View File

@@ -1,5 +1,6 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import '../styles/AdminPanel.css';
export default function Feature2() {
@@ -9,14 +10,14 @@ export default function Feature2() {
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.features.feature2}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">
@@ -32,4 +33,3 @@ export default function Feature2() {
</main>
);
}

View File

@@ -1,5 +1,6 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import '../styles/AdminPanel.css';
export default function Feature3() {
@@ -9,14 +10,14 @@ export default function Feature3() {
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.features.feature3}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">
@@ -32,4 +33,3 @@ export default function Feature3() {
</main>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import { useNotifications } from '../contexts/NotificationsContext';
import { notificationsAPI } from '../api/client';
import type { NotificationItem } from '../api/client';
@@ -95,14 +96,14 @@ export default function Notifications() {
return (
<main className="main-content notifications-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.notificationsPage.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content page-content--narrow">

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import '../styles/Search.css';
type SearchResult = {
@@ -77,14 +78,14 @@ export default function Search() {
return (
<main className="main-content search-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.searchPage.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import TabsScroller from '../components/TabsScroller';
import { sessionsAPI, twoFactorAPI } from '../api/client';
import type { UserSession } from '../api/client';
import '../styles/SettingsPage.css';
@@ -152,14 +153,14 @@ export default function Settings() {
return (
<main className="main-content settings-page-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.settings.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content page-content--narrow settings-tab-content">

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import Sidebar from '../components/Sidebar';
import TabsScroller from '../components/TabsScroller';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
@@ -181,7 +182,7 @@ export default function Users() {
<Sidebar />
<main className="main-content users-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
@@ -192,7 +193,7 @@ export default function Users() {
<span className="material-symbols-outlined">add</span>
<span>{t.usersPage.addUser}</span>
</button>
</div>
</TabsScroller>
</div>
<div className="page-content users-page">

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import TabsScroller from '../../components/TabsScroller';
import { analyticsAPI } from '../../api/client';
import type { AnalyticsOverview } from '../../api/client';
import '../../styles/AdminAnalytics.css';
@@ -46,14 +47,14 @@ export default function Analytics() {
return (
<main className="main-content admin-analytics-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.analyticsPage.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">
@@ -151,4 +152,3 @@ export default function Analytics() {
</main>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import TabsScroller from '../../components/TabsScroller';
import { auditAPI } from '../../api/client';
import type { AuditLogItem } from '../../api/client';
import '../../styles/AdminAudit.css';
@@ -59,14 +60,14 @@ export default function AuditLogs() {
return (
<main className="main-content admin-audit-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.auditPage.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content">
@@ -163,4 +164,3 @@ export default function AuditLogs() {
</main>
);
}

View File

@@ -6,6 +6,7 @@ import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
import type { ModuleId } from '../../contexts/ModulesContext';
import Feature1Tab from '../../components/admin/Feature1Tab';
import { SwipeTabs } from '../../components/SwipeTabs';
import TabsScroller from '../../components/TabsScroller';
import '../../styles/AdminPanel.css';
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
@@ -743,7 +744,7 @@ export default function Features() {
return (
<main className="main-content admin-panel-root">
<div className="page-tabs-container">
<div className="page-tabs-slider" ref={tabsContainerRef}>
<TabsScroller className="page-tabs-slider" ref={tabsContainerRef}>
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
@@ -774,7 +775,7 @@ export default function Features() {
</button>
);
})}
</div>
</TabsScroller>
</div>
<SwipeTabs

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import Sidebar from '../../components/Sidebar';
import TabsScroller from '../../components/TabsScroller';
import { settingsAPI } from '../../api/client';
import '../../styles/Settings.css';
@@ -60,14 +61,14 @@ export default function Settings() {
<Sidebar />
<main className="main-content settings-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.settings.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="page-content page-content--narrow">

View File

@@ -1,6 +1,7 @@
import { useAuth } from '../../contexts/AuthContext';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import TabsScroller from '../../components/TabsScroller';
import '../../styles/AdminPanel.css';
export default function Sources() {
@@ -15,14 +16,14 @@ export default function Sources() {
return (
<main className="main-content admin-panel-root">
<div className="admin-tabs-container">
<div className="admin-tabs-slider">
<TabsScroller className="admin-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="admin-title-section">
<span className="admin-title-text">{t.sourcesPage.title}</span>
</div>
</div>
</TabsScroller>
</div>
<div className="admin-tab-content">

View File

@@ -7,6 +7,7 @@ import { useSidebar } from '../../contexts/SidebarContext';
import type { SidebarMode } from '../../contexts/SidebarContext';
import { ChromePicker, HuePicker } from 'react-color';
import { SwipeTabs } from '../../components/SwipeTabs';
import TabsScroller from '../../components/TabsScroller';
import '../../styles/ThemeSettings.css';
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
@@ -660,7 +661,7 @@ export default function ThemeSettings() {
)}
{/* Modern Tab Navigation */}
<div className="page-tabs-container">
<div className="page-tabs-slider">
<TabsScroller className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
@@ -706,7 +707,7 @@ export default function ThemeSettings() {
<span>{t.theme.advancedTab}</span>
</button>
)}
</div>
</TabsScroller>
</div>
<SwipeTabs

View File

@@ -62,6 +62,7 @@ body {
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: smooth;
}
.page-tabs-slider::-webkit-scrollbar,
@@ -69,6 +70,102 @@ body {
display: none;
}
.tabs-scroll-shell {
position: relative;
display: inline-flex;
align-items: center;
max-width: 100%;
min-width: 0;
}
.tabs-scroll-slider {
cursor: grab;
overscroll-behavior-x: contain;
min-width: 0;
}
.tabs-scroll-slider--dragging {
cursor: grabbing;
user-select: none;
}
.tabs-scroll-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease, color 0.2s ease;
z-index: 2;
}
.tabs-scroll-arrow .material-symbols-outlined {
font-size: 20px;
}
.tabs-scroll-arrow.visible {
opacity: 1;
pointer-events: auto;
}
.tabs-scroll-arrow:hover {
background: var(--color-bg-card);
color: var(--color-text-primary);
}
.tabs-scroll-arrow:active {
transform: translateY(-50%) scale(0.96);
}
.tabs-scroll-arrow.left {
left: -12px;
}
.tabs-scroll-arrow.right {
right: -12px;
}
@media (max-width: 1024px) {
.tabs-scroll-slider {
touch-action: pan-x;
}
}
@media (max-width: 768px) {
.tabs-scroll-arrow {
width: 30px;
height: 30px;
}
.tabs-scroll-arrow.left {
left: -8px;
}
.tabs-scroll-arrow.right {
right: -8px;
}
}
@media (max-width: 480px) {
.tabs-scroll-arrow.left {
left: -6px;
}
.tabs-scroll-arrow.right {
right: -6px;
}
}
@supports (color: color-mix(in srgb, black, white)) {
.page-tabs-slider,
.admin-tabs-slider {