supafast-ui · sub-skill: MENÚS & SELECTS (no usamos <select> nativo)
Regla: para elegir entre opciones NO se usa <select> nativo ni el Select de shadcn por
defecto. Se usa un menú: un Button variant="outline" como trigger (icono + label + chevron)
que abre una lista de ítems, donde el seleccionado lleva un check (lucide Check) a la izquierda.
Es el patrón del “Ordenar” de las tablas.
Anatomía visual (de la captura “Ordenar”):
- Trigger = botón outline
sm: icono inicio (ArrowUpDown) + texto +ChevronDownque rota 180° al abrir. - Popup =
rounded-md border border-border bg-background p-1 shadow-sm,min-w-48. - Ítems =
Button variant="ghost" size="sm"a ancho completo,justify-start; el activo entext-primaryconCheckvisible, el resto con el checkinvisible(para que no salte el layout).
Patrón A — menú hecho a mano (el del “Ordenar”, canónico para tablas)
Código verbatim de shared/components/data-table/EntityTableToolbar.tsx. Trigger outline +
popup con click-outside + ítems role="menuitemradio". Es el que se ve en la UI de listados.
import { Button } from '@/shared/components/ui/button'
import { cn } from '@/shared/lib/utils'
import { ArrowUpDown, Check, ChevronDown, ListFilter } from 'lucide-react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
export type EntityTableSortOption<T extends string = string> = {
value: T
label: string
}
type EntityTableToolbarProps<T extends string> = {
count: number
singularLabel: string
pluralLabel: string
sortBy: T
sortOptions: Array<EntityTableSortOption<T>>
onSortByChange: (value: T) => void
activeFilterCount?: number
onOpenFilters?: () => void
searchSlot?: ReactNode
}
export function EntityTableToolbar<T extends string>({
count, singularLabel, pluralLabel, sortBy, sortOptions, onSortByChange,
activeFilterCount = 0, onOpenFilters, searchSlot,
}: EntityTableToolbarProps<T>) {
const [isSortOpen, setIsSortOpen] = useState(false)
const sortRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (sortRef.current && !sortRef.current.contains(event.target as Node)) {
setIsSortOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const selectedSort = sortOptions.find((option) => option.value === sortBy) ?? sortOptions[0]
return (
<div className="flex items-center justify-between gap-4 border-b border-border/60 px-4 py-3">
<div className="flex items-center gap-2">
{onOpenFilters ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenFilters} aria-label="Abrir filtros">
<ListFilter data-icon="inline-start" />
Filtros
{activeFilterCount > 0 ? (
<span className="ml-0.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-primary-foreground">
{activeFilterCount}
</span>
) : null}
</Button>
) : null}
<div ref={sortRef} className="relative">
<Button
type="button" variant="outline" size="sm"
aria-haspopup="menu" aria-expanded={isSortOpen}
onClick={() => setIsSortOpen((previous) => !previous)}
>
<ArrowUpDown data-icon="inline-start" />
Ordenar
<ChevronDown data-icon="inline-end" className={cn('transition-transform', isSortOpen && 'rotate-180')} />
</Button>
{isSortOpen ? (
<div
role="menu" aria-label="Opciones de orden"
className="absolute left-0 z-20 mt-1 min-w-48 rounded-md border border-border bg-background p-1 shadow-sm"
>
{sortOptions.map((option) => {
const isSelected = option.value === selectedSort.value
return (
<Button
key={option.value}
type="button" variant="ghost" size="sm"
role="menuitemradio" aria-checked={isSelected}
className={cn('w-full justify-start', isSelected && 'text-primary')}
onClick={() => { onSortByChange(option.value); setIsSortOpen(false) }}
>
<Check data-icon="inline-start" className={cn(!isSelected && 'invisible')} />
{option.label}
</Button>
)
})}
</div>
) : null}
</div>
<span className="text-sm text-muted-foreground">
{count} {count === 1 ? singularLabel : pluralLabel}
</span>
</div>
{searchSlot ? <div className="w-full max-w-xs">{searchSlot}</div> : null}
</div>
)
}
Uso (define solo las opciones, el toolbar hace el resto):
export type ObjectiveSortBy = '-name' | '-advertiser' | '-createdAt' | '-updatedAt'
const SORT_OPTIONS: Array<EntityTableSortOption<ObjectiveSortBy>> = [
{ value: '-name', label: 'Nombre' },
{ value: '-advertiser', label: 'Anunciante' },
{ value: '-createdAt', label: 'Fecha creación' },
{ value: '-updatedAt', label: 'Última actualización' },
]
<EntityTableToolbar
count={count} singularLabel="objetivo" pluralLabel="objetivos"
sortBy={sortBy} sortOptions={SORT_OPTIONS} onSortByChange={setSortBy}
searchSlot={<CampaignSearch … />}
/>
Claves de estilo: border-b border-border/60 px-4 py-3 en la toolbar; el contador
text-sm text-muted-foreground con singular/plural; el badge de filtros activos es un pill
bg-primary text-primary-foreground text-[10px].
Patrón B — DropdownMenu de @base-ui (para menús de acciones / con submenús / portales)
Usa el componente ui/dropdown-menu.tsx (envuelve @base-ui/react/menu). Preferible cuando
necesitas portal, posicionamiento robusto, submenús, separadores o ítems destructivos.
Para “elegir una opción con check” usa DropdownMenuRadioGroup + DropdownMenuRadioItem
(el indicador Check ya viene incorporado a la derecha):
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuItem, DropdownMenuSeparator,
} from '@/shared/components/ui/dropdown-menu'
import { Button } from '@/shared/components/ui/button'
import { ArrowUpDown, ChevronDown } from 'lucide-react'
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
<ArrowUpDown data-icon="inline-start" /> Ordenar
<ChevronDown data-icon="inline-end" />
</Button>
}
/>
<DropdownMenuContent align="start" className="min-w-48">
<DropdownMenuRadioGroup value={sortBy} onValueChange={setSortBy}>
<DropdownMenuRadioItem value="-name">Nombre</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="-advertiser">Anunciante</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="-createdAt">Fecha creación</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="-updatedAt">Última actualización</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
Notas del componente (ui/dropdown-menu.tsx):
- Es
@base-ui/react/menu, no Radix. El trigger se compone conrender={<Button … />}. - Popup:
rounded-lg bg-popover p-1 shadow-md ring-1 ring-foreground/10, animacionesdata-open/data-closed. - Ítems:
rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground. variant="destructive"enDropdownMenuItempara acciones peligrosas (texto/hover destructive).DropdownMenuSeparator=-mx-1 my-1 h-px bg-border. HayLabel,Group,Sub*,Shortcut.
Cuándo usar cuál
- Filtro/orden inline en una tabla o toolbar → Patrón A (
EntityTableToolbar), ligero y ya estilado. - Menú de acciones (editar/duplicar/eliminar), submenús, o necesitas portal/posicionamiento → Patrón B.
- Nunca
<select>nativo ni elSelectde shadcn salvo que el usuario lo pida explícitamente.