From 8b1d59fa74fe2dd918b82ce9ca82448e599dc249 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Tue, 30 Dec 2025 14:30:50 +0530 Subject: [PATCH] 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. --- .../StatusBar/ThemeDropdown/StyledWrapper.js | 160 +++++--- .../StatusBar/ThemeDropdown/index.js | 366 ++++++++++++------ 2 files changed, 361 insertions(+), 165 deletions(-) diff --git a/packages/bruno-app/src/components/StatusBar/ThemeDropdown/StyledWrapper.js b/packages/bruno-app/src/components/StatusBar/ThemeDropdown/StyledWrapper.js index 8ee95f244..b3366f445 100644 --- a/packages/bruno-app/src/components/StatusBar/ThemeDropdown/StyledWrapper.js +++ b/packages/bruno-app/src/components/StatusBar/ThemeDropdown/StyledWrapper.js @@ -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}; } `; diff --git a/packages/bruno-app/src/components/StatusBar/ThemeDropdown/index.js b/packages/bruno-app/src/components/StatusBar/ThemeDropdown/index.js index a25c1f680..cdd59ffe6 100644 --- a/packages/bruno-app/src/components/StatusBar/ThemeDropdown/index.js +++ b/packages/bruno-app/src/components/StatusBar/ThemeDropdown/index.js @@ -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) => ( -
- {themes.map((theme) => ( -
handleThemeSelect(theme.id, isLight)} - > - {theme.name} - {currentVariant === theme.id && ( - - )} -
- ))} -
- ); - - const menuContent = ( - -
- {/* Mode Section */} -
Mode
-
handleModeSelect('light')} - > - - Light - {storedTheme === 'light' && ( - - )} -
-
handleModeSelect('dark')} - > - - Dark - {storedTheme === 'dark' && ( - - )} -
-
handleModeSelect('system')} - > - - System - {storedTheme === 'system' && ( - - )} -
- -
- - {/* Light Themes with Submenu */} - setLightSubmenuOpen(false)} - appendTo="parent" - > -
{ - setLightSubmenuOpen(true); - setDarkSubmenuOpen(false); - }} - > - Light Themes - -
-
- - {/* Dark Themes with Submenu */} - setDarkSubmenuOpen(false)} - appendTo="parent" - > -
{ - setDarkSubmenuOpen(true); - setLightSubmenuOpen(false); - }} - > - Dark Themes - -
-
-
- - ); - 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 ( +
+
{label}
+ {themes.map((theme, index) => { + const isActive = currentVariant === theme.id; + return ( +
(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)} + > + {theme.name} + {isActive && } +
+ ); + })} +
+ ); + }; + + // Render mode buttons + const renderModeButtons = () => ( +
+ {MODE_BUTTONS.map((btn, index) => { + const Icon = btn.icon; + const isActive = storedTheme === btn.mode; + return ( + + ); + })} +
+ ); + + // Menu content + const menuContent = ( + +
+
+
Mode
+ {renderModeButtons()} +
+ +
+ {(storedTheme === 'light' || isSystemMode) + && renderThemeList(lightThemes, true, themeVariantLight, 'Light theme')} + {(storedTheme === 'dark' || isSystemMode) + && renderThemeList(darkThemes, false, themeVariantDark, 'Dark theme')} +
+
+
+ ); + return (