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'
|
} 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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
<ChevronDown
|
<div className="shrink-0 pl-1">
|
||||||
className={`w-3.5 h-3.5 text-muted transition-transform duration-200 shrink-0 ${
|
<ChevronDown
|
||||||
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>
|
</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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue