searchPanel.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import {useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import Fuse from 'fuse.js';
  4. import debounce from 'lodash/debounce';
  5. import TextField from 'app/components/forms/textField';
  6. import space from 'app/styles/space';
  7. import {IconData, iconGroups, IconPropName, iconProps, icons} from './data';
  8. import IconInfoBox from './infoBox';
  9. export type ExtendedIconData = IconData & {
  10. name: string;
  11. defaultProps?: Partial<Record<IconPropName, unknown>>;
  12. };
  13. type Results = {id: string; label?: string; icons: ExtendedIconData[]}[];
  14. export type SelectedIcon = {
  15. group: string;
  16. icon: string;
  17. };
  18. const SearchPanel = () => {
  19. /**
  20. * The same icon can appear in multiple groups,
  21. * so we also need to store which group the
  22. * selected icon is in
  23. */
  24. const [selectedIcon, setSelectedIcon] = useState<SelectedIcon>({group: '', icon: ''});
  25. /**
  26. * All the icons, split into iterable groups
  27. */
  28. const addIconNames = (iconData: IconData[]): ExtendedIconData[] =>
  29. iconData.map(icon => {
  30. const nameString = icon.id.split('-')[0];
  31. const name = nameString.charAt(0).toUpperCase() + nameString.slice(1);
  32. return {...icon, name};
  33. });
  34. const enumerateIconProps = (
  35. iconData: ExtendedIconData[],
  36. prop: string
  37. ): ExtendedIconData[] =>
  38. iconData.reduce((acc: ExtendedIconData[], cur: ExtendedIconData) => {
  39. const propData = iconProps[prop];
  40. switch (propData.type) {
  41. case 'select':
  42. const availableOptions: string[][] =
  43. cur.limitOptions?.[prop] ?? propData.options;
  44. return [
  45. ...acc,
  46. ...availableOptions.map(option => ({
  47. ...cur,
  48. id: `${cur.id}-${prop}-${option[0]}`,
  49. defaultProps: {
  50. ...cur.defaultProps,
  51. [prop]: option[0],
  52. },
  53. })),
  54. ];
  55. case 'boolean':
  56. return [
  57. ...acc,
  58. {...cur, defaultProps: {...cur.defaultProps, [prop]: false}},
  59. {
  60. ...cur,
  61. id: `${cur.id}-${prop}`,
  62. defaultProps: {...cur.defaultProps, [prop]: true},
  63. },
  64. ];
  65. default:
  66. return acc;
  67. }
  68. }, []);
  69. const enumerateIconVariants = (iconData: ExtendedIconData[]): ExtendedIconData[] =>
  70. iconData.reduce((acc: ExtendedIconData[], cur: ExtendedIconData) => {
  71. let iconVariants: ExtendedIconData[] = [{...cur, defaultProps: {}}];
  72. cur.additionalProps?.forEach(prop => {
  73. if (iconProps[prop].enumerate) {
  74. iconVariants = enumerateIconProps(iconVariants, prop);
  75. }
  76. });
  77. return [...acc, ...iconVariants];
  78. }, []);
  79. const groupedIcons: Results = iconGroups.map(group => {
  80. const filteredIcons = icons.filter(i => i.groups.includes(group.id));
  81. const namedIcons = addIconNames(filteredIcons);
  82. const enumeratedIcons = enumerateIconVariants(namedIcons);
  83. return {...group, icons: enumeratedIcons};
  84. });
  85. /**
  86. * Use Fuse.js to implement icon search
  87. */
  88. const [query, setQuery] = useState('');
  89. const [results, setResults] = useState<Results>(groupedIcons);
  90. const fuse = new Fuse(icons, {
  91. keys: ['id', 'groups', 'keywords'],
  92. limit: 5,
  93. });
  94. const debouncedSearch = useCallback(
  95. debounce(newQuery => {
  96. if (!newQuery) {
  97. setResults(groupedIcons);
  98. } else {
  99. const searchResults = fuse.search(newQuery, {limit: 5});
  100. const namedIcons = addIconNames(searchResults);
  101. const enumeratedIcons = enumerateIconVariants(namedIcons);
  102. setResults([{id: 'search', icons: enumeratedIcons}]);
  103. }
  104. }, 250),
  105. []
  106. );
  107. useEffect(() => {
  108. debouncedSearch(query);
  109. }, [query]);
  110. return (
  111. <Wrap>
  112. <TextField
  113. name="query"
  114. placeholder="Search icons"
  115. value={query}
  116. onChange={value => {
  117. setQuery(value as string);
  118. setSelectedIcon({group: '', icon: ''});
  119. }}
  120. />
  121. {results.map(group => (
  122. <GroupWrap key={group.id}>
  123. <GroupLabel>{group.label}</GroupLabel>
  124. <GroupIcons>
  125. {group.icons.map(icon => (
  126. <IconInfoBox
  127. key={icon.id}
  128. icon={icon}
  129. selectedIcon={selectedIcon}
  130. setSelectedIcon={setSelectedIcon}
  131. groupId={group.id}
  132. />
  133. ))}
  134. </GroupIcons>
  135. </GroupWrap>
  136. ))}
  137. </Wrap>
  138. );
  139. };
  140. export default SearchPanel;
  141. export const Wrap = styled('div')`
  142. margin-top: ${space(4)};
  143. `;
  144. const GroupWrap = styled('div')`
  145. margin: ${space(4)} 0;
  146. `;
  147. const GroupLabel = styled('p')`
  148. font-size: 1.125rem;
  149. font-weight: bold;
  150. margin-bottom: 0;
  151. `;
  152. const GroupIcons = styled('div')`
  153. display: grid;
  154. grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
  155. margin-top: ${space(1)};
  156. `;