mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
39c6cbb208
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 <img width="1547" height="719" alt="image" src="https://github.com/user-attachments/assets/4041f6b7-2a47-4e17-b020-448ac0103740" />
188 lines
5.3 KiB
TypeScript
188 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
import { ChevronDown, CheckIcon } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface MultiSelectPropOption {
|
|
value: string
|
|
label: string
|
|
icon?: React.ReactNode
|
|
}
|
|
|
|
interface MultiSelectProps {
|
|
options: MultiSelectPropOption[]
|
|
value: string[]
|
|
onValueChange: (value: string[]) => void
|
|
placeholder?: string
|
|
emptyMessage?: string
|
|
selectionName?: {
|
|
singular: string
|
|
plural: string
|
|
}
|
|
className?: string
|
|
triggerClassName?: string
|
|
triggerIcon?: React.ReactNode
|
|
'aria-label'?: string
|
|
size?: 'sm' | 'default'
|
|
}
|
|
|
|
export function MultiSelect({
|
|
options,
|
|
value,
|
|
onValueChange,
|
|
placeholder = 'Select items...',
|
|
selectionName = {
|
|
singular: 'item',
|
|
plural: 'items',
|
|
},
|
|
className,
|
|
triggerClassName,
|
|
triggerIcon,
|
|
'aria-label': ariaLabel,
|
|
}: MultiSelectProps) {
|
|
const [open, setOpen] = React.useState(false)
|
|
|
|
function handleToggle(optionValue: string, checked: boolean) {
|
|
const newValue = checked
|
|
? [...value, optionValue]
|
|
: value.filter((v) => v !== optionValue)
|
|
if (newValue.length > 0) {
|
|
onValueChange(newValue)
|
|
}
|
|
}
|
|
|
|
let displayText: string
|
|
if (value.length === options.length) {
|
|
displayText = `All ${selectionName.plural}`
|
|
} else if (value.length === 0) {
|
|
displayText = placeholder
|
|
} else {
|
|
displayText = `${value.length} ${value.length === 1 ? selectionName.singular : selectionName.plural}`
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
'justify-between border focus-visible:ring-[3px] focus-visible:ring-ring/50 font-normal px-3 py-2 h-9 text-xs',
|
|
triggerClassName
|
|
)}
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-haspopup="dialog"
|
|
aria-label={ariaLabel}
|
|
onKeyDown={(e) => {
|
|
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !open) {
|
|
e.preventDefault()
|
|
setOpen(true)
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{triggerIcon && (
|
|
<span
|
|
className="shrink-0 text-muted-foreground"
|
|
aria-hidden="true"
|
|
>
|
|
{triggerIcon}
|
|
</span>
|
|
)}
|
|
<span>{displayText}</span>
|
|
</div>
|
|
<ChevronDown className="h-3.5 w-3.5 opacity-50" aria-hidden="true" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className={cn('w-48 p-2', className)}
|
|
align="end"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
setOpen(false)
|
|
return
|
|
}
|
|
|
|
const checkboxes = Array.from(
|
|
e.currentTarget.querySelectorAll('input[type="checkbox"]')
|
|
) as HTMLInputElement[]
|
|
const currentIndex = checkboxes.indexOf(
|
|
document.activeElement as HTMLInputElement
|
|
)
|
|
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
if (currentIndex === -1) {
|
|
checkboxes[0]?.focus()
|
|
} else {
|
|
const nextIndex =
|
|
e.key === 'ArrowDown'
|
|
? (currentIndex + 1) % checkboxes.length
|
|
: (currentIndex - 1 + checkboxes.length) % checkboxes.length
|
|
checkboxes[nextIndex]?.focus()
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<fieldset className="space-y-2">
|
|
<legend className="sr-only">{ariaLabel || placeholder}</legend>
|
|
{options.map((option) => (
|
|
<MultiSelectOption
|
|
key={option.value}
|
|
label={option.label}
|
|
icon={option.icon}
|
|
checked={value.includes(option.value)}
|
|
onChange={(checked) => handleToggle(option.value, checked)}
|
|
/>
|
|
))}
|
|
</fieldset>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function MultiSelectOption({
|
|
label,
|
|
icon,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
label: string
|
|
icon?: React.ReactNode
|
|
checked: boolean
|
|
onChange: (checked: boolean) => void
|
|
}) {
|
|
return (
|
|
<label
|
|
className={cn(
|
|
"text-xs hover:bg-accent hover:text-accent-foreground has-[input:focus-visible]:bg-accent has-[input:focus-visible]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 select-none outline-hidden [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="sr-only peer focus:outline-hidden focus-visible:outline-2"
|
|
aria-label={label}
|
|
/>
|
|
{icon && (
|
|
<span className="shrink-0" aria-hidden="true">
|
|
{icon}
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-2">{label}</span>
|
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
|
{checked && <CheckIcon className="size-4" />}
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|