Skip to content

Commit e6a5454

Browse files
committed
feat: Icon picker
1 parent f54b0c8 commit e6a5454

10 files changed

Lines changed: 1492 additions & 1754 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@radix-ui/react-toggle": "^1.1.8",
4343
"@radix-ui/react-toggle-group": "^1.1.9",
4444
"@radix-ui/react-tooltip": "^1.2.6",
45+
"@tanstack/react-virtual": "^3.13.8",
4546
"@tiptap/extension-horizontal-rule": "^2.11.7",
4647
"@tiptap/extension-text-align": "^2.11.7",
4748
"@tiptap/extension-text-style": "^2.11.5",
@@ -58,7 +59,7 @@
5859
"embla-carousel-react": "^8.6.0",
5960
"input-otp": "^1.4.2",
6061
"lucide-react": "^0.508.0",
61-
"next": "15.2.4",
62+
"next": "^15.3.2",
6263
"next-themes": "^0.4.6",
6364
"posthog-js": "^1.234.1",
6465
"posthog-node": "^4.11.1",

src/app/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
DndContext,
66
DragOverlay,
77
DragStartEvent,
8-
KeyboardSensor,
98
PointerSensor,
109
useSensor,
1110
} from "@dnd-kit/core";
@@ -31,7 +30,6 @@ import { ToggleGroupNav } from "@/components/form-builder/ui/toggle-group-nav";
3130
import { useCallback, useMemo, useState } from "react";
3231
import {
3332
DependenciesImports,
34-
generateFormCode,
3533
} from "@/components/form-builder/helpers/generate-react-code";
3634
import { MainExport } from "@/components/form-builder/dialogs/generate-code-dialog";
3735
import { MobileNotification } from "@/components/form-builder/ui/mobile-notification";
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { Search, Check, X } from "lucide-react";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogHeader,
9+
DialogTitle,
10+
DialogTrigger,
11+
} from "@/components/ui/dialog";
12+
import { Input } from "@/components/ui/input";
13+
import { ScrollArea } from "@/components/ui/scroll-area";
14+
import { cn } from "@/lib/utils";
15+
import { icons} from "lucide-react";
16+
import type { LucideIcon } from "lucide-react";
17+
import { Button, buttonVariants } from "@/components/ui/button";
18+
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
19+
import { Suspense, useEffect } from "react";
20+
21+
interface IconPickerDialogProps {
22+
onSelect: (iconName: string) => void;
23+
selectedIcon?: string;
24+
}
25+
26+
const IconListGrid = ({
27+
search,
28+
onSelect,
29+
selectedIcon,
30+
setOpen,
31+
}: {
32+
search: string;
33+
onSelect: (iconName: string) => void;
34+
selectedIcon?: string;
35+
setOpen: (open: boolean) => void;
36+
}) => {
37+
const IconsKeyList = Object.keys(icons);
38+
const filterIcons = (search: string) => {
39+
if (search === "") {
40+
return IconsKeyList;
41+
}
42+
return IconsKeyList.filter((iconName) =>
43+
iconName.toLowerCase().includes(search.toLowerCase())
44+
);
45+
};
46+
47+
const parentRef = React.useRef<HTMLDivElement>(null);
48+
49+
const [hoveredIcon, setHoveredIcon] = React.useState<string | null>(null);
50+
const filteredIcons = filterIcons(search);
51+
const rowVirtualizer = useVirtualizer({
52+
count: Math.ceil(filteredIcons.length / 6),
53+
getScrollElement: () => parentRef.current,
54+
estimateSize: () => 72, // height of each row (48px) + gap (8px)
55+
overscan: 5,
56+
});
57+
58+
const rowItems = rowVirtualizer.getVirtualItems();
59+
60+
return (
61+
<div
62+
ref={parentRef}
63+
className="h-[400px] overflow-auto relative scrollbar-hide"
64+
style={{
65+
contain: "strict",
66+
}}
67+
>
68+
<div
69+
style={{
70+
height: `${rowVirtualizer.getTotalSize()}px`,
71+
width: "100%",
72+
position: "relative",
73+
}}
74+
>
75+
{rowItems.map((virtualRow: VirtualItem) => {
76+
const startIndex = virtualRow.index * 6;
77+
const rowIcons = filteredIcons.slice(startIndex, startIndex + 6);
78+
79+
return (
80+
<div
81+
key={virtualRow.index}
82+
className="absolute top-0 left-0 w-full grid grid-cols-6 gap-2"
83+
style={{
84+
transform: `translateY(${virtualRow.start}px)`,
85+
}}
86+
>
87+
{rowIcons.length > 0 &&
88+
rowIcons.map((iconName) => {
89+
const Icon = icons[
90+
iconName as keyof typeof icons
91+
] as LucideIcon;
92+
const isSelected = selectedIcon === iconName;
93+
const isHovered = hoveredIcon === iconName;
94+
95+
if (!Icon) {
96+
return null;
97+
}
98+
99+
return (
100+
<Button
101+
variant="outline"
102+
key={iconName}
103+
className={cn(
104+
"h-16 w-16",
105+
isSelected && "bg-accent text-accent-foreground border-primary",
106+
isHovered && "bg-accent/50"
107+
)}
108+
onClick={() => {
109+
onSelect(iconName);
110+
setOpen(false);
111+
}}
112+
onMouseEnter={() => setHoveredIcon(iconName)}
113+
onMouseLeave={() => setHoveredIcon(null)}
114+
>
115+
<Icon className="size-8 text-primary" strokeWidth={isSelected ? 1.5 : 1} />
116+
</Button>
117+
);
118+
})}
119+
</div>
120+
);
121+
})}
122+
</div>
123+
</div>
124+
);
125+
};
126+
export function IconPickerDialog({
127+
onSelect,
128+
selectedIcon,
129+
}: IconPickerDialogProps) {
130+
const [open, setOpen] = React.useState(false);
131+
const [search, setSearch] = React.useState("");
132+
const [selectedIconText, setSelectedIconText] = React.useState<string>();
133+
const [selectedIconIcon, setSelectedIconIcon] = React.useState<LucideIcon>();
134+
135+
useEffect(() => {
136+
setSelectedIconText(selectedIcon || "");
137+
setSelectedIconIcon(icons[selectedIcon as keyof typeof icons] as LucideIcon);
138+
}, [selectedIcon]);
139+
140+
const onIconSelect = (iconName: string) => {
141+
setSelectedIconText(iconName);
142+
setSelectedIconIcon(icons[iconName as keyof typeof icons] as LucideIcon);
143+
onSelect(iconName);
144+
setOpen(false);
145+
};
146+
147+
const onIconDeselect = () => {
148+
setSelectedIconText("");
149+
setSelectedIconIcon(undefined);
150+
onSelect("");
151+
setOpen(false);
152+
};
153+
154+
const Icon = selectedIconIcon ? selectedIconIcon : null;
155+
156+
return (
157+
<Dialog open={open} onOpenChange={setOpen}>
158+
<div
159+
className={cn(
160+
"flex flex-row items-center gap-2 w-full",
161+
buttonVariants({ variant: "outline", size: "sm" }),
162+
"text-sm font-normal text-left"
163+
)}
164+
>
165+
<DialogTrigger asChild>
166+
<div className="flex items-center justify-between w-full">
167+
{Icon ? <Icon className="size-4" /> : "Pick an Icon"}
168+
</div>
169+
</DialogTrigger>
170+
{selectedIconText && (
171+
<div className="flex items-center justify-center" onClick={onIconDeselect}>
172+
<X className="size-4 text-muted-foreground opacity-50"/>
173+
</div>
174+
)}
175+
</div>
176+
<DialogContent className="sm:max-w-[480px]">
177+
<DialogHeader>
178+
<DialogTitle>Pick an Icon</DialogTitle>
179+
</DialogHeader>
180+
<div className="relative">
181+
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
182+
<Input
183+
placeholder="Search icons..."
184+
className="pl-8"
185+
value={search}
186+
onChange={(e) => setSearch(e.target.value)}
187+
/>
188+
</div>
189+
<Suspense fallback={<div>Loading...</div>}>
190+
<IconListGrid
191+
search={search}
192+
onSelect={onIconSelect}
193+
setOpen={setOpen}
194+
selectedIcon={selectedIcon}
195+
/>
196+
<div
197+
className={cn(
198+
"absolute inset-x-0 bottom-6 h-12 z-10 bg-gradient-from-transparent bg-gradient-to-t from-white to-transparent"
199+
)}
200+
/>
201+
</Suspense>
202+
</DialogContent>
203+
</Dialog>
204+
);
205+
}

