Skip to content

Commit 6c7ee6f

Browse files
committed
bundle-analyzer: use <Select> and multiselect for top bar
The top bar is getting quite wide with all of its button toggle groups. This: - Converts the Compressed/Uncompressed and Client/Server toggles to shadcn `<Select>`s - Adds a custom `<MultiSelect>` implementation since this is missing in shadcn. This composes shadcn’s popovers and a series of checkboxes, along with accessibility attributes and keyboard shortcuts. - Uses this `<MultiSelect>` for the type filter - Removes the divider between these since there aren’t any groups left to divide
1 parent be7f3f3 commit 6c7ee6f

File tree

5 files changed

+523
-143
lines changed

5 files changed

+523
-143
lines changed

apps/bundle-analyzer/app/page.tsx

Lines changed: 92 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,28 @@ import { TreemapVisualizer } from '@/components/treemap-visualizer'
1313

1414
import { Badge } from '@/components/ui/badge'
1515
import { TreemapSkeleton } from '@/components/ui/skeleton'
16-
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
16+
import {
17+
Select,
18+
SelectContent,
19+
SelectItem,
20+
SelectTrigger,
21+
SelectValue,
22+
} from '@/components/ui/select'
23+
import { MultiSelect } from '@/components/ui/multi-select'
1724
import { AnalyzeData, ModulesData } from '@/lib/analyze-data'
1825
import { computeActiveEntries, computeModuleDepthMap } from '@/lib/module-graph'
1926
import { fetchStrict } from '@/lib/utils'
2027
import { formatBytes } from '@/lib/utils'
28+
import {
29+
File,
30+
FileArchive,
31+
Monitor,
32+
Server,
33+
FileCode,
34+
FileJson,
35+
Palette,
36+
Package,
37+
} from 'lucide-react'
2138

2239
enum Environment {
2340
Client = 'client',
@@ -266,6 +283,25 @@ export default function Home() {
266283
)
267284
}
268285

