1- import { useState , useEffect , useRef } from 'react'
1+ import { useMemo } from 'react'
22import { useLocation } from 'react-router-dom'
3- import { FaGithub , FaKeyboard , FaSearch } from 'react-icons/fa'
3+ import { FaGithub } from 'react-icons/fa'
44import { MdBrightnessAuto , MdLightMode , MdDarkMode } from 'react-icons/md'
5- import Tooltip from '@mui/material/Tooltip '
6- import { useHotkeysContext } from 'use-kbd'
5+ import { SpeedDial } from 'use-kbd '
6+ import type { SpeedDialAction } from 'use-kbd'
77import { useTheme } from '../contexts/ThemeContext'
88
99const GITHUB_BASE = 'https://github.com/runsascoded/use-kbd/tree/main/site/src/routes'
@@ -15,166 +15,43 @@ const ROUTE_FILES: Record<string, string> = {
1515 '/calendar' : 'CalendarDemo.tsx' ,
1616}
1717
18+ function ThemeIcon ( { theme } : { theme : string } ) {
19+ switch ( theme ) {
20+ case 'light' : return < MdLightMode />
21+ case 'dark' : return < MdDarkMode />
22+ default : return < MdBrightnessAuto />
23+ }
24+ }
25+
1826export function FloatingControls ( ) {
19- const ctx = useHotkeysContext ( )
2027 const { theme, setTheme, resolvedTheme } = useTheme ( )
2128 const location = useLocation ( )
22- const [ isVisible , setIsVisible ] = useState ( false )
23- const [ isHovering , setIsHovering ] = useState ( false )
24- const [ themeChangeKey , setThemeChangeKey ] = useState ( 0 )
25- const lastScrollY = useRef ( 0 )
26- const hideTimeout = useRef < ReturnType < typeof setTimeout > | null > ( null )
27- const prevTheme = useRef ( theme )
28- const themeAnimTimeout = useRef < ReturnType < typeof setTimeout > | null > ( null )
29-
30- // Detect touch-only devices (no hover capability)
31- // Use both media query AND screen width as fallback since DevTools doesn't always simulate (hover: hover) correctly
32- const canHover = typeof window !== 'undefined' && window . matchMedia ( '(hover: hover)' ) . matches
33- const isSmallScreen = typeof window !== 'undefined' && window . innerWidth <= 768
34- const isTouchDevice = ! canHover || isSmallScreen
35-
36- // Detect theme changes and show controls with animation
37- useEffect ( ( ) => {
38- if ( theme !== prevTheme . current ) {
39- prevTheme . current = theme
40- // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: show controls on theme change
41- setIsVisible ( true )
42- // Increment key to force animation restart even if already visible
43- setThemeChangeKey ( k => k + 1 )
44-
45- // Clear any existing timeouts
46- if ( hideTimeout . current ) {
47- clearTimeout ( hideTimeout . current )
48- }
49- if ( themeAnimTimeout . current ) {
50- clearTimeout ( themeAnimTimeout . current )
51- }
52-
53- // Hide after delay
54- hideTimeout . current = setTimeout ( ( ) => {
55- setIsVisible ( false )
56- } , 1500 )
57-
58- // Reset animation key after animation completes
59- themeAnimTimeout . current = setTimeout ( ( ) => {
60- setThemeChangeKey ( 0 )
61- } , 500 )
62- }
63- } , [ theme ] )
64-
65- useEffect ( ( ) => {
66- // On touch devices, always show unless explicitly hidden
67- if ( isTouchDevice ) {
68- // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: ensure visibility on touch
69- setIsVisible ( true )
70- return
71- }
72-
73- const handleScroll = ( ) => {
74- const currentScrollY = window . scrollY
75- const scrollingDown = currentScrollY > lastScrollY . current
76- const nearBottom = ( window . innerHeight + currentScrollY ) >= ( document . body . scrollHeight - 100 )
77-
78- if ( hideTimeout . current ) {
79- clearTimeout ( hideTimeout . current )
80- hideTimeout . current = null
81- }
8229
83- if ( ( scrollingDown && currentScrollY > 30 ) || nearBottom ) {
84- setIsVisible ( true )
85- hideTimeout . current = setTimeout ( ( ) => setIsVisible ( false ) , 2500 )
86- } else if ( ! scrollingDown ) {
87- setIsVisible ( false )
88- }
89-
90- lastScrollY . current = currentScrollY
91- }
92-
93- window . addEventListener ( 'scroll' , handleScroll , { passive : true } )
94- return ( ) => {
95- window . removeEventListener ( 'scroll' , handleScroll )
96- if ( hideTimeout . current ) clearTimeout ( hideTimeout . current )
97- }
98- } , [ isTouchDevice ] )
30+ const file = ROUTE_FILES [ location . pathname ] || 'Home.tsx'
31+ const githubUrl = `${ GITHUB_BASE } /${ file } `
9932
10033 const cycleTheme = ( ) => {
10134 if ( theme === 'light' ) setTheme ( 'dark' )
10235 else if ( theme === 'dark' ) setTheme ( 'system' )
10336 else setTheme ( 'light' )
10437 }
10538
106- const getThemeIcon = ( ) => {
107- switch ( theme ) {
108- case 'light' : return < MdLightMode />
109- case 'dark' : return < MdDarkMode />
110- case 'system' : return < MdBrightnessAuto />
111- }
112- }
113-
114- const getThemeLabel = ( ) => {
115- switch ( theme ) {
116- case 'light' : return 'Light'
117- case 'dark' : return 'Dark'
118- case 'system' : return `System (${ resolvedTheme } )`
119- }
120- }
121-
122- // On touch devices, always show
123- // On desktop, show on hover or after scroll
124- const showControls = isTouchDevice || isVisible || isHovering
125- const file = ROUTE_FILES [ location . pathname ] || 'Home.tsx'
126- const githubUrl = `${ GITHUB_BASE } /${ file } `
127-
128- return (
129- < div
130- className = "floating-controls-container"
131- onMouseEnter = { ( ) => setIsHovering ( true ) }
132- onMouseLeave = { ( ) => setIsHovering ( false ) }
133- >
134- < div className = { `floating-controls ${ showControls ? 'visible' : '' } ` } >
135- < Tooltip title = "View source on GitHub" arrow placement = "top" >
136- < a
137- href = { githubUrl }
138- target = "_blank"
139- rel = "noopener noreferrer"
140- className = "floating-btn github-link"
141- aria-label = "View source on GitHub"
142- >
143- < FaGithub />
144- </ a >
145- </ Tooltip >
146- { canHover ? (
147- < Tooltip title = "Keyboard shortcuts (?)" arrow placement = "top" >
148- < button
149- className = "floating-btn shortcuts-btn"
150- onClick = { ( ) => ctx . openModal ( ) }
151- aria-label = "Show keyboard shortcuts"
152- >
153- < FaKeyboard />
154- </ button >
155- </ Tooltip >
156- ) : (
157- < Tooltip title = "Search commands" arrow placement = "top" >
158- < button
159- className = "floating-btn search-btn"
160- onClick = { ( ) => ctx . openOmnibar ( ) }
161- aria-label = "Open command palette"
162- >
163- < FaSearch />
164- </ button >
165- </ Tooltip >
166- ) }
167- < Tooltip title = { `Theme: ${ getThemeLabel ( ) } ` } arrow placement = "top" >
168- < button
169- key = { themeChangeKey }
170- className = { `floating-btn theme-btn ${ themeChangeKey > 0 ? 'theme-changed' : '' } ` }
171- onClick = { cycleTheme }
172- aria-label = { `Current theme: ${ getThemeLabel ( ) } . Click to cycle themes.` }
173- >
174- { getThemeIcon ( ) }
175- </ button >
176- </ Tooltip >
177- </ div >
178- </ div >
179- )
39+ const themeLabel = theme === 'system' ? `System (${ resolvedTheme } )` : theme === 'light' ? 'Light' : 'Dark'
40+
41+ const actions : SpeedDialAction [ ] = useMemo ( ( ) => [
42+ {
43+ key : 'github' ,
44+ label : 'View source on GitHub' ,
45+ icon : < FaGithub /> ,
46+ href : githubUrl ,
47+ } ,
48+ {
49+ key : 'theme' ,
50+ label : `Theme: ${ themeLabel } ` ,
51+ icon : < ThemeIcon theme = { theme } /> ,
52+ onClick : cycleTheme ,
53+ } ,
54+ ] , [ githubUrl , theme , themeLabel ] )
55+
56+ return < SpeedDial actions = { actions } />
18057}
0 commit comments