Skip to content

Commit ecb9bad

Browse files
ryan-williamsclaude
andcommitted
Add SpeedDial FAB component with expandable secondary actions
Hover-peek + click-to-pin (chevron toggle), long-press support, column-reverse layout, pop-in animation, and configurable actions. Deprecate `MobileFAB` in favor of `SpeedDial`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4bc2351 commit ecb9bad

File tree

5 files changed

+391
-1
lines changed

5 files changed

+391
-1
lines changed

src/MobileFAB.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { useCallback, useEffect, useState } from 'react'
22
import { useMaybeHotkeysContext } from './HotkeysProvider'
33

4+
/**
5+
* @deprecated Use {@link SpeedDialProps} from `SpeedDial` instead.
6+
*/
47
export interface MobileFABProps {
58
/**
69
* Which modal to open when tapped.
@@ -52,6 +55,9 @@ function SearchIcon({ className }: { className?: string }) {
5255
}
5356

5457
/**
58+
* @deprecated Use `SpeedDial` instead, which supports expandable secondary actions,
59+
* hover-peek + click-to-pin, and cross-device support.
60+
*
5561
* Floating Action Button for triggering omnibar/lookup on mobile devices.
5662
*
5763
* On mobile, keyboard shortcuts aren't available, but users can still benefit

src/SearchTrigger.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function SearchIcon({ className }: { className?: string }) {
4141
* A simple button to trigger the omnibar or lookup modal.
4242
*
4343
* Use this to integrate search into your existing UI (FABs, menus, toolbars).
44-
* For a standalone floating action button, use `MobileFAB` instead.
44+
* For a standalone floating action button, use `SpeedDial` instead.
4545
*
4646
* @example
4747
* ```tsx

src/SpeedDial.tsx

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useMaybeHotkeysContext } from './HotkeysProvider'
3+
import { SearchIcon } from './SearchTrigger'
4+
5+
export interface SpeedDialAction {
6+
/** Unique key for React rendering */
7+
key: string
8+
/** Accessible label */
9+
label: string
10+
/** Icon element */
11+
icon: React.ReactNode
12+
/** Click handler (for button actions) */
13+
onClick?: () => void
14+
/** Link href (renders as <a> instead of <button>) */
15+
href?: string
16+
/** Open link in new tab (defaults to true for links) */
17+
external?: boolean
18+
}
19+
20+
export interface SpeedDialProps {
21+
/** Extra actions rendered above the built-in shortcuts action */
22+
actions?: SpeedDialAction[]
23+
/** Whether to show the built-in "Shortcuts" action that opens the omnibar (default: true) */
24+
showShortcuts?: boolean
25+
/** Position offset from viewport edges */
26+
position?: { bottom?: number; right?: number }
27+
/** Duration in ms for long-press to toggle sticky (default: 400) */
28+
longPressDuration?: number
29+
/** Custom icon for the primary button (defaults to SearchIcon) */
30+
primaryIcon?: React.ReactNode
31+
/** Accessible label for the primary button */
32+
ariaLabel?: string
33+
/** Custom CSS class for the container */
34+
className?: string
35+
}
36+
37+
function ChevronIcon({ direction }: { direction: 'up' | 'down' }) {
38+
const d = direction === 'up' ? 'M18 15l-6-6-6 6' : 'M6 9l6 6 6-6'
39+
return (
40+
<svg
41+
viewBox="0 0 24 24"
42+
fill="none"
43+
stroke="currentColor"
44+
strokeWidth="2.5"
45+
strokeLinecap="round"
46+
strokeLinejoin="round"
47+
style={{ width: '1em', height: '1em' }}
48+
>
49+
<path d={d} />
50+
</svg>
51+
)
52+
}
53+
54+
function KeyboardIcon() {
55+
return (
56+
<svg
57+
viewBox="0 0 24 24"
58+
fill="none"
59+
stroke="currentColor"
60+
strokeWidth="2"
61+
strokeLinecap="round"
62+
strokeLinejoin="round"
63+
style={{ width: '1em', height: '1em' }}
64+
>
65+
<rect x="2" y="4" width="20" height="16" rx="2" />
66+
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
67+
</svg>
68+
)
69+
}
70+
71+
export function SpeedDial({
72+
actions,
73+
showShortcuts = true,
74+
position,
75+
longPressDuration = 400,
76+
primaryIcon,
77+
ariaLabel = 'Open command palette',
78+
className,
79+
}: SpeedDialProps) {
80+
const ctx = useMaybeHotkeysContext()
81+
const [isSticky, setIsSticky] = useState(false)
82+
const [isHovered, setIsHovered] = useState(false)
83+
const containerRef = useRef<HTMLDivElement>(null)
84+
const primaryBtnRef = useRef<HTMLButtonElement>(null)
85+
86+
const isExpanded = isSticky || isHovered
87+
88+
// Click-outside to clear sticky
89+
useEffect(() => {
90+
if (!isSticky) return
91+
const handler = (e: MouseEvent | TouchEvent) => {
92+
const target = e.target as Node
93+
if (!document.contains(target)) return
94+
if (containerRef.current && !containerRef.current.contains(target)) {
95+
setIsSticky(false)
96+
}
97+
}
98+
document.addEventListener('mousedown', handler)
99+
document.addEventListener('touchstart', handler)
100+
return () => {
101+
document.removeEventListener('mousedown', handler)
102+
document.removeEventListener('touchstart', handler)
103+
}
104+
}, [isSticky])
105+
106+
// Long-press on primary button (non-passive touch)
107+
useEffect(() => {
108+
const btn = primaryBtnRef.current
109+
if (!btn) return
110+
111+
let timer: ReturnType<typeof setTimeout> | null = null
112+
let longPressFired = false
113+
114+
const onTouchStart = (e: TouchEvent) => {
115+
longPressFired = false
116+
timer = setTimeout(() => {
117+
longPressFired = true
118+
setIsSticky(s => !s)
119+
}, longPressDuration)
120+
}
121+
const onTouchEnd = (e: TouchEvent) => {
122+
if (timer) {
123+
clearTimeout(timer)
124+
timer = null
125+
}
126+
if (longPressFired) {
127+
e.preventDefault()
128+
}
129+
}
130+
const onTouchMove = () => {
131+
if (timer) {
132+
clearTimeout(timer)
133+
timer = null
134+
}
135+
}
136+
137+
btn.addEventListener('touchstart', onTouchStart, { passive: false })
138+
btn.addEventListener('touchend', onTouchEnd, { passive: false })
139+
btn.addEventListener('touchmove', onTouchMove, { passive: true })
140+
return () => {
141+
btn.removeEventListener('touchstart', onTouchStart)
142+
btn.removeEventListener('touchend', onTouchEnd)
143+
btn.removeEventListener('touchmove', onTouchMove)
144+
if (timer) clearTimeout(timer)
145+
}
146+
}, [longPressDuration])
147+
148+
const handlePrimaryClick = useCallback(() => {
149+
if (!ctx) return
150+
ctx.openOmnibar()
151+
}, [ctx])
152+
153+
const handleChevronClick = useCallback(() => {
154+
setIsSticky(s => !s)
155+
}, [])
156+
157+
if (!ctx) return null
158+
159+
const bottom = position?.bottom ?? 20
160+
const right = position?.right ?? 20
161+
162+
const containerClasses = ['kbd-speed-dial']
163+
if (isExpanded) containerClasses.push('kbd-speed-dial-expanded')
164+
if (className) containerClasses.push(className)
165+
166+
const chevronClasses = ['kbd-speed-dial-chevron']
167+
if (isSticky) chevronClasses.push('kbd-speed-dial-sticky')
168+
169+
// Built-in shortcuts action
170+
const builtinActions: SpeedDialAction[] = []
171+
if (showShortcuts) {
172+
builtinActions.push({
173+
key: '_kbd_shortcuts',
174+
label: 'Shortcuts',
175+
icon: <KeyboardIcon />,
176+
onClick: () => ctx.openOmnibar(),
177+
})
178+
}
179+
180+
const allSecondaryActions = [...builtinActions, ...(actions ?? [])]
181+
182+
return (
183+
<div
184+
ref={containerRef}
185+
className={containerClasses.join(' ')}
186+
style={{
187+
position: 'fixed',
188+
bottom: `calc(${bottom}px + env(safe-area-inset-bottom, 0px))`,
189+
right: `${right}px`,
190+
}}
191+
onMouseEnter={() => setIsHovered(true)}
192+
onMouseLeave={() => setIsHovered(false)}
193+
>
194+
{/* DOM order = visual bottom-to-top via column-reverse */}
195+
196+
{/* Primary button (bottom) */}
197+
<button
198+
ref={primaryBtnRef}
199+
type="button"
200+
className="kbd-speed-dial-primary"
201+
onClick={handlePrimaryClick}
202+
aria-label={ariaLabel}
203+
>
204+
{primaryIcon ?? <SearchIcon className="kbd-speed-dial-icon" />}
205+
</button>
206+
207+
{/* Chevron toggle (above primary) */}
208+
<button
209+
type="button"
210+
className={chevronClasses.join(' ')}
211+
onClick={handleChevronClick}
212+
aria-label={isExpanded ? 'Collapse actions' : 'Expand actions'}
213+
aria-expanded={isExpanded}
214+
>
215+
<ChevronIcon direction={isExpanded ? 'down' : 'up'} />
216+
</button>
217+
218+
{/* Secondary actions (above chevron, visible when expanded) */}
219+
{isExpanded && allSecondaryActions.map(action => {
220+
const cls = 'kbd-speed-dial-action'
221+
if (action.href) {
222+
const external = action.external ?? true
223+
return (
224+
<a
225+
key={action.key}
226+
className={cls}
227+
href={action.href}
228+
aria-label={action.label}
229+
title={action.label}
230+
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
231+
>
232+
{action.icon}
233+
</a>
234+
)
235+
}
236+
return (
237+
<button
238+
key={action.key}
239+
type="button"
240+
className={cls}
241+
onClick={action.onClick}
242+
aria-label={action.label}
243+
title={action.label}
244+
>
245+
{action.icon}
246+
</button>
247+
)
248+
})}
249+
</div>
250+
)
251+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export { KeybindingEditor } from './KeybindingEditor'
7474
export { LookupModal } from './LookupModal'
7575
export { MobileFAB } from './MobileFAB'
7676
export type { MobileFABProps } from './MobileFAB'
77+
export { SpeedDial } from './SpeedDial'
78+
export type { SpeedDialAction, SpeedDialProps } from './SpeedDial'
7779
export { SearchTrigger, SearchIcon } from './SearchTrigger'
7880
export type { SearchTriggerProps } from './SearchTrigger'
7981
export { Omnibar } from './Omnibar'

0 commit comments

Comments
 (0)