Last active
June 2, 2026 19:09
-
-
Save notflip/5f3e1c078b329f74c956dc7f863e4793 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { TextField } from "payload"; | |
| type IconFieldOptions = { | |
| name?: string; | |
| label?: string; | |
| required?: boolean; | |
| }; | |
| export function iconField({ | |
| name = "icon", | |
| label = "Icoon", | |
| required = false, | |
| }: IconFieldOptions = {}): TextField { | |
| return { | |
| type: "text", | |
| name, | |
| label, | |
| required, | |
| admin: { | |
| components: { | |
| Field: { | |
| path: "@kit/fields/icon/IconPickerComponent#IconPickerComponent", | |
| }, | |
| }, | |
| }, | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { iconNames } from "lucide-react/dynamic"; | |
| // Authoritative kebab-case list from lucide-react itself — no filtering needed. | |
| // Names are in the format expected by DynamicIcon: "star", "arrow-right", etc. | |
| export const ICON_NAMES: string[] = [...iconNames].sort(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { FieldLabel, useField } from "@payloadcms/ui"; | |
| import { useVirtualizer } from "@tanstack/react-virtual"; | |
| import * as LucideIcons from "lucide-react"; | |
| import type { LucideIcon } from "lucide-react"; | |
| import type { TextFieldClientProps } from "payload"; | |
| import React, { useEffect, useMemo, useRef, useState } from "react"; | |
| import { ICON_NAMES } from "./icon-list"; | |
| const ITEM_HEIGHT = 36; | |
| // Icons are stored as kebab-case ("arrow-right"); LucideIcons keys are PascalCase ("ArrowRight") | |
| function toPascalCase(kebab: string): string { | |
| return kebab.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(""); | |
| } | |
| function getIcon(kebabName: string): LucideIcon | undefined { | |
| return (LucideIcons as unknown as Record<string, LucideIcon>)[toPascalCase(kebabName)]; | |
| } | |
| export const IconPickerComponent: React.FC<TextFieldClientProps> = ({ field, path }) => { | |
| const { value, setValue } = useField<string>({ path }); | |
| const [open, setOpen] = useState(false); | |
| const [search, setSearch] = useState(""); | |
| const wrapperRef = useRef<HTMLDivElement>(null); | |
| const listRef = useRef<HTMLDivElement>(null); | |
| const searchRef = useRef<HTMLInputElement>(null); | |
| const filteredIcons = useMemo(() => { | |
| const q = search.trim().toLowerCase(); | |
| return q ? ICON_NAMES.filter((n) => n.toLowerCase().includes(q)) : ICON_NAMES; | |
| }, [search]); | |
| const virtualizer = useVirtualizer({ | |
| count: filteredIcons.length, | |
| getScrollElement: () => listRef.current, | |
| estimateSize: () => ITEM_HEIGHT, | |
| overscan: 5, | |
| }); | |
| useEffect(() => { | |
| if (!open) { | |
| setSearch(""); | |
| return; | |
| } | |
| requestAnimationFrame(() => searchRef.current?.focus()); | |
| const onDown = (e: MouseEvent) => { | |
| if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false); | |
| }; | |
| document.addEventListener("mousedown", onDown); | |
| return () => document.removeEventListener("mousedown", onDown); | |
| }, [open]); | |
| const SelectedIcon = value ? getIcon(value) : undefined; | |
| return ( | |
| <div className="field-type" ref={wrapperRef} style={{ position: "relative" }}> | |
| <FieldLabel label={field.label} /> | |
| <button | |
| type="button" | |
| onClick={() => setOpen((v) => !v)} | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 8, | |
| width: "100%", | |
| padding: "8px 10px", | |
| border: "1px solid var(--theme-elevation-150)", | |
| borderRadius: 6, | |
| background: "var(--theme-input-bg, var(--theme-bg))", | |
| color: "var(--theme-text)", | |
| fontSize: 14, | |
| cursor: "pointer", | |
| textAlign: "left", | |
| }} | |
| > | |
| {SelectedIcon ? ( | |
| <> | |
| <SelectedIcon width={16} height={16} strokeWidth={1.75} /> | |
| <span style={{ flex: 1 }}>{value}</span> | |
| <span | |
| role="button" | |
| aria-label="Verwijder icoon" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setValue(""); | |
| }} | |
| style={{ opacity: 0.45, fontSize: 11, lineHeight: 1, padding: "2px 4px" }} | |
| > | |
| ✕ | |
| </span> | |
| </> | |
| ) : ( | |
| <span style={{ opacity: 0.45 }}>Kies een icoon…</span> | |
| )} | |
| </button> | |
| {open && ( | |
| <div | |
| style={{ | |
| position: "absolute", | |
| top: "calc(100% + 4px)", | |
| left: 0, | |
| right: 0, | |
| zIndex: 50, | |
| background: "var(--theme-bg)", | |
| border: "1px solid var(--theme-elevation-150)", | |
| borderRadius: 6, | |
| boxShadow: "0 6px 20px rgba(0,0,0,0.12)", | |
| }} | |
| > | |
| <div style={{ padding: "8px 8px 4px" }}> | |
| <input | |
| ref={searchRef} | |
| type="search" | |
| placeholder="Zoek icoon…" | |
| value={search} | |
| onChange={(e) => setSearch(e.target.value)} | |
| style={{ | |
| width: "100%", | |
| padding: "6px 10px", | |
| border: "1px solid var(--theme-elevation-150)", | |
| borderRadius: 4, | |
| background: "var(--theme-elevation-50, rgba(0,0,0,0.03))", | |
| color: "var(--theme-text)", | |
| fontSize: 13, | |
| boxSizing: "border-box", | |
| }} | |
| /> | |
| </div> | |
| <div style={{ padding: "2px 10px 4px", fontSize: 11, opacity: 0.45 }}> | |
| {filteredIcons.length} iconen | |
| </div> | |
| <div ref={listRef} style={{ height: 280, overflowY: "auto" }}> | |
| <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> | |
| {virtualizer.getVirtualItems().map((row) => { | |
| const name = filteredIcons[row.index]; | |
| const Icon = getIcon(name); | |
| const isSelected = value === name; | |
| return ( | |
| <div | |
| key={row.key} | |
| data-index={row.index} | |
| ref={virtualizer.measureElement} | |
| onClick={() => { | |
| setValue(name); | |
| setOpen(false); | |
| }} | |
| style={{ | |
| position: "absolute", | |
| top: row.start, | |
| left: 0, | |
| right: 0, | |
| height: ITEM_HEIGHT, | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 8, | |
| padding: "0 10px", | |
| cursor: "pointer", | |
| fontSize: 13, | |
| background: isSelected ? "var(--theme-elevation-100)" : "transparent", | |
| color: isSelected ? "var(--theme-text)" : "var(--theme-elevation-800)", | |
| }} | |
| > | |
| {Icon && <Icon width={15} height={15} strokeWidth={1.5} />} | |
| <span>{name}</span> | |
| {isSelected && ( | |
| <span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.6 }}>✓</span> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment