feat: replace unstyled native html selects with rich custom styled select components

This commit is contained in:
Gabriel Menezes 2026-10-11 10:36:09 -04:00
parent 6412bb20d1
commit f2f9666fcf
4 changed files with 270 additions and 105 deletions

View file

@ -5,6 +5,7 @@ import type { OrganizationMember } from '../../services/organizationService'
import { projectService } from '../../services/projectService' import { projectService } from '../../services/projectService'
import { categoryService } from '../../services/categoryService' import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService' import { organizationService } from '../../services/organizationService'
import { CustomSelect } from '../ui/Select'
interface CreateTaskModalProps { interface CreateTaskModalProps {
isOpen: boolean isOpen: boolean
@ -272,18 +273,20 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
</button> </button>
</div> </div>
) : ( ) : (
<select <CustomSelect
value={projectId ?? ''} value={projectId ?? 0}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : undefined)} onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand" placeholder="Sem projeto"
> leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
<option value="">Sem projeto</option> options={[
{projects.map((p) => ( { value: 0, label: 'Sem projeto' },
<option key={p.id} value={p.id}> ...projects.map((p) => ({
{p.name} value: p.id,
</option> label: p.name,
))} color: p.color || '#0f766e',
</select> })),
]}
/>
)} )}
</div> </div>
@ -297,7 +300,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setShowAddCategory(!showAddCategory)} onClick={() => setShowAddCategory(!showAddCategory)}
className="text-[11px] text-accent hover:underline font-medium" className="text-[11px] text-accent hover:underline font-medium cursor-pointer"
> >
{showAddCategory ? 'Cancelar' : '+ Nova'} {showAddCategory ? 'Cancelar' : '+ Nova'}
</button> </button>
@ -321,18 +324,20 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
</button> </button>
</div> </div>
) : ( ) : (
<select <CustomSelect
value={categoryId ?? ''} value={categoryId ?? 0}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)} onChange={(val) => setCategoryId(Number(val) === 0 ? undefined : Number(val))}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand" placeholder="Sem categoria"
> leftIcon={<Tag className="w-3.5 h-3.5 text-accent" />}
<option value="">Sem categoria</option> options={[
{categories.map((c) => ( { value: 0, label: 'Sem categoria' },
<option key={c.id} value={c.id}> ...categories.map((c) => ({
{c.name} value: c.id,
</option> label: c.name,
))} color: c.color || '#d97706',
</select> })),
]}
/>
)} )}
</div> </div>
</div> </div>
@ -345,20 +350,20 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
<User className="w-3.5 h-3.5 text-muted" /> <User className="w-3.5 h-3.5 text-muted" />
Responsável Responsável
</label> </label>
<select <CustomSelect
value={assigneeUserId ?? ''} value={assigneeUserId ?? 0}
onChange={(e) => onChange={(val) => setAssigneeUserId(Number(val) === 0 ? undefined : Number(val))}
setAssigneeUserId(e.target.value ? Number(e.target.value) : undefined) placeholder="Atribuir a mim mesmo / Sem responsável"
} leftIcon={<User className="w-3.5 h-3.5 text-muted" />}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand" options={[
> { value: 0, label: 'Sem responsável (atribuir a mim)' },
<option value="">Atribuir a mim mesmo / Sem responsável</option> ...members.map((m) => ({
{members.map((m) => ( value: m.userId,
<option key={m.userId} value={m.userId}> label: m.userName,
{m.userName} ({m.userEmail}) description: m.userEmail,
</option> })),
))} ]}
</select> />
</div> </div>
{/* Status */} {/* Status */}
@ -366,16 +371,16 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
<label className="block text-xs font-semibold text-foreground mb-1.5"> <label className="block text-xs font-semibold text-foreground mb-1.5">
Status Inicial Status Inicial
</label> </label>
<select <CustomSelect
value={status} value={status}
onChange={(e) => setStatus(e.target.value as TaskStatus)} onChange={(val) => setStatus(val as TaskStatus)}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand" options={[
> { value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
<option value="NOT_STARTED">A Fazer (NOT_STARTED)</option> { value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
<option value="IN_PROGRESS">Em Andamento (IN_PROGRESS)</option> { value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
<option value="COMPLETED">Concluído (COMPLETED)</option> { value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
<option value="CANCELED">Cancelado (CANCELED)</option> ]}
</select> />
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@ import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2 } from 'lucide-react' import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2 } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task' import type { Task, TaskStatus } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { CustomSelect } from '../ui/Select'
interface TaskListViewProps { interface TaskListViewProps {
tasks: Task[] tasks: Task[]
@ -12,24 +13,6 @@ interface TaskListViewProps {
activeTaskSeconds?: number activeTaskSeconds?: number
} }
const statusBadge: Record<TaskStatus, { label: string; badgeClass: string }> = {
NOT_STARTED: {
label: 'A Fazer',
badgeClass: 'bg-zinc-800 text-zinc-300 border-zinc-700/60',
},
IN_PROGRESS: {
label: 'Em Andamento',
badgeClass: 'bg-teal-500/15 text-teal-300 border-teal-500/30',
},
COMPLETED: {
label: 'Concluído',
badgeClass: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
},
CANCELED: {
label: 'Cancelado',
badgeClass: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
},
}
export const TaskListView: React.FC<TaskListViewProps> = ({ export const TaskListView: React.FC<TaskListViewProps> = ({
tasks, tasks,
@ -145,17 +128,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
{/* Status Dropdown */} {/* Status Dropdown */}
<td className="py-3.5 px-4 whitespace-nowrap"> <td className="py-3.5 px-4 whitespace-nowrap">
<select <div className="w-36">
value={task.status} <CustomSelect
onChange={(e) => onStatusChange(task, e.target.value as TaskStatus)} size="sm"
aria-label="Status da tarefa" value={task.status}
className={`text-[11px] font-medium px-2 py-1 rounded-lg border cursor-pointer focus:outline-none focus:ring-1 focus:ring-brand ${statusBadge[task.status].badgeClass}`} onChange={(newStatus) => onStatusChange(task, newStatus as TaskStatus)}
> options={[
<option value="NOT_STARTED" className="bg-surface-raised text-foreground">A Fazer</option> { value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
<option value="IN_PROGRESS" className="bg-surface-raised text-foreground">Em Andamento</option> { value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
<option value="COMPLETED" className="bg-surface-raised text-foreground">Concluído</option> { value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
<option value="CANCELED" className="bg-surface-raised text-foreground">Cancelado</option> { value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
</select> ]}
/>
</div>
</td> </td>
{/* Time Spent */} {/* Time Spent */}

View file

@ -0,0 +1,176 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { ChevronDown, Check } from 'lucide-react'
export interface SelectOption<T = string | number> {
value: T
label: string
icon?: ReactNode
color?: string
badgeClass?: string
description?: string
}
export interface CustomSelectProps<T = string | number> {
value: T | undefined
onChange: (value: T) => void
options: SelectOption<T>[]
placeholder?: string
className?: string
triggerClassName?: string
disabled?: boolean
leftIcon?: ReactNode
size?: 'sm' | 'md'
}
export function CustomSelect<T extends string | number>({
value,
onChange,
options,
placeholder = 'Selecione uma opção',
className = '',
triggerClassName = '',
disabled = false,
leftIcon,
size = 'md',
}: CustomSelectProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Find currently selected option
const selectedOption = options.find((opt) => opt.value === value)
// Close on outside click
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleOutsideClick)
}
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
}
}, [isOpen])
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen])
const handleSelect = (optValue: T) => {
onChange(optValue)
setIsOpen(false)
}
const isSmall = size === 'sm'
return (
<div ref={containerRef} className={`relative inline-block w-full ${className}`}>
{/* Trigger Button */}
<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 ${
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}`}
>
<div className="flex items-center gap-2 truncate">
{/* Custom Left Icon if supplied */}
{leftIcon && <span className="text-muted shrink-0">{leftIcon}</span>}
{/* Option's custom icon or colored dot */}
{selectedOption?.color && (
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: selectedOption.color }}
/>
)}
{selectedOption?.icon && (
<span className="shrink-0">{selectedOption.icon}</span>
)}
{/* Label or Placeholder */}
{selectedOption ? (
<span className="truncate font-medium text-foreground">
{selectedOption.label}
</span>
) : (
<span className="truncate text-muted">{placeholder}</span>
)}
</div>
{/* Chevron Indicator */}
<ChevronDown
className={`w-3.5 h-3.5 text-muted transition-transform duration-200 shrink-0 ${
isOpen ? 'rotate-180 text-brand' : ''
}`}
/>
</button>
{/* Dropdown Popover */}
{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 className="space-y-0.5">
{options.map((option) => {
const isSelected = option.value === value
return (
<button
key={String(option.value)}
type="button"
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 ${
isSelected
? 'bg-brand/12 text-brand font-semibold'
: 'text-foreground hover:bg-surface-raised hover:text-foreground'
}`}
>
<div className="flex items-center gap-2.5 truncate">
{/* Colored dot */}
{option.color && (
<span
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-xs"
style={{ backgroundColor: option.color }}
/>
)}
{/* Icon */}
{option.icon && (
<span className="shrink-0">{option.icon}</span>
)}
{/* Label and optional description */}
<div className="truncate">
<p className="truncate leading-tight">{option.label}</p>
{option.description && (
<p className="text-[10px] text-muted truncate mt-0.5 font-normal">
{option.description}
</p>
)}
</div>
</div>
{/* Checkmark indicator */}
{isSelected && (
<Check className="w-3.5 h-3.5 text-brand shrink-0" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View file

@ -22,6 +22,7 @@ import type { Task, TaskStatus, Project } from '../types/task'
import { TaskKanbanView } from '../components/tasks/TaskKanbanView' import { TaskKanbanView } from '../components/tasks/TaskKanbanView'
import { TaskListView } from '../components/tasks/TaskListView' import { TaskListView } from '../components/tasks/TaskListView'
import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
import { CustomSelect } from '../components/ui/Select'
export const TasksPage: React.FC = () => { export const TasksPage: React.FC = () => {
const { activeOrg } = useAuth() const { activeOrg } = useAuth()
@ -367,39 +368,37 @@ export const TasksPage: React.FC = () => {
{/* Project Filter */} {/* Project Filter */}
{projects.length > 0 && ( {projects.length > 0 && (
<div className="relative"> <div className="w-48">
<select <CustomSelect
value={selectedProjectId} value={selectedProjectId}
onChange={(e) => onChange={(val) => setSelectedProjectId(val)}
setSelectedProjectId( leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
e.target.value === 'ALL' ? 'ALL' : Number(e.target.value) options={[
) { value: 'ALL', label: 'Todos os Projetos' },
} ...projects.map((p) => ({
className="pl-7 pr-3.5 py-2 rounded-xl border border-border-soft bg-surface-raised/80 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand cursor-pointer" value: p.id,
> label: p.name,
<option value="ALL">Todos os Projetos</option> color: p.color || '#0f766e',
{projects.map((p) => ( })),
<option key={p.id} value={p.id}> ]}
{p.name} />
</option>
))}
</select>
<Folder className="w-3.5 h-3.5 text-brand absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div> </div>
)} )}
{/* Status Filter */} {/* Status Filter */}
<select <div className="w-44">
value={selectedStatus} <CustomSelect
onChange={(e) => setSelectedStatus(e.target.value as TaskStatus | 'ALL')} value={selectedStatus}
className="px-3.5 py-2 rounded-xl border border-border-soft bg-surface-raised/80 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand cursor-pointer" onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')}
> options={[
<option value="ALL">Todos os Status</option> { value: 'ALL', label: 'Todos os Status' },
<option value="NOT_STARTED">A Fazer</option> { value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
<option value="IN_PROGRESS">Em Andamento</option> { value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
<option value="COMPLETED">Concluído</option> { value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
<option value="CANCELED">Cancelado</option> { value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
</select> ]}
/>
</div>
</div> </div>
</div> </div>