src/components/form-builder/form-components/form-button.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { HtmlGroup } from "../sidebar/groups/html-group";
66
import { ButtonGroup } from "../sidebar/groups/button-group";
77
import { cn, escapeHtml } from "@/lib/utils";
88
import { UseFormReturn, FieldValues, ControllerRenderProps } from "react-hook-form";
9+
import { Icon } from "../helpers/icon-render";
10+
import { icons, Rows } from "lucide-react";
911

1012
export function FormButton(component: FormComponentModel, form: UseFormReturn<FieldValues, undefined>, field: ControllerRenderProps) {
1113
return (
@@ -17,6 +19,7 @@ export function FormButton(component: FormComponentModel, form: UseFormReturn<Fi
1719
type={component.getField("attributes.type")}
1820
variant={component.getField("properties.variant")}
1921
>
22+
{component.getField("properties.style.icon") && <Icon name={component.getField("properties.style.icon")} className="size-4" />}
2023
{component.getField("content")}
2124
</Button>
2225
);
@@ -38,12 +41,15 @@ export function getReactCode(component: FormComponentModel): ReactCode {
3841
type="${component.getField("attributes.type")}"
3942
variant="${component.getField("properties.variant")}"
4043
>
44+
${component.getField("properties.style.icon") && `<${component.getField("properties.style.icon")} className="size-4" />`}
4145
${escapeHtml(component.getField("content"))}
4246
</Button>
4347
`,
4448
dependencies: {
4549
"@/components/ui/button": ["Button"],
46-
50+
...(component.getField("properties.style.icon") && {
51+
[`lucide-react`]: [component.getField("properties.style.icon")],
52+
}),
4753
},
4854
};
4955
}

src/components/form-builder/sidebar/groups/button-group.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ import {
88
SelectValue,
99
} from "@/components/ui/select";
1010
import { Input } from "@/components/ui/input";
11+
import { IconPickerDialog } from "../../dialogs/icon-picker-dialog";
1112

12-
type propertiesWhitelist = "type" | "content" | "variant";
13+
type propertiesWhitelist = "type" | "content" | "variant" | "icon";
1314

1415
export type ButtonGroupProps = {
1516
whitelist?: propertiesWhitelist[];
1617
};
1718

1819
export function ButtonGroup({
19-
whitelist = ["type", "content", "variant"],
20+
whitelist = ["type", "content", "variant", "icon"],
2021
}: ButtonGroupProps) {
2122
const { updateComponent, selectedComponent } = useFormBuilderStore();
2223

@@ -28,6 +29,7 @@ export function ButtonGroup({
2829
let defaultInputContent = selectedComponent.getField("content");
2930
let defaultValueVariant =
3031
selectedComponent.getField("properties.variant") || "default";
32+
let defaultValueIcon = selectedComponent.getField("properties.style.icon");
3133

3234
const handleChange = (
3335
field: string,
@@ -101,6 +103,14 @@ export function ButtonGroup({
101103
</div>
102104
</div>
103105
)}
106+
{whitelist.includes("icon") && (
107+
<div className="grid grid-cols-2 gap-2 items-center">
108+
<Label className="text-xs text-gray-400">Icon</Label>
109+
<div className="flex flex-row items-center gap-2">
110+
<IconPickerDialog onSelect={(iconName) => handleChange("properties.style.icon", iconName, true)} selectedIcon={defaultValueIcon} />
111+
</div>
112+
</div>
113+
)}
104114
</>
105115
);
106116
}

0 commit comments

Comments
 (0)