sanity-plugin-iconify 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Wannes SalomΓ©
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,284 @@
1
+ <div align="center">
2
+ <img src="https://api.iconify.design/line-md:iconify1.svg?color=%23026c9c" width="100" />
3
+ <h1 align="center">Sanity Plugin Iconify</h1>
4
+ <h3>Custom input with over 150,000 open source vector icons</h3>
5
+
6
+ <img src="https://img.shields.io/badge/TypeScript-3178C6.svg?style&logo=TypeScript&logoColor=white" alt="TypeScript" />
7
+ <img src="https://snyk.io/test/github/waspeer/sanity-plugin-iconify/badge.svg" alt"Known Vulnerabilities" />
8
+ <img src="https://img.shields.io/github/license/waspeer/sanity-plugin-iconify?style&color=5D6D7E" alt="GitHub license" />
9
+ </div>
10
+
11
+ ---
12
+
13
+ ## πŸ“’ Table of Contents
14
+ - [πŸ“’ Table of Contents](#-table-of-contents)
15
+ - [πŸ“ Overview](#-overview)
16
+ - [πŸš€ Getting Started](#-getting-started)
17
+ - [Installation](#installation)
18
+ - [Configuration](#configuration)
19
+ - [Add Icon schema type](#add-icon-schema-type)
20
+ - [✨ Usage](#-usage)
21
+ - [Options](#options)
22
+ - [`collections`](#collections)
23
+ - [`showName`](#showname)
24
+ - [Output](#output)
25
+ - [Preview](#preview)
26
+ - [🀝 Contributing](#-contributing)
27
+ - [πŸ§ͺ Develop \& test](#-develop--test)
28
+ - [πŸ‘ Acknowledgments](#-acknowledgments)
29
+ - [πŸ“„ License](#-license)
30
+
31
+ ---
32
+
33
+
34
+ ## πŸ“ Overview
35
+
36
+ Enhance your [Sanity](https://www.sanity.io/) project with the Iconify plugin, which introduces an Icon schema type and a custom input component. Leveraging the extensive library of open-source vector icons available through the [Iconify](https://iconify.design/), this plugin enables you to effortlessly select and integrate over 150,000 icons from popular icon sets directly into your Sanity project.
37
+
38
+ <div align="center">
39
+ <img src="https://github.com/waspeer/sanity-plugin-iconify/assets/11842931/f937c953-a87d-431e-85b6-e3938d69ddac" width="600" />
40
+ </div>
41
+
42
+ ## πŸš€ Getting Started
43
+
44
+ ### Installation
45
+
46
+ Install the plugin:
47
+
48
+ ```sh
49
+ npm install sanity-plugin-iconify
50
+ ```
51
+
52
+ ```sh
53
+ yarn add sanity-plugin-iconify
54
+ ```
55
+
56
+ ```sh
57
+ pnpm add sanity-plugin-iconify
58
+ ```
59
+
60
+ ### Configuration
61
+
62
+ Then add it as a plugin in `sanity.config.ts` (or .js):
63
+
64
+ ```ts
65
+ import { defineConfig } from 'sanity';
66
+ import { iconify } from 'sanity-plugin-iconify';
67
+
68
+ export default defineConfig({
69
+ //...
70
+ plugins: [iconify({
71
+ // Optional configuration
72
+
73
+ // Filter icons by collection for all Icon fields (this field has typed autocomplete ✨)
74
+ // Defaults to empty array (all collections)
75
+ collections: ['fa-brands', 'mdi', ...],
76
+
77
+ // Shows the selected icon name and collection underneath the icon picker
78
+ // Defaults to false
79
+ showName: false,
80
+ })],
81
+ });
82
+ ```
83
+
84
+ (Read more on configuration options [here](#options).)
85
+
86
+ ### Add Icon schema type
87
+
88
+ Use the Icon schema type in your Sanity schemas:
89
+
90
+ ```ts
91
+ const type = defineType({
92
+ type: 'document'
93
+ name: 'myDocument',
94
+ title: 'My Document',
95
+ // ...
96
+ fields: [
97
+ // ...
98
+ {
99
+ name: 'myIcon',
100
+ title: 'My Icon',
101
+ type: 'icon', // <-- Icon schema type
102
+ },
103
+ ],
104
+ })
105
+ ```
106
+
107
+ Learn more about Sanity schema types [here](https://www.sanity.io/docs/schema-types).
108
+
109
+ ## ✨ Usage
110
+
111
+ ### Options
112
+
113
+ #### `collections`
114
+
115
+ Filter icons by collection for all Icon fields. This option allows you to categorize icons based on predefined sets, making it easier to navigate and select from a curated list of icons. You can specify this field both in the plugin options and in the schema type options, with the latter overriding the plugin options. The package includes types for all collection prefixes, enabling typed autocomplete for this field.
116
+
117
+ **Default:** `[]` (all collections)
118
+
119
+ ```ts
120
+ import { defineConfig } from 'sanity';
121
+
122
+ export default defineConfig({
123
+ //...
124
+ plugins: [iconify({
125
+ collections: ['fa-brands', 'mdi', ...], // <-- Filter icons by collection for all Icon fields
126
+ })],
127
+ });
128
+ ```
129
+
130
+ ```ts
131
+ const type = defineType({
132
+ type: 'document'
133
+ name: 'myDocument',
134
+ title: 'My Document',
135
+ // ...
136
+ fields: [
137
+ // ...
138
+ {
139
+ name: 'myIcon',
140
+ title: 'My Icon',
141
+ type: 'icon',
142
+ options: {
143
+ collections: ['fa-brands', 'mdi', ...], // <-- Filter icons by collection for this field
144
+ },
145
+ },
146
+ ],
147
+ })
148
+ ```
149
+
150
+ #### `showName`
151
+
152
+ Enables the display of the selected icon's name and collection underneath the icon picker, providing a quick reference and ease of identification. This field can be specified in both the plugin options and the schema type options, with the schema type options having priority over the plugin options.
153
+
154
+ **Default:** `false`
155
+
156
+ ```ts
157
+ import { defineConfig } from 'sanity';
158
+
159
+ export default defineConfig({
160
+ //...
161
+ plugins: [iconify({
162
+ showName: true, // <-- Shows the selected icon name and collection underneath all icon pickers
163
+ })],
164
+ });
165
+ ```
166
+
167
+ ```ts
168
+ const type = defineType({
169
+ type: 'document'
170
+ name: 'myDocument',
171
+ title: 'My Document',
172
+ // ...
173
+ fields: [
174
+ // ...
175
+ {
176
+ name: 'myIcon',
177
+ title: 'My Icon',
178
+ type: 'icon',
179
+ options: {
180
+ showName: true, // <-- Shows the selected icon name and collection underneath this icon picker
181
+ },
182
+ },
183
+ ],
184
+ })
185
+ ```
186
+
187
+ ### Output
188
+
189
+ The Icon schema type outputs an object with the icon name.
190
+
191
+ ```ts
192
+ {
193
+ _type: 'icon',
194
+ name: string; // The iconify name of the icon
195
+ }
196
+ ```
197
+
198
+ This name can be utilized in your frontend to render the icon dynamically. For instance, using [React Iconify](https://iconify.design/docs/icon-components/react/) allows you to render the icon as demonstrated below:
199
+
200
+ ```tsx
201
+ import { Icon } from '@iconify/react';
202
+
203
+ <Icon icon={icon.name} />
204
+ ```
205
+
206
+ This will render an SVG on demand, which looks great and is very performant. There are also libraries/API's for:
207
+
208
+ - [Vue](https://iconify.design/docs/icon-components/vue/)
209
+ - [Svelte](https://iconify.design/docs/icon-components/svelte/)
210
+ - [Astro](https://iconify.design/docs/usage/svg/astro/)
211
+ - [And more (Plain SVGs, Tailwind, Web Components, etc.)](https://iconify.design/docs/usage/)
212
+
213
+ For further information, you may refer to the [official documentation](https://iconify.design/docs).
214
+
215
+ ### Preview
216
+
217
+ The plugin includes a custom list preview that automatically renders the selected icon accompanied by its name and collection.
218
+
219
+ <img width="618" alt="array-of-icons" src="https://github.com/waspeer/sanity-plugin-iconify/assets/11842931/0c1c32a1-e796-434f-ae64-c134f9b51ab9">
220
+
221
+ If needed you can override this preview component by specifying your own in the schema type options. Use the `@iconify/react` package to render the icon.
222
+
223
+ ```tsx
224
+ import { Icon } from '@iconify/react';
225
+ import { defineType } from 'sanity';
226
+
227
+ const type = defineType({
228
+ type: 'array'
229
+ name: 'myArray',
230
+ title: 'My array of icons',
231
+ of: [
232
+ {
233
+ type: 'icon',
234
+ components: {
235
+ preview: (props: PreviewProps) => {
236
+ return props.renderDefault({
237
+ ...props,
238
+ title: 'Custom title',
239
+ subtitle: 'Custom subtitle',
240
+ media: <Icon icon={props.title as string} />, // <-- Renders the selected icon as media
241
+ });
242
+ },
243
+ },
244
+ },
245
+ ],
246
+ })
247
+ ```
248
+
249
+ Learn more about Sanity previews [here](https://www.sanity.io/docs/previews-list-views).
250
+
251
+ ## 🀝 Contributing
252
+
253
+ Contributions, whether in the form of code enhancements, bug fixes, documentation, or design improvements, are always welcome! Here are the steps to get started:
254
+
255
+ 1. Fork the project repository. This creates a copy of the project on your account that you can modify without affecting the original project.
256
+ 2. Clone the forked repository to your local machine using a Git client like Git or GitHub Desktop.
257
+ 3. Create a new branch with a descriptive name (e.g., `new-feature-branch` or `bugfix-issue-123`).
258
+ ```sh
259
+ git checkout -b new-feature-branch
260
+ ```
261
+ 1. Make changes to the project's codebase.
262
+ 2. Commit your changes to your local branch with a clear conventional commit message that explains the changes you've made.
263
+ ```sh
264
+ git commit -m 'feat: Implemented new feature.'
265
+ ```
266
+ 1. Push your changes to your forked repository on GitHub using the following command
267
+ ```sh
268
+ git push origin new-feature-branch
269
+ ```
270
+ 1. Create a new pull request to the original project repository. In the pull request, describe the changes you've made and why they are necessary. Make sure to update or add documentation as relevant. I will review your changes, provide feedback, or merge them into the main branch.
271
+
272
+ ## πŸ§ͺ Develop & test
273
+
274
+ This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) with default configuration for build & watch scripts.
275
+
276
+ See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) on how to run this plugin with hotreload in the studio.
277
+
278
+ ## πŸ‘ Acknowledgments
279
+
280
+ - [sanity-plugin-icon-picker](https://github.com/christopherafbjur/sanity-plugin-icon-picker) - is a solid alternative built around `react-icons`.
281
+
282
+ ## πŸ“„ License
283
+
284
+ [MIT](LICENSE) Β© Wannes SalomΓ©
package/package.json ADDED
@@ -0,0 +1,109 @@
1
+ {
2
+ "name": "sanity-plugin-iconify",
3
+ "version": "1.0.0",
4
+ "description": "Icon picker based on Iconify",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin"
8
+ ],
9
+ "homepage": "https://github.com/waspeer/sanity-plugin-iconify#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/waspeer/sanity-plugin-iconify/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+ssh://git@github.com/waspeer/sanity-plugin-iconify.git"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Wannes SalomΓ© <mail@wannessalome.nl>",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "source": "./src/index.ts",
23
+ "require": "./dist/index.js",
24
+ "import": "./dist/index.esm.js",
25
+ "default": "./dist/index.esm.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "main": "./dist/index.js",
30
+ "module": "./dist/index.esm.js",
31
+ "source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "sanity.json",
36
+ "src",
37
+ "v2-incompatible.js"
38
+ ],
39
+ "scripts": {
40
+ "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict",
41
+ "clean": "rimraf dist",
42
+ "format": "prettier --write --cache --ignore-unknown .",
43
+ "link-watch": "pnpm run generate-types && plugin-kit link-watch",
44
+ "lint": "eslint .",
45
+ "prepublish-only": "run-s build",
46
+ "watch": "pkg-utils watch --strict",
47
+ "generate-types": "tsx src/lib/generate-types",
48
+ "prepare": "husky install"
49
+ },
50
+ "dependencies": {
51
+ "@headlessui/react": "^1.7.17",
52
+ "@iconify/react": "^4.1.1",
53
+ "@iconify/utils": "^2.1.10",
54
+ "@sanity/icons": "^2.4.1",
55
+ "@sanity/incompatible-plugin": "^1.0.4",
56
+ "@sanity/ui": "^1.8.2",
57
+ "@tanstack/react-query": "^4.35.3",
58
+ "change-case": "^4.1.2",
59
+ "ts-pattern": "^5.0.5",
60
+ "use-debounce": "^9.0.4"
61
+ },
62
+ "devDependencies": {
63
+ "@commitlint/cli": "^17.7.1",
64
+ "@commitlint/config-conventional": "^17.7.0",
65
+ "@commitlint/load": "^17.7.1",
66
+ "@iconify/json": "^2.2.116",
67
+ "@iconify/types": "^2.0.0",
68
+ "@sanity/pkg-utils": "^2.4.9",
69
+ "@sanity/plugin-kit": "^3.1.10",
70
+ "@sanity/semantic-release-preset": "^4.1.4",
71
+ "@tanstack/eslint-plugin-query": "^4.34.1",
72
+ "@tanstack/react-query-devtools": "^4.35.3",
73
+ "@types/react": "^18.2.21",
74
+ "@types/styled-components": "^5.1.27",
75
+ "@typescript-eslint/eslint-plugin": "^6.7.0",
76
+ "@typescript-eslint/parser": "^6.7.0",
77
+ "eslint": "^8.49.0",
78
+ "eslint-config-prettier": "^9.0.0",
79
+ "eslint-config-sanity": "^6.0.0",
80
+ "eslint-plugin-prettier": "^5.0.0",
81
+ "eslint-plugin-react": "^7.33.2",
82
+ "eslint-plugin-react-hooks": "^4.6.0",
83
+ "husky": "^8.0.3",
84
+ "lint-staged": "^14.0.1",
85
+ "npm-run-all": "^4.1.5",
86
+ "prettier": "^3.0.3",
87
+ "prettier-plugin-packagejson": "^2.4.5",
88
+ "react": "^18.2.0",
89
+ "react-dom": "^18.2.0",
90
+ "react-is": "^18.2.0",
91
+ "rimraf": "^5.0.1",
92
+ "sanity": "^3.16.7",
93
+ "tsx": "^3.12.10",
94
+ "typescript": "^5.2.2"
95
+ },
96
+ "peerDependencies": {
97
+ "react": "^18",
98
+ "sanity": "^3",
99
+ "styled-components": "^5"
100
+ },
101
+ "engines": {
102
+ "node": ">=14"
103
+ },
104
+ "sanityPlugin": {
105
+ "verifyPackage": {
106
+ "scripts": false
107
+ }
108
+ }
109
+ }
package/sanity.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "parts": [
3
+ {
4
+ "implements": "part:@sanity/base/sanity-root",
5
+ "path": "./v2-incompatible.js"
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,43 @@
1
+ import { Box, Card, Grid, Text } from '@sanity/ui';
2
+ import { ReactNode } from 'react';
3
+ import styled from 'styled-components';
4
+
5
+ export const ComboboxWrapper = styled(Grid)`
6
+ grid-template-columns: 1fr min-content;
7
+ position: relative;
8
+ `;
9
+
10
+ export const OptionsWrapper = styled(Box)`
11
+ box-sizing: border-box;
12
+ padding: 0.5rem;
13
+
14
+ & [role='listbox'] {
15
+ display: grid;
16
+ grid-template-columns: repeat(10, minmax(min(100%, 1rem), 1fr));
17
+ gap: 0.5rem;
18
+ list-style: none;
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+
23
+ & [role='option'] {
24
+ display: grid;
25
+ place-items: center;
26
+
27
+ & button {
28
+ aspect-ratio: 1;
29
+ cursor: pointer;
30
+ width: 100%;
31
+ }
32
+ }
33
+ `;
34
+
35
+ export function MessageWrapper({ children }: { children: ReactNode }) {
36
+ return (
37
+ <Card padding={4}>
38
+ <Text align="center" muted>
39
+ {children}
40
+ </Text>
41
+ </Card>
42
+ );
43
+ }
@@ -0,0 +1,100 @@
1
+ import { Combobox } from '@headlessui/react';
2
+ import { Popover, useToast } from '@sanity/ui';
3
+ import { ChangeEventHandler, useCallback, useEffect, useId, useRef } from 'react';
4
+ import { match } from 'ts-pattern';
5
+ import { useSearch } from '../lib/api';
6
+ import { ComboboxWrapper, OptionsWrapper } from './iconify-combobox.styles';
7
+ import { SearchInput } from './search-input';
8
+ import { SearchResults, SearchResultsProps } from './search-result';
9
+ import { UnsetButton } from './unset-button';
10
+
11
+ export interface IconifyComboboxProps {
12
+ selectedIcon: string | null;
13
+ onSelect: (newValue: string) => void;
14
+ collections: string[] | null;
15
+ }
16
+
17
+ export function IconifyCombobox(props: IconifyComboboxProps) {
18
+ const { selectedIcon, onSelect: pushSelection, collections } = props;
19
+
20
+ const id = useId();
21
+ const toast = useToast();
22
+ const inputRef = useRef<HTMLInputElement>(null);
23
+
24
+ const { term, setTerm, debouncedTerm, isInitialLoading, isError, error, data, isPreviousData } =
25
+ useSearch({
26
+ collections,
27
+ });
28
+
29
+ const handleTermChange: ChangeEventHandler<HTMLInputElement> = useCallback(
30
+ (event) => setTerm(event.target.value),
31
+ [setTerm],
32
+ );
33
+
34
+ const handleSelect = useCallback(
35
+ (icon: string) => {
36
+ pushSelection(icon);
37
+ setTerm('', true);
38
+ },
39
+ [pushSelection, setTerm],
40
+ );
41
+
42
+ const handleUnset = useCallback(() => handleSelect(''), [handleSelect]);
43
+
44
+ useEffect(() => {
45
+ if (isError) {
46
+ console.error('Iconify input error:', error);
47
+
48
+ toast.push({
49
+ id,
50
+ status: 'error',
51
+ title: 'Iconify input error',
52
+ description: error?.message,
53
+ });
54
+ }
55
+ }, [error, id, isError, toast]);
56
+
57
+ return (
58
+ <ComboboxWrapper>
59
+ <Combobox onChange={handleSelect}>
60
+ {({ open }) => (
61
+ <>
62
+ <SearchInput
63
+ ref={inputRef}
64
+ term={term}
65
+ selectedIcon={selectedIcon}
66
+ onChange={handleTermChange}
67
+ />
68
+
69
+ <Popover
70
+ open={open}
71
+ placement="bottom"
72
+ arrow={false}
73
+ matchReferenceWidth
74
+ portal
75
+ constrainSize
76
+ referenceElement={inputRef.current}
77
+ content={
78
+ <OptionsWrapper>
79
+ <SearchResults
80
+ state={match<boolean>(true)
81
+ .returnType<SearchResultsProps['state']>()
82
+ .with(isInitialLoading, () => 'loading')
83
+ .with(!debouncedTerm, () => 'initial')
84
+ .with(isError, () => 'error')
85
+ .with(!data || data.length === 0, () => 'empty')
86
+ .with(isPreviousData, () => 'stale')
87
+ .otherwise(() => 'data')}
88
+ data={data}
89
+ />
90
+ </OptionsWrapper>
91
+ }
92
+ />
93
+ </>
94
+ )}
95
+ </Combobox>
96
+
97
+ {selectedIcon ? <UnsetButton onUnset={handleUnset} /> : null}
98
+ </ComboboxWrapper>
99
+ );
100
+ }
@@ -0,0 +1 @@
1
+ export * from './iconify-combobox';
@@ -0,0 +1,32 @@
1
+ import { Combobox } from '@headlessui/react';
2
+ import { Icon } from '@iconify/react';
3
+ import { TextInput } from '@sanity/ui';
4
+ import { ChangeEventHandler, forwardRef } from 'react';
5
+
6
+ // ------------ //
7
+ // SEARCH INPUT //
8
+ // ------------ //
9
+
10
+ interface SearchInputProps {
11
+ term: string;
12
+ selectedIcon: string | null;
13
+ onChange: ChangeEventHandler<HTMLInputElement>;
14
+ }
15
+
16
+ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>((props, ref) => {
17
+ const { term, selectedIcon, onChange } = props;
18
+
19
+ return (
20
+ <Combobox.Input
21
+ as={TextInput}
22
+ ref={ref}
23
+ inputMode="search"
24
+ value={term}
25
+ onChange={onChange}
26
+ icon={selectedIcon ? <Icon icon={selectedIcon} /> : null}
27
+ placeholder={selectedIcon ? 'Search and replace selected icon...' : 'Search for an icon...'}
28
+ />
29
+ );
30
+ });
31
+
32
+ SearchInput.displayName = 'SearchInput';
@@ -0,0 +1,43 @@
1
+ import { Combobox } from '@headlessui/react';
2
+ import { Icon } from '@iconify/react';
3
+ import { Button } from '@sanity/ui';
4
+ import { MessageWrapper } from './iconify-combobox.styles';
5
+
6
+ // -------------- //
7
+ // SEARCH RESULTS //
8
+ // -------------- //
9
+
10
+ export interface SearchResultsProps {
11
+ state: 'initial' | 'loading' | 'error' | 'empty' | 'data' | 'stale';
12
+ data?: string[];
13
+ }
14
+
15
+ export function SearchResults(props: SearchResultsProps) {
16
+ const { state, data } = props;
17
+
18
+ if (state === 'initial') {
19
+ return <MessageWrapper>Search for icons</MessageWrapper>;
20
+ }
21
+
22
+ if (state === 'loading') {
23
+ return <MessageWrapper>Searching...</MessageWrapper>;
24
+ }
25
+
26
+ if (state === 'error') {
27
+ return <MessageWrapper>Something went wrong...</MessageWrapper>;
28
+ }
29
+
30
+ if (state === 'empty') {
31
+ return <MessageWrapper>No icons found</MessageWrapper>;
32
+ }
33
+
34
+ return (
35
+ <Combobox.Options style={{ opacity: state === 'stale' ? 0.5 : 1 }}>
36
+ {data!.map((icon) => (
37
+ <Combobox.Option key={icon} value={icon}>
38
+ {({ active }) => <Button mode="bleed" icon={<Icon icon={icon} />} selected={active} />}
39
+ </Combobox.Option>
40
+ ))}
41
+ </Combobox.Options>
42
+ );
43
+ }
@@ -0,0 +1,20 @@
1
+ import { TrashIcon } from '@sanity/icons';
2
+ import { Button, Card } from '@sanity/ui';
3
+
4
+ // ------------ //
5
+ // UNSET BUTTON //
6
+ // ------------ //
7
+
8
+ interface UnsetButtonProps {
9
+ onUnset: () => void;
10
+ }
11
+
12
+ export function UnsetButton(props: UnsetButtonProps) {
13
+ const { onUnset } = props;
14
+
15
+ return (
16
+ <Card border borderLeft={false} padding={1} display="flex">
17
+ <Button icon={<TrashIcon />} onClick={onUnset} mode="bleed" fontSize={1} padding={2} />
18
+ </Card>
19
+ );
20
+ }
@@ -0,0 +1,64 @@
1
+ import { Flex, Stack, Text } from '@sanity/ui';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { useCallback } from 'react';
4
+ import { ObjectInputProps, set, unset } from 'sanity';
5
+ import { IconifyCombobox } from './combobox';
6
+ import { IconOptions, IconifyPluginConfig } from './lib/types';
7
+ import { usePrettyIconName } from './lib/use-pretty-icon-name';
8
+
9
+ const queryClient = new QueryClient();
10
+
11
+ interface IconifyInputProps extends ObjectInputProps {
12
+ config: IconifyPluginConfig;
13
+ }
14
+
15
+ export function IconifyInput(props: IconifyInputProps) {
16
+ const { config, value, onChange: pushChange, schemaType } = props;
17
+
18
+ const selectedIcon = value?.name ?? null;
19
+ const prettyName = usePrettyIconName(selectedIcon);
20
+
21
+ const options: IconOptions = schemaType.options;
22
+ const collections =
23
+ (!options?.collections?.length && options.collections) ||
24
+ (!config?.collections?.length && config.collections) ||
25
+ null;
26
+ const showName = options?.showName ?? config?.showName ?? false;
27
+
28
+ const handleSelect = useCallback(
29
+ (icon: string) => {
30
+ pushChange(icon === '' ? unset() : set(icon, ['name']));
31
+ },
32
+ [pushChange],
33
+ );
34
+
35
+ return (
36
+ <QueryClientProvider client={queryClient}>
37
+ <Stack space={2}>
38
+ <IconifyCombobox
39
+ selectedIcon={selectedIcon}
40
+ onSelect={handleSelect}
41
+ collections={collections}
42
+ />
43
+
44
+ {showName && selectedIcon ? (
45
+ <Flex gap={1}>
46
+ <Text size={1} muted>
47
+ Selected:
48
+ </Text>
49
+
50
+ <Text size={1} weight="semibold">
51
+ {prettyName?.name ?? selectedIcon}
52
+ </Text>
53
+
54
+ {prettyName?.collection && (
55
+ <Text size={1} muted style={{ fontStyle: 'italic' }}>
56
+ by {prettyName?.collection}
57
+ </Text>
58
+ )}
59
+ </Flex>
60
+ ) : null}
61
+ </Stack>
62
+ </QueryClientProvider>
63
+ );
64
+ }
@@ -0,0 +1,46 @@
1
+ import { FieldProps, ObjectInputProps, definePlugin } from 'sanity';
2
+ import { IconifyInput } from './iconify-input';
3
+ import { IconifyPreview } from './iconify-preview';
4
+ import { IconifyPluginConfig } from './lib/types';
5
+
6
+ /**
7
+ * Usage in `sanity.config.ts` (or .js)
8
+ *
9
+ * ```ts
10
+ * import { defineConfig } from 'sanity'
11
+ * import { iconify } from 'sanity-plugin-iconify'
12
+ *
13
+ * export default defineConfig({
14
+ * // ...
15
+ * plugins: [iconify()],
16
+ * })
17
+ * ```
18
+ */
19
+ export const iconify = definePlugin<IconifyPluginConfig | void>((config = {}) => {
20
+ return {
21
+ name: 'sanity-plugin-iconify',
22
+ schema: {
23
+ types: [
24
+ {
25
+ name: 'icon',
26
+ title: 'Icon',
27
+ type: 'object',
28
+ fields: [
29
+ {
30
+ name: 'name',
31
+ title: 'Name',
32
+ type: 'string',
33
+ },
34
+ ],
35
+ components: {
36
+ input: (props: ObjectInputProps) => <IconifyInput {...props} config={config!} />,
37
+ preview: IconifyPreview,
38
+
39
+ // This makes sure the input component is not indented
40
+ field: (props: FieldProps) => props.renderDefault({ ...props, level: 0 }),
41
+ },
42
+ },
43
+ ],
44
+ },
45
+ };
46
+ });
@@ -0,0 +1,20 @@
1
+ import { Icon } from '@iconify/react';
2
+ import { PreviewProps } from 'sanity';
3
+ import { usePrettyIconName } from './lib/use-pretty-icon-name';
4
+
5
+ export function IconifyPreview(props: PreviewProps) {
6
+ const { title } = props;
7
+ const prettyName = usePrettyIconName(typeof title === 'string' ? title : null);
8
+
9
+ // We check this double to avoid the TS error
10
+ if (typeof title === 'string' && prettyName) {
11
+ return props.renderDefault({
12
+ ...props,
13
+ media: <Icon icon={title} />,
14
+ title: prettyName.name,
15
+ subtitle: prettyName.collection,
16
+ });
17
+ }
18
+
19
+ return props.renderDefault(props);
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './iconify-plugin';
2
+ export type * from './lib/types';
package/src/lib/api.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { IconifyInfo } from '@iconify/types';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { IconifySearchResult } from './types';
4
+ import { useCallback, useState } from 'react';
5
+ import { useDebounce } from 'use-debounce';
6
+
7
+ const BASE_API_URL = 'https://api.iconify.design';
8
+
9
+ function fetchJson<T>({ url, signal }: { url: string | URL; signal?: AbortSignal }): Promise<T> {
10
+ return fetch(url, { signal })
11
+ .then((response) => {
12
+ if (!response.ok) {
13
+ throw new Error(`Network error: status ${response.status}`);
14
+ }
15
+
16
+ return response.json();
17
+ })
18
+ .then(
19
+ (result) => result as T,
20
+ (error) => {
21
+ if (error instanceof Error) {
22
+ throw error;
23
+ } else {
24
+ console.error(`Unknown error: ${error}`);
25
+ throw new Error('Something went wrong');
26
+ }
27
+ },
28
+ );
29
+ }
30
+
31
+ export function useSearch({ collections }: { collections: string[] | null }) {
32
+ const queryClient = useQueryClient();
33
+ const [term, setTerm] = useState('');
34
+ const [debouncedTerm, setDebouncedTerm] = useDebounce(term, 500);
35
+
36
+ const updateTerm = useCallback(
37
+ (newTerm: string, updateImmediately = false) => {
38
+ setTerm(newTerm);
39
+
40
+ if (updateImmediately) {
41
+ setDebouncedTerm(newTerm);
42
+ }
43
+ },
44
+ [setDebouncedTerm],
45
+ );
46
+
47
+ const { isInitialLoading, isError, error, data, isPreviousData } = useQuery<string[], Error>({
48
+ queryKey: ['search', collections, debouncedTerm],
49
+ queryFn: async ({ signal }) => {
50
+ const url = new URL(`/search`, BASE_API_URL);
51
+
52
+ url.searchParams.append('query', debouncedTerm);
53
+ url.searchParams.append('limit', '60');
54
+
55
+ if (collections) {
56
+ url.searchParams.append('prefixes', collections.join(','));
57
+ }
58
+
59
+ const result = debouncedTerm ? await fetchJson<IconifySearchResult>({ url, signal }) : null;
60
+
61
+ if (result) {
62
+ // Cache the info for each collection
63
+ Object.entries(result.collections).forEach(([prefix, info]) => {
64
+ queryClient.setQueryData<IconifyInfo>(['iconSetInfo', prefix], info);
65
+ });
66
+ }
67
+
68
+ return result?.icons ?? [];
69
+ },
70
+ enabled: debouncedTerm.length > 0,
71
+ keepPreviousData: debouncedTerm.length > 0,
72
+ staleTime: 5 * 60 * 1000, // 5 minutes
73
+ });
74
+
75
+ return {
76
+ term,
77
+ setTerm: updateTerm,
78
+ debouncedTerm,
79
+ isInitialLoading,
80
+ isError,
81
+ error,
82
+ data,
83
+ isPreviousData,
84
+ };
85
+ }
86
+
87
+ export function useIconSetInfo({ prefix }: { prefix?: string | null }) {
88
+ return useQuery<IconifyInfo | null, Error>({
89
+ queryKey: ['iconSetInfo', prefix],
90
+ queryFn: async ({ signal }) => {
91
+ if (!prefix) return null;
92
+
93
+ const url = new URL('/collection', BASE_API_URL);
94
+
95
+ url.searchParams.append('prefix', prefix);
96
+ url.searchParams.append('info', 'true');
97
+
98
+ const result = await fetchJson<{ info: IconifyInfo }>({ url, signal });
99
+
100
+ return result?.info ?? null;
101
+ },
102
+ staleTime: Infinity,
103
+ });
104
+ }
@@ -0,0 +1,23 @@
1
+ import { lookupCollections } from '@iconify/json';
2
+ import { writeFile } from 'node:fs/promises';
3
+
4
+ /**
5
+ * Generates TypeScript types for available Iconify icon collections.
6
+ *
7
+ * Retrieves all the available Iconify icon collections from the @iconify/json package.
8
+ * Then creates a type `IconPrefix` with all the collection prefixes and writes this to src/icon-types.gen.ts.
9
+ *
10
+ * This allows us to get autocomplete/validation for icon collections.
11
+ *
12
+ * @returns {Promise<void>}
13
+ */
14
+ async function generateTypes() {
15
+ const collections = await lookupCollections();
16
+ const prefixes = Object.keys(collections);
17
+
18
+ const types = `export type IconPrefix = ${prefixes.map((prefix) => `'${prefix}'`).join(' | ')};`;
19
+
20
+ await writeFile('src/lib/icon-types.gen.ts', types);
21
+ }
22
+
23
+ generateTypes();
@@ -0,0 +1,36 @@
1
+ import { IconifyInfo } from '@iconify/types';
2
+ import type { BaseSchemaDefinition } from 'sanity';
3
+
4
+ // If this line errors, the type definitions have not been generated.
5
+ import type { IconPrefix } from './icon-types.gen';
6
+
7
+ // Export types that need to be bundled with the plugin
8
+ export type { BaseSchemaDefinition } from 'sanity';
9
+ export type { IconPrefix };
10
+
11
+ export interface IconOptions {
12
+ collections?: IconPrefix[];
13
+ showName?: boolean;
14
+ }
15
+
16
+ export interface IconifyPluginConfig {
17
+ collections?: IconPrefix[];
18
+ showName?: boolean;
19
+ }
20
+
21
+ export interface IconifySearchResult {
22
+ icons: string[];
23
+ collections: Record<string, IconifyInfo>;
24
+ }
25
+
26
+ // Extend the Sanity schema types to include our custom type
27
+ declare module 'sanity' {
28
+ interface IconDefinition extends BaseSchemaDefinition {
29
+ type: 'icon';
30
+ options?: IconOptions;
31
+ }
32
+
33
+ export interface IntrinsicDefinitions {
34
+ icon: IconDefinition;
35
+ }
36
+ }
@@ -0,0 +1,20 @@
1
+ import { stringToIcon } from '@iconify/utils';
2
+ import { sentenceCase } from 'change-case';
3
+ import { useMemo } from 'react';
4
+ import { useIconSetInfo } from './api';
5
+
6
+ export function usePrettyIconName(name?: string | null) {
7
+ const iconMeta = useMemo(() => (name ? stringToIcon(name) : null), [name]);
8
+ const iconSetInfo = useIconSetInfo({ prefix: iconMeta?.prefix ?? null });
9
+
10
+ return useMemo(
11
+ () =>
12
+ iconMeta
13
+ ? {
14
+ name: sentenceCase(iconMeta.name),
15
+ collection: iconSetInfo.data?.name ?? iconMeta.prefix,
16
+ }
17
+ : null,
18
+ [iconMeta, iconSetInfo.data?.name],
19
+ );
20
+ }
@@ -0,0 +1,11 @@
1
+ const { showIncompatiblePluginDialog } = require('@sanity/incompatible-plugin');
2
+ const { name, version, sanityExchangeUrl } = require('./package.json');
3
+
4
+ export default showIncompatiblePluginDialog({
5
+ name: name,
6
+ versions: {
7
+ v3: version,
8
+ v2: undefined,
9
+ },
10
+ sanityExchangeUrl,
11
+ });