Skip to content

Commit d6e8224

Browse files
ryan-williamsclaude
andcommitted
Use SpeedDial in demo site, fix shortcuts action to open ShortcutsModal
- Built-in keyboard action now calls `openModal()` instead of `openOmnibar()` - Primary button defaults to page bg color (`--kbd-bg`) with border, not accent blue — customizable via `--kbd-speed-dial-primary-bg/color` - Replace `FloatingControls` with `<SpeedDial>` in demo site - Remove `.floating-controls-*` CSS from `_shared.scss` - Update `TableDemo` click-outside guard to use `.kbd-speed-dial` - Update e2e test for SpeedDial selector 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ecb9bad commit d6e8224

File tree

6 files changed

+43
-264
lines changed

6 files changed

+43
-264
lines changed

site/e2e/hotkeys.spec.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ test.describe('Data Table Demo', () => {
745745
await expect(rows.nth(3)).not.toHaveClass(/selected/)
746746
})
747747

748-
test('clicking FAB to open omnibar does not reset table selection', async ({ page }) => {
748+
test('clicking SpeedDial to open omnibar does not reset table selection', async ({ page }) => {
749749
// Bug: clicking the floating search button triggered document click handler
750750
// which reset hoveredIndex to -1, causing off-by-one errors
751751
const rows = page.locator('.data-table tbody tr')
@@ -754,20 +754,11 @@ test.describe('Data Table Demo', () => {
754754
await rows.nth(2).click()
755755
await expect(rows.nth(2)).toHaveClass(/selected/)
756756

757-
// Make controls visible first (they might be hidden)
758-
await page.evaluate(() => {
759-
const controls = document.querySelector('.floating-controls')
760-
if (controls) controls.classList.add('visible')
761-
})
757+
// Click the SpeedDial primary button (always visible)
758+
const primaryBtn = page.locator('.kbd-speed-dial-primary')
759+
await primaryBtn.click()
762760
await page.waitForTimeout(100)
763761

764-
// Click any button in floating controls
765-
const anyFloatingBtn = page.locator('.floating-controls .floating-btn').first()
766-
if (await anyFloatingBtn.isVisible()) {
767-
await anyFloatingBtn.click()
768-
await page.waitForTimeout(100)
769-
}
770-
771762
// Selection should still be on row 2 (not reset to -1)
772763
await expect(rows.nth(2)).toHaveClass(/selected/)
773764
})
Lines changed: 32 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useState, useEffect, useRef } from 'react'
1+
import { useMemo } from 'react'
22
import { useLocation } from 'react-router-dom'
3-
import { FaGithub, FaKeyboard, FaSearch } from 'react-icons/fa'
3+
import { FaGithub } from 'react-icons/fa'
44
import { 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'
77
import { useTheme } from '../contexts/ThemeContext'
88

99
const 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+
1826
export 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
}

site/src/routes/TableDemo.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,7 @@ function DataTable() {
100100
target.closest('.kbd-backdrop') ||
101101
target.closest('.kbd-omnibar') ||
102102
target.closest('.kbd-omnibar-backdrop') ||
103-
target.closest('.floating-controls-container') ||
104-
target.closest('.floating-controls')
103+
target.closest('.kbd-speed-dial')
105104
) {
106105
return
107106
}

site/src/styles/_shared.scss

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -42,93 +42,3 @@
4242
}
4343
}
4444

45-
// Floating Controls (theme toggle, etc.)
46-
.floating-controls-container {
47-
position: fixed;
48-
bottom: 0;
49-
right: 0;
50-
width: 180px;
51-
height: 120px;
52-
z-index: 1000;
53-
}
54-
55-
.floating-controls {
56-
position: absolute;
57-
bottom: 1.5rem;
58-
right: 1.5rem;
59-
display: flex;
60-
gap: 0.75rem;
61-
align-items: center;
62-
opacity: 0;
63-
transform: translateY(80px);
64-
pointer-events: none;
65-
transition: all 0.3s ease;
66-
67-
&.visible {
68-
opacity: 1;
69-
transform: translateY(0);
70-
pointer-events: auto;
71-
}
72-
}
73-
74-
.floating-btn {
75-
width: 2.75rem;
76-
height: 2.75rem;
77-
border-radius: 50%;
78-
background: var(--bg-tertiary);
79-
border: 2px solid var(--border-secondary);
80-
box-shadow: 0 4px 12px var(--shadow);
81-
cursor: pointer;
82-
display: flex;
83-
align-items: center;
84-
justify-content: center;
85-
font-size: 1.1rem;
86-
color: var(--text-primary);
87-
text-decoration: none;
88-
transition: all 0.2s ease;
89-
90-
&:hover {
91-
transform: translateY(-2px);
92-
box-shadow: 0 6px 16px var(--shadow);
93-
border-color: var(--link-color);
94-
color: var(--link-color);
95-
96-
svg {
97-
transform: scale(1.1);
98-
}
99-
}
100-
101-
svg {
102-
transition: transform 0.2s ease;
103-
}
104-
105-
&.theme-changed {
106-
animation: theme-pulse 0.4s ease-out;
107-
border-color: var(--link-color);
108-
}
109-
}
110-
111-
@keyframes theme-pulse {
112-
0% { transform: scale(1); box-shadow: 0 4px 12px var(--shadow); }
113-
50% { transform: scale(1.15); box-shadow: 0 0 20px var(--link-color); }
114-
100% { transform: scale(1); box-shadow: 0 4px 12px var(--shadow); }
115-
}
116-
117-
@media (max-width: 768px) {
118-
.floating-controls-container {
119-
width: 140px;
120-
height: 90px;
121-
}
122-
123-
.floating-controls {
124-
bottom: 1rem;
125-
right: 1rem;
126-
gap: 0.5rem;
127-
}
128-
129-
.floating-btn {
130-
width: 2.25rem;
131-
height: 2.25rem;
132-
font-size: 0.9rem;
133-
}
134-
}

src/SpeedDial.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function SpeedDial({
173173
key: '_kbd_shortcuts',
174174
label: 'Shortcuts',
175175
icon: <KeyboardIcon />,
176-
onClick: () => ctx.openOmnibar(),
176+
onClick: () => ctx.openModal(),
177177
})
178178
}
179179

src/styles.css

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,8 @@
12351235
--kbd-speed-dial-font: 22px;
12361236
--kbd-speed-dial-font-sm: 18px;
12371237
--kbd-speed-dial-gap: 8px;
1238+
--kbd-speed-dial-primary-bg: var(--kbd-bg);
1239+
--kbd-speed-dial-primary-color: var(--kbd-text);
12381240
}
12391241

12401242
@media (max-width: 768px) {
@@ -1258,9 +1260,9 @@
12581260
width: var(--kbd-speed-dial-size);
12591261
height: var(--kbd-speed-dial-size);
12601262
border-radius: 50%;
1261-
background-color: var(--kbd-accent);
1262-
color: white;
1263-
border: none;
1263+
background-color: var(--kbd-speed-dial-primary-bg);
1264+
color: var(--kbd-speed-dial-primary-color);
1265+
border: 1px solid var(--kbd-border);
12641266
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
12651267
cursor: pointer;
12661268
display: flex;

0 commit comments

Comments
 (0)