Add tab bar position setting with edge swipe sidebar

- Add theme_tab_bar_position setting (top/bottom/responsive)
- Tab bar is now fixed at top, stays visible during scroll
- Bottom position uses fixed positioning with safe-area-inset
- Add edge swipe gesture to open sidebar on mobile
- Remove backdrop-filter for better scroll performance
- Simplify TabsScroller by removing inline style manipulation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-23 02:05:49 +01:00
parent f2c6389b21
commit 02c14e3fbd
9 changed files with 428 additions and 26 deletions

View File

@@ -210,6 +210,17 @@ register_setting(SettingDefinition(
category="theme" category="theme"
)) ))
register_setting(SettingDefinition(
key="theme_tab_bar_position",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="top",
description="Position of the tab bar (top, bottom, or responsive)",
category="theme",
choices=["top", "bottom", "responsive"]
))
# ============================================================================= # =============================================================================
# MODULE/FEATURE SETTINGS (Global, Database) # MODULE/FEATURE SETTINGS (Global, Database)

View File

@@ -40,6 +40,7 @@ const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
const rafRef = useRef(0); const rafRef = useRef(0);
const [showLeft, setShowLeft] = useState(false); const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(false); const [showRight, setShowRight] = useState(false);
const dragRef = useRef({ const dragRef = useRef({
pointerId: null as number | null, pointerId: null as number | null,
startX: 0, startX: 0,
@@ -65,13 +66,23 @@ const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
const updateOverflow = useCallback(() => { const updateOverflow = useCallback(() => {
const node = sliderRef.current; const node = sliderRef.current;
if (!node) return; if (!node) return;
const hasTabButtons = Boolean(node.querySelector('.page-tab-btn, .admin-tab-btn'));
const hasTabsValue = hasTabButtons ? 'true' : 'false';
node.dataset.hasTabs = hasTabsValue;
const container = node.closest('.page-tabs-container, .admin-tabs-container');
if (container instanceof HTMLElement) {
container.dataset.hasTabs = hasTabsValue;
}
const mainContent = node.closest('.main-content');
if (mainContent instanceof HTMLElement) {
mainContent.dataset.hasTabs = hasTabsValue;
}
if (!showArrows) { if (!showArrows) {
setShowLeft(false); setShowLeft(false);
setShowRight(false); setShowRight(false);
return; return;
} }
const hasTabs = Boolean(node.querySelector('.page-tab-btn, .admin-tab-btn')); if (!hasTabButtons) {
if (!hasTabs) {
setShowLeft(false); setShowLeft(false);
setShowRight(false); setShowRight(false);
return; return;
@@ -93,6 +104,7 @@ const TabsScroller = forwardRef<HTMLDivElement, TabsScrollerProps>(
updateOverflow(); updateOverflow();
const node = sliderRef.current; const node = sliderRef.current;
if (!node) return undefined; if (!node) return undefined;
const handleScroll = () => scheduleOverflowUpdate(); const handleScroll = () => scheduleOverflowUpdate();
node.addEventListener('scroll', handleScroll, { passive: true }); node.addEventListener('scroll', handleScroll, { passive: true });
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;

View File

@@ -1,8 +1,11 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import { settingsAPI } from '../api/client'; import { settingsAPI } from '../api/client';
const EDGE_SWIPE_THRESHOLD = 30; // pixels from left edge to start swipe
const SWIPE_MIN_DISTANCE = 50; // minimum swipe distance to trigger
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic'; export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
interface SidebarContextType { interface SidebarContextType {
@@ -85,6 +88,12 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
// Can only toggle if mode is 'toggle' // Can only toggle if mode is 'toggle'
const canToggle = sidebarMode === 'toggle'; const canToggle = sidebarMode === 'toggle';
// Sync collapsed state to document root for CSS selectors
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-sidebar-collapsed', String(isCollapsed));
}, [isCollapsed]);
const toggleCollapse = () => { const toggleCollapse = () => {
if (canToggle) { if (canToggle) {
setUserCollapsed((prev) => { setUserCollapsed((prev) => {
@@ -103,6 +112,67 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
setIsMobileOpen(false); setIsMobileOpen(false);
}; };
const openMobileMenu = useCallback(() => {
setIsMobileOpen(true);
}, []);
// Edge swipe detection for mobile
const swipeRef = useRef({
startX: 0,
startY: 0,
isEdgeSwipe: false,
});
useEffect(() => {
const isMobile = () => window.innerWidth <= 768;
const handleTouchStart = (e: TouchEvent) => {
if (!isMobile()) return;
const touch = e.touches[0];
if (!touch) return;
// Check if touch started near left edge
if (touch.clientX <= EDGE_SWIPE_THRESHOLD) {
swipeRef.current = {
startX: touch.clientX,
startY: touch.clientY,
isEdgeSwipe: true,
};
} else {
swipeRef.current.isEdgeSwipe = false;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!swipeRef.current.isEdgeSwipe) return;
const touch = e.touches[0];
if (!touch) return;
const dx = touch.clientX - swipeRef.current.startX;
const dy = Math.abs(touch.clientY - swipeRef.current.startY);
// If horizontal movement is greater than vertical and exceeds threshold, open sidebar
if (dx > SWIPE_MIN_DISTANCE && dx > dy * 2) {
openMobileMenu();
swipeRef.current.isEdgeSwipe = false; // Prevent multiple triggers
}
};
const handleTouchEnd = () => {
swipeRef.current.isEdgeSwipe = false;
};
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handleTouchMove, { passive: true });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [openMobileMenu]);
const setSidebarMode = useCallback(async (mode: SidebarMode) => { const setSidebarMode = useCallback(async (mode: SidebarMode) => {
try { try {
if (!token) return; if (!token) return;

View File

@@ -12,6 +12,7 @@ export type FontFamily = 'sans' | 'inter' | 'roboto';
export type ColorPalette = 'default' | 'monochrome' | 'monochromeBlue' | 'sepia' | 'nord' | 'dracula' | 'solarized' | 'github' | 'ocean' | 'forest' | 'midnight' | 'sunset'; export type ColorPalette = 'default' | 'monochrome' | 'monochromeBlue' | 'sepia' | 'nord' | 'dracula' | 'solarized' | 'github' | 'ocean' | 'forest' | 'midnight' | 'sunset';
export type ControlLocation = 'sidebar' | 'user_menu'; export type ControlLocation = 'sidebar' | 'user_menu';
export type TabBarPosition = 'top' | 'bottom' | 'responsive';
interface ThemeContextType { interface ThemeContextType {
theme: Theme; theme: Theme;
@@ -28,6 +29,7 @@ interface ThemeContextType {
showLanguageToggle: boolean; showLanguageToggle: boolean;
showDarkModeLogin: boolean; showDarkModeLogin: boolean;
showLanguageLogin: boolean; showLanguageLogin: boolean;
tabBarPosition: TabBarPosition;
hasInitializedSettings: boolean; hasInitializedSettings: boolean;
isLoadingSettings: boolean; isLoadingSettings: boolean;
toggleTheme: () => void; toggleTheme: () => void;
@@ -44,6 +46,7 @@ interface ThemeContextType {
setShowLanguageToggle: (show: boolean) => void; setShowLanguageToggle: (show: boolean) => void;
setShowDarkModeLogin: (show: boolean) => void; setShowDarkModeLogin: (show: boolean) => void;
setShowLanguageLogin: (show: boolean) => void; setShowLanguageLogin: (show: boolean) => void;
setTabBarPosition: (position: TabBarPosition) => void;
loadThemeFromBackend: () => Promise<void>; loadThemeFromBackend: () => Promise<void>;
saveThemeToBackend: (overrides?: Partial<{ saveThemeToBackend: (overrides?: Partial<{
accentColor: AccentColor; accentColor: AccentColor;
@@ -59,6 +62,7 @@ interface ThemeContextType {
showLanguageToggle: boolean; showLanguageToggle: boolean;
showDarkModeLogin: boolean; showDarkModeLogin: boolean;
showLanguageLogin: boolean; showLanguageLogin: boolean;
tabBarPosition: TabBarPosition;
}>) => Promise<void>; }>) => Promise<void>;
} }
@@ -480,6 +484,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false); const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false);
const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true); const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true);
const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false); const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false);
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>('top');
const [hasInitializedSettings, setHasInitializedSettings] = useState<boolean>(false); const [hasInitializedSettings, setHasInitializedSettings] = useState<boolean>(false);
const [isLoadingSettings, setIsLoadingSettings] = useState<boolean>(false); const [isLoadingSettings, setIsLoadingSettings] = useState<boolean>(false);
@@ -546,6 +551,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
root.style.setProperty('--font-sans', FONTS[fontFamily]); root.style.setProperty('--font-sans', FONTS[fontFamily]);
}, [fontFamily]); }, [fontFamily]);
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-tab-position', tabBarPosition);
}, [tabBarPosition]);
// Apply color palette with overrides // Apply color palette with overrides
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@@ -653,6 +663,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
console.error('Failed to parse custom colors:', e); console.error('Failed to parse custom colors:', e);
} }
} }
if (themeData.theme_tab_bar_position) {
setTabBarPositionState(themeData.theme_tab_bar_position as TabBarPosition);
}
} catch (error) { } catch (error) {
console.error('Failed to load theme from backend:', error); console.error('Failed to load theme from backend:', error);
} finally { } finally {
@@ -676,6 +689,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
showLanguageToggle: boolean; showLanguageToggle: boolean;
showDarkModeLogin: boolean; showDarkModeLogin: boolean;
showLanguageLogin: boolean; showLanguageLogin: boolean;
tabBarPosition: TabBarPosition;
}>) => { }>) => {
try { try {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
@@ -694,6 +708,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle; if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin; if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin; if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
if (overrides.tabBarPosition !== undefined) payload.theme_tab_bar_position = overrides.tabBarPosition;
} else { } else {
payload.theme_accent_color = accentColor; payload.theme_accent_color = accentColor;
payload.theme_border_radius = borderRadius; payload.theme_border_radius = borderRadius;
@@ -708,6 +723,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
payload.theme_show_language_toggle = showLanguageToggle; payload.theme_show_language_toggle = showLanguageToggle;
payload.theme_show_dark_mode_login = showDarkModeLogin; payload.theme_show_dark_mode_login = showDarkModeLogin;
payload.theme_show_language_login = showLanguageLogin; payload.theme_show_language_login = showLanguageLogin;
payload.theme_tab_bar_position = tabBarPosition;
} }
if (Object.keys(payload).length === 0) return; if (Object.keys(payload).length === 0) return;
@@ -717,7 +733,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
console.error('Failed to save theme to backend:', error); console.error('Failed to save theme to backend:', error);
throw error; throw error;
} }
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]); }, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin, tabBarPosition]);
// Load theme settings for both authenticated and unauthenticated users // Load theme settings for both authenticated and unauthenticated users
useEffect(() => { useEffect(() => {
@@ -776,6 +792,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setShowLanguageLoginState(show); setShowLanguageLoginState(show);
}; };
const setTabBarPosition = (position: TabBarPosition) => {
setTabBarPositionState(position);
};
return ( return (
<ThemeContext.Provider <ThemeContext.Provider
value={{ value={{
@@ -793,6 +813,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
showLanguageToggle, showLanguageToggle,
showDarkModeLogin, showDarkModeLogin,
showLanguageLogin, showLanguageLogin,
tabBarPosition,
hasInitializedSettings, hasInitializedSettings,
isLoadingSettings, isLoadingSettings,
toggleTheme, toggleTheme,
@@ -808,6 +829,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setShowLanguageToggle, setShowLanguageToggle,
setShowDarkModeLogin, setShowDarkModeLogin,
setShowLanguageLogin, setShowLanguageLogin,
setTabBarPosition,
loadThemeFromBackend, loadThemeFromBackend,
saveThemeToBackend, saveThemeToBackend,
setCustomColors: setCustomColorsState, setCustomColors: setCustomColorsState,

View File

@@ -271,6 +271,15 @@
"roboto": "Roboto", "roboto": "Roboto",
"robotoDesc": "Google's classic" "robotoDesc": "Google's classic"
}, },
"tabBarPosition": "Tab Position",
"tabBarPositions": {
"top": "Top",
"topDesc": "Tab bar always on top",
"bottom": "Bottom",
"bottomDesc": "Tab bar always on bottom",
"responsive": "Responsive",
"responsiveDesc": "Top on desktop, bottom on mobile"
},
"palettes": { "palettes": {
"default": "Default", "default": "Default",
"defaultDesc": "Standard modern palette", "defaultDesc": "Standard modern palette",

View File

@@ -271,6 +271,15 @@
"roboto": "Roboto", "roboto": "Roboto",
"robotoDesc": "Il classico di Google" "robotoDesc": "Il classico di Google"
}, },
"tabBarPosition": "Posizione Tab",
"tabBarPositions": {
"top": "Sopra",
"topDesc": "Barra dei tab sempre in alto",
"bottom": "Sotto",
"bottomDesc": "Barra dei tab sempre in basso",
"responsive": "Responsivo",
"responsiveDesc": "In alto su desktop, in basso su mobile"
},
"palettes": { "palettes": {
"default": "Predefinito", "default": "Predefinito",
"defaultDesc": "Palette moderna standard", "defaultDesc": "Palette moderna standard",

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext'; import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext'; import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette, TabBarPosition } from '../../contexts/ThemeContext';
import { useTranslation } from '../../contexts/LanguageContext'; import { useTranslation } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useSidebar } from '../../contexts/SidebarContext'; import { useSidebar } from '../../contexts/SidebarContext';
@@ -39,6 +39,8 @@ export default function ThemeSettings() {
setColorPalette, setColorPalette,
customColors, customColors,
setCustomColors, setCustomColors,
tabBarPosition,
setTabBarPosition,
saveThemeToBackend saveThemeToBackend
} = useTheme(); } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -96,6 +98,11 @@ export default function ThemeSettings() {
saveThemeToBackend({ colorPalette: palette }).catch(console.error); saveThemeToBackend({ colorPalette: palette }).catch(console.error);
}; };
const handleTabBarPositionChange = async (position: TabBarPosition) => {
setTabBarPosition(position);
saveThemeToBackend({ tabBarPosition: position }).catch(console.error);
};
// Color picker popup state // Color picker popup state
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null); const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
const hasUserModifiedCustomColors = useRef(false); const hasUserModifiedCustomColors = useRef(false);
@@ -161,6 +168,12 @@ export default function ThemeSettings() {
{ id: 'roboto', label: t.theme.fontOptions.roboto, description: t.theme.fontOptions.robotoDesc, fontStyle: 'Roboto' }, { id: 'roboto', label: t.theme.fontOptions.roboto, description: t.theme.fontOptions.robotoDesc, fontStyle: 'Roboto' },
]; ];
const tabBarPositions: { id: TabBarPosition; label: string; description: string }[] = [
{ id: 'top', label: t.theme.tabBarPositions?.top || 'Sopra', description: t.theme.tabBarPositions?.topDesc || 'Barra dei tab sempre in alto' },
{ id: 'bottom', label: t.theme.tabBarPositions?.bottom || 'Sotto', description: t.theme.tabBarPositions?.bottomDesc || 'Barra dei tab sempre in basso' },
{ id: 'responsive', label: t.theme.tabBarPositions?.responsive || 'Responsivo', description: t.theme.tabBarPositions?.responsiveDesc || 'In alto su desktop, in basso su mobile' },
];
const palettes: { id: ColorPalette; label: string; description: string }[] = [ const palettes: { id: ColorPalette; label: string; description: string }[] = [
{ id: 'monochrome', label: t.theme.palettes.monochrome, description: t.theme.palettes.monochromeDesc }, { id: 'monochrome', label: t.theme.palettes.monochrome, description: t.theme.palettes.monochromeDesc },
{ id: 'default', label: t.theme.palettes.default, description: t.theme.palettes.defaultDesc }, { id: 'default', label: t.theme.palettes.default, description: t.theme.palettes.defaultDesc },
@@ -471,6 +484,40 @@ export default function ThemeSettings() {
))} ))}
</div> </div>
</div> </div>
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.tabBarPosition || 'Posizione Tab'}</h3>
</div>
<div className="option-cards">
{tabBarPositions.map((pos) => (
<div
key={pos.id}
role="button"
tabIndex={0}
className={`option-card ${tabBarPosition === pos.id ? 'active' : ''}`}
onClick={() => handleTabBarPositionChange(pos.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleTabBarPositionChange(pos.id);
}
}}
>
<div className="option-preview">
<div className={`tabbar-preview tabbar-preview-${pos.id}`}>
<div className="tabbar-line"></div>
<div className="tabbar-content"></div>
</div>
</div>
<div className="option-info">
<span className="option-name">{pos.label}</span>
<span className="option-description">{pos.description}</span>
</div>
</div>
))}
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -29,13 +29,31 @@
/* ========== PAGE STRUCTURE ========== */ /* ========== PAGE STRUCTURE ========== */
/* Page header container - centered with symmetric padding */ /* Page header container - fixed at top */
.page-tabs-container, .page-tabs-container,
.admin-tabs-container { .admin-tabs-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 0.75rem; padding: 0.75rem;
background: transparent; position: fixed;
top: 0;
left: var(--sidebar-width);
right: 0;
z-index: 100;
}
/* Handle collapsed sidebar for fixed header (desktop only) */
@media (min-width: 769px) {
[data-sidebar-collapsed='true'] .page-tabs-container,
[data-sidebar-collapsed='true'] .admin-tabs-container {
left: var(--sidebar-width-collapsed);
}
}
/* Add top padding to content to account for fixed header */
.page-content,
.admin-tab-swipe {
padding-top: calc(var(--page-padding-y) + 60px);
} }
/* Ensure no extra margin from body */ /* Ensure no extra margin from body */
@@ -76,7 +94,6 @@ label,
gap: 4px; gap: 4px;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
max-width: 100%; max-width: 100%;
backdrop-filter: blur(14px) saturate(1.15);
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -423,8 +440,9 @@ label,
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet); padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
} }
.page-content { .page-content,
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet); .admin-tab-swipe {
padding-top: calc(var(--page-padding-y-tablet) + 60px);
} }
.admin-tab-content { .admin-tab-content {
@@ -472,6 +490,12 @@ label,
.page-tabs-container, .page-tabs-container,
.admin-tabs-container { .admin-tabs-container {
padding: 0.75rem; padding: 0.75rem;
left: 0;
}
.page-content,
.admin-tab-swipe {
padding-top: calc(var(--page-padding-y-mobile) + 60px);
} }
.page-tabs-slider, .page-tabs-slider,
@@ -517,29 +541,29 @@ label,
} }
/* Hide title section when tabs are present on mobile */ /* Hide title section when tabs are present on mobile */
.page-tabs-slider:has(.page-tab-btn) .page-title-section, .page-tabs-slider[data-has-tabs='true'] .page-title-section,
.page-tabs-slider:has(.admin-tab-btn) .page-title-section, .page-tabs-slider[data-has-tabs='true'] .admin-title-section,
.admin-tabs-slider:has(.admin-tab-btn) .admin-title-section, .admin-tabs-slider[data-has-tabs='true'] .page-title-section,
.admin-tabs-slider:has(.page-tab-btn) .admin-title-section { .admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
display: none; display: none;
} }
/* Add padding-left when tabs are present to avoid logo overlap */ /* Add padding-left when tabs are present to avoid logo overlap */
.page-tabs-slider:has(.page-tab-btn), .page-tabs-slider[data-has-tabs='true'],
.page-tabs-slider:has(.admin-tab-btn), .admin-tabs-slider[data-has-tabs='true'] {
.admin-tabs-slider:has(.admin-tab-btn),
.admin-tabs-slider:has(.page-tab-btn) {
padding-left: 72px; padding-left: 72px;
} }
/* Center title section absolutely when no tabs are present on mobile */ /* Center title section absolutely when no tabs are present on mobile */
.page-tabs-slider:not(:has(.page-tab-btn)):not(:has(.admin-tab-btn)), .page-tabs-slider[data-has-tabs='false'],
.admin-tabs-slider:not(:has(.admin-tab-btn)):not(:has(.page-tab-btn)) { .admin-tabs-slider[data-has-tabs='false'] {
justify-content: center; justify-content: center;
} }
.page-tabs-slider:not(:has(.page-tab-btn)):not(:has(.admin-tab-btn)) .page-title-section, .page-tabs-slider[data-has-tabs='false'] .page-title-section,
.admin-tabs-slider:not(:has(.admin-tab-btn)):not(:has(.page-tab-btn)) .admin-title-section { .page-tabs-slider[data-has-tabs='false'] .admin-title-section,
.admin-tabs-slider[data-has-tabs='false'] .page-title-section,
.admin-tabs-slider[data-has-tabs='false'] .admin-title-section {
display: flex; display: flex;
position: absolute; position: absolute;
left: 50%; left: 50%;
@@ -550,8 +574,10 @@ label,
} }
/* Lighter icon color in dark theme when only title is shown */ /* Lighter icon color in dark theme when only title is shown */
.page-tabs-slider:not(:has(.page-tab-btn)):not(:has(.admin-tab-btn)) .page-title-section .material-symbols-outlined, .page-tabs-slider[data-has-tabs='false'] .page-title-section .material-symbols-outlined,
.admin-tabs-slider:not(:has(.admin-tab-btn)):not(:has(.page-tab-btn)) .admin-title-section .material-symbols-outlined { .page-tabs-slider[data-has-tabs='false'] .admin-title-section .material-symbols-outlined,
.admin-tabs-slider[data-has-tabs='false'] .page-title-section .material-symbols-outlined,
.admin-tabs-slider[data-has-tabs='false'] .admin-title-section .material-symbols-outlined {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@@ -767,8 +793,7 @@ label,
@supports (color: color-mix(in srgb, black, white)) { @supports (color: color-mix(in srgb, black, white)) {
[data-theme='dark'] .page-tabs-slider, [data-theme='dark'] .page-tabs-slider,
[data-theme='dark'] .admin-tabs-slider { [data-theme='dark'] .admin-tabs-slider {
background: color-mix(in srgb, var(--color-bg-sidebar) 88%, transparent); background: color-mix(in srgb, var(--color-bg-sidebar) 95%, transparent);
backdrop-filter: blur(18px) saturate(1.15);
} }
} }
@@ -815,3 +840,122 @@ label,
[data-theme='dark'][data-accent='auto'] .admin-tab-btn.active:focus-visible { [data-theme='dark'][data-accent='auto'] .admin-tab-btn.active:focus-visible {
box-shadow: 0 0 25px 4px rgba(229, 231, 235, 0.5); box-shadow: 0 0 25px 4px rgba(229, 231, 235, 0.5);
} }
/* ========== TAB BAR POSITION ========== */
/*
* Tab bar positioning system:
* - 'top': default, no changes needed
* - 'bottom': fixed at bottom on all screens (only when tabs are present)
* - 'responsive': top on desktop (>768px), bottom on mobile (<=768px)
*
* Uses data-has-tabs set by TabsScroller to avoid :has() support issues.
*/
/* Bottom position - fixed at bottom (global setting for all pages) */
/* Note: Primary positioning is handled by TabsScroller component with inline styles.
These CSS rules serve as fallback/additional styling. */
[data-tab-position='bottom'] .page-tabs-container,
[data-tab-position='bottom'] .admin-tabs-container {
position: fixed;
top: auto;
bottom: 0;
left: var(--sidebar-width);
right: 0;
z-index: 100;
padding: 0.5rem;
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
}
/* Handle collapsed sidebar */
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .page-tabs-container,
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .admin-tabs-container {
left: var(--sidebar-width-collapsed);
}
/* Adjust slider styling for bottom position */
[data-tab-position='bottom'] .page-tabs-slider,
[data-tab-position='bottom'] .admin-tabs-slider {
width: 100%;
justify-content: flex-start;
}
/* Hide title section when bar is at bottom (only on pages with tabs) */
[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .page-title-section,
[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .admin-title-section,
[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .page-title-section,
[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
display: none;
}
/* Hide divider when bar is at bottom */
[data-tab-position='bottom'] .page-tabs-slider .page-tabs-divider,
[data-tab-position='bottom'] .page-tabs-slider .admin-tabs-divider,
[data-tab-position='bottom'] .admin-tabs-slider .page-tabs-divider,
[data-tab-position='bottom'] .admin-tabs-slider .admin-tabs-divider {
display: none;
}
/* Add bottom padding to main content when bar is at bottom */
[data-tab-position='bottom'] .main-content {
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
}
/* Mobile: full width at bottom */
@media (max-width: 768px) {
[data-tab-position='bottom'] .page-tabs-container,
[data-tab-position='bottom'] .admin-tabs-container {
left: 0;
}
/* Override collapsed state on mobile */
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .page-tabs-container,
[data-tab-position='bottom'][data-sidebar-collapsed='true'] .admin-tabs-container {
left: 0;
}
/* Keep proper spacing for mobile menu button - inherits padding-left: 72px from mobile rules */
}
/* Responsive position - top on desktop, bottom on mobile */
@media (max-width: 768px) {
[data-tab-position='responsive'] .page-tabs-container,
[data-tab-position='responsive'] .admin-tabs-container {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
padding: 0.5rem;
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
}
[data-tab-position='responsive'] .page-tabs-slider,
[data-tab-position='responsive'] .admin-tabs-slider {
width: 100%;
justify-content: flex-start;
/* padding-left: 72px inherited from mobile rules for menu button spacing */
}
/* Hide title section when bar is at bottom (only on pages with tabs) */
[data-tab-position='responsive'] .page-tabs-slider[data-has-tabs='true'] .page-title-section,
[data-tab-position='responsive'] .page-tabs-slider[data-has-tabs='true'] .admin-title-section,
[data-tab-position='responsive'] .admin-tabs-slider[data-has-tabs='true'] .page-title-section,
[data-tab-position='responsive'] .admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
display: none;
}
/* Hide divider when bar is at bottom */
[data-tab-position='responsive'] .page-tabs-slider .page-tabs-divider,
[data-tab-position='responsive'] .page-tabs-slider .admin-tabs-divider,
[data-tab-position='responsive'] .admin-tabs-slider .page-tabs-divider,
[data-tab-position='responsive'] .admin-tabs-slider .admin-tabs-divider {
display: none;
}
/* Add bottom padding to main content */
[data-tab-position='responsive'] .main-content {
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
}
}

View File

@@ -455,6 +455,79 @@
color: var(--color-accent); color: var(--color-accent);
} }
/* Tab Bar Position Preview */
.tabbar-preview {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
position: relative;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabbar-preview .tabbar-line {
height: 6px;
background: var(--color-accent);
border-radius: 2px;
margin: 3px;
flex-shrink: 0;
}
.tabbar-preview .tabbar-content {
flex: 1;
background: var(--color-bg-card);
margin: 0 3px;
border-radius: 2px;
}
/* Top position */
.tabbar-preview-top {
flex-direction: column;
}
.tabbar-preview-top .tabbar-line {
margin-bottom: 0;
}
.tabbar-preview-top .tabbar-content {
margin-bottom: 3px;
}
/* Bottom position */
.tabbar-preview-bottom {
flex-direction: column-reverse;
}
.tabbar-preview-bottom .tabbar-line {
margin-top: 0;
}
.tabbar-preview-bottom .tabbar-content {
margin-top: 3px;
}
/* Responsive position - shows split layout */
.tabbar-preview-responsive {
flex-direction: row;
padding: 3px;
gap: 2px;
}
.tabbar-preview-responsive .tabbar-line {
width: 6px;
height: auto;
margin: 0;
flex-shrink: 0;
}
.tabbar-preview-responsive .tabbar-content {
flex: 1;
margin: 0;
}
/* ========== PREVIEW TAB ========== */ /* ========== PREVIEW TAB ========== */
/* Preview Container - Grid layout for preview cards */ /* Preview Container - Grid layout for preview cards */
@@ -1502,3 +1575,8 @@
border-color: #e5e7eb !important; border-color: #e5e7eb !important;
box-shadow: 0 0 0 3px rgba(229, 231, 235, 0.15) !important; box-shadow: 0 0 0 3px rgba(229, 231, 235, 0.15) !important;
} }
/* Tab bar preview accent */
[data-theme='dark'][data-accent='auto'] .tabbar-preview .tabbar-line {
background: #e5e7eb;
}