Skip to content

Commit 5099903

Browse files
authored
bundle-analyzer: add kbd shortcut labels and fix focus bugs (#87001)
- Adds decorated kbd-style shortcut labels to cue the user into using them - Listens only for the appropriate OS or Ctrl key on Mac/Others - Esc now clears and focuses the search field and the treemap focus - Does not trigger in the case where the user has another element focused ![image.png](https://app.graphite.com/user-attachments/assets/6a85d1e7-a711-432d-86b0-f65c0b89de74.png)
1 parent 24ccec0 commit 5099903

File tree

4 files changed

+144
-61
lines changed

4 files changed

+144
-61
lines changed

apps/bundle-analyzer/app/page.tsx

Lines changed: 17 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
'use client'
22

33
import type React from 'react'
4-
import { useEffect, useMemo, useRef, useState } from 'react'
4+
import { useEffect, useMemo, useState } from 'react'
55
import useSWR from 'swr'
66
import { ErrorState } from '@/components/error-state'
7-
import {
8-
RouteTypeahead,
9-
type RouteTypeaheadRef,
10-
} from '@/components/route-typeahead'
7+
import { FileSearch } from '@/components/file-search'
8+
import { RouteTypeahead } from '@/components/route-typeahead'
119
import { Sidebar } from '@/components/sidebar'
1210
import { TreemapVisualizer } from '@/components/treemap-visualizer'
1311

1412
import { Badge } from '@/components/ui/badge'
15-
import { Input } from '@/components/ui/input'
1613
import { TreemapSkeleton } from '@/components/ui/skeleton'
1714
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
1815
import { AnalyzeData, ModulesData } from '@/lib/analyze-data'
@@ -72,31 +69,27 @@ export default function Home() {
7269
client?: boolean
7370
} | null>(null)
7471
const [searchQuery, setSearchQuery] = useState('')
75-
const [searchFocused, setSearchFocused] = useState(false)
76-
77-
const routeTypeaheadRef = useRef<RouteTypeaheadRef>(null)
78-
const searchInputRef = useRef<HTMLInputElement>(null)
7972

8073
useEffect(() => {
8174
const handleKeyDown = (e: KeyboardEvent) => {
82-
// Cmd+K or Ctrl+K to focus route filter
83-
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
84-
e.preventDefault()
85-
routeTypeaheadRef.current?.focus()
86-
}
87-
// / to focus search (only if not already in an input)
88-
else if (
89-
e.key === '/' &&
90-
!['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)
91-
) {
92-
e.preventDefault()
93-
searchInputRef.current?.focus()
75+
// esc clears current treemap source selection
76+
if (e.key === 'Escape') {
77+
const activeElement = document.activeElement
78+
const isInputFocused =
79+
activeElement && ['INPUT', 'TEXTAREA'].includes(activeElement.tagName)
80+
81+
if (!isInputFocused) {
82+
e.preventDefault()
83+
const rootSourceIndex = getRootSourceIndex(analyzeData)
84+
setSelectedSourceIndex(rootSourceIndex)
85+
setFocusedSourceIndex(rootSourceIndex)
86+
}
9487
}
9588
}
9689

9790
window.addEventListener('keydown', handleKeyDown)
9891
return () => window.removeEventListener('keydown', handleKeyDown)
99-
}, [])
92+
}, [analyzeData])
10093

10194
// Compute module depth map from active entries
10295
const moduleDepthMap = useMemo(() => {
@@ -155,7 +148,6 @@ export default function Home() {
155148
<div className="flex-none px-4 py-2 border-b border-border flex items-center gap-4">
156149
<div className="basis-1/3 flex">
157150
<RouteTypeahead
158-
ref={routeTypeaheadRef}
159151
selectedRoute={selectedRoute}
160152
onRouteSelected={(route) => {
161153
setSelectedRoute(route)
@@ -196,27 +188,7 @@ export default function Home() {
196188
<ToggleGroupItem value="asset">Asset</ToggleGroupItem>
197189
</ToggleGroup>
198190

199-
{!searchFocused && (
200-
<div className="flex items-center gap-4 text-xs">
201-
<p className="text-muted-foreground">
202-
{
203-
analyzeData.source(focusedSourceIndex ?? rootSourceIndex)
204-
?.path
205-
}
206-
</p>
207-
</div>
208-
)}
209-
210-
<Input
211-
ref={searchInputRef}
212-
type="search"
213-
value={searchQuery}
214-
onChange={(e) => setSearchQuery(e.target.value)}
215-
onFocus={() => setSearchFocused(true)}
216-
onBlur={() => setSearchFocused(false)}
217-
placeholder="Search files..."
218-
className="w-48 focus:w-80 transition-all duration-200"
219-
/>
191+
<FileSearch value={searchQuery} onChange={setSearchQuery} />
220192
</>
221193
)}
222194
</div>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
import { Input } from '@/components/ui/input'
5+
import { Kbd } from '@/components/ui/kbd'
6+
7+
interface FileSearchProps {
8+
value: string
9+
onChange: (value: string) => void
10+
}
11+
12+
export function FileSearch({ value, onChange }: FileSearchProps) {
13+
const [focused, setFocused] = useState(false)
14+
const inputRef = useRef<HTMLInputElement>(null)
15+
16+
useEffect(() => {
17+
const handleKeyDown = (e: KeyboardEvent) => {
18+
if (
19+
e.key === '/' &&
20+
!['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)
21+
) {
22+
e.preventDefault()
23+
inputRef.current?.focus()
24+
} else if (
25+
e.key === 'Escape' &&
26+
document.activeElement === inputRef.current
27+
) {
28+
e.preventDefault()
29+
onChange('')
30+
inputRef.current?.blur()
31+
}
32+
}
33+
34+
window.addEventListener('keydown', handleKeyDown)
35+
return () => window.removeEventListener('keydown', handleKeyDown)
36+
}, [onChange])
37+
38+
const handleFocus = () => {
39+
setFocused(true)
40+
}
41+
42+
const handleBlur = () => {
43+
setFocused(false)
44+
}
45+
46+
return (
47+
<div className="relative">
48+
<Input
49+
ref={inputRef}
50+
type="search"
51+
value={value}
52+
onChange={(e) => onChange(e.target.value)}
53+
onFocus={handleFocus}
54+
onBlur={handleBlur}
55+
placeholder="Search files..."
56+
className="w-48 focus:w-80 transition-all duration-200 pr-8"
57+
/>
58+
{!value && !focused && (
59+
<Kbd className="absolute right-2 top-1/2 -translate-y-1/2">/</Kbd>
60+
)}
61+
</div>
62+
)
63+
}

apps/bundle-analyzer/components/route-typeahead.tsx

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import useSWR from 'swr'
44
import { Check, ChevronsUpDown, Loader, Route } from 'lucide-react'
5-
import { forwardRef, useImperativeHandle, useState } from 'react'
5+
import { useEffect, useState } from 'react'
66
import { Button } from '@/components/ui/button'
77
import {
88
Command,
@@ -19,27 +19,44 @@ import {
1919
} from '@/components/ui/popover'
2020
import { cn, jsonFetcher } from '@/lib/utils'
2121
import { NetworkError } from '@/lib/errors'
22+
import { Kbd } from '@/components/ui/kbd'
2223

2324
interface RouteTypeaheadProps {
2425
selectedRoute: string | null
2526
onRouteSelected: (routeName: string) => void
2627
}
2728

28-
export interface RouteTypeaheadRef {
29-
focus: () => void
30-
}
31-
32-
export const RouteTypeahead = forwardRef<
33-
RouteTypeaheadRef,
34-
RouteTypeaheadProps
35-
>(function RouteTypeahead({ selectedRoute, onRouteSelected }, ref) {
29+
export function RouteTypeahead({
30+
selectedRoute,
31+
onRouteSelected,
32+
}: RouteTypeaheadProps) {
3633
const [open, setOpen] = useState(false)
34+
const [shortcutLabel, setShortcutLabel] = useState<string | null>(null)
3735

38-
useImperativeHandle(ref, () => ({
39-
focus: () => {
40-
setOpen(true)
41-
},
42-
}))
36+
useEffect(() => {
37+
const isAppleDevice = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
38+
setShortcutLabel(isAppleDevice ? '⌘K' : 'Ctrl+K')
39+
40+
const handleKeyDown = (e: KeyboardEvent) => {
41+
const activeElement = document.activeElement
42+
const isInputFocused =
43+
activeElement && ['INPUT', 'TEXTAREA'].includes(activeElement.tagName)
44+
45+
if (isInputFocused) return
46+
47+
const isShortcutPressed = isAppleDevice
48+
? e.metaKey && e.key === 'k'
49+
: e.ctrlKey && e.key === 'k'
50+
51+
if (isShortcutPressed) {
52+
e.preventDefault()
53+
setOpen(true)
54+
}
55+
}
56+
57+
window.addEventListener('keydown', handleKeyDown)
58+
return () => window.removeEventListener('keydown', handleKeyDown)
59+
}, [])
4360

4461
const {
4562
data: routes,
@@ -96,7 +113,10 @@ export const RouteTypeahead = forwardRef<
96113

97114
{ctaText}
98115
</div>
99-
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
116+
<div className="flex items-center gap-2">
117+
{shortcutLabel && <Kbd>{shortcutLabel}</Kbd>}
118+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
119+
</div>
100120
</Button>
101121
</PopoverTrigger>
102122
<PopoverContent className="w-96 p-0">
@@ -133,4 +153,4 @@ export const RouteTypeahead = forwardRef<
133153
</Popover>
134154
</div>
135155
)
136-
})
156+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { cn } from '@/lib/utils'
2+
3+
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
4+
return (
5+
<kbd
6+
data-slot="kbd"
7+
className={cn(
8+
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
9+
"[&_svg:not([class*='size-'])]:size-3",
10+
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
11+
className
12+
)}
13+
{...props}
14+
/>
15+
)
16+
}
17+
18+
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
19+
return (
20+
<kbd
21+
data-slot="kbd-group"
22+
className={cn('inline-flex items-center gap-1', className)}
23+
{...props}
24+
/>
25+
)
26+
}
27+
28+
export { Kbd, KbdGroup }

0 commit comments

Comments
 (0)