|
2 | 2 | 'use client'; |
3 | 3 |
|
4 | 4 | import {Autocomplete, GridLayout, ListBox, ListBoxItem, Size, useFilter, Virtualizer} from 'react-aria-components'; |
| 5 | +import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; |
5 | 6 | import Close from '@react-spectrum/s2/icons/Close'; |
6 | 7 | import {Content, Heading, IllustratedMessage, pressScale, SearchField, Skeleton, Text} from '@react-spectrum/s2'; |
7 | 8 | import {focusRing, iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; |
8 | 9 | import {iconAliases} from './iconAliases.js'; |
9 | 10 | // @ts-ignore |
10 | 11 | import icons from '/packages/@react-spectrum/s2/s2wf-icons/*.svg'; |
| 12 | +import InfoCircle from '@react-spectrum/s2/icons/InfoCircle'; |
11 | 13 | // eslint-disable-next-line monorepo/no-internal-import |
12 | 14 | import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults'; |
13 | | -import React, {useCallback, useMemo, useRef} from 'react'; |
| 15 | +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; |
14 | 16 |
|
15 | 17 | export const iconList = Object.keys(icons).map(name => ({id: name.replace(/^S2_Icon_(.*?)(Size\d+)?_2.*/, '$1'), icon: icons[name].default})); |
16 | 18 |
|
| 19 | +export function useIconFilter() { |
| 20 | + let {contains} = useFilter({sensitivity: 'base'}); |
| 21 | + return useCallback((textValue: string, inputValue: string) => { |
| 22 | + // Check for alias matches |
| 23 | + for (const alias of Object.keys(iconAliases)) { |
| 24 | + if (contains(alias, inputValue) && iconAliases[alias].includes(textValue)) { |
| 25 | + return true; |
| 26 | + } |
| 27 | + } |
| 28 | + // Also compare for substrings in the icon's actual name |
| 29 | + return textValue != null && contains(textValue, inputValue); |
| 30 | + }, [contains]); |
| 31 | +} |
| 32 | + |
| 33 | +export function useCopyImport() { |
| 34 | + let [copiedId, setCopiedId] = useState<string | null>(null); |
| 35 | + let timeout = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 36 | + |
| 37 | + useEffect(() => { |
| 38 | + return () => { |
| 39 | + if (timeout.current) { |
| 40 | + clearTimeout(timeout.current); |
| 41 | + } |
| 42 | + }; |
| 43 | + }, []); |
| 44 | + |
| 45 | + let handleCopyImport = useCallback((id: string) => { |
| 46 | + if (timeout.current) { |
| 47 | + clearTimeout(timeout.current); |
| 48 | + } |
| 49 | + navigator.clipboard.writeText(`import ${id} from '@react-spectrum/s2/icons/${id}';`).then(() => { |
| 50 | + setCopiedId(id); |
| 51 | + timeout.current = setTimeout(() => setCopiedId(null), 2000); |
| 52 | + }).catch(() => { |
| 53 | + // noop |
| 54 | + }); |
| 55 | + }, []); |
| 56 | + |
| 57 | + return {copiedId, handleCopyImport}; |
| 58 | +} |
| 59 | + |
| 60 | +function CopyInfoMessage() { |
| 61 | + return ( |
| 62 | + <div className={style({display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4})}> |
| 63 | + <InfoCircle styles={iconStyle({size: 'XS'})} /> |
| 64 | + <span className={style({font: 'ui'})}>Press an item to copy its import statement</span> |
| 65 | + </div> |
| 66 | + ); |
| 67 | +} |
| 68 | + |
| 69 | +interface IconListBoxProps { |
| 70 | + items: typeof iconList, |
| 71 | + copiedId: string | null, |
| 72 | + onAction: (item: string) => void, |
| 73 | + listBoxClassName?: string |
| 74 | +} |
| 75 | + |
| 76 | +function IconListBox({items, copiedId, onAction, listBoxClassName}: IconListBoxProps) { |
| 77 | + return ( |
| 78 | + <Virtualizer layout={GridLayout} layoutOptions={{minItemSize: new Size(64, 64), maxItemSize: new Size(64, 64), minSpace: new Size(12, 12), preserveAspectRatio: true}}> |
| 79 | + <ListBox |
| 80 | + onAction={(item) => onAction(item.toString())} |
| 81 | + items={items} |
| 82 | + layout="grid" |
| 83 | + className={listBoxClassName || style({width: '100%', scrollPaddingY: 4})} |
| 84 | + dependencies={[copiedId]} |
| 85 | + renderEmptyState={() => ( |
| 86 | + <IllustratedMessage styles={style({marginX: 'auto', marginY: 32})}> |
| 87 | + <NoSearchResults /> |
| 88 | + <Heading>No results</Heading> |
| 89 | + <Content>Try a different search term.</Content> |
| 90 | + </IllustratedMessage> |
| 91 | + )}> |
| 92 | + {item => <IconItem item={item} isCopied={copiedId === item.id} />} |
| 93 | + </ListBox> |
| 94 | + </Virtualizer> |
| 95 | + ); |
| 96 | +} |
| 97 | + |
17 | 98 | const itemStyle = style({ |
18 | 99 | ...focusRing(), |
19 | 100 | size: 'full', |
@@ -42,53 +123,35 @@ const itemStyle = style({ |
42 | 123 | cursor: 'default' |
43 | 124 | }); |
44 | 125 |
|
45 | | -let handleCopyImport = (id: string) => { |
46 | | - navigator.clipboard.writeText(`import ${id} from '@react-spectrum/s2/icons/${id}';`).then(() => { |
47 | | - // noop |
48 | | - }).catch(() => { |
49 | | - // noop |
50 | | - }); |
51 | | -}; |
52 | | - |
53 | 126 | interface IconSearchViewProps { |
54 | 127 | filteredItems: typeof iconList |
55 | 128 | } |
56 | 129 |
|
57 | 130 | export function IconSearchView({filteredItems}: IconSearchViewProps) { |
| 131 | + let {copiedId, handleCopyImport} = useCopyImport(); |
| 132 | + |
58 | 133 | return ( |
59 | | - <Virtualizer layout={GridLayout} layoutOptions={{minItemSize: new Size(64, 64), maxItemSize: new Size(64, 64), minSpace: new Size(12, 12), preserveAspectRatio: true}}> |
60 | | - <ListBox |
61 | | - onAction={(item) => handleCopyImport(item.toString())} |
62 | | - items={filteredItems} |
63 | | - layout="grid" |
64 | | - className={style({width: '100%', scrollPaddingY: 4})} |
65 | | - renderEmptyState={() => ( |
66 | | - <IllustratedMessage styles={style({marginX: 'auto', marginY: 32})}> |
67 | | - <NoSearchResults /> |
68 | | - <Heading>No results</Heading> |
69 | | - <Content>Try a different search term.</Content> |
70 | | - </IllustratedMessage> |
71 | | - )}> |
72 | | - {item => <IconItem item={item} />} |
73 | | - </ListBox> |
74 | | - </Virtualizer> |
| 134 | + <> |
| 135 | + <CopyInfoMessage /> |
| 136 | + <IconListBox items={filteredItems} copiedId={copiedId} onAction={handleCopyImport} /> |
| 137 | + </> |
75 | 138 | ); |
76 | 139 | } |
77 | 140 |
|
78 | | -function IconItem({item}) { |
| 141 | +function IconItem({item, isCopied = false}: {item: typeof iconList[number], isCopied?: boolean}) { |
79 | 142 | let Icon = item.icon; |
80 | 143 | let ref = useRef(null); |
81 | 144 | return ( |
82 | 145 | <ListBoxItem id={item.id} value={item} textValue={item.id} className={itemStyle} ref={ref} style={pressScale(ref)}> |
83 | | - <Icon styles={iconStyle({size: 'XL'})} /> |
| 146 | + {isCopied ? <CheckmarkCircle styles={iconStyle({size: 'XL'})} /> : <Icon styles={iconStyle({size: 'XL'})} />} |
84 | 147 | <div |
85 | 148 | className={style({ |
86 | 149 | maxWidth: '100%', |
87 | 150 | textOverflow: 'ellipsis', |
88 | 151 | overflow: 'hidden', |
89 | 152 | whiteSpace: 'nowrap' |
90 | 153 | })}> |
91 | | - {item.id} |
| 154 | + {isCopied ? 'Copied!' : item.id} |
92 | 155 | </div> |
93 | 156 | </ListBoxItem> |
94 | 157 | ); |
@@ -163,39 +226,23 @@ export function IconSearchSkeleton() { |
163 | 226 | ); |
164 | 227 | } |
165 | 228 |
|
166 | | -export function IconCards() { |
167 | | - let {contains} = useFilter({sensitivity: 'base'}); |
168 | | - let filter = useCallback((textValue, inputValue) => { |
169 | | - // check if we're typing part of a category alias |
170 | | - for (const alias of Object.keys(iconAliases)) { |
171 | | - if (contains(alias, inputValue) && iconAliases[alias].includes(textValue)) { |
172 | | - return true; |
173 | | - } |
174 | | - } |
175 | | - // also compare for substrings in the icon's actual name |
176 | | - return textValue != null && contains(textValue, inputValue); |
177 | | - }, [contains]); |
| 229 | +export function IconsPageSearch() { |
| 230 | + let filter = useIconFilter(); |
| 231 | + let {copiedId, handleCopyImport} = useCopyImport(); |
| 232 | + |
178 | 233 | return ( |
179 | | - <Autocomplete filter={filter}> |
180 | | - <div className={style({display: 'flex', flexDirection: 'column', gap: 8})}> |
181 | | - <SearchField size="L" aria-label="Search icons" placeholder="Search icons" /> |
182 | | - <Virtualizer layout={GridLayout} layoutOptions={{minItemSize: new Size(64, 64), maxItemSize: new Size(64, 64), minSpace: new Size(12, 12), preserveAspectRatio: true}}> |
183 | | - <ListBox |
184 | | - onAction={(item) => handleCopyImport(item.toString())} |
| 234 | + <> |
| 235 | + <Autocomplete filter={filter}> |
| 236 | + <div className={style({display: 'flex', flexDirection: 'column', gap: 8})}> |
| 237 | + <SearchField size="L" aria-label="Search icons" placeholder="Search icons" /> |
| 238 | + <CopyInfoMessage /> |
| 239 | + <IconListBox |
185 | 240 | items={iconList} |
186 | | - layout="grid" |
187 | | - className={style({height: 440, width: '100%', maxHeight: '100%', overflow: 'auto', scrollPaddingY: 4})} |
188 | | - renderEmptyState={() => ( |
189 | | - <IllustratedMessage styles={style({marginX: 'auto', marginY: 32})}> |
190 | | - <NoSearchResults /> |
191 | | - <Heading>No results</Heading> |
192 | | - <Content>Try a different search term.</Content> |
193 | | - </IllustratedMessage> |
194 | | - )}> |
195 | | - {item => <IconItem item={item} />} |
196 | | - </ListBox> |
197 | | - </Virtualizer> |
198 | | - </div> |
199 | | - </Autocomplete> |
| 241 | + copiedId={copiedId} |
| 242 | + onAction={handleCopyImport} |
| 243 | + listBoxClassName={style({height: 440, width: '100%', maxHeight: '100%', overflow: 'auto', scrollPaddingY: 4})} /> |
| 244 | + </div> |
| 245 | + </Autocomplete> |
| 246 | + </> |
200 | 247 | ); |
201 | 248 | } |
0 commit comments