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

View file

@ -20,8 +20,16 @@ export interface CustomSelectProps<T = string | number> {
disabled?: boolean disabled?: boolean
leftIcon?: ReactNode leftIcon?: ReactNode
size?: 'sm' | 'md' 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>({ export function CustomSelect<T extends string | number>({
value, value,
onChange, onChange,
@ -32,14 +40,15 @@ export function CustomSelect<T extends string | number>({
disabled = false, disabled = false,
leftIcon, leftIcon,
size = 'md', size = 'md',
label,
}: CustomSelectProps<T>) { }: CustomSelectProps<T>) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// Find currently selected option // Opção atualmente selecionada
const selectedOption = options.find((opt) => opt.value === value) const selectedOption = options.find((opt) => opt.value === value)
// Close on outside click // Fechar ao clicar fora
useEffect(() => { useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => { const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@ -55,7 +64,7 @@ export function CustomSelect<T extends string | number>({
} }
}, [isOpen]) }, [isOpen])
// Close on Escape key // Fechar ao pressionar Escape
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) { if (e.key === 'Escape' && isOpen) {
@ -74,54 +83,71 @@ export function CustomSelect<T extends string | number>({
const isSmall = size === 'sm' const isSmall = size === 'sm'
return ( return (
<div ref={containerRef} className={`relative inline-block w-full ${className}`}> <div ref={containerRef} className={`relative w-full text-left ${className}`}>
{/* Trigger Button */} {/* 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 <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsOpen(!isOpen)} onClick={() => !disabled && 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 ${ 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 isOpen
? 'border-brand ring-2 ring-brand/20 shadow-xs' ? 'border-brand ring-2 ring-brand/25 shadow-sm shadow-brand/10 bg-surface'
: 'hover:border-brand/40 hover:bg-surface-raised' : 'border-border-soft hover:border-brand/40 hover:bg-surface-raised active:scale-[0.995]'
} ${isSmall ? 'px-2.5 py-1 text-xs' : 'px-3.5 py-2.5 text-xs'} ${triggerClassName}`} } ${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"> <div className="flex items-center gap-2.5 truncate flex-1 min-w-0">
{/* Custom Left Icon if supplied */} {/* Custom Left Icon */}
{leftIcon && <span className="text-muted shrink-0">{leftIcon}</span>} {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 && ( {selectedOption?.color && (
<span <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 }} style={{ backgroundColor: selectedOption.color }}
/> />
)} )}
{/* Option's custom icon */}
{selectedOption?.icon && ( {selectedOption?.icon && (
<span className="shrink-0">{selectedOption.icon}</span> <span className="shrink-0 flex items-center">{selectedOption.icon}</span>
)} )}
{/* Label or Placeholder */} {/* Label or Placeholder */}
{selectedOption ? ( {selectedOption ? (
<span className="truncate font-medium text-foreground"> <span className="truncate font-semibold text-foreground tracking-tight">
{selectedOption.label} {selectedOption.label}
</span> </span>
) : ( ) : (
<span className="truncate text-muted">{placeholder}</span> <span className="truncate text-muted/80 font-normal">{placeholder}</span>
)} )}
</div> </div>
{/* Chevron Indicator */} {/* Chevron Icon - HeroUI animated rotation */}
<div className="shrink-0 pl-1">
<ChevronDown <ChevronDown
className={`w-3.5 h-3.5 text-muted transition-transform duration-200 shrink-0 ${ className={`w-4 h-4 text-muted transition-transform duration-250 ease-out ${
isOpen ? 'rotate-180 text-brand' : '' isOpen ? 'rotate-180 text-brand' : 'group-hover:text-foreground'
}`} }`}
/> />
</div>
</button> </button>
{/* Dropdown Popover */} {/* Popover ListBox - Estilo HeroUI v3 Dropdown */}
{isOpen && ( {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"> <div className="space-y-0.5">
{options.map((option) => { {options.map((option) => {
const isSelected = option.value === value const isSelected = option.value === value
@ -129,29 +155,31 @@ export function CustomSelect<T extends string | number>({
<button <button
key={String(option.value)} key={String(option.value)}
type="button" type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(option.value)} 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 isSelected
? 'bg-brand/12 text-brand font-semibold' ? 'bg-brand/15 text-brand font-bold shadow-xs'
: 'text-foreground hover:bg-surface-raised hover:text-foreground' : '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 */} {/* Colored dot */}
{option.color && ( {option.color && (
<span <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 }} style={{ backgroundColor: option.color }}
/> />
)} )}
{/* Icon */} {/* Icon */}
{option.icon && ( {option.icon && (
<span className="shrink-0">{option.icon}</span> <span className="shrink-0 flex items-center">{option.icon}</span>
)} )}
{/* Label and optional description */} {/* Label and description */}
<div className="truncate"> <div className="truncate flex-1 min-w-0">
<p className="truncate leading-tight">{option.label}</p> <p className="truncate leading-tight">{option.label}</p>
{option.description && ( {option.description && (
<p className="text-[10px] text-muted truncate mt-0.5 font-normal"> <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>
</div> </div>
{/* Checkmark indicator */} {/* Checkmark indicator for selected item */}
{isSelected && ( {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> </button>
) )

View file

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