sanity-plugin-iconify 3.0.0-beta.1 → 3.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/README.md +2 -0
- package/dist/index.cjs +115 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -9
- package/dist/index.d.ts +49 -9
- package/dist/index.js +115 -51
- package/dist/index.js.map +1 -1
- package/package.json +15 -5
- package/src/combobox/iconify-combobox.tsx +76 -7
- package/src/combobox/search-input.tsx +16 -14
- package/src/combobox/search-result.tsx +37 -30
- package/src/combobox/unset-button.tsx +8 -1
- package/src/iconify-input.tsx +9 -5
- package/src/iconify-preview.tsx +3 -2
- package/src/lib/icon-types.gen.ts +1 -1
- package/src/lib/types.test.ts +85 -0
- package/src/lib/types.ts +44 -8
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Popover, useToast } from '@sanity/ui';
|
|
2
2
|
import { useCombobox } from 'downshift';
|
|
3
|
-
import { useCallback, useEffect, useId, useRef } from 'react';
|
|
3
|
+
import { memo, useCallback, useEffect, useId, useRef } from 'react';
|
|
4
|
+
import type { ObjectInputProps } from 'sanity';
|
|
4
5
|
import { match } from 'ts-pattern';
|
|
5
6
|
import { useSearch } from '../lib/api';
|
|
6
7
|
import { OptionsWrapper } from './iconify-combobox.styles';
|
|
@@ -13,15 +14,65 @@ export interface IconifyComboboxProps {
|
|
|
13
14
|
selectedIcon: string | null;
|
|
14
15
|
onSelect: (newValue: string) => void;
|
|
15
16
|
collections: string[] | null;
|
|
17
|
+
studioElementProps?: ObjectInputProps['elementProps'];
|
|
18
|
+
fieldFocused?: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
export function IconifyCombobox(props: IconifyComboboxProps) {
|
|
19
|
-
const {
|
|
21
|
+
export const IconifyCombobox = memo(function IconifyCombobox(props: IconifyComboboxProps) {
|
|
22
|
+
const {
|
|
23
|
+
selectedIcon,
|
|
24
|
+
onSelect: pushSelection,
|
|
25
|
+
collections,
|
|
26
|
+
studioElementProps,
|
|
27
|
+
fieldFocused,
|
|
28
|
+
} = props;
|
|
20
29
|
|
|
21
30
|
const id = useId();
|
|
22
31
|
const toast = useToast();
|
|
23
32
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
33
|
|
|
34
|
+
// Compose our inputRef (used by the Popover for positioning) with Studio's focusRef.
|
|
35
|
+
// Standard Sanity inputs spread all of elementProps onto the native element, which
|
|
36
|
+
// lets Studio programmatically re-focus the field and track focus via onPathFocus/onPathBlur.
|
|
37
|
+
const composedInputRef = useCallback(
|
|
38
|
+
(node: HTMLInputElement | null) => {
|
|
39
|
+
inputRef.current = node;
|
|
40
|
+
if (studioElementProps?.ref) {
|
|
41
|
+
(studioElementProps.ref as React.RefObject<HTMLInputElement | null>).current = node;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
[studioElementProps?.ref],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Studio steals focus to its field-actions-trigger immediately after field activation, then
|
|
48
|
+
// re-focuses the actual input — all within the same macrotask as the original focus event.
|
|
49
|
+
// We suppress the onPathBlur for this activation blur so Studio's focused state stays accurate.
|
|
50
|
+
// The flag is reset via setTimeout so any blur in a later macrotask (real user navigation) is
|
|
51
|
+
// forwarded normally.
|
|
52
|
+
const suppressNextBlur = useRef(false);
|
|
53
|
+
|
|
54
|
+
const handleFocus = useCallback(
|
|
55
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
56
|
+
suppressNextBlur.current = true;
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
suppressNextBlur.current = false;
|
|
59
|
+
}, 0);
|
|
60
|
+
studioElementProps?.onFocus?.(event as unknown as React.FocusEvent<HTMLDivElement>);
|
|
61
|
+
},
|
|
62
|
+
[studioElementProps],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const handleBlur = useCallback(
|
|
66
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
67
|
+
if (suppressNextBlur.current) {
|
|
68
|
+
suppressNextBlur.current = false;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
studioElementProps?.onBlur?.(event as unknown as React.FocusEvent<HTMLDivElement>);
|
|
72
|
+
},
|
|
73
|
+
[studioElementProps],
|
|
74
|
+
);
|
|
75
|
+
|
|
25
76
|
const { term, setTerm, debouncedTerm, isLoading, isError, error, data, isPreviousData } =
|
|
26
77
|
useSearch({
|
|
27
78
|
collections,
|
|
@@ -35,6 +86,7 @@ export function IconifyCombobox(props: IconifyComboboxProps) {
|
|
|
35
86
|
getItemProps,
|
|
36
87
|
selectItem,
|
|
37
88
|
setInputValue,
|
|
89
|
+
closeMenu,
|
|
38
90
|
} = useCombobox({
|
|
39
91
|
items: data ?? [],
|
|
40
92
|
inputValue: term,
|
|
@@ -52,11 +104,18 @@ export function IconifyCombobox(props: IconifyComboboxProps) {
|
|
|
52
104
|
},
|
|
53
105
|
});
|
|
54
106
|
|
|
107
|
+
// Close the menu when Studio considers the field unfocused. This is the state→UI equivalent
|
|
108
|
+
// of the old stateReducer: instead of intercepting downshift's blur handling imperatively,
|
|
109
|
+
// we let Studio's focused state drive whether the menu should be open.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!fieldFocused) closeMenu();
|
|
112
|
+
}, [fieldFocused, closeMenu]);
|
|
113
|
+
|
|
55
114
|
const handleUnset = useCallback(() => {
|
|
56
115
|
pushSelection('');
|
|
57
116
|
setTerm('', true);
|
|
58
117
|
selectItem('');
|
|
59
|
-
}, []);
|
|
118
|
+
}, [pushSelection, setTerm, selectItem]);
|
|
60
119
|
|
|
61
120
|
useEffect(() => {
|
|
62
121
|
if (isError) {
|
|
@@ -71,11 +130,21 @@ export function IconifyCombobox(props: IconifyComboboxProps) {
|
|
|
71
130
|
}
|
|
72
131
|
}, [error, id, isError, toast]);
|
|
73
132
|
|
|
133
|
+
// Destructure onBlur out so downshift's InputBlur doesn't close the menu —
|
|
134
|
+
// menu lifecycle is now driven by fieldFocused via the effect above.
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
136
|
+
const { onBlur: _downshiftOnBlur, ...inputProps } = getInputProps({
|
|
137
|
+
ref: composedInputRef,
|
|
138
|
+
id: studioElementProps?.id,
|
|
139
|
+
'aria-describedby': studioElementProps?.['aria-describedby'],
|
|
140
|
+
onFocus: handleFocus,
|
|
141
|
+
});
|
|
142
|
+
|
|
74
143
|
return (
|
|
75
144
|
<div>
|
|
76
|
-
{/* @ts-expect-error wrong typings */}
|
|
77
145
|
<SearchInput
|
|
78
|
-
{...
|
|
146
|
+
{...inputProps}
|
|
147
|
+
onBlur={handleBlur}
|
|
79
148
|
selectedIcon={selectedIcon}
|
|
80
149
|
suffix={selectedIcon ? <UnsetButton onUnset={handleUnset} /> : null}
|
|
81
150
|
/>
|
|
@@ -109,4 +178,4 @@ export function IconifyCombobox(props: IconifyComboboxProps) {
|
|
|
109
178
|
/>
|
|
110
179
|
</div>
|
|
111
180
|
);
|
|
112
|
-
}
|
|
181
|
+
});
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import { Icon } from '@iconify/react';
|
|
2
2
|
import { TextInput } from '@sanity/ui';
|
|
3
|
-
import { forwardRef } from 'react';
|
|
3
|
+
import { forwardRef, memo } from 'react';
|
|
4
4
|
|
|
5
5
|
interface SearchInputProps extends React.HTMLAttributes<HTMLInputElement> {
|
|
6
6
|
selectedIcon: string | null;
|
|
7
7
|
suffix?: React.ReactNode;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export const SearchInput =
|
|
11
|
-
|
|
10
|
+
export const SearchInput = memo(
|
|
11
|
+
forwardRef<HTMLInputElement, SearchInputProps>((props, ref) => {
|
|
12
|
+
const { selectedIcon, suffix, ...rest } = props;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
})
|
|
14
|
+
return (
|
|
15
|
+
<TextInput
|
|
16
|
+
{...rest}
|
|
17
|
+
ref={ref}
|
|
18
|
+
inputMode="search"
|
|
19
|
+
icon={selectedIcon ? <Icon icon={selectedIcon} /> : null}
|
|
20
|
+
placeholder={selectedIcon ? 'Search and replace selected icon...' : 'Search for an icon...'}
|
|
21
|
+
suffix={suffix}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
24
26
|
|
|
25
27
|
SearchInput.displayName = 'SearchInput';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Icon } from '@iconify/react';
|
|
2
2
|
import { Button } from '@sanity/ui';
|
|
3
3
|
import type { UseComboboxGetItemPropsOptions, UseComboboxGetItemPropsReturnValue } from 'downshift';
|
|
4
|
-
import { forwardRef } from 'react';
|
|
4
|
+
import { forwardRef, memo } from 'react';
|
|
5
5
|
import { MessageWrapper } from './iconify-combobox.styles';
|
|
6
6
|
|
|
7
7
|
type GetItemProps = (
|
|
@@ -15,34 +15,41 @@ export interface SearchResultsProps extends React.HTMLAttributes<HTMLUListElemen
|
|
|
15
15
|
highlightedIndex: number;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export const SearchResults =
|
|
19
|
-
|
|
18
|
+
export const SearchResults = memo(
|
|
19
|
+
forwardRef<HTMLUListElement, SearchResultsProps>((props, ref) => {
|
|
20
|
+
const { state, data, getItemProps, highlightedIndex, ...rest } = props;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
{(() => {
|
|
25
|
+
switch (state) {
|
|
26
|
+
case 'initial':
|
|
27
|
+
return <MessageWrapper>Search for icons</MessageWrapper>;
|
|
28
|
+
case 'loading':
|
|
29
|
+
return <MessageWrapper>Searching...</MessageWrapper>;
|
|
30
|
+
case 'error':
|
|
31
|
+
return <MessageWrapper>Something went wrong...</MessageWrapper>;
|
|
32
|
+
case 'empty':
|
|
33
|
+
return <MessageWrapper>No icons found</MessageWrapper>;
|
|
34
|
+
}
|
|
35
|
+
})()}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
<ul
|
|
38
|
+
{...rest}
|
|
39
|
+
ref={ref}
|
|
40
|
+
data-testid="iconify-results"
|
|
41
|
+
style={{ opacity: state === 'stale' ? 0.5 : 1 }}
|
|
42
|
+
>
|
|
43
|
+
{(state === 'data' || state === 'stale') &&
|
|
44
|
+
data?.map((icon, index) => (
|
|
45
|
+
<li key={icon} {...getItemProps({ item: icon, index })}>
|
|
46
|
+
<Button padding={3} mode="bleed" selected={index === highlightedIndex}>
|
|
47
|
+
<Icon icon={icon} width="100%" height="100%" />
|
|
48
|
+
</Button>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
@@ -14,7 +14,14 @@ export function UnsetButton(props: UnsetButtonProps) {
|
|
|
14
14
|
|
|
15
15
|
return (
|
|
16
16
|
<Card border borderLeft={false} padding={1} display="flex" radius={2}>
|
|
17
|
-
<Button
|
|
17
|
+
<Button
|
|
18
|
+
data-testid="iconify-unset"
|
|
19
|
+
icon={<TrashIcon />}
|
|
20
|
+
onClick={onUnset}
|
|
21
|
+
mode="bleed"
|
|
22
|
+
fontSize={1}
|
|
23
|
+
padding={2}
|
|
24
|
+
/>
|
|
18
25
|
</Card>
|
|
19
26
|
);
|
|
20
27
|
}
|
package/src/iconify-input.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Flex, Stack, Text, ThemeProvider } from '@sanity/ui';
|
|
2
2
|
import { buildTheme } from '@sanity/ui/theme';
|
|
3
|
-
import { useCallback } from 'react';
|
|
3
|
+
import { memo, useCallback } from 'react';
|
|
4
4
|
import type { ObjectInputProps } from 'sanity';
|
|
5
5
|
import { set, unset } from 'sanity';
|
|
6
6
|
import { IconifyCombobox } from './combobox';
|
|
@@ -8,12 +8,14 @@ import { QueryClientProvider } from './lib/query-client';
|
|
|
8
8
|
import type { IconifyPluginConfig, IconOptions } from './lib/types';
|
|
9
9
|
import { usePrettyIconName } from './lib/use-pretty-icon-name';
|
|
10
10
|
|
|
11
|
+
const theme = buildTheme();
|
|
12
|
+
|
|
11
13
|
interface IconifyInputProps extends ObjectInputProps {
|
|
12
14
|
config: IconifyPluginConfig;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export function IconifyInput(props: IconifyInputProps) {
|
|
16
|
-
const { config, value, onChange: pushChange, schemaType } = props;
|
|
17
|
+
export const IconifyInput = memo(function IconifyInput(props: IconifyInputProps) {
|
|
18
|
+
const { config, value, onChange: pushChange, schemaType, elementProps, focused } = props;
|
|
17
19
|
|
|
18
20
|
const selectedIcon: string | null = value?.name ?? null;
|
|
19
21
|
|
|
@@ -33,12 +35,14 @@ export function IconifyInput(props: IconifyInputProps) {
|
|
|
33
35
|
|
|
34
36
|
return (
|
|
35
37
|
<QueryClientProvider>
|
|
36
|
-
<ThemeProvider theme={
|
|
38
|
+
<ThemeProvider theme={theme}>
|
|
37
39
|
<Stack space={2}>
|
|
38
40
|
<IconifyCombobox
|
|
39
41
|
selectedIcon={selectedIcon}
|
|
40
42
|
onSelect={handleSelect}
|
|
41
43
|
collections={collections}
|
|
44
|
+
studioElementProps={elementProps}
|
|
45
|
+
fieldFocused={focused}
|
|
42
46
|
/>
|
|
43
47
|
|
|
44
48
|
{showName && selectedIcon ? <IconifyNameDisplay name={selectedIcon} /> : null}
|
|
@@ -46,7 +50,7 @@ export function IconifyInput(props: IconifyInputProps) {
|
|
|
46
50
|
</ThemeProvider>
|
|
47
51
|
</QueryClientProvider>
|
|
48
52
|
);
|
|
49
|
-
}
|
|
53
|
+
});
|
|
50
54
|
|
|
51
55
|
interface IconifyNameDisplayProps {
|
|
52
56
|
name?: string | null;
|
package/src/iconify-preview.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Icon } from '@iconify/react';
|
|
2
2
|
import type { IconifyIconName } from '@iconify/utils';
|
|
3
3
|
import { stringToIcon } from '@iconify/utils';
|
|
4
|
+
import { memo } from 'react';
|
|
4
5
|
import type { PreviewProps } from 'sanity';
|
|
5
6
|
import { QueryClientProvider } from './lib/query-client';
|
|
6
7
|
import { usePrettyIconName } from './lib/use-pretty-icon-name';
|
|
@@ -9,7 +10,7 @@ import { usePrettyIconName } from './lib/use-pretty-icon-name';
|
|
|
9
10
|
// ICONIFY PREVIEW //
|
|
10
11
|
// --------------- //
|
|
11
12
|
|
|
12
|
-
export function IconifyPreview(props: PreviewProps) {
|
|
13
|
+
export const IconifyPreview = memo(function IconifyPreview(props: PreviewProps) {
|
|
13
14
|
const { title } = props;
|
|
14
15
|
const iconName = typeof props.title === 'string' ? stringToIcon(props.title) : null;
|
|
15
16
|
|
|
@@ -23,7 +24,7 @@ export function IconifyPreview(props: PreviewProps) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
return props.renderDefault(props);
|
|
26
|
-
}
|
|
27
|
+
});
|
|
27
28
|
|
|
28
29
|
// --------------------- //
|
|
29
30
|
// ICONIFY PREVIEW INNER //
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export type IconPrefix = 'material-symbols' | 'material-symbols-light' | 'ic' | 'mdi' | 'mdi-light' | 'line-md' | 'solar' | 'tabler' | 'mingcute' | 'ri' | 'mynaui' | 'iconamoon' | 'iconoir' | 'lucide' | 'lucide-lab' | 'uil' | 'tdesign' | 'si' | '
|
|
1
|
+
export type IconPrefix = 'material-symbols' | 'material-symbols-light' | 'ic' | 'mdi' | 'mdi-light' | 'line-md' | 'solar' | 'tabler' | 'boxicons' | 'mingcute' | 'ri' | 'mynaui' | 'iconamoon' | 'iconoir' | 'lucide' | 'lucide-lab' | 'uil' | 'tdesign' | 'si' | 'majesticons' | 'gg' | 'flowbite' | 'basil' | 'pixelarticons' | 'pixel' | 'akar-icons' | 'ci' | 'proicons' | 'typcn' | 'meteor-icons' | 'prime' | 'circum' | 'fe' | 'eos-icons' | 'bitcoin-icons' | 'humbleicons' | 'uim' | 'uit' | 'uis' | 'gridicons' | 'mi' | 'cuida' | 'weui' | 'duo-icons' | 'svg-spinners' | 'hugeicons' | 'lets-icons' | 'streamline-ultimate' | 'streamline-plump' | 'streamline-sharp' | 'mage' | 'stash' | 'lineicons' | 'wordpress' | 'icon-park-outline' | 'icon-park-solid' | 'icon-park-twotone' | 'jam' | 'streamline-cyber' | 'guidance' | 'carbon' | 'ion' | 'famicons' | 'ant-design' | 'lsicon' | 'gravity-ui' | 'cil' | 'roentgen' | 'ep' | 'charm' | 'quill' | 'bytesize' | 'bi' | 'streamline-pixel' | 'streamline-block' | 'rivet-icons' | 'nimbus' | 'formkit' | 'fluent' | 'ph' | 'glyphs' | 'glyphs-poly' | 'teenyicons' | 'clarity' | 'streamline-freehand' | 'ix' | 'octicon' | 'memory' | 'system-uicons' | 'radix-icons' | 'zondicons' | 'uiw' | 'codex' | 'ei' | 'heroicons' | 'sidekickicons' | 'pepicons-pop' | 'pepicons-print' | 'pepicons-pencil' | 'f7' | 'pajamas' | 'garden' | 'streamline' | 'streamline-flex' | 'fa7-solid' | 'fa7-regular' | 'picon' | 'ooui' | 'maki' | 'temaki' | 'oui' | 'nrk' | 'dinkie-icons' | 'qlementine-icons' | 'streamline-ultimate-color' | 'streamline-plump-color' | 'streamline-freehand-color' | 'streamline-kameleon-color' | 'streamline-stickies-color' | 'fluent-color' | 'streamline-color' | 'streamline-flex-color' | 'streamline-sharp-color' | 'streamline-cyber-color' | 'icon-park' | 'marketeq' | 'vscode-icons' | 'codicon' | 'material-icon-theme' | 'file-icons' | 'devicon' | 'devicon-plain' | 'catppuccin' | 'skill-icons' | 'unjs' | 'simple-icons' | 'logos' | 'streamline-logos' | 'cib' | 'fa7-brands' | 'bxl' | 'nonicons' | 'arcticons' | 'cbi' | 'brandico' | 'entypo-social' | 'token' | 'token-branded' | 'cryptocurrency' | 'cryptocurrency-color' | 'openmoji' | 'twemoji' | 'noto' | 'fluent-emoji-flat' | 'fluent-emoji-high-contrast' | 'noto-v1' | 'emojione' | 'emojione-monotone' | 'emojione-v1' | 'fxemoji' | 'streamline-emojis' | 'circle-flags' | 'flag' | 'flagpack' | 'cif' | 'gis' | 'map' | 'geo' | 'game-icons' | 'fad' | 'academicons' | 'wi' | 'meteocons' | 'healthicons' | 'medical-icon' | 'covid' | 'la' | 'eva' | 'dashicons' | 'flat-color-icons' | 'entypo' | 'foundation' | 'raphael' | 'icons8' | 'iwwa' | 'gala' | 'heroicons-outline' | 'heroicons-solid' | 'bx' | 'bxs' | 'fa6-solid' | 'fa6-regular' | 'fa6-brands' | 'fa-solid' | 'fa-regular' | 'fa-brands' | 'fa' | 'fluent-mdl2' | 'fontisto' | 'icomoon-free' | 'subway' | 'oi' | 'wpf' | 'simple-line-icons' | 'et' | 'el' | 'vaadin' | 'grommet-icons' | 'whh' | 'si-glyph' | 'zmdi' | 'ls' | 'bpmn' | 'flat-ui' | 'vs' | 'topcoat' | 'il' | 'websymbol' | 'fontelico' | 'ps' | 'feather' | 'mono-icons' | 'pepicons' | 'fluent-emoji';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { defineField } from 'sanity';
|
|
2
|
+
import type { IntrinsicDefinitions } from 'sanity';
|
|
3
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
4
|
+
import type { IconDefinition, IconValue } from './types';
|
|
5
|
+
|
|
6
|
+
// These are type-level tests: they fail at compile time if types are wrong,
|
|
7
|
+
// not at runtime. Credit: danilo-arioli (PR #13).
|
|
8
|
+
|
|
9
|
+
describe('IconDefinition type safety', () => {
|
|
10
|
+
it('registers icon in IntrinsicDefinitions (module augmentation)', () => {
|
|
11
|
+
// If this fails, the declare module 'sanity' augmentation is broken and
|
|
12
|
+
// Sanity won't recognise 'icon' as a valid type string in defineField/defineType.
|
|
13
|
+
expectTypeOf<IntrinsicDefinitions['icon']>().toMatchTypeOf<IconDefinition>();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('types value as IconValue in validation callbacks', () => {
|
|
17
|
+
defineField({
|
|
18
|
+
name: 'icon',
|
|
19
|
+
type: 'icon',
|
|
20
|
+
validation: (Rule) =>
|
|
21
|
+
Rule.custom((value) => {
|
|
22
|
+
expectTypeOf(value).toExtend<IconValue>();
|
|
23
|
+
expectTypeOf(value?.name).toExtend<string | undefined>();
|
|
24
|
+
return value?.name ? true : 'Icon is required';
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('types value as IconValue in hidden callbacks', () => {
|
|
30
|
+
defineField({
|
|
31
|
+
name: 'icon',
|
|
32
|
+
type: 'icon',
|
|
33
|
+
hidden: ({ value, document }) => {
|
|
34
|
+
expectTypeOf(value).toExtend<IconValue>();
|
|
35
|
+
expectTypeOf(value?.name).toExtend<string | undefined>();
|
|
36
|
+
return value?.name === 'hidden-icon' || document?._type === 'simple';
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('types value as IconValue in readOnly callbacks', () => {
|
|
42
|
+
defineField({
|
|
43
|
+
name: 'icon',
|
|
44
|
+
type: 'icon',
|
|
45
|
+
readOnly: ({ value, document }) => {
|
|
46
|
+
expectTypeOf(value).toExtend<IconValue>();
|
|
47
|
+
expectTypeOf(value?.name).toExtend<string | undefined>();
|
|
48
|
+
return document?.published === true && (value?.name?.startsWith('system:') ?? false);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('types value as IconValue in initialValue callbacks', () => {
|
|
54
|
+
defineField({
|
|
55
|
+
name: 'icon',
|
|
56
|
+
type: 'icon',
|
|
57
|
+
initialValue: (params, context) => {
|
|
58
|
+
expectTypeOf(context.currentUser).toMatchTypeOf<{ id: string } | null>();
|
|
59
|
+
return { name: 'mdi:home' } satisfies IconValue;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts collapsible and collapsed options', () => {
|
|
65
|
+
defineField({
|
|
66
|
+
name: 'icon',
|
|
67
|
+
type: 'icon',
|
|
68
|
+
options: {
|
|
69
|
+
collapsible: true,
|
|
70
|
+
collapsed: false,
|
|
71
|
+
collections: ['mdi'],
|
|
72
|
+
showName: true,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects unknown options', () => {
|
|
78
|
+
defineField({
|
|
79
|
+
name: 'icon',
|
|
80
|
+
type: 'icon',
|
|
81
|
+
// @ts-expect-error unknown option should not be accepted
|
|
82
|
+
options: { unknownOption: true },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/lib/types.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { IconifyInfo } from '@iconify/types';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BaseSchemaDefinition,
|
|
4
|
+
ConditionalPropertyCallbackContext,
|
|
5
|
+
FieldGroupDefinition,
|
|
6
|
+
FieldsetDefinition,
|
|
7
|
+
InitialValueProperty,
|
|
8
|
+
ObjectOptions,
|
|
9
|
+
RuleDef,
|
|
10
|
+
ValidationBuilder,
|
|
11
|
+
} from 'sanity';
|
|
3
12
|
|
|
4
13
|
// If this line errors, the type definitions have not been generated.
|
|
5
14
|
import type { IconPrefix } from './icon-types.gen';
|
|
@@ -8,10 +17,10 @@ import type { IconPrefix } from './icon-types.gen';
|
|
|
8
17
|
export type { BaseSchemaDefinition } from 'sanity';
|
|
9
18
|
export type { IconPrefix };
|
|
10
19
|
|
|
11
|
-
export
|
|
20
|
+
export type IconOptions = {
|
|
12
21
|
collections?: IconPrefix[];
|
|
13
22
|
showName?: boolean;
|
|
14
|
-
}
|
|
23
|
+
} & Pick<ObjectOptions, 'collapsed' | 'collapsible'>;
|
|
15
24
|
|
|
16
25
|
export interface IconifyPluginConfig {
|
|
17
26
|
collections?: IconPrefix[];
|
|
@@ -23,13 +32,40 @@ export interface IconifySearchResult {
|
|
|
23
32
|
collections: Record<string, IconifyInfo>;
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
export type IconValue =
|
|
36
|
+
| {
|
|
37
|
+
name?: string;
|
|
38
|
+
}
|
|
39
|
+
| undefined;
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
42
|
+
export interface IconRule extends RuleDef<IconRule, IconValue> {}
|
|
43
|
+
|
|
44
|
+
export type IconConditionalPropertyCallbackContext = Omit<
|
|
45
|
+
ConditionalPropertyCallbackContext,
|
|
46
|
+
'value'
|
|
47
|
+
> & {
|
|
48
|
+
value: IconValue;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type IconConditionalProperty =
|
|
52
|
+
| boolean
|
|
53
|
+
| ((context: IconConditionalPropertyCallbackContext) => boolean)
|
|
54
|
+
| undefined;
|
|
55
|
+
|
|
56
|
+
export interface IconDefinition extends Omit<BaseSchemaDefinition, 'hidden' | 'readOnly'> {
|
|
57
|
+
type: 'icon';
|
|
58
|
+
groups?: FieldGroupDefinition[];
|
|
59
|
+
fieldsets?: FieldsetDefinition[];
|
|
60
|
+
options?: IconOptions;
|
|
61
|
+
hidden?: IconConditionalProperty;
|
|
62
|
+
readOnly?: IconConditionalProperty;
|
|
63
|
+
validation?: ValidationBuilder<IconRule, IconValue>;
|
|
64
|
+
initialValue?: InitialValueProperty<any, IconValue>;
|
|
65
|
+
}
|
|
66
|
+
|
|
26
67
|
// Extend the Sanity schema types to include our custom type
|
|
27
68
|
declare module 'sanity' {
|
|
28
|
-
interface IconDefinition extends BaseSchemaDefinition {
|
|
29
|
-
type: 'icon';
|
|
30
|
-
options?: IconOptions;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
69
|
export interface IntrinsicDefinitions {
|
|
34
70
|
icon: IconDefinition;
|
|
35
71
|
}
|