mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(categorias): adiciona seletor pesquisável de ícones
This commit is contained in:
@@ -1,15 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { RiMoreLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -25,6 +19,7 @@ import { getCategoryIconOptions } from "@/shared/lib/categories/icons";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
import { CategoryIcon } from "./category-icon";
|
||||
import { CategoryPickerDialog } from "./category-picker-dialog";
|
||||
import { TypeSelectContent } from "./category-select-items";
|
||||
import type { CategoryFormValues } from "./types";
|
||||
|
||||
@@ -33,17 +28,17 @@ interface CategoryFormFieldsProps {
|
||||
onChange: (field: keyof CategoryFormValues, value: string) => void;
|
||||
}
|
||||
|
||||
const iconOptions = getCategoryIconOptions();
|
||||
|
||||
export function CategoryFormFields({
|
||||
values,
|
||||
onChange,
|
||||
}: CategoryFormFieldsProps) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const iconOptions = getCategoryIconOptions();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const handleIconSelect = (icon: string) => {
|
||||
onChange("icon", icon);
|
||||
setPopoverOpen(false);
|
||||
};
|
||||
const selectedIconLabel = useMemo(() => {
|
||||
return iconOptions.find((o) => o.value === values.icon)?.label ?? null;
|
||||
}, [values.icon]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
@@ -83,45 +78,36 @@ export function CategoryFormFields({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ícone</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md border p-2 text-left transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-muted/30 text-primary">
|
||||
{values.icon ? (
|
||||
<CategoryIcon name={values.icon} className="size-7" />
|
||||
<CategoryIcon name={values.icon} className="size-5" />
|
||||
) : (
|
||||
<RiMoreLine className="size-6 text-muted-foreground" />
|
||||
<RiMoreLine className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
Selecionar ícone
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[480px] p-3" align="start">
|
||||
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
|
||||
{iconOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleIconSelect(option.value)}
|
||||
className={cn(
|
||||
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
|
||||
values.icon === option.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
title={option.label}
|
||||
>
|
||||
<CategoryIcon name={option.value} className="size-6" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Escolha um ícone que represente melhor esta categoria.
|
||||
</p>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{selectedIconLabel ?? "Selecionar ícone"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Clique para trocar o ícone
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<CategoryPickerDialog
|
||||
open={pickerOpen}
|
||||
value={values.icon}
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={(icon) => onChange("icon", icon)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { RemixiconComponentType } from "@remixicon/react";
|
||||
import * as RemixIcons from "@remixicon/react";
|
||||
import type { ComponentType } from "react";
|
||||
import { getIconComponent } from "@/shared/utils/icons";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
const ICONS = RemixIcons as Record<string, RemixiconComponentType | undefined>;
|
||||
const FALLBACK_ICON = ICONS.RiPriceTag3Line;
|
||||
|
||||
interface CategoryIconProps {
|
||||
name?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ name, className }: CategoryIconProps) {
|
||||
const IconComponent =
|
||||
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
|
||||
const IconComponent = (
|
||||
name ? getIconComponent(name) : getIconComponent("RiPriceTag3Line")
|
||||
) as ComponentType<{ className?: string }> | null;
|
||||
|
||||
if (!IconComponent) {
|
||||
return (
|
||||
|
||||
117
src/features/categories/components/category-picker-dialog.tsx
Normal file
117
src/features/categories/components/category-picker-dialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { CATEGORY_ICON_GROUPS } from "@/shared/lib/categories/icons";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
import { CategoryIcon } from "./category-icon";
|
||||
|
||||
interface CategoryPickerDialogProps {
|
||||
open: boolean;
|
||||
value: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (icon: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryPickerDialog({
|
||||
open,
|
||||
value,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: CategoryPickerDialogProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) setSearch("");
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const query = search.toLowerCase().trim();
|
||||
if (!query) return CATEGORY_ICON_GROUPS;
|
||||
|
||||
return CATEGORY_ICON_GROUPS.flatMap((group) => {
|
||||
const icons = group.icons.filter(
|
||||
(icon) =>
|
||||
icon.label.toLowerCase().includes(query) ||
|
||||
group.label.toLowerCase().includes(query),
|
||||
);
|
||||
return icons.length > 0 ? [{ ...group, icons }] : [];
|
||||
});
|
||||
}, [search]);
|
||||
|
||||
const totalVisible = filteredGroups.reduce(
|
||||
(acc, g) => acc + g.icons.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Escolher ícone</DialogTitle>
|
||||
<DialogDescription>
|
||||
Selecione o ícone que melhor representa esta categoria.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pesquisar ícone..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{totalVisible === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Nenhum ícone encontrado para “{search}”
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1">
|
||||
{filteredGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{group.icons.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(option.value);
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label={option.label}
|
||||
aria-pressed={value === option.value}
|
||||
title={option.label}
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
value === option.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<CategoryIcon name={option.value} className="size-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user