feat: enhance ThemeDropdown with keyboard navigation and improved layout (#6554)

* feat: enhance ThemeDropdown with keyboard navigation and improved layout

- Added keyboard navigation support for theme selection.
- Refactored theme selection UI to improve accessibility and user experience.
- Updated styles for better responsiveness and visual clarity.
- Consolidated theme rendering logic for light and dark themes.

* style: update ThemeDropdown styles for focus and active states

- Removed outline styles for focused states to enhance visual clarity.
- Adjusted background and border colors for active state to improve accessibility and user experience.
This commit is contained in:
Abhishek S Lal
2025-12-30 14:30:50 +05:30
committed by GitHub
parent 3a6f2f26ee
commit 8b1d59fa74
2 changed files with 361 additions and 165 deletions

View File

@@ -2,79 +2,149 @@ import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
/* Main container */
.theme-menu {
min-width: 160px;
padding: 4px 0;
min-width: 200px;
height: 325px;
padding: 8px;
background: ${(props) => props.theme.dropdown.bg};
border: 1px solid ${(props) => props.theme.dropdown.separator};
border-radius: ${(props) => props.theme.border.radius.md};
box-shadow: ${(props) => props.theme.dropdown.shadow};
border-radius: 6px;
box-shadow: 0px 1px 4px 0px #0000000D;
outline: none;
&.two-columns {
min-width: 400px;
}
}
.menu-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
/* Mode section */
.mode-section {
padding: 0 8px 12px 8px;
margin: 0 -8px;
border-bottom: 1px solid ${(props) => props.theme.dropdown.separator};
}
.mode-label {
font-size: 12px;
color: ${(props) => props.theme.dropdown.mutedText};
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.menu-item {
.mode-buttons {
display: flex;
gap: 10px;
}
.mode-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 8px 4px;
border: 1px solid ${(props) => props.theme.dropdown.separator};
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.dropdown.mutedText};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.dropdown.color};
}
&.focused {
background: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.dropdown.color};
outline: none;
}
&.active {
background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.08)};
border-color: ${(props) => props.theme.dropdown.selectedColor};
color: ${(props) => props.theme.dropdown.selectedColor};
&.focused {
background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.15)};
outline: none;
}
}
}
/* Theme lists container */
.theme-lists {
display: flex;
gap: 24px;
&.two-columns {
gap: 0;
.theme-list {
flex: 1;
padding: 8px 0;
&:first-child {
padding-right: 12px;
border-right: 1px solid ${(props) => props.theme.dropdown.separator};
}
&:last-child {
padding-left: 12px;
}
}
}
}
/* Individual theme list */
.theme-list {
min-width: 180px;
padding-top: 8px;
}
.theme-list-label {
font-size: 12px;
color: ${(props) => props.theme.dropdown.mutedText};
margin-bottom: 8px;
}
/* Theme item */
.theme-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
height: 26px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
outline: none;
color: ${(props) => props.theme.dropdown.color};
font-size: ${(props) => props.theme.font.size.sm};
&:hover {
&:hover,
&.focused {
background: ${(props) => props.theme.dropdown.hoverBg};
}
&.active {
color: ${(props) => props.theme.dropdown.selectedColor};
background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.07)};
}
background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.08)};
&.has-submenu {
padding-right: 8px;
&.focused {
background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.15)};
}
}
}
.menu-item-icon {
margin-right: 8px;
opacity: 0.7;
}
.menu-item-label {
.theme-item-label {
flex: 1;
white-space: nowrap;
}
.check-icon {
flex-shrink: 0;
margin-left: 12px;
color: ${(props) => props.theme.dropdown.selectedColor};
margin-left: 8px;
}
.chevron-icon {
opacity: 0.6;
margin-left: 8px;
}
.menu-divider {
height: 1px;
background: ${(props) => props.theme.dropdown.separator};
margin: 4px 0;
}
.submenu {
min-width: 180px;
padding: 4px 0;
background: ${(props) => props.theme.dropdown.bg};
border: 1px solid ${(props) => props.theme.dropdown.separator};
border-radius: ${(props) => props.theme.border.radius.md};
box-shadow: ${(props) => props.theme.dropdown.shadow};
}
`;

View File

@@ -1,17 +1,36 @@
import React, { useState } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import Tippy from '@tippyjs/react';
import { IconChevronRight, IconCheck, IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons';
import { IconCheck, IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import { useTheme } from 'providers/Theme';
import { getLightThemes, getDarkThemes } from 'themes/index';
import StyledWrapper from './StyledWrapper';
// Constants
const MODES = ['light', 'dark', 'system'];
const MODE_BUTTONS = [
{ mode: 'light', icon: IconSun, title: 'Light' },
{ mode: 'dark', icon: IconMoon, title: 'Dark' },
{ mode: 'system', icon: IconDeviceDesktop, title: 'System' }
];
const ThemeDropdown = ({ children }) => {
// Dropdown state
const [isOpen, setIsOpen] = useState(false);
const [lightSubmenuOpen, setLightSubmenuOpen] = useState(false);
const [darkSubmenuOpen, setDarkSubmenuOpen] = useState(false);
const [tooltipEnabled, setTooltipEnabled] = useState(true);
// Keyboard navigation state
const [focusedSection, setFocusedSection] = useState('mode');
const [focusedIndex, setFocusedIndex] = useState(0);
const [isKeyboardNav, setIsKeyboardNav] = useState(false);
// Refs for focus management
const menuRef = useRef(null);
const modeButtonRefs = useRef([]);
const lightItemRefs = useRef([]);
const darkItemRefs = useRef([]);
// Theme context
const {
storedTheme,
setStoredTheme,
@@ -21,156 +40,263 @@ const ThemeDropdown = ({ children }) => {
setThemeVariantDark
} = useTheme();
// Theme data
const lightThemes = getLightThemes();
const darkThemes = getDarkThemes();
const isSystemMode = storedTheme === 'system';
const handleModeSelect = (mode) => {
setStoredTheme(mode);
// Helper to get class names for focusable items
const getFocusedClass = (section, index) => {
return isKeyboardNav && focusedSection === section && focusedIndex === index ? 'focused' : '';
};
// Handlers
const handleModeSelect = (mode) => setStoredTheme(mode);
const handleThemeSelect = (themeId, isLight) => {
if (isLight) {
setThemeVariantLight(themeId);
} else {
setThemeVariantDark(themeId);
}
setIsOpen(false);
setLightSubmenuOpen(false);
setDarkSubmenuOpen(false);
};
const renderSubmenu = (themes, isLight, currentVariant) => (
<div className="submenu">
{themes.map((theme) => (
<div
key={theme.id}
className={`menu-item ${currentVariant === theme.id ? 'active' : ''}`}
onClick={() => handleThemeSelect(theme.id, isLight)}
>
<span className="menu-item-label">{theme.name}</span>
{currentVariant === theme.id && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
))}
</div>
);
const menuContent = (
<StyledWrapper>
<div className="theme-menu">
{/* Mode Section */}
<div className="menu-label">Mode</div>
<div
className={`menu-item ${storedTheme === 'light' ? 'active' : ''}`}
onClick={() => handleModeSelect('light')}
>
<IconSun size={14} strokeWidth={1.5} className="menu-item-icon" />
<span className="menu-item-label">Light</span>
{storedTheme === 'light' && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
<div
className={`menu-item ${storedTheme === 'dark' ? 'active' : ''}`}
onClick={() => handleModeSelect('dark')}
>
<IconMoon size={14} strokeWidth={1.5} className="menu-item-icon" />
<span className="menu-item-label">Dark</span>
{storedTheme === 'dark' && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
<div
className={`menu-item ${storedTheme === 'system' ? 'active' : ''}`}
onClick={() => handleModeSelect('system')}
>
<IconDeviceDesktop size={14} strokeWidth={1.5} className="menu-item-icon" />
<span className="menu-item-label">System</span>
{storedTheme === 'system' && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
<div className="menu-divider" />
{/* Light Themes with Submenu */}
<Tippy
content={renderSubmenu(lightThemes, true, themeVariantLight)}
placement="right-start"
interactive={true}
arrow={false}
offset={[0, 2]}
animation={false}
visible={lightSubmenuOpen}
onClickOutside={() => setLightSubmenuOpen(false)}
appendTo="parent"
>
<div
className="menu-item has-submenu"
onMouseEnter={() => {
setLightSubmenuOpen(true);
setDarkSubmenuOpen(false);
}}
>
<span className="menu-item-label">Light Themes</span>
<IconChevronRight size={14} strokeWidth={2} className="chevron-icon" />
</div>
</Tippy>
{/* Dark Themes with Submenu */}
<Tippy
content={renderSubmenu(darkThemes, false, themeVariantDark)}
placement="right-start"
interactive={true}
arrow={false}
offset={[0, 2]}
animation={false}
visible={darkSubmenuOpen}
onClickOutside={() => setDarkSubmenuOpen(false)}
appendTo="parent"
>
<div
className="menu-item has-submenu"
onMouseEnter={() => {
setDarkSubmenuOpen(true);
setLightSubmenuOpen(false);
}}
>
<span className="menu-item-label">Dark Themes</span>
<IconChevronRight size={14} strokeWidth={2} className="chevron-icon" />
</div>
</Tippy>
</div>
</StyledWrapper>
);
const handleOpen = () => {
setTooltipEnabled(false);
setIsOpen(true);
setFocusedSection('mode');
setFocusedIndex(0);
setIsKeyboardNav(false);
};
const handleClose = () => {
setIsOpen(false);
setLightSubmenuOpen(false);
setDarkSubmenuOpen(false);
// Small delay before re-enabling tooltip to prevent flash
setTimeout(() => setTooltipEnabled(true), 100);
};
const handleMouseEnter = (section, index) => {
setIsKeyboardNav(false);
setFocusedSection(section);
setFocusedIndex(index);
};
// Get available sections based on current mode
const getAvailableSections = useCallback(() => {
if (isSystemMode) return ['mode', 'light', 'dark'];
return storedTheme === 'light' ? ['mode', 'light'] : ['mode', 'dark'];
}, [isSystemMode, storedTheme]);
// Get max index for a section
const getMaxIndex = useCallback((section) => {
switch (section) {
case 'mode': return 2;
case 'light': return lightThemes.length - 1;
case 'dark': return darkThemes.length - 1;
default: return 0;
}
}, [lightThemes.length, darkThemes.length]);
// Get mode index for returning to mode section
const getModeIndex = useCallback(() => {
return MODES.indexOf(storedTheme);
}, [storedTheme]);
// Focus element based on current section and index
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
const refs = {
mode: modeButtonRefs,
light: lightItemRefs,
dark: darkItemRefs
};
refs[focusedSection]?.current[focusedIndex]?.focus();
}, 0);
return () => clearTimeout(timer);
}, [isOpen, focusedSection, focusedIndex]);
// Keyboard navigation handler
const handleKeyDown = useCallback((e) => {
if (!isOpen) return;
const sections = getAvailableSections();
const maxIndex = getMaxIndex(focusedSection);
const navigationHandlers = {
'Escape': () => {
e.preventDefault();
handleClose();
},
'ArrowDown': () => {
e.preventDefault();
setIsKeyboardNav(true);
if (focusedSection === 'mode') {
setFocusedSection(sections[1]);
setFocusedIndex(0);
} else if (focusedIndex < maxIndex) {
setFocusedIndex(focusedIndex + 1);
}
},
'ArrowUp': () => {
e.preventDefault();
setIsKeyboardNav(true);
if (focusedSection !== 'mode') {
if (focusedIndex > 0) {
setFocusedIndex(focusedIndex - 1);
} else {
setFocusedSection('mode');
setFocusedIndex(getModeIndex());
}
}
},
'ArrowLeft': () => {
e.preventDefault();
setIsKeyboardNav(true);
if (focusedSection === 'mode') {
if (focusedIndex > 0) setFocusedIndex(focusedIndex - 1);
} else if (isSystemMode && focusedSection === 'dark') {
setFocusedSection('light');
setFocusedIndex(Math.min(focusedIndex, lightThemes.length - 1));
}
},
'ArrowRight': () => {
e.preventDefault();
setIsKeyboardNav(true);
if (focusedSection === 'mode') {
if (focusedIndex < 2) setFocusedIndex(focusedIndex + 1);
} else if (isSystemMode && focusedSection === 'light') {
setFocusedSection('dark');
setFocusedIndex(Math.min(focusedIndex, darkThemes.length - 1));
}
},
'Enter': () => {
e.preventDefault();
if (focusedSection === 'mode') {
handleModeSelect(MODES[focusedIndex]);
} else if (focusedSection === 'light') {
handleThemeSelect(lightThemes[focusedIndex].id, true);
} else if (focusedSection === 'dark') {
handleThemeSelect(darkThemes[focusedIndex].id, false);
}
},
' ': () => navigationHandlers.Enter(),
'Tab': () => handleClose()
};
navigationHandlers[e.key]?.();
}, [
isOpen, focusedSection, focusedIndex, getAvailableSections,
getMaxIndex, getModeIndex, isSystemMode, lightThemes, darkThemes
]);
// Set up keyboard listener
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
// Render theme list
const renderThemeList = (themes, isLight, currentVariant, label) => {
const refs = isLight ? lightItemRefs : darkItemRefs;
const section = isLight ? 'light' : 'dark';
return (
<div className="theme-list" role="listbox" aria-label={label}>
<div className="theme-list-label">{label}</div>
{themes.map((theme, index) => {
const isActive = currentVariant === theme.id;
return (
<div
key={theme.id}
ref={(el) => (refs.current[index] = el)}
className={`theme-item ${isActive ? 'active' : ''} ${getFocusedClass(section, index)}`}
role="option"
aria-selected={isActive}
tabIndex={-1}
onClick={() => handleThemeSelect(theme.id, isLight)}
onMouseEnter={() => handleMouseEnter(section, index)}
>
<span className="theme-item-label">{theme.name}</span>
{isActive && <IconCheck size={14} strokeWidth={2} className="check-icon" />}
</div>
);
})}
</div>
);
};
// Render mode buttons
const renderModeButtons = () => (
<div className="mode-buttons" role="radiogroup" aria-labelledby="mode-label">
{MODE_BUTTONS.map((btn, index) => {
const Icon = btn.icon;
const isActive = storedTheme === btn.mode;
return (
<button
key={btn.mode}
ref={(el) => (modeButtonRefs.current[index] = el)}
className={`mode-button ${isActive ? 'active' : ''} ${getFocusedClass('mode', index)}`}
role="radio"
aria-checked={isActive}
tabIndex={-1}
onClick={() => handleModeSelect(btn.mode)}
onMouseEnter={() => handleMouseEnter('mode', index)}
title={btn.title}
>
<Icon size={18} strokeWidth={1.5} />
</button>
);
})}
</div>
);
// Menu content
const menuContent = (
<StyledWrapper>
<div
ref={menuRef}
className={`theme-menu ${isSystemMode ? 'two-columns' : ''}`}
role="dialog"
aria-label="Theme selector"
>
<div className="mode-section">
<div className="mode-label" id="mode-label">Mode</div>
{renderModeButtons()}
</div>
<div className={`theme-lists ${isSystemMode ? 'two-columns' : ''}`}>
{(storedTheme === 'light' || isSystemMode)
&& renderThemeList(lightThemes, true, themeVariantLight, 'Light theme')}
{(storedTheme === 'dark' || isSystemMode)
&& renderThemeList(darkThemes, false, themeVariantDark, 'Dark theme')}
</div>
</div>
</StyledWrapper>
);
return (
<ToolHint text="Theme" toolhintId="ThemeDropdown" place="top" offset={10} hidden={!tooltipEnabled}>
<Tippy
content={menuContent}
placement="top-start"
interactive={true}
interactive
arrow={false}
animation={false}
visible={isOpen}
onClickOutside={handleClose}
appendTo="parent"
>
<div onClick={() => isOpen ? handleClose() : handleOpen()}>
<div onClick={() => (isOpen ? handleClose() : handleOpen())}>
{children}
</div>
</Tippy>