@mvriu5/payload-icon-picker 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -0
- package/dist/IconField.d.ts +27 -0
- package/dist/IconField.js +364 -0
- package/dist/exports/client.d.ts +2 -0
- package/dist/exports/client.js +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +132 -0
- package/package.json +134 -0
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Payload Icon Picker
|
|
2
|
+
|
|
3
|
+
Payload plugin for adding an icon picker field to the admin UI. The field stores a string, while editors can search and choose from the icons you register in the plugin config.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @mvriu5/payload-icon-picker
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Install the icon library you want to use in your project, for example:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add lucide-react
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Register all icons once in your Payload config, then use `iconField()` wherever a Payload text field should become an icon picker.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { postgresAdapter } from "@payloadcms/db-postgres"
|
|
23
|
+
import { lexicalEditor } from "@payloadcms/richtext-lexical"
|
|
24
|
+
import * as Icons from "lucide-react"
|
|
25
|
+
import { buildConfig } from "payload"
|
|
26
|
+
import { iconField, payloadIconPlugin } from "@mvriu5/payload-icon-picker"
|
|
27
|
+
|
|
28
|
+
export default buildConfig({
|
|
29
|
+
collections: [
|
|
30
|
+
{
|
|
31
|
+
slug: "posts",
|
|
32
|
+
fields: [
|
|
33
|
+
{
|
|
34
|
+
name: "title",
|
|
35
|
+
type: "text",
|
|
36
|
+
required: true,
|
|
37
|
+
},
|
|
38
|
+
iconField({
|
|
39
|
+
name: "icon",
|
|
40
|
+
label: "Icon",
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
plugins: [
|
|
46
|
+
payloadIconPlugin({
|
|
47
|
+
icons: Icons,
|
|
48
|
+
resolveIcon: ({ name }) => name,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
db: postgresAdapter({
|
|
52
|
+
pool: {
|
|
53
|
+
connectionString: process.env.DATABASE_URL,
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
editor: lexicalEditor(),
|
|
57
|
+
secret: process.env.PAYLOAD_SECRET,
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Stored Value
|
|
62
|
+
|
|
63
|
+
`iconField()` creates a normal Payload `text` field. The selected icon is resolved to a string and stored in the database.
|
|
64
|
+
|
|
65
|
+
By default, the stored value is `icon.value ?? icon.name`. Use `resolveIcon` to choose a different format:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
payloadIconPlugin({
|
|
69
|
+
icons: Icons,
|
|
70
|
+
resolveIcon: ({ name }) => `lucide:${name}`,
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Selecting `ArrowRight` would store:
|
|
75
|
+
|
|
76
|
+
```txt
|
|
77
|
+
lucide:ArrowRight
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Resolving Stored Icons
|
|
81
|
+
|
|
82
|
+
Use `createIconResolver()` to turn a stored string back into the registered icon.
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
import * as Icons from "lucide-react"
|
|
86
|
+
import { createIconResolver } from "@mvriu5/payload-icon-picker"
|
|
87
|
+
|
|
88
|
+
const resolveStoredIcon = createIconResolver({
|
|
89
|
+
icons: Icons,
|
|
90
|
+
resolveIcon: ({ name }) => name,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
export function PostIcon({ icon }: { icon?: string }) {
|
|
94
|
+
const resolvedIcon = resolveStoredIcon(icon)
|
|
95
|
+
const Icon = resolvedIcon?.Icon
|
|
96
|
+
|
|
97
|
+
if (!Icon) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return <Icon aria-hidden size={24} />
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Icon Inputs
|
|
106
|
+
|
|
107
|
+
You can pass a full icon library namespace:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import * as Icons from "lucide-react"
|
|
111
|
+
|
|
112
|
+
payloadIconPlugin({
|
|
113
|
+
icons: Icons,
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or pass explicit icon metadata:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
payloadIconPlugin({
|
|
121
|
+
icons: [
|
|
122
|
+
{
|
|
123
|
+
Icon: Icons.Home,
|
|
124
|
+
keywords: ["house", "start"],
|
|
125
|
+
label: "Home",
|
|
126
|
+
name: "Home",
|
|
127
|
+
value: "home",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Field Options
|
|
134
|
+
|
|
135
|
+
`iconField()` accepts normal single-value Payload text field options, plus picker labels:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
iconField({
|
|
139
|
+
name: "icon",
|
|
140
|
+
label: "Icon",
|
|
141
|
+
required: true,
|
|
142
|
+
placeholder: "Search icons",
|
|
143
|
+
noResultsLabel: "No icons found",
|
|
144
|
+
admin: {
|
|
145
|
+
position: "sidebar",
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
The dev Payload config in `dev/payload.config.ts` registers the plugin with a small local icon registry so the picker can be tested without installing another icon package.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pnpm dev
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Generate the Payload import map after changing admin components:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pnpm generate:importmap
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Build the package:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm build
|
|
168
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TextFieldClientProps } from "payload";
|
|
2
|
+
import type { ElementType } from "react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
type IconComponent = ElementType<{
|
|
5
|
+
"aria-hidden"?: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
focusable?: boolean;
|
|
8
|
+
size?: number | string;
|
|
9
|
+
}>;
|
|
10
|
+
export type IconFieldIcon = {
|
|
11
|
+
Icon?: IconComponent;
|
|
12
|
+
component?: IconComponent;
|
|
13
|
+
keywords?: string[];
|
|
14
|
+
label?: string;
|
|
15
|
+
name: string;
|
|
16
|
+
svg?: string;
|
|
17
|
+
value?: string;
|
|
18
|
+
};
|
|
19
|
+
export type IconFieldIconRecord = Record<string, IconComponent> | Record<string, IconFieldIcon>;
|
|
20
|
+
export type IconFieldProps = TextFieldClientProps & {
|
|
21
|
+
icons?: IconFieldIcon[] | IconFieldIconRecord;
|
|
22
|
+
noResultsLabel?: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
resolveIcon?: (icon: IconFieldIcon) => string;
|
|
25
|
+
};
|
|
26
|
+
export declare const IconField: React.FC<IconFieldProps>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { FieldLabel, useField } from "@payloadcms/ui";
|
|
4
|
+
import { icons as lucideIcons } from "lucide-react";
|
|
5
|
+
import React, { useMemo, useState } from "react";
|
|
6
|
+
const defaultResolveIcon = (icon)=>icon.value ?? icon.name;
|
|
7
|
+
const ICON_BATCH_SIZE = 120;
|
|
8
|
+
const normalizeIcons = (icons)=>{
|
|
9
|
+
if (!icons) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(icons)) {
|
|
13
|
+
return icons;
|
|
14
|
+
}
|
|
15
|
+
return Object.entries(icons).map(([name, icon])=>{
|
|
16
|
+
if (isIconComponent(icon)) {
|
|
17
|
+
return {
|
|
18
|
+
Icon: icon,
|
|
19
|
+
name
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
...icon
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const isIconComponent = (icon)=>{
|
|
29
|
+
if (typeof icon === "function" || typeof icon === "string") {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return !("Icon" in icon || "component" in icon || "label" in icon || "value" in icon);
|
|
33
|
+
};
|
|
34
|
+
const getIconSearchText = (icon)=>[
|
|
35
|
+
icon.name,
|
|
36
|
+
icon.label,
|
|
37
|
+
icon.value,
|
|
38
|
+
...icon.keywords ?? []
|
|
39
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
40
|
+
export const IconField = ({ field, icons: iconsFromProps, noResultsLabel = "No icons found", path, placeholder = "Search icons", resolveIcon = defaultResolveIcon, validate })=>{
|
|
41
|
+
const { custom = {}, description } = field.admin ?? {};
|
|
42
|
+
const { label, required } = field;
|
|
43
|
+
const iconsFromField = custom.icons;
|
|
44
|
+
const resolvedIcons = useMemo(()=>normalizeIcons(iconsFromProps ?? iconsFromField), [
|
|
45
|
+
iconsFromField,
|
|
46
|
+
iconsFromProps
|
|
47
|
+
]);
|
|
48
|
+
const { errorMessage, setValue, showError, value } = useField({
|
|
49
|
+
path,
|
|
50
|
+
validate: validate
|
|
51
|
+
});
|
|
52
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
53
|
+
const [query, setQuery] = useState("");
|
|
54
|
+
const [visibleCount, setVisibleCount] = useState(ICON_BATCH_SIZE);
|
|
55
|
+
const selectedIcon = useMemo(()=>resolvedIcons.find((icon)=>resolveIcon(icon) === value), [
|
|
56
|
+
resolvedIcons,
|
|
57
|
+
resolveIcon,
|
|
58
|
+
value
|
|
59
|
+
]);
|
|
60
|
+
const filteredIcons = useMemo(()=>{
|
|
61
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
62
|
+
if (!normalizedQuery) {
|
|
63
|
+
return resolvedIcons;
|
|
64
|
+
}
|
|
65
|
+
return resolvedIcons.filter((icon)=>getIconSearchText(icon).includes(normalizedQuery));
|
|
66
|
+
}, [
|
|
67
|
+
query,
|
|
68
|
+
resolvedIcons
|
|
69
|
+
]);
|
|
70
|
+
const visibleIcons = useMemo(()=>filteredIcons.slice(0, visibleCount), [
|
|
71
|
+
filteredIcons,
|
|
72
|
+
visibleCount
|
|
73
|
+
]);
|
|
74
|
+
const hasMoreIcons = visibleCount < filteredIcons.length;
|
|
75
|
+
const openDialog = ()=>{
|
|
76
|
+
setVisibleCount(ICON_BATCH_SIZE);
|
|
77
|
+
setIsDialogOpen(true);
|
|
78
|
+
};
|
|
79
|
+
const closeDialog = ()=>{
|
|
80
|
+
setIsDialogOpen(false);
|
|
81
|
+
setQuery("");
|
|
82
|
+
setVisibleCount(ICON_BATCH_SIZE);
|
|
83
|
+
};
|
|
84
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
85
|
+
className: "field-type text payload-icon-picker",
|
|
86
|
+
children: [
|
|
87
|
+
/*#__PURE__*/ _jsx(FieldLabel, {
|
|
88
|
+
label: label,
|
|
89
|
+
path: path,
|
|
90
|
+
required: required
|
|
91
|
+
}),
|
|
92
|
+
description ? /*#__PURE__*/ _jsx("div", {
|
|
93
|
+
className: "field-description",
|
|
94
|
+
children: description
|
|
95
|
+
}) : null,
|
|
96
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
97
|
+
style: {
|
|
98
|
+
display: "grid",
|
|
99
|
+
gap: 8
|
|
100
|
+
},
|
|
101
|
+
children: [
|
|
102
|
+
/*#__PURE__*/ _jsxs("button", {
|
|
103
|
+
"aria-haspopup": "dialog",
|
|
104
|
+
"aria-expanded": isDialogOpen,
|
|
105
|
+
onClick: openDialog,
|
|
106
|
+
style: {
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
background: "var(--theme-input-bg)",
|
|
109
|
+
border: "1px solid var(--theme-elevation-150)",
|
|
110
|
+
borderRadius: 4,
|
|
111
|
+
color: "inherit",
|
|
112
|
+
cursor: "pointer",
|
|
113
|
+
display: "flex",
|
|
114
|
+
font: "inherit",
|
|
115
|
+
gap: 10,
|
|
116
|
+
minHeight: 40,
|
|
117
|
+
padding: "8px 10px",
|
|
118
|
+
textAlign: "left",
|
|
119
|
+
width: "100%"
|
|
120
|
+
},
|
|
121
|
+
type: "button",
|
|
122
|
+
children: [
|
|
123
|
+
selectedIcon ? /*#__PURE__*/ _jsx(IconPreview, {
|
|
124
|
+
icon: selectedIcon
|
|
125
|
+
}) : null,
|
|
126
|
+
/*#__PURE__*/ _jsx("span", {
|
|
127
|
+
style: {
|
|
128
|
+
color: selectedIcon ? "inherit" : "var(--theme-elevation-500)",
|
|
129
|
+
flex: 1,
|
|
130
|
+
overflow: "hidden",
|
|
131
|
+
textOverflow: "ellipsis",
|
|
132
|
+
whiteSpace: "nowrap"
|
|
133
|
+
},
|
|
134
|
+
children: selectedIcon ? selectedIcon.label ?? selectedIcon.name : placeholder
|
|
135
|
+
}),
|
|
136
|
+
value ? /*#__PURE__*/ _jsx("span", {
|
|
137
|
+
onClick: (event)=>{
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
setValue("");
|
|
140
|
+
},
|
|
141
|
+
onKeyDown: (event)=>{
|
|
142
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
event.stopPropagation();
|
|
145
|
+
setValue("");
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
role: "button",
|
|
149
|
+
tabIndex: 0,
|
|
150
|
+
style: {
|
|
151
|
+
borderLeft: "1px solid var(--theme-elevation-150)",
|
|
152
|
+
color: "var(--theme-elevation-600)",
|
|
153
|
+
cursor: "pointer",
|
|
154
|
+
paddingLeft: 10
|
|
155
|
+
},
|
|
156
|
+
children: "Clear"
|
|
157
|
+
}) : null
|
|
158
|
+
]
|
|
159
|
+
}),
|
|
160
|
+
isDialogOpen ? /*#__PURE__*/ _jsx("div", {
|
|
161
|
+
"aria-modal": "true",
|
|
162
|
+
onClick: closeDialog,
|
|
163
|
+
role: "dialog",
|
|
164
|
+
style: {
|
|
165
|
+
alignItems: "center",
|
|
166
|
+
background: "rgb(0 0 0 / 45%)",
|
|
167
|
+
display: "flex",
|
|
168
|
+
inset: 0,
|
|
169
|
+
justifyContent: "center",
|
|
170
|
+
padding: 24,
|
|
171
|
+
position: "fixed",
|
|
172
|
+
zIndex: 1000
|
|
173
|
+
},
|
|
174
|
+
children: /*#__PURE__*/ _jsxs("div", {
|
|
175
|
+
onClick: (event)=>event.stopPropagation(),
|
|
176
|
+
style: {
|
|
177
|
+
background: "var(--theme-bg)",
|
|
178
|
+
border: "1px solid var(--theme-elevation-150)",
|
|
179
|
+
borderRadius: 4,
|
|
180
|
+
boxShadow: "0 20px 60px rgb(0 0 0 / 30%)",
|
|
181
|
+
display: "grid",
|
|
182
|
+
gap: 16,
|
|
183
|
+
maxHeight: "min(720px, calc(100vh - 48px))",
|
|
184
|
+
maxWidth: 760,
|
|
185
|
+
overflow: "hidden",
|
|
186
|
+
padding: 20,
|
|
187
|
+
width: "100%"
|
|
188
|
+
},
|
|
189
|
+
children: [
|
|
190
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
191
|
+
style: {
|
|
192
|
+
alignItems: "center",
|
|
193
|
+
display: "flex",
|
|
194
|
+
gap: 12
|
|
195
|
+
},
|
|
196
|
+
children: [
|
|
197
|
+
/*#__PURE__*/ _jsx("h3", {
|
|
198
|
+
style: {
|
|
199
|
+
flex: 1,
|
|
200
|
+
fontSize: 18,
|
|
201
|
+
margin: 0
|
|
202
|
+
},
|
|
203
|
+
children: "Select icon"
|
|
204
|
+
}),
|
|
205
|
+
/*#__PURE__*/ _jsx("button", {
|
|
206
|
+
onClick: closeDialog,
|
|
207
|
+
type: "button",
|
|
208
|
+
children: "Close"
|
|
209
|
+
})
|
|
210
|
+
]
|
|
211
|
+
}),
|
|
212
|
+
/*#__PURE__*/ _jsx("input", {
|
|
213
|
+
autoFocus: true,
|
|
214
|
+
"aria-label": "Search icons",
|
|
215
|
+
onChange: (event)=>{
|
|
216
|
+
setQuery(event.target.value);
|
|
217
|
+
setVisibleCount(ICON_BATCH_SIZE);
|
|
218
|
+
},
|
|
219
|
+
placeholder: placeholder,
|
|
220
|
+
style: {
|
|
221
|
+
border: "1px solid var(--theme-elevation-150)",
|
|
222
|
+
borderRadius: 4,
|
|
223
|
+
font: "inherit",
|
|
224
|
+
padding: "8px 10px",
|
|
225
|
+
width: "100%"
|
|
226
|
+
},
|
|
227
|
+
type: "search",
|
|
228
|
+
value: query
|
|
229
|
+
}),
|
|
230
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
231
|
+
"aria-label": "Icon options",
|
|
232
|
+
role: "listbox",
|
|
233
|
+
onScroll: (event)=>{
|
|
234
|
+
const element = event.currentTarget;
|
|
235
|
+
const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
236
|
+
if (distanceToBottom < 240 && hasMoreIcons) {
|
|
237
|
+
setVisibleCount((count)=>Math.min(count + ICON_BATCH_SIZE, filteredIcons.length));
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
style: {
|
|
241
|
+
display: "grid",
|
|
242
|
+
gap: 8,
|
|
243
|
+
gridTemplateColumns: "repeat(auto-fill, minmax(112px, 1fr))",
|
|
244
|
+
maxHeight: "min(440px, calc(100vh - 260px))",
|
|
245
|
+
minHeight: 220,
|
|
246
|
+
overflowY: "auto",
|
|
247
|
+
paddingRight: 2
|
|
248
|
+
},
|
|
249
|
+
children: [
|
|
250
|
+
filteredIcons.length > 0 ? visibleIcons.map((icon)=>{
|
|
251
|
+
const iconValue = resolveIcon(icon);
|
|
252
|
+
const isSelected = iconValue === value;
|
|
253
|
+
return /*#__PURE__*/ _jsxs("button", {
|
|
254
|
+
"aria-selected": isSelected,
|
|
255
|
+
onClick: ()=>{
|
|
256
|
+
setValue(iconValue);
|
|
257
|
+
closeDialog();
|
|
258
|
+
},
|
|
259
|
+
role: "option",
|
|
260
|
+
style: {
|
|
261
|
+
alignItems: "center",
|
|
262
|
+
background: isSelected ? "var(--theme-elevation-100)" : "transparent",
|
|
263
|
+
border: `1px solid ${isSelected ? "var(--theme-success-500)" : "var(--theme-elevation-150)"}`,
|
|
264
|
+
borderRadius: 4,
|
|
265
|
+
cursor: "pointer",
|
|
266
|
+
display: "grid",
|
|
267
|
+
gap: 6,
|
|
268
|
+
justifyItems: "center",
|
|
269
|
+
minHeight: 82,
|
|
270
|
+
padding: 8
|
|
271
|
+
},
|
|
272
|
+
title: icon.label ?? icon.name,
|
|
273
|
+
type: "button",
|
|
274
|
+
children: [
|
|
275
|
+
/*#__PURE__*/ _jsx(IconPreview, {
|
|
276
|
+
icon: icon
|
|
277
|
+
}),
|
|
278
|
+
/*#__PURE__*/ _jsx("span", {
|
|
279
|
+
style: {
|
|
280
|
+
fontSize: 12,
|
|
281
|
+
maxWidth: "100%",
|
|
282
|
+
overflow: "hidden",
|
|
283
|
+
textOverflow: "ellipsis",
|
|
284
|
+
whiteSpace: "nowrap"
|
|
285
|
+
},
|
|
286
|
+
children: icon.label ?? icon.name
|
|
287
|
+
})
|
|
288
|
+
]
|
|
289
|
+
}, iconValue);
|
|
290
|
+
}) : /*#__PURE__*/ _jsx("div", {
|
|
291
|
+
children: noResultsLabel
|
|
292
|
+
}),
|
|
293
|
+
hasMoreIcons ? /*#__PURE__*/ _jsx("button", {
|
|
294
|
+
onClick: ()=>setVisibleCount((count)=>Math.min(count + ICON_BATCH_SIZE, filteredIcons.length)),
|
|
295
|
+
style: {
|
|
296
|
+
gridColumn: "1 / -1",
|
|
297
|
+
minHeight: 40
|
|
298
|
+
},
|
|
299
|
+
type: "button",
|
|
300
|
+
children: "Load more"
|
|
301
|
+
}) : null
|
|
302
|
+
]
|
|
303
|
+
}),
|
|
304
|
+
value ? /*#__PURE__*/ _jsx("button", {
|
|
305
|
+
onClick: ()=>{
|
|
306
|
+
setValue("");
|
|
307
|
+
closeDialog();
|
|
308
|
+
},
|
|
309
|
+
style: {
|
|
310
|
+
justifySelf: "start"
|
|
311
|
+
},
|
|
312
|
+
type: "button",
|
|
313
|
+
children: "Clear selection"
|
|
314
|
+
}) : null
|
|
315
|
+
]
|
|
316
|
+
})
|
|
317
|
+
}) : null
|
|
318
|
+
]
|
|
319
|
+
}),
|
|
320
|
+
showError && errorMessage ? /*#__PURE__*/ _jsx("div", {
|
|
321
|
+
className: "field-error",
|
|
322
|
+
children: errorMessage
|
|
323
|
+
}) : null
|
|
324
|
+
]
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
const IconPreview = ({ icon })=>{
|
|
328
|
+
const PreviewIcon = icon.Icon ?? icon.component;
|
|
329
|
+
const LucideIcon = lucideIcons[icon.name];
|
|
330
|
+
if (PreviewIcon) {
|
|
331
|
+
return /*#__PURE__*/ _jsx(PreviewIcon, {
|
|
332
|
+
"aria-hidden": true,
|
|
333
|
+
focusable: false,
|
|
334
|
+
size: 24
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (LucideIcon) {
|
|
338
|
+
return /*#__PURE__*/ _jsx(LucideIcon, {
|
|
339
|
+
"aria-hidden": true,
|
|
340
|
+
focusable: false,
|
|
341
|
+
size: 24
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (icon.svg) {
|
|
345
|
+
return /*#__PURE__*/ _jsx("span", {
|
|
346
|
+
"aria-hidden": true,
|
|
347
|
+
dangerouslySetInnerHTML: {
|
|
348
|
+
__html: icon.svg
|
|
349
|
+
},
|
|
350
|
+
style: {
|
|
351
|
+
display: "inline-flex",
|
|
352
|
+
lineHeight: 1
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return /*#__PURE__*/ _jsx("span", {
|
|
357
|
+
"aria-hidden": true,
|
|
358
|
+
style: {
|
|
359
|
+
fontSize: 20,
|
|
360
|
+
lineHeight: 1
|
|
361
|
+
},
|
|
362
|
+
children: icon.label?.charAt(0) ?? icon.name.charAt(0)
|
|
363
|
+
});
|
|
364
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { IconField } from "../IconField.js";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Config, TextField } from "payload";
|
|
2
|
+
import type { IconFieldIcon, IconFieldIconRecord } from "./IconField.js";
|
|
3
|
+
export type PayloadIconPluginConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* Icons from the selected icon library. You can pass an array or a module namespace,
|
|
6
|
+
* e.g. `import * as Icons from 'lucide-react'`.
|
|
7
|
+
*/
|
|
8
|
+
icons: IconFieldIcon[] | IconFieldIconRecord;
|
|
9
|
+
/**
|
|
10
|
+
* Disable only the admin picker behavior. The field remains a text field so stored
|
|
11
|
+
* data and database schema stay stable.
|
|
12
|
+
*/
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Maps a selected icon to the string that is stored in Payload.
|
|
16
|
+
*/
|
|
17
|
+
resolveIcon?: (icon: IconFieldIcon) => string;
|
|
18
|
+
};
|
|
19
|
+
export type IconFieldConfig = Omit<TextField, "admin" | "hasMany" | "maxRows" | "minRows" | "type"> & {
|
|
20
|
+
admin?: TextField["admin"];
|
|
21
|
+
noResultsLabel?: string;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
};
|
|
24
|
+
export type ResolveIconFromStringConfig = {
|
|
25
|
+
icons: IconFieldIcon[] | IconFieldIconRecord;
|
|
26
|
+
resolveIcon?: (icon: IconFieldIcon) => string;
|
|
27
|
+
};
|
|
28
|
+
export type ResolvedIcon = IconFieldIcon & {
|
|
29
|
+
resolvedValue: string;
|
|
30
|
+
};
|
|
31
|
+
export declare const iconField: ({ admin, noResultsLabel, placeholder, ...field }: IconFieldConfig) => TextField;
|
|
32
|
+
export declare const payloadIconPlugin: (pluginOptions: PayloadIconPluginConfig) => (config: Config) => Config;
|
|
33
|
+
export declare const createIconResolver: ({ icons, resolveIcon }: ResolveIconFromStringConfig) => (value: null | string | undefined) => ResolvedIcon | undefined;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const ICON_FIELD_MARKER = "payloadIconPicker";
|
|
2
|
+
const ICON_FIELD_COMPONENT = "@mvriu5/payload-icon-picker/client#IconField";
|
|
3
|
+
export const iconField = ({ admin, noResultsLabel, placeholder, ...field })=>({
|
|
4
|
+
...field,
|
|
5
|
+
hasMany: false,
|
|
6
|
+
type: "text",
|
|
7
|
+
admin: {
|
|
8
|
+
...admin,
|
|
9
|
+
custom: {
|
|
10
|
+
...admin?.custom ?? {},
|
|
11
|
+
[ICON_FIELD_MARKER]: {
|
|
12
|
+
noResultsLabel,
|
|
13
|
+
placeholder
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
export const payloadIconPlugin = (pluginOptions)=>(config)=>{
|
|
19
|
+
if (pluginOptions.disabled) {
|
|
20
|
+
return config;
|
|
21
|
+
}
|
|
22
|
+
const icons = normalizeIconsForClient(pluginOptions.icons, pluginOptions.resolveIcon);
|
|
23
|
+
config.collections = config.collections?.map((collection)=>({
|
|
24
|
+
...collection,
|
|
25
|
+
fields: withIconFields(collection.fields, icons)
|
|
26
|
+
}));
|
|
27
|
+
config.globals = config.globals?.map((global)=>({
|
|
28
|
+
...global,
|
|
29
|
+
fields: withIconFields(global.fields, icons)
|
|
30
|
+
}));
|
|
31
|
+
return config;
|
|
32
|
+
};
|
|
33
|
+
export const createIconResolver = ({ icons, resolveIcon })=>{
|
|
34
|
+
const iconMap = new Map();
|
|
35
|
+
normalizeIcons(icons).forEach((icon)=>{
|
|
36
|
+
const resolvedValue = resolveIcon ? resolveIcon(icon) : icon.value ?? icon.name;
|
|
37
|
+
iconMap.set(resolvedValue, {
|
|
38
|
+
...icon,
|
|
39
|
+
resolvedValue
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
return (value)=>{
|
|
43
|
+
if (!value) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return iconMap.get(value);
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const withIconFields = (fields, icons)=>(fields ?? []).map((field)=>{
|
|
50
|
+
const fieldWithNestedFields = field;
|
|
51
|
+
if ("admin" in field && field.admin?.custom?.[ICON_FIELD_MARKER]) {
|
|
52
|
+
const marker = field.admin.custom[ICON_FIELD_MARKER];
|
|
53
|
+
const textField = field;
|
|
54
|
+
const admin = textField.admin;
|
|
55
|
+
return {
|
|
56
|
+
...textField,
|
|
57
|
+
admin: {
|
|
58
|
+
...admin,
|
|
59
|
+
components: {
|
|
60
|
+
...admin?.components ?? {},
|
|
61
|
+
Field: {
|
|
62
|
+
clientProps: {
|
|
63
|
+
icons,
|
|
64
|
+
noResultsLabel: marker.noResultsLabel,
|
|
65
|
+
placeholder: marker.placeholder
|
|
66
|
+
},
|
|
67
|
+
path: ICON_FIELD_COMPONENT
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
custom: admin?.custom
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (fieldWithNestedFields.fields) {
|
|
75
|
+
return {
|
|
76
|
+
...fieldWithNestedFields,
|
|
77
|
+
fields: withIconFields(fieldWithNestedFields.fields, icons)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (fieldWithNestedFields.tabs) {
|
|
81
|
+
return {
|
|
82
|
+
...fieldWithNestedFields,
|
|
83
|
+
tabs: fieldWithNestedFields.tabs.map((tab)=>({
|
|
84
|
+
...tab,
|
|
85
|
+
fields: withIconFields(tab.fields, icons)
|
|
86
|
+
}))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (fieldWithNestedFields.blocks) {
|
|
90
|
+
return {
|
|
91
|
+
...fieldWithNestedFields,
|
|
92
|
+
blocks: fieldWithNestedFields.blocks.map((block)=>({
|
|
93
|
+
...block,
|
|
94
|
+
fields: withIconFields(block.fields, icons)
|
|
95
|
+
}))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return field;
|
|
99
|
+
});
|
|
100
|
+
const normalizeIconsForClient = (icons, resolveIcon)=>{
|
|
101
|
+
return normalizeIcons(icons).map(({ Icon, component, ...icon })=>({
|
|
102
|
+
...icon,
|
|
103
|
+
value: resolveIcon ? resolveIcon({
|
|
104
|
+
Icon,
|
|
105
|
+
component,
|
|
106
|
+
...icon
|
|
107
|
+
}) : icon.value ?? icon.name
|
|
108
|
+
}));
|
|
109
|
+
};
|
|
110
|
+
const normalizeIcons = (icons)=>{
|
|
111
|
+
if (Array.isArray(icons)) {
|
|
112
|
+
return icons;
|
|
113
|
+
}
|
|
114
|
+
return Object.entries(icons).map(([name, icon])=>{
|
|
115
|
+
if (isIconComponent(icon)) {
|
|
116
|
+
return {
|
|
117
|
+
Icon: icon,
|
|
118
|
+
name
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
name,
|
|
123
|
+
...icon
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
const isIconComponent = (icon)=>{
|
|
128
|
+
if (typeof icon === "function" || typeof icon === "string") {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return !("Icon" in icon || "component" in icon || "label" in icon || "value" in icon);
|
|
132
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mvriu5/payload-icon-picker",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Payload plugin introducing an icon picker into the CMS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"import": "./src/exports/client.ts",
|
|
15
|
+
"types": "./src/exports/client.ts",
|
|
16
|
+
"default": "./src/exports/client.ts"
|
|
17
|
+
},
|
|
18
|
+
"./rsc": {
|
|
19
|
+
"import": "./src/exports/rsc.ts",
|
|
20
|
+
"types": "./src/exports/rsc.ts",
|
|
21
|
+
"default": "./src/exports/rsc.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"main": "./src/index.ts",
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
|
31
|
+
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
|
32
|
+
"build:types": "tsc --outDir dist --rootDir ./src",
|
|
33
|
+
"clean": "rimraf {dist,*.tsbuildinfo}",
|
|
34
|
+
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
|
35
|
+
"dev": "next dev dev --turbo",
|
|
36
|
+
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
|
|
37
|
+
"dev:generate-types": "pnpm dev:payload generate:types",
|
|
38
|
+
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
|
39
|
+
"generate:importmap": "pnpm dev:generate-importmap",
|
|
40
|
+
"generate:types": "pnpm dev:generate-types",
|
|
41
|
+
"prepublishOnly": "pnpm clean && pnpm build",
|
|
42
|
+
"test": "pnpm test:unit && pnpm test:components",
|
|
43
|
+
"test:e2e": "playwright test",
|
|
44
|
+
"test:e2e:headed": "playwright test --headed",
|
|
45
|
+
"test:components": "vitest run tests/components",
|
|
46
|
+
"test:unit": "vitest run tests/unit",
|
|
47
|
+
"knip": "knip"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@payloadcms/db-postgres": "3.84.1",
|
|
51
|
+
"@payloadcms/next": "3.84.1",
|
|
52
|
+
"@payloadcms/richtext-lexical": "3.84.1",
|
|
53
|
+
"@payloadcms/ui": "3.84.1",
|
|
54
|
+
"@playwright/test": "1.58.2",
|
|
55
|
+
"@swc/cli": "0.6.0",
|
|
56
|
+
"@types/node": "22.19.9",
|
|
57
|
+
"@types/react": "19.2.14",
|
|
58
|
+
"@types/react-dom": "19.2.3",
|
|
59
|
+
"copyfiles": "2.4.1",
|
|
60
|
+
"cross-env": "^7.0.3",
|
|
61
|
+
"eslint": "^9.23.0",
|
|
62
|
+
"graphql": "^16.8.1",
|
|
63
|
+
"mongodb-memory-server": "10.1.4",
|
|
64
|
+
"next": "16.2.6",
|
|
65
|
+
"payload": "3.84.1",
|
|
66
|
+
"prettier": "^3.4.2",
|
|
67
|
+
"react": "19.2.6",
|
|
68
|
+
"react-dom": "19.2.6",
|
|
69
|
+
"rimraf": "3.0.2",
|
|
70
|
+
"sharp": "0.34.2",
|
|
71
|
+
"typescript": "5.7.3",
|
|
72
|
+
"vitest": "4.0.18"
|
|
73
|
+
},
|
|
74
|
+
"peerDependencies": {
|
|
75
|
+
"payload": "^3.84.1",
|
|
76
|
+
"react": "^19.2.6",
|
|
77
|
+
"react-dom": "^19.2.6"
|
|
78
|
+
},
|
|
79
|
+
"engines": {
|
|
80
|
+
"node": "^18.20.2 || >=20.9.0",
|
|
81
|
+
"pnpm": "^9 || ^10 || ^11"
|
|
82
|
+
},
|
|
83
|
+
"publishConfig": {
|
|
84
|
+
"exports": {
|
|
85
|
+
".": {
|
|
86
|
+
"import": "./dist/index.js",
|
|
87
|
+
"types": "./dist/index.d.ts",
|
|
88
|
+
"default": "./dist/index.js"
|
|
89
|
+
},
|
|
90
|
+
"./client": {
|
|
91
|
+
"import": "./dist/exports/client.js",
|
|
92
|
+
"types": "./dist/exports/client.d.ts",
|
|
93
|
+
"default": "./dist/exports/client.js"
|
|
94
|
+
},
|
|
95
|
+
"./rsc": {
|
|
96
|
+
"import": "./dist/exports/rsc.js",
|
|
97
|
+
"types": "./dist/exports/rsc.d.ts",
|
|
98
|
+
"default": "./dist/exports/rsc.js"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"main": "./dist/index.js",
|
|
102
|
+
"types": "./dist/index.d.ts"
|
|
103
|
+
},
|
|
104
|
+
"knip": {
|
|
105
|
+
"entry": [
|
|
106
|
+
"src/exports/client.ts",
|
|
107
|
+
"dev/payload.config.ts",
|
|
108
|
+
"dev/next.config.mjs",
|
|
109
|
+
"dev/app/**/*.{ts,tsx}"
|
|
110
|
+
],
|
|
111
|
+
"ignore": [
|
|
112
|
+
"dev/payload-types.ts"
|
|
113
|
+
],
|
|
114
|
+
"ignoreDependencies": [
|
|
115
|
+
"prettier"
|
|
116
|
+
],
|
|
117
|
+
"project": [
|
|
118
|
+
"src/**/*.{ts,tsx}",
|
|
119
|
+
"dev/**/*.{ts,tsx,mjs}"
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
"pnpm": {
|
|
123
|
+
"onlyBuiltDependencies": [
|
|
124
|
+
"sharp",
|
|
125
|
+
"esbuild",
|
|
126
|
+
"unrs-resolver"
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
"registry": "https://registry.npmjs.org/",
|
|
130
|
+
"dependencies": {
|
|
131
|
+
"knip": "^6.24.0",
|
|
132
|
+
"lucide-react": "^1.23.0"
|
|
133
|
+
}
|
|
134
|
+
}
|