Referencia / Sub-Skill

menus selects

UBICACIÓN: skills/supafast-ui/references/menus-selects.md

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 + ChevronDown que 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 en text-primary con Check visible, el resto con el check invisible (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 con render={<Button … />}.
  • Popup: rounded-lg bg-popover p-1 shadow-md ring-1 ring-foreground/10, animaciones data-open/data-closed.
  • Ítems: rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground.
  • variant="destructive" en DropdownMenuItem para acciones peligrosas (texto/hover destructive).
  • DropdownMenuSeparator = -mx-1 my-1 h-px bg-border. Hay Label, 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 el Select de shadcn salvo que el usuario lo pida explícitamente.