refactor(ui): modernize Select component with HeroUI v3 aesthetics and replace native selects

This commit is contained in:
Gabriel Menezes 2026-10-22 10:25:33 -04:00
parent 93c06cdad8
commit b41e664d8a
3 changed files with 144 additions and 102 deletions

View file

@ -13,6 +13,7 @@ import {
} from 'lucide-react'
import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences'
import type { Project, Category } from '../../types/task'
import { CustomSelect } from '../ui/Select'
interface CreateBiCardModalProps {
isOpen: boolean
@ -253,69 +254,67 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
{/* Period Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Período</label>
<select
<CustomSelect
value={period}
onChange={(e) => setPeriod(e.target.value as BiPeriodFilter)}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="ALL">Todo o Histórico</option>
<option value="TODAY">Apenas Hoje</option>
<option value="WEEK">Esta Semana</option>
<option value="MONTH">Este Mês</option>
</select>
onChange={(val) => setPeriod(val as BiPeriodFilter)}
options={[
{ value: 'ALL', label: 'Todo o Histórico' },
{ value: 'TODAY', label: 'Apenas Hoje' },
{ value: 'WEEK', label: 'Esta Semana' },
{ value: 'MONTH', label: 'Este Mês' },
]}
/>
</div>
{/* Status Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Filtro por Status</label>
<select
<CustomSelect
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="ALL">Todos os Status</option>
<option value="COMPLETED">Apenas Concluídas</option>
<option value="IN_PROGRESS">Apenas Em Andamento</option>
<option value="NOT_STARTED">Apenas A Fazer</option>
</select>
onChange={(val) => setStatusFilter(val as any)}
options={[
{ value: 'ALL', label: 'Todos os Status' },
{ value: 'COMPLETED', label: 'Apenas Concluídas', color: '#10b981' },
{ value: 'IN_PROGRESS', label: 'Apenas Em Andamento', color: '#14b8a6' },
{ value: 'NOT_STARTED', label: 'Apenas A Fazer', color: '#71717a' },
]}
/>
</div>
{/* Project Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Iniciativa / Projeto</label>
<select
value={projectId ?? ''}
onChange={(e) =>
setProjectId(e.target.value ? Number(e.target.value) : undefined)
}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="">Todos os Projetos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<CustomSelect
value={projectId ?? 0}
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Todos os Projetos"
options={[
{ value: 0, label: 'Todos os Projetos' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color,
})),
]}
/>
</div>
{/* Category Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Categoria / Tag</label>
<select
value={categoryId ?? ''}
onChange={(e) =>
setCategoryId(e.target.value ? Number(e.target.value) : undefined)
}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="">Todas as Categorias</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<CustomSelect
value={categoryId ?? 0}
onChange={(val) => setCategoryId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Todas as Categorias"
options={[
{ value: 0, label: 'Todas as Categorias' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/>
</div>
{/* Data Scope Filter */}
@ -324,15 +323,27 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
<span>Escopo dos Dados (Atribuição)</span>
<span className="text-[11px] text-muted font-normal">Individual vs Equipe</span>
</label>
<select
<CustomSelect
value={scope}
onChange={(e) => setScope(e.target.value as BiCardScope)}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="INHERIT">🔗 Seguir filtro global da página (Padrão)</option>
<option value="MINE">👤 Sempre Apenas Minhas Tarefas (Individual)</option>
<option value="TEAM">👥 Sempre Toda a Equipe (Workspace Geral)</option>
</select>
onChange={(val) => setScope(val as BiCardScope)}
options={[
{
value: 'INHERIT',
label: '🔗 Seguir filtro global da página (Padrão)',
description: 'Acompanha a alternância da tela',
},
{
value: 'MINE',
label: '👤 Sempre Apenas Minhas Tarefas (Individual)',
description: 'Fixo nas suas demandas',
},
{
value: 'TEAM',
label: '👥 Sempre Toda a Equipe (Workspace Geral)',
description: 'Fixo em todo o time',
},
]}
/>
</div>
</div>
</div>

View file

@ -20,8 +20,16 @@ export interface CustomSelectProps<T = string | number> {
disabled?: boolean
leftIcon?: ReactNode
size?: 'sm' | 'md'
label?: string
}
/**
* CustomSelect com estética e tokens do HeroUI v3:
* - Acabamento bordered com backdrop-blur suave
* - Foco dinâmico com ring colorido e leve elevação
* - Popover em glassmorphism com animação de zoom/fade
* - Itens com highlight de hover e seleção com checkmark
*/
export function CustomSelect<T extends string | number>({
value,
onChange,
@ -32,14 +40,15 @@ export function CustomSelect<T extends string | number>({
disabled = false,
leftIcon,
size = 'md',
label,
}: CustomSelectProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Find currently selected option
// Opção atualmente selecionada
const selectedOption = options.find((opt) => opt.value === value)
// Close on outside click
// Fechar ao clicar fora
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@ -55,7 +64,7 @@ export function CustomSelect<T extends string | number>({
}
}, [isOpen])
// Close on Escape key
// Fechar ao pressionar Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
@ -74,54 +83,71 @@ export function CustomSelect<T extends string | number>({
const isSmall = size === 'sm'
return (
<div ref={containerRef} className={`relative inline-block w-full ${className}`}>
{/* Trigger Button */}
<div ref={containerRef} className={`relative w-full text-left ${className}`}>
{/* Label opcional no estilo HeroUI */}
{label && (
<label className="block text-xs font-bold text-foreground mb-1.5 tracking-tight">
{label}
</label>
)}
{/* Trigger Button - Estilo HeroUI v3 Bordered / Faint */}
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between gap-2.5 rounded-xl border border-border-soft bg-surface-raised/90 backdrop-blur-md text-foreground transition-all duration-200 outline-none select-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer ${
onClick={() => !disabled && setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
className={`w-full flex items-center justify-between gap-2.5 rounded-xl sm:rounded-2xl border bg-surface/80 dark:bg-zinc-900/60 backdrop-blur-md text-foreground transition-all duration-200 outline-hidden select-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ${
isOpen
? 'border-brand ring-2 ring-brand/20 shadow-xs'
: 'hover:border-brand/40 hover:bg-surface-raised'
} ${isSmall ? 'px-2.5 py-1 text-xs' : 'px-3.5 py-2.5 text-xs'} ${triggerClassName}`}
? 'border-brand ring-2 ring-brand/25 shadow-sm shadow-brand/10 bg-surface'
: 'border-border-soft hover:border-brand/40 hover:bg-surface-raised active:scale-[0.995]'
} ${isSmall ? 'min-h-[34px] px-3 py-1 text-xs' : 'min-h-[42px] px-3.5 py-2 text-xs sm:text-sm'} ${triggerClassName}`}
>
<div className="flex items-center gap-2 truncate">
{/* Custom Left Icon if supplied */}
{leftIcon && <span className="text-muted shrink-0">{leftIcon}</span>}
<div className="flex items-center gap-2.5 truncate flex-1 min-w-0">
{/* Custom Left Icon */}
{leftIcon && <span className="text-muted shrink-0 flex items-center">{leftIcon}</span>}
{/* Option's custom icon or colored dot */}
{/* Option's custom dot color */}
{selectedOption?.color && (
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-xs ring-2 ring-surface"
style={{ backgroundColor: selectedOption.color }}
/>
)}
{/* Option's custom icon */}
{selectedOption?.icon && (
<span className="shrink-0">{selectedOption.icon}</span>
<span className="shrink-0 flex items-center">{selectedOption.icon}</span>
)}
{/* Label or Placeholder */}
{selectedOption ? (
<span className="truncate font-medium text-foreground">
<span className="truncate font-semibold text-foreground tracking-tight">
{selectedOption.label}
</span>
) : (
<span className="truncate text-muted">{placeholder}</span>
<span className="truncate text-muted/80 font-normal">{placeholder}</span>
)}
</div>
{/* Chevron Indicator */}
{/* Chevron Icon - HeroUI animated rotation */}
<div className="shrink-0 pl-1">
<ChevronDown
className={`w-3.5 h-3.5 text-muted transition-transform duration-200 shrink-0 ${
isOpen ? 'rotate-180 text-brand' : ''
className={`w-4 h-4 text-muted transition-transform duration-250 ease-out ${
isOpen ? 'rotate-180 text-brand' : 'group-hover:text-foreground'
}`}
/>
</div>
</button>
{/* Dropdown Popover */}
{/* Popover ListBox - Estilo HeroUI v3 Dropdown */}
{isOpen && (
<div className="absolute left-0 right-0 mt-1.5 z-50 min-w-[200px] max-h-64 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 backdrop-blur-xl p-1.5 shadow-panel animate-in fade-in zoom-in-95 duration-150">
<div
role="listbox"
tabIndex={-1}
className="absolute left-0 right-0 mt-1.5 z-50 min-w-[200px] max-h-64 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 dark:bg-zinc-900/95 backdrop-blur-xl p-1.5 shadow-2xl animate-in fade-in zoom-in-95 duration-150 ease-out"
>
<div className="space-y-0.5">
{options.map((option) => {
const isSelected = option.value === value
@ -129,29 +155,31 @@ export function CustomSelect<T extends string | number>({
<button
key={String(option.value)}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(option.value)}
className={`w-full flex items-center justify-between gap-2.5 px-3 py-2 rounded-xl text-left text-xs transition-colors cursor-pointer ${
className={`w-full flex items-center justify-between gap-2.5 px-3 py-2 rounded-xl text-left text-xs sm:text-sm transition-all duration-150 cursor-pointer ${
isSelected
? 'bg-brand/12 text-brand font-semibold'
: 'text-foreground hover:bg-surface-raised hover:text-foreground'
? 'bg-brand/15 text-brand font-bold shadow-xs'
: 'text-foreground hover:bg-brand/10 hover:text-brand'
}`}
>
<div className="flex items-center gap-2.5 truncate">
<div className="flex items-center gap-2.5 truncate flex-1 min-w-0">
{/* Colored dot */}
{option.color && (
<span
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-xs"
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-xs ring-1 ring-surface"
style={{ backgroundColor: option.color }}
/>
)}
{/* Icon */}
{option.icon && (
<span className="shrink-0">{option.icon}</span>
<span className="shrink-0 flex items-center">{option.icon}</span>
)}
{/* Label and optional description */}
<div className="truncate">
{/* Label and description */}
<div className="truncate flex-1 min-w-0">
<p className="truncate leading-tight">{option.label}</p>
{option.description && (
<p className="text-[10px] text-muted truncate mt-0.5 font-normal">
@ -161,9 +189,9 @@ export function CustomSelect<T extends string | number>({
</div>
</div>
{/* Checkmark indicator */}
{/* Checkmark indicator for selected item */}
{isSelected && (
<Check className="w-3.5 h-3.5 text-brand shrink-0" />
<Check className="w-3.5 h-3.5 text-brand shrink-0 ml-1.5" />
)}
</button>
)

View file

@ -20,6 +20,7 @@ import { categoryService } from '../services/categoryService'
import { organizationService, type OrganizationMember } from '../services/organizationService'
import type { Task, Project, Category } from '../types/task'
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
import { CustomSelect } from '../components/ui/Select'
export const AnalyticsPage: React.FC = () => {
const { user, activeOrg } = useAuth()
@ -316,19 +317,21 @@ export const AnalyticsPage: React.FC = () => {
</div>
{/* Specific Member Dropdown */}
<div className="flex items-center gap-1.5">
<select
<div className="w-56">
<CustomSelect
size="sm"
value={selectedMember === 'ALL' || selectedMember === 'MINE' ? '' : selectedMember}
onChange={(e) => handleMemberChange(e.target.value || 'ALL')}
className="px-3 py-1.5 rounded-xl border border-border-soft bg-surface text-xs font-medium text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="">Outro Membro...</option>
{members.map((m) => (
<option key={m.userId} value={m.userEmail}>
{m.userName} ({m.userEmail})
</option>
))}
</select>
onChange={(val) => handleMemberChange(val || 'ALL')}
placeholder="Outro Membro..."
options={[
{ value: '', label: 'Outro Membro...' },
...members.map((m) => ({
value: m.userEmail,
label: m.userName,
description: m.userEmail,
})),
]}
/>
</div>
</div>