286+
const typeFilterOptions = [
287+
{
288+
value: 'js',
289+
label: 'JavaScript',
290+
icon: <FileCode className="h-3.5 w-3.5" />,
291+
},
292+
{ value: 'css', label: 'CSS', icon: <Palette className="h-3.5 w-3.5" /> },
293+
{
294+
value: 'json',
295+
label: 'JSON',
296+
icon: <FileJson className="h-3.5 w-3.5" />,
297+
},
298+
{
299+
value: 'asset',
300+
label: 'Asset',
301+
icon: <Package className="h-3.5 w-3.5" />,
302+
},
303+
]
304+
269305
function TopBar({
270306
analyzeData,
271307
selectedRoute,
@@ -296,8 +332,8 @@ function TopBar({
296332
setSearchQuery: (query: string) => void
297333
}) {
298334
return (
299-
<div className="flex-none px-4 py-2 border-b border-border flex items-center gap-4">
300-
<div className="basis-1/3 flex">
335+
<div className="flex-none px-4 py-2 border-b border-border flex items-center gap-3">
336+
<div className="flex-1 flex">
301337
<RouteTypeahead
302338
selectedRoute={selectedRoute}
303339
onRouteSelected={(route) => {
@@ -308,58 +344,66 @@ function TopBar({
308344
/>
309345
</div>
310346

311-
<div className="basis-2/3 flex justify-end items-center space-x-4">
347+
<div className="flex items-center gap-2">
312348
{analyzeData && (
313349
<>
314-
<ToggleGroup
315-
type="single"
350+
<Select
316351
value={sizeMode}
317-
onValueChange={(value) => {
318-
if (value) setSizeMode(value as SizeMode)
319-
}}
320-
size="sm"
352+
onValueChange={(value) => setSizeMode(value as SizeMode)}
321353
>
322-
<ToggleGroupItem value={SizeMode.Uncompressed}>
323-
Uncompressed
324-
</ToggleGroupItem>
325-
<ToggleGroupItem value={SizeMode.Compressed}>
326-
Compressed
327-
</ToggleGroupItem>
328-
</ToggleGroup>
329-
330-
<ControlDivider />
331-
332-
<ToggleGroup
333-
type="single"
354+
<SelectTrigger className="w-fit min-w-[120px] h-8 text-xs">
355+
<SelectValue />
356+
</SelectTrigger>
357+
<SelectContent>
358+
<SelectItem value={SizeMode.Uncompressed} className="text-xs">
359+
<div className="flex items-center gap-1.5">
360+
<File className="h-3.5 w-3.5" />
361+
<span className="text-xs">Uncompressed</span>
362+
</div>
363+
</SelectItem>
364+
<SelectItem value={SizeMode.Compressed} className="text-xs">
365+
<div className="flex items-center gap-1.5">
366+
<FileArchive className="h-3.5 w-3.5" />
367+
<span className="text-xs">Compressed</span>
368+
</div>
369+
</SelectItem>
370+
</SelectContent>
371+
</Select>
372+
373+
<Select
334374
value={environmentFilter}
335-
onValueChange={(value) => {
336-
if (value) setEnvironmentFilter(value as Environment)
337-
}}
338-
size="sm"
375+
onValueChange={(value) =>
376+
setEnvironmentFilter(value as Environment)
377+
}
339378
>
340-
<ToggleGroupItem value={Environment.Client}>
341-
Client
342-
</ToggleGroupItem>
343-
<ToggleGroupItem value={Environment.Server}>
344-
Server
345-
</ToggleGroupItem>
346-
</ToggleGroup>
347-
348-
<ControlDivider />
349-
350-
<ToggleGroup
351-
type="multiple"
379+
<SelectTrigger className="w-fit min-w-[100px] h-8 text-xs">
380+
<SelectValue />
381+
</SelectTrigger>
382+
<SelectContent>
383+
<SelectItem value={Environment.Client} className="text-xs">
384+
<div className="flex items-center gap-1.5">
385+
<Monitor className="h-3.5 w-3.5" />
386+
<span className="text-xs">Client</span>
387+
</div>
388+
</SelectItem>
389+
<SelectItem value={Environment.Server} className="text-xs">
390+
<div className="flex items-center gap-1.5">
391+
<Server className="h-3.5 w-3.5" />
392+
<span className="text-xs">Server</span>
393+
</div>
394+
</SelectItem>
395+
</SelectContent>
396+
</Select>
397+
398+
<MultiSelect
399+
options={typeFilterOptions}
352400
value={typeFilter}
353-
onValueChange={(value) => {
354-
if (value.length > 0) setTypeFilter(value)
355-
}}
356-
size="sm"
357-
>
358-
<ToggleGroupItem value="js">JS</ToggleGroupItem>
359-
<ToggleGroupItem value="css">CSS</ToggleGroupItem>
360-
<ToggleGroupItem value="json">JSON</ToggleGroupItem>
361-
<ToggleGroupItem value="asset">Asset</ToggleGroupItem>
362-
</ToggleGroup>
401+
onValueChange={setTypeFilter}
402+
emptyMessage="All Types"
403+
selectionName="type"
404+
triggerIcon={<FileCode className="h-3.5 w-3.5" />}
405+
aria-label="Filter by file type"
406+
/>
363407

364408
<ControlDivider />
365409

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { Button } from '@/components/ui/button'
5+
import {
6+
Popover,
7+
PopoverContent,
8+
PopoverTrigger,
9+
} from '@/components/ui/popover'
10+
import { ChevronDown } from 'lucide-react'
11+
import { cn } from '@/lib/utils'
12+
13+
export interface MultiSelectPropOption {
14+
value: string
15+
label: string
16+
icon?: React.ReactNode
17+
}
18+
19+
interface MultiSelectProps {
20+
options: MultiSelectPropOption[]
21+
value: string[]
22+
onValueChange: (value: string[]) => void
23+
placeholder?: string
24+
emptyMessage?: string
25+
selectionName?: string
26+
className?: string
27+
triggerClassName?: string
28+
triggerIcon?: React.ReactNode
29+
'aria-label'?: string
30+
}
31+
32+
export function MultiSelect({
33+
options,
34+
value,
35+
onValueChange,
36+
placeholder = 'Select items...',
37+
emptyMessage = 'All items',
38+
selectionName,
39+
className,
40+
triggerClassName,
41+
triggerIcon,
42+
'aria-label': ariaLabel,
43+
}: MultiSelectProps) {
44+
const [open, setOpen] = React.useState(false)
45+
46+
const handleToggle = (optionValue: string, checked: boolean) => {
47+
const newValue = checked
48+
? [...value, optionValue]
49+
: value.filter((v) => v !== optionValue)
50+
if (newValue.length > 0) {
51+
onValueChange(newValue)
52+
}
53+
}
54+
55+
const displayText =
56+
value.length === options.length
57+
? emptyMessage
58+
: value.length === 0
59+
? placeholder
60+
: selectionName
61+
? `${value.length} ${selectionName}${value.length === 1 ? '' : 's'}`
62+
: `${value.length} selected`
63+
64+
return (
65+
<Popover open={open} onOpenChange={setOpen}>
66+
<PopoverTrigger asChild>
67+
<Button
68+
variant="outline"
69+
size="sm"
70+
className={cn(
71+
'h-8 w-[120px] justify-between text-xs px-3 focus-visible:ring-[3px] focus-visible:ring-ring/50',
72+
triggerClassName
73+
)}
74+
role="combobox"
75+
aria-expanded={open}
76+
aria-haspopup="dialog"
77+
aria-label={ariaLabel}
78+
onKeyDown={(e) => {
79+
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !open) {
80+
e.preventDefault()
81+
setOpen(true)
82+
}
83+
}}
84+
>
85+
<div className="flex items-center gap-1">
86+
{triggerIcon && (
87+
<span className="shrink-0" aria-hidden="true">
88+
{triggerIcon}
89+
</span>
90+
)}
91+
<span>{displayText}</span>
92+
</div>
93+
<ChevronDown className="h-3.5 w-3.5 opacity-50" aria-hidden="true" />
94+
</Button>
95+
</PopoverTrigger>
96+
<PopoverContent
97+
className={cn('w-48 p-2', className)}
98+
align="end"
99+
onKeyDown={(e) => {
100+
if (e.key === 'Escape') {
101+
setOpen(false)
102+
return
103+
}
104+
105+
const checkboxes = Array.from(
106+
e.currentTarget.querySelectorAll('input[type="checkbox"]')
107+
) as HTMLInputElement[]
108+
const currentIndex = checkboxes.indexOf(
109+
document.activeElement as HTMLInputElement
110+
)
111+
112+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
113+
e.preventDefault()
114+
if (currentIndex === -1) {
115+
checkboxes[0]?.focus()
116+
} else {
117+
const nextIndex =
118+
e.key === 'ArrowDown'
119+
? (currentIndex + 1) % checkboxes.length
120+
: (currentIndex - 1 + checkboxes.length) % checkboxes.length
121+
checkboxes[nextIndex]?.focus()
122+
}
123+
}
124+
}}
125+
>
126+
<fieldset className="space-y-2">
127+
<legend className="sr-only">{ariaLabel || placeholder}</legend>
128+
{options.map((option) => (
129+
<MultiSelectOption
130+
key={option.value}
131+
label={option.label}
132+
icon={option.icon}
133+
checked={value.includes(option.value)}
134+
onChange={(checked) => handleToggle(option.value, checked)}
135+
/>
136+
))}
137+
</fieldset>
138+
</PopoverContent>
139+
</Popover>
140+
)
141+
}
142+
143+
function MultiSelectOption({
144+
label,
145+
icon,
146+
checked,
147+
onChange,
148+
}: {
149+
label: string
150+
icon?: React.ReactNode
151+
checked: boolean
152+
onChange: (checked: boolean) => void
153+
}) {
154+
return (
155+
<label
156+
className="flex items-center gap-2 cursor-pointer hover:bg-accent focus-within:bg-accent rounded px-2 py-1.5 transition-colors"
157+
onClick={(e) => {
158+
e.preventDefault()
159+
onChange(!checked)
160+
}}
161+
onKeyDown={(e) => {
162+
if (e.key === 'Enter' || e.key === ' ') {
163+
e.preventDefault()
164+
onChange(!checked)
165+
}
166+
}}
167+
>
168+
<input
169+
type="checkbox"
170+
checked={checked}
171+
onChange={(e) => onChange(e.target.checked)}
172+
className="h-3.5 w-3.5 rounded border-input focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
173+
aria-label={label}
174+
/>
175+
{icon && (
176+
<span className="shrink-0" aria-hidden="true">
177+
{icon}
178+
</span>
179+
)}
180+
<span className="text-xs">{label}</span>
181+
</label>
182+
)
183+
}

0 commit comments

Comments
 (0)