Explorar o código

Add item library concept

Jeremie Pardou-Piquemal %!s(int64=3) %!d(string=hai) anos
pai
achega
ac492b676b

+ 56 - 15
src/components/AddItemButton.jsx

@@ -1,21 +1,48 @@
 import React from "react";
+import { nanoid } from "nanoid";
 import { useRecoilValue } from "recoil";
 import { useTranslation } from "react-i18next";
-import styled from "styled-components";
-
-import AvailableItems from "./AvailableItems";
-import NewItems from "./NewItems";
+import ItemLibrary from "./ItemLibrary";
 
 import { AvailableItemListAtom } from "./Board/";
 
 import Touch from "../ui/Touch";
 import SidePanel from "../ui/SidePanel";
 
-const AvailableItemList = styled.div`
-  margin-top: 2em;
-  color: white;
-  list-type: none;
-`;
+import { itemMap } from "./boardComponents";
+
+// Keep compatibility with previous availableItems shape
+const migrateAvailableItemList = (old) => {
+  const groupMap = old.reduce((acc, { groupId, ...item }) => {
+    if (!acc[groupId]) {
+      acc[groupId] = [];
+    }
+    acc[groupId].push(item);
+    return acc;
+  }, {});
+  return Object.keys(groupMap).map((name) => ({
+    name,
+    items: groupMap[name],
+  }));
+};
+
+const adaptItem = (item) => ({
+  type: item.type,
+  template: item,
+  component: itemMap[item.type].component,
+  name: item.name || item.label || item.text || itemMap[item.type].name,
+  uid: nanoid(),
+});
+
+const adaptAvailableItems = (nodes) => {
+  return nodes.map((node) => {
+    if (node.type) {
+      return adaptItem(node);
+    } else {
+      return { ...node, items: adaptAvailableItems(node.items) };
+    }
+  });
+};
 
 const AddItemButton = () => {
   const { t } = useTranslation();
@@ -24,6 +51,24 @@ const AddItemButton = () => {
   const [showAddPanel, setShowAddPanel] = React.useState(false);
   const [tab, setTab] = React.useState("standard");
 
+  const defaultItemLibrary = React.useMemo(
+    () =>
+      Object.keys(itemMap).map((key) => ({
+        type: key,
+        ...itemMap[key],
+        uid: nanoid(),
+      })),
+    []
+  );
+
+  const availableItemLibrary = React.useMemo(() => {
+    let itemList = availableItemList;
+    if (itemList.length && itemList[0].groupId) {
+      itemList = migrateAvailableItemList(itemList);
+    }
+    return adaptAvailableItems(itemList);
+  }, [availableItemList]);
+
   return (
     <>
       <Touch
@@ -64,12 +109,8 @@ const AddItemButton = () => {
           )}
         </nav>
         <section className="content">
-          {tab === "standard" && <NewItems />}
-          {tab === "other" && (
-            <AvailableItemList>
-              <AvailableItems />
-            </AvailableItemList>
-          )}
+          {tab === "standard" && <ItemLibrary items={defaultItemLibrary} />}
+          {tab === "other" && <ItemLibrary items={availableItemLibrary} />}
         </section>
       </SidePanel>
     </>

+ 0 - 102
src/components/AvailableItems.jsx

@@ -1,102 +0,0 @@
-import React, { memo } from "react";
-import { useRecoilValue, useRecoilCallback } from "recoil";
-import { useItems } from "../components/Board/Items";
-import { nanoid } from "nanoid";
-import { AvailableItemListAtom, PanZoomRotateAtom } from "./Board";
-import { useTranslation } from "react-i18next";
-
-const AvailableItem = memo(({ data }) => {
-  const { label } = data;
-  const { pushItem } = useItems();
-
-  const addItem = useRecoilCallback(
-    ({ snapshot }) => async () => {
-      const { centerX, centerY } = await snapshot.getPromise(PanZoomRotateAtom);
-      pushItem({ ...data, x: centerX, y: centerY, id: nanoid() });
-    },
-    [data, pushItem]
-  );
-
-  return (
-    <span style={{ cursor: "pointer" }} onClick={addItem}>
-      {label}
-    </span>
-  );
-});
-
-AvailableItem.displayName = "AvailableItem";
-
-const AvailableItems = () => {
-  const { t } = useTranslation();
-  const availableItemList = useRecoilValue(AvailableItemListAtom);
-  const [filter, setFilter] = React.useState("");
-  const { pushItem } = useItems();
-
-  let items = availableItemList;
-  if (filter.length) {
-    items = availableItemList.filter(({ label }) =>
-      label.toLowerCase().includes(filter.toLowerCase())
-    );
-  }
-
-  const groupIds = React.useMemo(
-    () => [...new Set(items.map((item) => item.groupId))],
-    [items]
-  );
-
-  const addItems = useRecoilCallback(
-    ({ snapshot }) => async (groupId) => {
-      const { centerX, centerY } = await snapshot.getPromise(PanZoomRotateAtom);
-      items
-        .filter((item) => item.groupId === groupId)
-        .forEach((data) => {
-          pushItem({ ...data, x: centerX, y: centerY, id: nanoid() });
-        });
-    },
-    [items, pushItem]
-  );
-
-  return (
-    <>
-      <input
-        value={filter}
-        onChange={(e) => setFilter(e.target.value)}
-        style={{ marginBottom: "1em" }}
-      />
-      {groupIds.map((groupId) => {
-        return (
-          <div key={groupId}>
-            <details
-              style={{ textAlign: "left", marginLeft: "10px" }}
-              open={filter.length}
-            >
-              <summary style={{ cursor: "pointer" }}>
-                {groupId}{" "}
-                <span
-                  style={{ fontSize: "0.6em" }}
-                  onClick={(e) => {
-                    e.preventDefault();
-                    addItems(groupId);
-                  }}
-                >
-                  [{t("Add all")}]
-                </span>
-              </summary>
-              <ul>
-                {items
-                  .filter((item) => item.groupId === groupId)
-                  .map((item) => (
-                    <li key={item.id}>
-                      <AvailableItem data={item} />
-                    </li>
-                  ))}
-              </ul>
-            </details>
-          </div>
-        );
-      })}
-    </>
-  );
-};
-
-export default memo(AvailableItems);

+ 15 - 110
src/components/Board/Items/Item.jsx

@@ -71,124 +71,18 @@ const ItemWrapper = styled.div.attrs(({ rotation, locked, selected }) => {
   }
 `;
 
-/*const Item = ({
-  setState,
-  state: { type, x, y, rotation = 0, id, locked, layer, ...rest } = {},
-  animate = "hvr-pop",
-  isSelected,
-  getComponent,
-}) => {
-  const itemRef = React.useRef(null);
-  const [unlock, setUnlock] = React.useState(false);
-  const isMountedRef = React.useRef(false);
-  const animateRef = React.useRef(null);
-
-  // Allow to operate on locked item if key is pressed
-  React.useEffect(() => {
-    const onKeyDown = (e) => {
-      if (e.key === "u" || e.key === "l") {
-        setUnlock(true);
-      }
-    };
-    const onKeyUp = (e) => {
-      if (e.key === "u" || e.key === "l") {
-        setUnlock(false);
-      }
-    };
-    document.addEventListener("keydown", onKeyDown);
-    document.addEventListener("keyup", onKeyUp);
-    return () => {
-      document.removeEventListener("keydown", onKeyDown);
-      document.removeEventListener("keyup", onKeyUp);
-    };
-  }, []);
-
-  const Component = getComponent(type);
-
-  const updateState = React.useCallback(
-    (callbackOrItem, sync = true) => setState(id, callbackOrItem, sync),
-    [setState, id]
-  );
-
-  // Update actual size when update
-  React.useEffect(() => {
-    isMountedRef.current = true;
-    return () => {
-      isMountedRef.current = false;
-    };
-  }, []);
-
-  React.useEffect(() => {
-    animateRef.current.className = animate;
-  }, [animate]);
-
-  const removeClass = (e) => {
-    e.target.className = "";
-  };
-
-  return (
-    <div
-      style={{
-        transform: `translate(${x}px, ${y}px)`,
-        display: "inline-block",
-        zIndex: (layer || 0) + 3,
-        position: "absolute",
-        top: 0,
-        left: 0,
-      }}
-    >
-      <ItemWrapper
-        rotation={rotation}
-        locked={locked && !unlock}
-        selected={isSelected}
-        ref={itemRef}
-        layer={layer}
-        id={id}
-      >
-        <div ref={animateRef} onAnimationEnd={removeClass}>
-          <Component {...rest} setState={updateState} />
-          <div className="corner top-left"></div>
-          <div className="corner top-right"></div>
-          <div className="corner bottom-left"></div>
-          <div className="corner bottom-right"></div>
-        </div>
-      </ItemWrapper>
-    </div>
-  );
-};*/
-
 const Item = ({
   setState,
   state: { type, rotation = 0, id, locked, layer, ...rest } = {},
   animate = "hvr-pop",
   isSelected,
   getComponent,
+  unlocked,
 }) => {
   const itemRef = React.useRef(null);
-  const [unlock, setUnlock] = React.useState(false);
   const isMountedRef = React.useRef(false);
   const animateRef = React.useRef(null);
 
-  // Allow to operate on locked item if key is pressed
-  React.useEffect(() => {
-    const onKeyDown = (e) => {
-      if (e.key === "u" || e.key === "l") {
-        setUnlock(true);
-      }
-    };
-    const onKeyUp = (e) => {
-      if (e.key === "u" || e.key === "l") {
-        setUnlock(false);
-      }
-    };
-    document.addEventListener("keydown", onKeyDown);
-    document.addEventListener("keyup", onKeyUp);
-    return () => {
-      document.removeEventListener("keydown", onKeyDown);
-      document.removeEventListener("keyup", onKeyUp);
-    };
-  }, []);
-
   const Component = getComponent(type);
 
   const updateState = React.useCallback(
@@ -215,7 +109,7 @@ const Item = ({
   return (
     <ItemWrapper
       rotation={rotation}
-      locked={locked && !unlock}
+      locked={locked && !unlocked}
       selected={isSelected}
       ref={itemRef}
       layer={layer}
@@ -235,11 +129,22 @@ const Item = ({
 const MemoizedItem = memo(
   Item,
   (
-    { state: prevState, setState: prevSetState, isSelected: prevIsSelected },
-    { state: nextState, setState: nextSetState, isSelected: nextIsSelected }
+    {
+      state: prevState,
+      setState: prevSetState,
+      isSelected: prevIsSelected,
+      unlocked: prevUnlocked,
+    },
+    {
+      state: nextState,
+      setState: nextSetState,
+      isSelected: nextIsSelected,
+      unlocked: nextUnlocked,
+    }
   ) => {
     return (
       prevIsSelected === nextIsSelected &&
+      prevUnlocked === nextUnlocked &&
       prevSetState === nextSetState &&
       JSON.stringify(prevState) === JSON.stringify(nextState)
     );

+ 28 - 0
src/components/Board/Items/ItemList.jsx

@@ -4,11 +4,38 @@ import useItems from "./useItems";
 import { ItemListAtom, ItemMapAtom, selectedItemsAtom } from "../";
 import { useRecoilValue } from "recoil";
 
+/** Allow to operate on locked items while u or l key is pressed  */
+const useUnlock = () => {
+  const [unlock, setUnlock] = React.useState(false);
+
+  React.useEffect(() => {
+    const onKeyDown = (e) => {
+      if (e.key === "u" || e.key === "l") {
+        setUnlock(true);
+      }
+    };
+    const onKeyUp = (e) => {
+      if (e.key === "u" || e.key === "l") {
+        setUnlock(false);
+      }
+    };
+    document.addEventListener("keydown", onKeyDown);
+    document.addEventListener("keyup", onKeyUp);
+    return () => {
+      document.removeEventListener("keydown", onKeyDown);
+      document.removeEventListener("keyup", onKeyUp);
+    };
+  }, []);
+
+  return unlock;
+};
+
 const ItemList = ({ getComponent }) => {
   const { updateItem } = useItems();
   const itemList = useRecoilValue(ItemListAtom);
   const itemMap = useRecoilValue(ItemMapAtom);
   const selectedItems = useRecoilValue(selectedItemsAtom);
+  const unlocked = useUnlock();
 
   return itemList.map((itemId) => (
     <Item
@@ -16,6 +43,7 @@ const ItemList = ({ getComponent }) => {
       state={itemMap[itemId]}
       setState={updateItem}
       isSelected={selectedItems.includes(itemId)}
+      unlocked={unlocked}
       getComponent={getComponent}
     />
   ));

+ 209 - 0
src/components/ItemLibrary.jsx

@@ -0,0 +1,209 @@
+import React, { memo } from "react";
+import { nanoid } from "nanoid";
+import { useRecoilCallback } from "recoil";
+
+import { useItems } from "../components/Board/Items";
+import useToggle from "../hooks/useToggle";
+import Chevron from "../ui/Chevron";
+import { PanZoomRotateAtom } from "./Board";
+
+import styled from "styled-components";
+import { search } from "../utils";
+import { useTranslation } from "react-i18next";
+import { debounce } from "lodash";
+
+const StyledItemList = styled.ul`
+  display: flex;
+  flex-flow: row wrap;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  & li.group {
+    background-color: rgba(0, 0, 0, 0.1);
+    padding: 0 0.5em;
+    flex-basis: 100%;
+  }
+  overflow: visible;
+`;
+
+const StyledItem = styled.li`
+  display: block;
+  padding: 0.5em;
+  margin: 0.2em;
+  cursor: pointer;
+  opacity: 0.7;
+
+  &:hover {
+    opacity: 1;
+  }
+  & > div {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    pointer-events: none;
+    max-width: 80px;
+    & > span {
+      margin-top: 0.2em;
+      text-align: center;
+      max-width: 80px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      padding: 0.2em 0.5em;
+    }
+  }
+  &:hover > div > span {
+    z-index: 2;
+    max-width: none;
+    overflow: visible;
+    background-color: #222;
+    box-shadow: 0px 3px 6px #00000029;
+  }
+`;
+
+const size = 60;
+
+const NewItem = memo(({ type, template, component: Component, name }) => {
+  const { pushItem } = useItems();
+
+  const addItem = useRecoilCallback(
+    ({ snapshot }) => async () => {
+      const { centerX, centerY } = await snapshot.getPromise(PanZoomRotateAtom);
+      pushItem({
+        ...template,
+        x: centerX,
+        y: centerY,
+        id: nanoid(),
+        type,
+      });
+    },
+    [pushItem, template, type]
+  );
+
+  return (
+    <>
+      <StyledItem onClick={addItem}>
+        <div>
+          <Component {...template} width={size} height={size} size={size} />
+          <span>{name}</span>
+        </div>
+      </StyledItem>
+    </>
+  );
+});
+
+NewItem.displayName = "NewItem";
+
+const SubItemList = ({ name, items }) => {
+  const { t } = useTranslation();
+  const [open, toggleOpen] = useToggle(false);
+  const { pushItem } = useItems();
+
+  const addItems = useRecoilCallback(
+    ({ snapshot }) => async (items) => {
+      const { centerX, centerY } = await snapshot.getPromise(PanZoomRotateAtom);
+      items.forEach(({ template }, index) => {
+        pushItem({
+          ...template,
+          x: centerX + 2 * index,
+          y: centerY + 2 * index,
+          id: nanoid(),
+        });
+      });
+    },
+    [pushItem]
+  );
+
+  return (
+    <>
+      <h3 onClick={toggleOpen} style={{ cursor: "pointer" }}>
+        {open ? (
+          <Chevron orientation="bottom" color="#8c8c8c" />
+        ) : (
+          <Chevron color="#8c8c8c" />
+        )}{" "}
+        {name}{" "}
+        <span
+          style={{ fontSize: "0.6em" }}
+          onClick={(e) => {
+            e.preventDefault();
+            e.stopPropagation();
+            addItems(items);
+          }}
+        >
+          [{t("Add all")}]
+        </span>
+      </h3>
+      {open && <ItemList items={items} />}
+    </>
+  );
+};
+
+const ItemList = ({ items }) => {
+  return (
+    <StyledItemList>
+      {items.map((node) => {
+        if (node.type) {
+          return <NewItem {...node} key={node.uid} />;
+        } else {
+          // it's a group
+          return (
+            <li key={`group_${node.name}`} className="group">
+              <SubItemList {...node} />
+            </li>
+          );
+        }
+      })}
+    </StyledItemList>
+  );
+};
+
+const MemoizedItemList = memo(ItemList);
+
+const filterItems = (filter, nodes) => {
+  return nodes.reduce((acc, node) => {
+    if (node.type) {
+      if (search(filter, node.name)) {
+        acc.push(node);
+      }
+      return acc;
+    } else {
+      const filteredItems = filterItems(filter, node.items);
+      if (filteredItems.length) {
+        acc.push({ ...node, items: filteredItems });
+      }
+      return acc;
+    }
+  }, []);
+};
+
+const ItemLibrary = ({ items }) => {
+  const { t } = useTranslation();
+  const [filter, setFilter] = React.useState("");
+  const [filteredItems, setFilteredItems] = React.useState(items);
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const debouncedFilterItems = React.useCallback(
+    debounce((filter, items) => {
+      setFilteredItems(filterItems(filter, items));
+    }, 500),
+    []
+  );
+
+  React.useEffect(() => {
+    debouncedFilterItems(filter, items);
+  }, [debouncedFilterItems, filter, items]);
+
+  return (
+    <>
+      <input
+        onChange={(e) => setFilter(e.target.value)}
+        style={{ marginBottom: "1em" }}
+        placeholder={t("Search...")}
+      />
+      <MemoizedItemList items={filteredItems} />
+    </>
+  );
+};
+
+export default memo(ItemLibrary);

+ 2 - 2
src/components/boardComponents/Counter.jsx

@@ -12,7 +12,7 @@ const CounterPane = styled.div`
       padding: 1rem;
     }
     input {
-      width: 3em;
+      width: 2em;
     }
     h3 {
       user-select: none;
@@ -73,7 +73,7 @@ const Counter = ({
           <input
             style={{
               textColor,
-              width: "4em",
+              width: "2.5em",
               display: "block",
               textAlign: "center",
               border: "none",

+ 21 - 8
src/components/boardComponents/Cylinder.jsx

@@ -2,17 +2,30 @@ import React from "react";
 import { darken } from "color2k";
 
 const Cylinder = ({ size = 50, color = "#b3b3b3" }) => {
-    const colorDarken = darken(color, 0.25);
+  const colorDarken = darken(color, 0.25);
 
-    return (
-      <svg version="1.1" id="fi_758454" xmlns="http://www.w3.org/2000/svg" height={size} width={size} viewBox="0 0 377.208 377.208">
-        <path style={{fill: colorDarken}} d="M188.604,111.804c-85.159,0-154.645-22.988-159.869-52.245h-0.522v261.747
+  return (
+    <svg
+      version="1.1"
+      id="fi_758454"
+      xmlns="http://www.w3.org/2000/svg"
+      height={size}
+      width={size}
+      viewBox="0 0 377.208 377.208"
+    >
+      <path
+        style={{ fill: colorDarken }}
+        d="M188.604,111.804c-85.159,0-154.645-22.988-159.869-52.245h-0.522v261.747
             c0,30.824,71.576,55.902,160.392,55.902s160.392-25.078,160.392-55.902V59.559h-0.522
-            C343.249,88.816,273.763,111.804,188.604,111.804z"></path>
-        <path style={{fill: color}} d="M188.604,111.804c85.159,0,154.645-22.988,159.869-52.245c0.522-1.045,0.522-2.612,0.522-3.657
+            C343.249,88.816,273.763,111.804,188.604,111.804z"
+      ></path>
+      <path
+        style={{ fill: color }}
+        d="M188.604,111.804c85.159,0,154.645-22.988,159.869-52.245c0.522-1.045,0.522-2.612,0.522-3.657
             C348.996,25.078,277.42,0,188.604,0S28.212,25.078,28.212,55.902c0,1.045,0,2.612,0.522,3.657
-            C33.959,88.816,103.445,111.804,188.604,111.804z"></path>
-      </svg>
+            C33.959,88.816,103.445,111.804,188.604,111.804z"
+      ></path>
+    </svg>
   );
 };
 

+ 14 - 14
src/components/boardComponents/index.jsx

@@ -44,7 +44,7 @@ export const itemMap = {
       "remove",
     ],
     form: RectFormFields,
-    label: i18n.t("Rectangle"),
+    name: i18n.t("Rectangle"),
     template: {},
   },
   cube: {
@@ -60,7 +60,7 @@ export const itemMap = {
       "remove",
     ],
     form: CubeFormFields,
-    label: i18n.t("Cube"),
+    name: i18n.t("Cube"),
     template: {},
   },
   cylinder: {
@@ -76,7 +76,7 @@ export const itemMap = {
       "remove",
     ],
     form: CylinderFormFields,
-    label: i18n.t("Cylinder"),
+    name: i18n.t("Cylinder"),
     template: {},
   },
   round: {
@@ -92,7 +92,7 @@ export const itemMap = {
       "remove",
     ],
     form: RoundFormFields,
-    label: i18n.t("Round"),
+    name: i18n.t("Round"),
     template: {},
   },
   token: {
@@ -108,7 +108,7 @@ export const itemMap = {
       "remove",
     ],
     form: TokenFormFields,
-    label: i18n.t("Token"),
+    name: i18n.t("Token"),
     template: {},
   },
   meeple: {
@@ -124,7 +124,7 @@ export const itemMap = {
       "remove",
     ],
     form: MeepleFormFields,
-    label: i18n.t("Meeple"),
+    name: i18n.t("Meeple"),
     template: {},
   },
   pawn: {
@@ -140,7 +140,7 @@ export const itemMap = {
       "remove",
     ],
     form: PawnFormFields,
-    label: i18n.t("Pawn"),
+    name: i18n.t("Pawn"),
     template: {},
   },
   jewel: {
@@ -156,7 +156,7 @@ export const itemMap = {
       "remove",
     ],
     form: JewelFormFields,
-    label: i18n.t("Jewel"),
+    name: i18n.t("Jewel"),
     template: {},
   },
   checkerboard: {
@@ -164,7 +164,7 @@ export const itemMap = {
     defaultActions: ["clone", "lock", "remove"],
     availableActions: ["clone", "lock", "remove"],
     form: CheckerBoardFormFields,
-    label: i18n.t("Checkerboard"),
+    name: i18n.t("Checkerboard"),
     template: {},
   },
   image: {
@@ -233,7 +233,7 @@ export const itemMap = {
       }
     },
     form: ImageFormFields,
-    label: i18n.t("Image"),
+    name: i18n.t("Image"),
     template: {},
   },
   counter: {
@@ -241,7 +241,7 @@ export const itemMap = {
     defaultActions: ["clone", "lock", "remove"],
     availableActions: ["clone", "lock", "remove"],
     form: CounterFormFields,
-    label: i18n.t("Counter"),
+    name: i18n.t("Counter"),
     template: {},
   },
   dice: {
@@ -255,7 +255,7 @@ export const itemMap = {
       "alignAsSquare",
     ],
     form: DiceFormFields,
-    label: i18n.t("Dice"),
+    name: i18n.t("Dice"),
     template: {},
   },
   note: {
@@ -270,7 +270,7 @@ export const itemMap = {
       "alignAsSquare",
     ],
     form: NoteFormFields,
-    label: i18n.t("Note"),
+    name: i18n.t("Note"),
     template: {},
   },
   zone: {
@@ -284,7 +284,7 @@ export const itemMap = {
       "alignAsSquare",
     ],
     form: ZoneFormFields,
-    label: i18n.t("Zone"),
+    name: i18n.t("Zone"),
     template: {},
   },
 };

+ 80 - 3
src/games/testGame.js

@@ -193,13 +193,90 @@ const genGame = () => {
     items,
     availableItems: [
       {
-        groupId: "Group",
-        label: "Rect",
+        name: "Blue rect",
+        label: "rect",
         type: "rect",
-        color: "#00D022",
+        color: "#0000D2",
         width: 80,
         height: 80,
       },
+      {
+        name: "First group",
+        items: [
+          {
+            name: "Green rect",
+            label: "rect",
+            type: "rect",
+            color: "#00D022",
+            width: 80,
+            height: 80,
+          },
+          {
+            label: "Red rect",
+            type: "rect",
+            color: "#D00022",
+            width: 80,
+            height: 80,
+          },
+        ],
+      },
+      {
+        name: "Second group",
+        items: [
+          {
+            name: "Green pawn",
+            label: "rect",
+            type: "pawn",
+            color: "#00D022",
+            size: 80,
+          },
+          {
+            name: "Red pawn",
+            type: "pawn",
+            color: "#D00022",
+            size: 80,
+          },
+          {
+            name: "blue pawn",
+            type: "pawn",
+            color: "#2000D2",
+            size: 80,
+          },
+          {
+            name: "Third nested group",
+            items: [
+              {
+                name: "Green rect",
+                label: "rect",
+                type: "rect",
+                color: "#00D022",
+                width: 80,
+                height: 80,
+              },
+              {
+                name: "Red rect",
+                type: "rect",
+                color: "#D00022",
+                width: 80,
+                height: 80,
+              },
+            ],
+          },
+        ],
+      },
+      {
+        name: "Green circle",
+        label: "round",
+        type: "round",
+        color: "#00D022",
+        size: 80,
+      },
+      {
+        name: "Red circle",
+        type: "round",
+        color: "#D00022",
+        size: 80,
+      },
     ],
     board: {
       size: 1000,

+ 35 - 0
src/ui/Chevron.jsx

@@ -0,0 +1,35 @@
+import React from "react";
+import styled from "styled-components";
+
+const StyledChevron = styled.span`
+  position: relative;
+  display: inline-block;
+  ${({ orientation, color }) => {
+    switch (orientation) {
+      case "top":
+      case "bottom":
+        return `border-color: ${color} transparent;`;
+      default:
+        return `border-color: transparent ${color};`;
+    }
+  }}
+  border-style: solid;
+  ${({ orientation, size }) => {
+    switch (orientation) {
+      case "top":
+        return `border-width: 0 ${size / 2}px  ${size}px ${size / 2}px;`;
+      case "bottom":
+        return `border-width: ${size}px ${size / 2}px 0 ${size / 2}px;`;
+      case "left":
+        return `border-width: ${size / 2}px ${size}px ${size / 2}px 0;`;
+      default:
+        return `border-width: ${size / 2}px 0 ${size / 2}px ${size}px;`;
+    }
+  }}
+`;
+
+const Spinner = ({ size = 14, orientation = "right", color = "#fff" }) => (
+  <StyledChevron size={size} orientation={orientation} color={color} />
+);
+
+export default Spinner;

+ 15 - 1
src/utils/index.js

@@ -1,9 +1,10 @@
+import Diacritics from "diacritic";
+
 /**
  * Check if element or parent has className.
  * @param {DOMElement} element
  * @param {string} className
  */
-
 export const hasClass = (element, className) =>
   typeof element.className === "string" &&
   element.className.split(" ").includes(className);
@@ -60,3 +61,16 @@ export const getPointerState = (e) => {
 export const randInt = (min, max) => {
   return Math.floor(Math.random() * (max - min + 1)) + min;
 };
+
+const cleanWord = (word) => {
+  return Diacritics.clean(word).toLowerCase();
+};
+
+export const search = (term, string) => {
+  let strings = string;
+  if (typeof string === "string") {
+    strings = [string];
+  }
+  const cleanedTerm = cleanWord(term);
+  return strings.some((s) => cleanWord(s).includes(cleanedTerm));
+};

+ 4 - 10
src/views/GameListView.jsx

@@ -1,7 +1,6 @@
 import React from "react";
 import { useTranslation, Trans } from "react-i18next";
 import styled from "styled-components";
-import Diacritics from "diacritic";
 import { useQuery } from "react-query";
 
 import { getGames } from "../utils/api";
@@ -15,6 +14,7 @@ import GameListItem from "./GameListItem";
 import playerSVG from "../images/player.svg";
 import languageSVG from "../images/language.svg";
 import clockSVG from "../images/clock.svg";
+import { search } from "../utils";
 
 const Header = styled.header`
   background-color: var(--bg-color);
@@ -151,10 +151,6 @@ const Content = styled.div`
   background-color: var(--bg-secondary-color);
 `;
 
-const cleanWord = (word) => {
-  return Diacritics.clean(word).toLowerCase();
-};
-
 const hasIntervalOverlap = (interval1, interval2) => {
   return interval1[0] <= interval2[1] && interval1[1] >= interval2[0];
 };
@@ -209,14 +205,12 @@ const GameListView = () => {
     return gameList
       ? gameList.filter((game) => {
           return (
-            (filterCriteria.searchTerm === NULL_SEARCH_TERM ||
-              cleanWord(game.defaultName).includes(
-                cleanWord(filterCriteria.searchTerm)
-              )) &&
+          (filterCriteria.searchTerm === NULL_SEARCH_TERM ||
+              search(filterCriteria.searchTerm, game.defaultName)) &&
             hasRequestedValues(filterCriteria.nbOfPlayers, game.playerCount) &&
             hasRequestedValues(filterCriteria.durations, game.duration) &&
             hasAllowedMaterialLanguage(filterCriteria, game)
-          );
+        );
         })
       : [];
   }, [gameList, filterCriteria]);