refactor(ui): modernize Select component with HeroUI v3 aesthetics and replace native selects
This commit is contained in:
parent
93c06cdad8
commit
b41e664d8a
3 changed files with 144 additions and 102 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue