Icon picker component (#11310)

* icon picker component

* keep box the same size when filtering icons
This commit is contained in:
Josh Hawkins 2024-05-09 08:22:48 -05:00 committed by GitHub
parent 50ee447e52
commit f8523d9ddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 233 additions and 85 deletions

View File

@ -1,8 +1,4 @@
import {
CameraGroupConfig,
FrigateConfig,
GROUP_ICONS,
} from "@/types/frigateConfig";
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
@ -10,7 +6,6 @@ import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { getIconForGroup } from "@/utils/iconUtil";
import { LuPencil, LuPlus } from "react-icons/lu";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { Drawer, DrawerContent } from "../ui/drawer";
@ -31,13 +26,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
@ -62,6 +50,9 @@ import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { usePersistence } from "@/hooks/use-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu";
import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker";
import { isValidIconName } from "@/utils/iconUtil";
type CameraGroupSelectorProps = {
className?: string;
@ -168,7 +159,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
isDesktop ? showTooltip(undefined) : null
}
>
{getIconForGroup(config.icon)}
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
icon={LuIcons[config.icon]}
className="size-4"
/>
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
@ -503,7 +499,12 @@ export function CameraGroupEdit({
cameras: z.array(z.string()).min(2, {
message: "You must select at least two cameras.",
}),
icon: z.string(),
icon: z
.string()
.min(1, { message: "You must select an icon." })
.refine((value) => Object.keys(LuIcons).includes(value), {
message: "Invalid icon",
}),
});
const onSubmit = useCallback(
@ -559,10 +560,10 @@ export function CameraGroupEdit({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
mode: "onSubmit",
defaultValues: {
name: (editingGroup && editingGroup[0]) ?? "",
icon: editingGroup && editingGroup[1].icon,
icon: editingGroup && (editingGroup[1].icon as IconName),
cameras: editingGroup && editingGroup[1].cameras,
},
});
@ -571,7 +572,7 @@ export function CameraGroupEdit({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6 overflow-y-hidden"
className="mt-2 space-y-6 overflow-y-auto"
>
<FormField
control={form.control}
@ -631,29 +632,20 @@ export function CameraGroupEdit({
control={form.control}
name="icon"
render={({ field }) => (
<FormItem className="space-y-3">
<FormItem className="flex flex-col space-y-2">
<FormLabel>Icon</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
</FormControl>
<SelectContent>
{GROUP_ICONS.map((gIcon) => (
<SelectItem key={gIcon} value={gIcon}>
<div className="flex flex-row justify-start items-center gap-2">
<div className="size-4">{getIconForGroup(gIcon)}</div>
{gIcon}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<IconPicker
selectedIcon={{
name: field.value,
Icon: field.value
? LuIcons[field.value as IconName]
: undefined,
}}
setSelectedIcon={(newIcon) => {
field.onChange(newIcon?.name ?? undefined);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -662,7 +654,7 @@ export function CameraGroupEdit({
<Separator className="flex my-2 bg-secondary" />
<div className="flex flex-row gap-2 pt-5">
<div className="flex flex-row gap-2 py-5 md:pb-0">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>

View File

@ -0,0 +1,154 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { IconType } from "react-icons";
import * as LuIcons from "react-icons/lu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { IoClose } from "react-icons/io5";
import Heading from "../ui/heading";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
export type IconName = keyof typeof LuIcons;
export type IconElement = {
name?: string;
Icon?: IconType;
};
type IconPickerProps = {
selectedIcon?: IconElement;
setSelectedIcon?: React.Dispatch<
React.SetStateAction<IconElement | undefined>
>;
};
export default function IconPicker({
selectedIcon,
setSelectedIcon,
}: IconPickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const iconSets = useMemo(() => [...Object.entries(LuIcons)], []);
const icons = useMemo(
() =>
iconSets.filter(
([name]) =>
name.toLowerCase().includes(searchTerm.toLowerCase()) ||
searchTerm === "",
),
[iconSets, searchTerm],
);
const handleIconSelect = useCallback(
({ name, Icon }: IconElement) => {
if (setSelectedIcon) {
setSelectedIcon({ name, Icon });
}
setSearchTerm("");
},
[setSelectedIcon],
);
return (
<div ref={containerRef}>
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<PopoverTrigger asChild>
{!selectedIcon?.name || !selectedIcon?.Icon ? (
<Button className="text-muted-foreground w-full mt-2">
Select an icon
</Button>
) : (
<div className="hover:cursor-pointer">
<div className="flex flex-row w-full justify-between items-center gap-2 my-3">
<div className="flex flex-row items-center gap-2">
<selectedIcon.Icon size={15} />
<div className="text-sm">
{selectedIcon.name
.replace(/^Lu/, "")
.replace(/([A-Z])/g, " $1")}
</div>
</div>
<IoClose
className="mx-2 hover:cursor-pointer"
onClick={() => {
handleIconSelect({ name: undefined, Icon: undefined });
}}
/>
</div>
</div>
)}
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
container={containerRef.current}
className="max-h-[50dvh]"
>
<div className="flex flex-row justify-between items-center mb-3">
<Heading as="h4">Select an icon</Heading>
<IoClose
size={15}
className="hover:cursor-pointer"
onClick={() => {
setOpen(false);
}}
/>
</div>
<Input
type="text"
placeholder="Search for an icon..."
className="mb-3"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="flex flex-col flex-1 h-[20dvh]">
<div className="grid grid-cols-6 my-2 gap-2 max-h-[20dvh] overflow-y-auto pr-1">
{icons.map(([name, Icon]) => (
<div
key={name}
className={cn(
"flex flex-row justify-center items-start hover:cursor-pointer p-1 rounded-lg",
selectedIcon?.name === name
? "bg-selected text-white"
: "hover:bg-secondary-foreground",
)}
>
<Icon
size={20}
onClick={() => {
handleIconSelect({ name, Icon });
setOpen(false);
}}
/>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
type IconRendererProps = {
icon: IconType;
size?: number;
className?: string;
};
export function IconRenderer({ icon, size, className }: IconRendererProps) {
return <>{React.createElement(icon, { size, className })}</>;
}

View File

@ -1,29 +1,36 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null;
}
>(
(
{ className, container, align = "center", sideOffset = 4, ...props },
ref,
) => (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label";
import { useNavigate } from "react-router-dom";
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { isMobile } from "react-device-detect";
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
// Color data
const colors = [
@ -207,6 +208,8 @@ function UIPlayground() {
const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true);
const birdseyeConfig = config?.birdseye;
const [selectedIcon, setSelectedIcon] = useState<IconElement>();
return (
<>
<div className="w-full h-full">
@ -214,6 +217,15 @@ function UIPlayground() {
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
<Heading as="h2">UI Playground</Heading>
<IconPicker
selectedIcon={selectedIcon}
setSelectedIcon={setSelectedIcon}
/>
{selectedIcon?.name && (
<p>Selected icon name: {selectedIcon.name}</p>
)}
<Heading as="h4" className="my-5">
Scrubber
</Heading>

View File

@ -1,3 +1,4 @@
import { IconName } from "@/components/icons/IconPicker";
import { LivePlayerMode } from "./live";
export interface UiConfig {
@ -222,11 +223,9 @@ export interface CameraConfig {
};
}
export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const;
export type CameraGroupConfig = {
cameras: string[];
icon: (typeof GROUP_ICONS)[number];
icon: IconName;
order: number;
};

View File

@ -1,3 +1,4 @@
import { IconName } from "@/components/icons/IconPicker";
import { BsPersonWalking } from "react-icons/bs";
import {
FaAmazon,
@ -6,35 +7,18 @@ import {
FaCarSide,
FaCat,
FaCheckCircle,
FaCircle,
FaDog,
FaFedex,
FaFire,
FaLeaf,
FaUps,
} from "react-icons/fa";
import { GiHummingbird } from "react-icons/gi";
import { LuBox, LuLassoSelect } from "react-icons/lu";
import * as LuIcons from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md";
export function getIconTypeForGroup(icon: string) {
switch (icon) {
case "car":
return FaCarSide;
case "cat":
return FaCat;
case "dog":
return FaDog;
case "leaf":
return FaLeaf;
default:
return FaCircle;
}
}
export function getIconForGroup(icon: string, className: string = "size-4") {
const GroupIcon = getIconTypeForGroup(icon);
return <GroupIcon className={className} />;
export function isValidIconName(value: string): value is IconName {
return Object.keys(LuIcons).includes(value as IconName);
}
export function getIconForLabel(label: string, className?: string) {