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

View file

@ -2,6 +2,7 @@ import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2 } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { CustomSelect } from '../ui/Select'
interface TaskListViewProps {
tasks: Task[]
@ -12,24 +13,6 @@ interface TaskListViewProps {
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> = ({
tasks,
@ -145,17 +128,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
{/* Status Dropdown */}
<td className="py-3.5 px-4 whitespace-nowrap">
<select
<div className="w-36">
<CustomSelect
size="sm"
value={task.status}
onChange={(e) => onStatusChange(task, e.target.value as TaskStatus)}
aria-label="Status da tarefa"
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}`}
>
<option value="NOT_STARTED" className="bg-surface-raised text-foreground">A Fazer</option>
<option value="IN_PROGRESS" className="bg-surface-raised text-foreground">Em Andamento</option>
<option value="COMPLETED" className="bg-surface-raised text-foreground">Concluído</option>
<option value="CANCELED" className="bg-surface-raised text-foreground">Cancelado</option>
</select>
onChange={(newStatus) => onStatusChange(task, newStatus as TaskStatus)}
options={[
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
]}
/>
</div>
</td>
{/* 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 { TaskListView } from '../components/tasks/TaskListView'
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
import { CustomSelect } from '../components/ui/Select'
export const TasksPage: React.FC = () => {
const { activeOrg } = useAuth()
@ -367,39 +368,37 @@ export const TasksPage: React.FC = () => {
{/* Project Filter */}
{projects.length > 0 && (
<div className="relative">
<select
<div className="w-48">
<CustomSelect
value={selectedProjectId}
onChange={(e) =>
setSelectedProjectId(
e.target.value === 'ALL' ? 'ALL' : Number(e.target.value)
)
}
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"
>
<option value="ALL">Todos os Projetos</option>
{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" />
onChange={(val) => setSelectedProjectId(val)}
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
options={[
{ value: 'ALL', label: 'Todos os Projetos' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color || '#0f766e',
})),
]}
/>
</div>
)}
{/* Status Filter */}
<select
<div className="w-44">
<CustomSelect
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as TaskStatus | 'ALL')}
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"
>
<option value="ALL">Todos os Status</option>
<option value="NOT_STARTED">A Fazer</option>
<option value="IN_PROGRESS">Em Andamento</option>
<option value="COMPLETED">Concluído</option>
<option value="CANCELED">Cancelado</option>
</select>
onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')}
options={[
{ value: 'ALL', label: 'Todos os Status' },
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
]}
/>
</div>
</div>
</div>