Forráskód Böngészése

Merge pull request #2219 from pyth-network/cprussin/search-dialog

feat(insights): add search dialog
Connor Prussin 10 hónapja
szülő
commit
6b60679314

+ 27 - 7
apps/insights/src/components/PublisherTag/index.module.scss

@@ -3,12 +3,18 @@
 .publisherTag {
   display: flex;
   flex-flow: row nowrap;
-  gap: theme.spacing(4);
+  gap: theme.spacing(3);
   align-items: center;
+  width: 100%;
+
+  .icon,
+  .undisclosedIconWrapper {
+    width: theme.spacing(10);
+    height: theme.spacing(10);
+  }
 
   .icon {
-    width: theme.spacing(9);
-    height: theme.spacing(9);
+    flex: none;
     display: grid;
     place-content: center;
 
@@ -20,16 +26,22 @@
     }
   }
 
+  .name {
+    color: theme.color("heading");
+    font-weight: theme.font-weight("medium");
+  }
+
+  .publisherKey,
+  .icon {
+    color: theme.color("foreground");
+  }
+
   .nameAndKey {
     display: flex;
     flex-flow: column nowrap;
     gap: theme.spacing(1);
     align-items: flex-start;
 
-    .name {
-      color: theme.color("heading");
-    }
-
     .key {
       margin-bottom: -#{theme.spacing(2)};
     }
@@ -55,4 +67,12 @@
       border-radius: theme.border-radius("full");
     }
   }
+
+  &[data-compact] {
+    .icon,
+    .undisclosedIconWrapper {
+      width: theme.spacing(6);
+      height: theme.spacing(6);
+    }
+  }
 }

+ 57 - 30
apps/insights/src/components/PublisherTag/index.tsx

@@ -1,49 +1,41 @@
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import clsx from "clsx";
-import { type ComponentProps, type ReactNode } from "react";
+import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
 import { PublisherKey } from "../PublisherKey";
 
-type Props =
-  | { isLoading: true }
-  | ({
-      isLoading?: false;
-      publisherKey: string;
-    } & (
-      | { name: string; icon: ReactNode }
-      | { name?: undefined; icon?: undefined }
-    ));
+type Props = ComponentProps<"div"> & { compact?: boolean | undefined } & (
+    | { isLoading: true }
+    | ({
+        isLoading?: false;
+        publisherKey: string;
+      } & (
+        | { name: string; icon: ReactNode }
+        | { name?: undefined; icon?: undefined }
+      ))
+  );
 
-export const PublisherTag = (props: Props) => (
+export const PublisherTag = ({ className, ...props }: Props) => (
   <div
     data-loading={props.isLoading ? "" : undefined}
-    className={styles.publisherTag}
+    data-compact={props.compact ? "" : undefined}
+    className={clsx(styles.publisherTag, className)}
+    {...omitKeys(props, [
+      "compact",
+      "isLoading",
+      "publisherKey",
+      "name",
+      "icon",
+    ])}
   >
     {props.isLoading ? (
       <Skeleton fill className={styles.icon} />
     ) : (
       <div className={styles.icon}>{props.icon ?? <UndisclosedIcon />}</div>
     )}
-    {props.isLoading ? (
-      <Skeleton width={30} />
-    ) : (
-      <>
-        {props.name ? (
-          <div className={styles.nameAndKey}>
-            <div className={styles.name}>{props.name}</div>
-            <PublisherKey
-              className={styles.key ?? ""}
-              publisherKey={props.publisherKey}
-              size="xs"
-            />
-          </div>
-        ) : (
-          <PublisherKey publisherKey={props.publisherKey} size="sm" />
-        )}
-      </>
-    )}
+    <Contents {...props} />
   </div>
 );
 
@@ -52,3 +44,38 @@ const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => (
     <Broadcast className={styles.undisclosedIcon} />
   </div>
 );
+
+const Contents = (props: Props) => {
+  if (props.isLoading) {
+    return <Skeleton width={30} />;
+  } else if (props.compact) {
+    return props.name ? (
+      <div className={styles.name}>{props.name}</div>
+    ) : (
+      <PublisherKey publisherKey={props.publisherKey} size="xs" />
+    );
+  } else if (props.name) {
+    return (
+      <div className={styles.nameAndKey}>
+        <div className={styles.name}>{props.name}</div>
+        <PublisherKey
+          className={styles.key ?? ""}
+          publisherKey={props.publisherKey}
+          size="xs"
+        />
+      </div>
+    );
+  } else {
+    return <PublisherKey publisherKey={props.publisherKey} size="sm" />;
+  }
+};
+
+const omitKeys = <T extends Record<string, unknown>>(
+  obj: T,
+  keys: string[],
+) => {
+  const omitSet = new Set(keys);
+  return Object.fromEntries(
+    Object.entries(obj).filter(([key]) => !omitSet.has(key)),
+  );
+};

+ 49 - 17
apps/insights/src/components/Root/index.tsx

@@ -1,37 +1,69 @@
+import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
 import { Root as BaseRoot } from "@pythnetwork/next-root";
 import { NuqsAdapter } from "nuqs/adapters/next/app";
 import type { ReactNode } from "react";
+import { createElement } from "react";
 
 import { Footer } from "./footer";
 import { Header } from "./header";
 // import { MobileMenu } from "./mobile-menu";
 import styles from "./index.module.scss";
+import { SearchDialogProvider } from "./search-dialog";
 import { TabRoot, TabPanel } from "./tabs";
 import {
   IS_PRODUCTION_SERVER,
   GOOGLE_ANALYTICS_ID,
   AMPLITUDE_API_KEY,
 } from "../../config/server";
+import { toHex } from "../../hex";
+import { getPublishers } from "../../services/clickhouse";
+import { getData } from "../../services/pyth";
 import { LivePricesProvider } from "../LivePrices";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 
 type Props = {
   children: ReactNode;
 };
 
-export const Root = ({ children }: Props) => (
-  <BaseRoot
-    amplitudeApiKey={AMPLITUDE_API_KEY}
-    googleAnalyticsId={GOOGLE_ANALYTICS_ID}
-    enableAccessibilityReporting={!IS_PRODUCTION_SERVER}
-    providers={[NuqsAdapter, LivePricesProvider]}
-    className={styles.root}
-  >
-    <TabRoot className={styles.tabRoot ?? ""}>
-      <Header className={styles.header} />
-      <main className={styles.main}>
-        <TabPanel>{children}</TabPanel>
-      </main>
-      <Footer />
-    </TabRoot>
-  </BaseRoot>
-);
+export const Root = async ({ children }: Props) => {
+  const [data, publishers] = await Promise.all([getData(), getPublishers()]);
+
+  return (
+    <BaseRoot
+      amplitudeApiKey={AMPLITUDE_API_KEY}
+      googleAnalyticsId={GOOGLE_ANALYTICS_ID}
+      enableAccessibilityReporting={!IS_PRODUCTION_SERVER}
+      providers={[NuqsAdapter, LivePricesProvider]}
+      className={styles.root}
+    >
+      <SearchDialogProvider
+        feeds={data.map((feed) => ({
+          id: feed.symbol,
+          key: toHex(feed.product.price_account),
+          displaySymbol: feed.product.display_symbol,
+          icon: <PriceFeedIcon symbol={feed.symbol} />,
+          assetClass: feed.product.asset_type,
+        }))}
+        publishers={publishers.map((publisher) => {
+          const knownPublisher = lookupPublisher(publisher.key);
+          return {
+            id: publisher.key,
+            medianScore: publisher.medianScore,
+            ...(knownPublisher && {
+              name: knownPublisher.name,
+              icon: createElement(knownPublisher.icon.color),
+            }),
+          };
+        })}
+      >
+        <TabRoot className={styles.tabRoot ?? ""}>
+          <Header className={styles.header} />
+          <main className={styles.main}>
+            <TabPanel>{children}</TabPanel>
+          </main>
+          <Footer />
+        </TabRoot>
+      </SearchDialogProvider>
+    </BaseRoot>
+  );
+};

+ 16 - 5
apps/insights/src/components/Root/search-button.tsx

@@ -6,11 +6,22 @@ import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { useMemo } from "react";
 import { useIsSSR } from "react-aria";
 
-export const SearchButton = () => (
-  <Button beforeIcon={MagnifyingGlass} variant="outline" size="sm" rounded>
-    <SearchText />
-  </Button>
-);
+import { useToggleSearchDialog } from "./search-dialog";
+
+export const SearchButton = () => {
+  const toggleSearchDialog = useToggleSearchDialog();
+  return (
+    <Button
+      onPress={toggleSearchDialog}
+      beforeIcon={MagnifyingGlass}
+      variant="outline"
+      size="sm"
+      rounded
+    >
+      <SearchText />
+    </Button>
+  );
+};
 
 const SearchText = () => {
   const isSSR = useIsSSR();

+ 100 - 0
apps/insights/src/components/Root/search-dialog.module.scss

@@ -0,0 +1,100 @@
+@use "@pythnetwork/component-library/theme";
+
+.modalOverlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(from black r g b / 30%);
+  z-index: 1;
+
+  .searchMenu {
+    position: relative;
+    top: theme.spacing(32);
+    margin: 0 auto;
+    outline: none;
+    background: theme.color("background", "secondary");
+    border-radius: theme.border-radius("2xl");
+    padding: theme.spacing(1);
+    max-height: theme.spacing(120);
+    display: flex;
+    flex-flow: column nowrap;
+    flex-grow: 1;
+    gap: theme.spacing(1);
+    width: fit-content;
+
+    .searchBar {
+      flex: none;
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(2);
+      align-items: center;
+      padding: theme.spacing(1);
+
+      .closeButton {
+        margin-left: theme.spacing(8);
+      }
+    }
+
+    .body {
+      background: theme.color("background", "primary");
+      border-radius: theme.border-radius("xl");
+      flex-grow: 1;
+      overflow: hidden;
+      display: flex;
+
+      .listbox {
+        outline: none;
+        overflow: auto;
+        flex-grow: 1;
+
+        .item {
+          padding: theme.spacing(3) theme.spacing(4);
+          display: flex;
+          flex-flow: row nowrap;
+          align-items: center;
+          width: 100%;
+          cursor: pointer;
+          transition: background-color 100ms linear;
+          outline: none;
+          text-decoration: none;
+          border-top: 1px solid theme.color("background", "secondary");
+
+          &[data-is-first] {
+            border-top: none;
+          }
+
+          & > *:last-child {
+            flex-shrink: 0;
+          }
+
+          &[data-focused] {
+            background-color: theme.color(
+              "button",
+              "outline",
+              "background",
+              "hover"
+            );
+          }
+
+          &[data-pressed] {
+            background-color: theme.color(
+              "button",
+              "outline",
+              "background",
+              "active"
+            );
+          }
+
+          .itemType {
+            width: theme.spacing(21);
+            flex-shrink: 0;
+            margin-right: theme.spacing(6);
+          }
+
+          .itemTag {
+            flex-grow: 1;
+          }
+        }
+      }
+    }
+  }
+}

+ 342 - 0
apps/insights/src/components/Root/search-dialog.tsx

@@ -0,0 +1,342 @@
+"use client";
+
+import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Button } from "@pythnetwork/component-library/Button";
+import { ModalDialog } from "@pythnetwork/component-library/ModalDialog";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
+import {
+  Virtualizer,
+  ListLayout,
+} from "@pythnetwork/component-library/Virtualizer";
+import {
+  ListBox,
+  ListBoxItem,
+} from "@pythnetwork/component-library/unstyled/ListBox";
+import {
+  type ReactNode,
+  useState,
+  useCallback,
+  useEffect,
+  createContext,
+  use,
+  useMemo,
+} from "react";
+import { useCollator, useFilter } from "react-aria";
+
+import styles from "./search-dialog.module.scss";
+import { NoResults } from "../NoResults";
+import { PriceFeedTag } from "../PriceFeedTag";
+import { PublisherTag } from "../PublisherTag";
+import { Score } from "../Score";
+
+const CLOSE_DURATION_IN_SECONDS = 0.1;
+const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_SECONDS * 1000;
+
+const INPUTS = new Set(["input", "select", "button", "textarea"]);
+
+const SearchDialogOpenContext = createContext<
+  ReturnType<typeof useSearchDialogStateContext> | undefined
+>(undefined);
+
+type Props = {
+  children: ReactNode;
+  feeds: {
+    id: string;
+    key: string;
+    displaySymbol: string;
+    icon: ReactNode;
+    assetClass: string;
+  }[];
+  publishers: ({
+    id: string;
+    medianScore: number;
+  } & (
+    | { name: string; icon: ReactNode }
+    | { name?: undefined; icon?: undefined }
+  ))[];
+};
+
+export const SearchDialogProvider = ({
+  children,
+  feeds,
+  publishers,
+}: Props) => {
+  const searchDialogState = useSearchDialogStateContext();
+  const [search, setSearch] = useState("");
+  const [type, setType] = useState<ResultType | "">("");
+  const collator = useCollator();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+
+  const updateSelectedType = useCallback((set: Set<ResultType | "">) => {
+    setType(set.values().next().value ?? "");
+  }, []);
+
+  const close = useCallback(() => {
+    searchDialogState.close();
+    setTimeout(() => {
+      setSearch("");
+      setType("");
+    }, CLOSE_DURATION_IN_MS);
+  }, [searchDialogState, setSearch, setType]);
+
+  const handleOpenChange = useCallback(
+    (isOpen: boolean) => {
+      if (!isOpen) {
+        close();
+      }
+    },
+    [close],
+  );
+
+  const results = useMemo(
+    () =>
+      [
+        ...(type === ResultType.Publisher
+          ? []
+          : feeds
+              .filter((feed) => filter.contains(feed.displaySymbol, search))
+              .map((feed) => ({
+                type: ResultType.PriceFeed as const,
+                ...feed,
+              }))),
+        ...(type === ResultType.PriceFeed
+          ? []
+          : publishers
+              .filter(
+                (publisher) =>
+                  filter.contains(publisher.id, search) ||
+                  (publisher.name && filter.contains(publisher.name, search)),
+              )
+              .map((publisher) => ({
+                type: ResultType.Publisher as const,
+                ...publisher,
+              }))),
+      ].sort((a, b) =>
+        collator.compare(
+          a.type === ResultType.PriceFeed ? a.displaySymbol : (a.name ?? a.id),
+          b.type === ResultType.PriceFeed ? b.displaySymbol : (b.name ?? b.id),
+        ),
+      ),
+    [feeds, publishers, collator, filter, search, type],
+  );
+
+  return (
+    <>
+      <SearchDialogOpenContext value={searchDialogState}>
+        {children}
+      </SearchDialogOpenContext>
+      <ModalDialog
+        key="search-modal"
+        isOpen={searchDialogState.isOpen}
+        onOpenChange={handleOpenChange}
+        overlayVariants={{
+          unmounted: { backgroundColor: "#00000000" },
+          hidden: { backgroundColor: "#00000000" },
+          visible: { backgroundColor: "#00000080" },
+        }}
+        overlayClassName={styles.modalOverlay ?? ""}
+        variants={{
+          visible: {
+            y: 0,
+            transition: { type: "spring", duration: 0.8, bounce: 0.35 },
+          },
+          hidden: {
+            y: "calc(-100% - 8rem)",
+            transition: { ease: "linear", duration: CLOSE_DURATION_IN_SECONDS },
+          },
+          unmounted: {
+            y: "calc(-100% - 8rem)",
+          },
+        }}
+        className={styles.searchMenu ?? ""}
+        aria-label="Search"
+      >
+        <div className={styles.searchBar}>
+          <SearchInput
+            size="md"
+            width={90}
+            placeholder="Asset symbol, publisher name or id"
+            value={search}
+            onChange={setSearch}
+            // eslint-disable-next-line jsx-a11y/no-autofocus
+            autoFocus
+          />
+          <SingleToggleGroup
+            selectedKeys={[type]}
+            // @ts-expect-error react-aria coerces everything to Key for some reason...
+            onSelectionChange={updateSelectedType}
+            items={[
+              { id: "", children: "All" },
+              { id: ResultType.PriceFeed, children: "Price Feeds" },
+              { id: ResultType.Publisher, children: "Publishers" },
+            ]}
+          />
+          <Button
+            className={styles.closeButton ?? ""}
+            beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+            slot="close"
+            hideText
+            rounded
+            variant="ghost"
+            size="sm"
+          >
+            Close
+          </Button>
+        </div>
+        <div className={styles.body}>
+          <Virtualizer layout={new ListLayout()}>
+            <ListBox
+              aria-label="Search"
+              items={results}
+              className={styles.listbox ?? ""}
+              // eslint-disable-next-line jsx-a11y/no-autofocus
+              autoFocus={false}
+              // @ts-expect-error looks like react-aria isn't exposing this
+              // property in the typescript types correctly...
+              shouldFocusOnHover
+              onAction={close}
+              renderEmptyState={() => (
+                <NoResults
+                  query={search}
+                  onClearSearch={() => {
+                    setSearch("");
+                  }}
+                />
+              )}
+            >
+              {(result) => (
+                <ListBoxItem
+                  textValue={
+                    result.type === ResultType.PriceFeed
+                      ? result.displaySymbol
+                      : (result.name ?? result.id)
+                  }
+                  className={styles.item ?? ""}
+                  href={`${result.type === ResultType.PriceFeed ? "/price-feeds" : "/publishers"}/${encodeURIComponent(result.id)}`}
+                  data-is-first={result.id === results[0]?.id ? "" : undefined}
+                >
+                  <div className={styles.itemType}>
+                    <Badge
+                      variant={
+                        result.type === ResultType.PriceFeed
+                          ? "warning"
+                          : "info"
+                      }
+                      style="filled"
+                      size="xs"
+                    >
+                      {result.type === ResultType.PriceFeed
+                        ? "PRICE FEED"
+                        : "PUBLISHER"}
+                    </Badge>
+                  </div>
+                  {result.type === ResultType.PriceFeed ? (
+                    <>
+                      <PriceFeedTag
+                        compact
+                        symbol={result.displaySymbol}
+                        icon={result.icon}
+                        className={styles.itemTag}
+                      />
+                      <Badge variant="neutral" style="outline" size="xs">
+                        {result.assetClass.toUpperCase()}
+                      </Badge>
+                    </>
+                  ) : (
+                    <>
+                      <PublisherTag
+                        className={styles.itemTag}
+                        compact
+                        publisherKey={result.id}
+                        {...(result.name && {
+                          name: result.name,
+                          icon: result.icon,
+                        })}
+                      />
+                      <Score score={result.medianScore} />
+                    </>
+                  )}
+                </ListBoxItem>
+              )}
+            </ListBox>
+          </Virtualizer>
+        </div>
+      </ModalDialog>
+    </>
+  );
+};
+
+enum ResultType {
+  PriceFeed,
+  Publisher,
+}
+
+const useSearchDialogStateContext = () => {
+  const [isOpen, setIsOpen] = useState(false);
+  const toggleIsOpen = useCallback(() => {
+    setIsOpen((value) => !value);
+  }, [setIsOpen]);
+  const close = useCallback(() => {
+    setIsOpen(false);
+  }, [setIsOpen]);
+  const open = useCallback(() => {
+    setIsOpen(true);
+  }, [setIsOpen]);
+
+  const handleKeyDown = useCallback(
+    (event: KeyboardEvent) => {
+      const activeElement = document.activeElement;
+      const tagName = activeElement?.tagName.toLowerCase();
+      const isEditing =
+        !tagName ||
+        INPUTS.has(tagName) ||
+        (activeElement !== null &&
+          "isContentEditable" in activeElement &&
+          activeElement.isContentEditable);
+      const isSlash = event.key === "/";
+      // Meta key for mac, ctrl key for non-mac
+      const isCtrlK = event.key === "k" && (event.metaKey || event.ctrlKey);
+
+      if (!isEditing && (isSlash || isCtrlK)) {
+        event.preventDefault();
+        toggleIsOpen();
+      }
+    },
+    [toggleIsOpen],
+  );
+
+  useEffect(() => {
+    window.addEventListener("keydown", handleKeyDown);
+    return () => {
+      window.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [handleKeyDown]);
+
+  return {
+    isOpen,
+    setIsOpen,
+    toggleIsOpen,
+    open,
+    close,
+  };
+};
+
+const useSearchDialogState = () => {
+  const value = use(SearchDialogOpenContext);
+  if (value) {
+    return value;
+  } else {
+    throw new NotInitializedError();
+  }
+};
+
+export const useToggleSearchDialog = () => useSearchDialogState().toggleIsOpen;
+
+class NotInitializedError extends Error {
+  constructor() {
+    super("This component must be contained within a <SearchDialogProvider>");
+    this.name = "NotInitializedError";
+  }
+}

+ 1 - 0
packages/component-library/src/Badge/index.module.scss

@@ -7,6 +7,7 @@
   transition-duration: 100ms;
   transition-timing-function: linear;
   border: 1px solid var(--badge-color);
+  white-space: nowrap;
 
   &[data-size="xs"] {
     line-height: theme.spacing(4);

+ 39 - 36
packages/component-library/src/MainNavTabs/index.tsx

@@ -2,7 +2,7 @@
 
 import clsx from "clsx";
 import { motion } from "motion/react";
-import type { ComponentProps } from "react";
+import { type ComponentProps, useId } from "react";
 
 import styles from "./index.module.scss";
 import buttonStyles from "../Button/index.module.scss";
@@ -14,38 +14,41 @@ type OwnProps = {
 };
 type Props = Omit<ComponentProps<typeof TabList>, keyof OwnProps> & OwnProps;
 
-export const MainNavTabs = ({ className, pathname, ...props }: Props) => (
-  <TabList
-    aria-label="Main Navigation"
-    className={clsx(styles.mainNavTabs, className)}
-    dependencies={[pathname]}
-    {...props}
-  >
-    {({ className: tabClassName, children, ...tab }) => (
-      <Tab
-        className={clsx(styles.tab, buttonStyles.button, tabClassName)}
-        data-size="sm"
-        data-variant="ghost"
-        data-rounded
-        data-selectable={pathname === tab.href ? undefined : ""}
-        {...tab}
-      >
-        {(args) => (
-          <>
-            {args.isSelected && (
-              <motion.span
-                layoutId="bubble"
-                className={styles.bubble}
-                transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
-                style={{ originY: "top" }}
-              />
-            )}
-            <span className={buttonStyles.text}>
-              {typeof children === "function" ? children(args) : children}
-            </span>
-          </>
-        )}
-      </Tab>
-    )}
-  </TabList>
-);
+export const MainNavTabs = ({ className, pathname, ...props }: Props) => {
+  const id = useId();
+  return (
+    <TabList
+      aria-label="Main Navigation"
+      className={clsx(styles.mainNavTabs, className)}
+      dependencies={[pathname]}
+      {...props}
+    >
+      {({ className: tabClassName, children, ...tab }) => (
+        <Tab
+          className={clsx(styles.tab, buttonStyles.button, tabClassName)}
+          data-size="sm"
+          data-variant="ghost"
+          data-rounded
+          data-selectable={pathname === tab.href ? undefined : ""}
+          {...tab}
+        >
+          {(args) => (
+            <>
+              {args.isSelected && (
+                <motion.span
+                  layoutId={`${id}-bubble`}
+                  className={styles.bubble}
+                  transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
+                  style={{ originY: "top" }}
+                />
+              )}
+              <span className={buttonStyles.text}>
+                {typeof children === "function" ? children(args) : children}
+              </span>
+            </>
+          )}
+        </Tab>
+      )}
+    </TabList>
+  );
+};

+ 19 - 1
packages/component-library/src/ModalDialog/index.tsx

@@ -16,6 +16,7 @@ import {
   ModalOverlay,
   Dialog,
   DialogTrigger,
+  Select,
 } from "react-aria-components";
 
 import { useSetOverlayVisible } from "../overlay-visible-context.js";
@@ -42,6 +43,23 @@ export const ModalDialogTrigger = (
   );
 };
 
+export const ModalSelect = (props: ComponentProps<typeof DialogTrigger>) => {
+  const [animation, setAnimation] = useState<AnimationState>("unmounted");
+
+  const handleOpenChange = useCallback(
+    (isOpen: boolean) => {
+      setAnimation(isOpen ? "visible" : "hidden");
+    },
+    [setAnimation],
+  );
+
+  return (
+    <ModalAnimationContext value={[animation, setAnimation]}>
+      <Select onOpenChange={handleOpenChange} {...props} />
+    </ModalAnimationContext>
+  );
+};
+
 const ModalAnimationContext = createContext<
   [AnimationState, Dispatch<SetStateAction<AnimationState>>] | undefined
 >(undefined);
@@ -113,7 +131,7 @@ export const ModalDialog = ({
       {...(overlayClassName && { className: overlayClassName })}
       {...(isOpen !== undefined && { isOpen })}
     >
-      <Modal>
+      <Modal style={{ height: 0 }}>
         {(...args) => (
           <MotionDialog {...props}>
             {typeof children === "function" ? children(...args) : children}

+ 1 - 1
packages/component-library/src/SearchInput/index.tsx

@@ -4,7 +4,7 @@ import { CircleNotch } from "@phosphor-icons/react/dist/ssr/CircleNotch";
 import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import clsx from "clsx";
-import { type CSSProperties, type ComponentProps } from "react";
+import type { CSSProperties, ComponentProps } from "react";
 
 import styles from "./index.module.scss";
 import { Button } from "../unstyled/Button/index.js";

+ 51 - 0
packages/component-library/src/SingleToggleGroup/index.module.scss

@@ -0,0 +1,51 @@
+@use "../theme";
+
+.singleToggleGroup {
+  gap: theme.spacing(2);
+
+  @include theme.row;
+
+  .toggleButton {
+    position: relative;
+
+    .bubble {
+      position: absolute;
+      inset: 0;
+      border-radius: theme.button-border-radius("sm");
+      background-color: theme.color("button", "solid", "background", "normal");
+      outline: 4px solid transparent;
+      outline-offset: 0;
+      z-index: -1;
+      transition-property: background-color, outline-color;
+      transition-duration: 100ms;
+      transition-timing-function: linear;
+    }
+
+    &[data-selected] {
+      color: theme.color("button", "solid", "foreground");
+      pointer-events: none;
+
+      &[data-selectable] {
+        pointer-events: auto;
+
+        &[data-hovered] .bubble {
+          background-color: theme.color(
+            "button",
+            "solid",
+            "background",
+            "hover"
+          );
+        }
+
+        &[data-pressed] .bubble {
+          background-color: theme.color(
+            "button",
+            "solid",
+            "background",
+            "active"
+          );
+        }
+      }
+    }
+  }
+}

+ 29 - 0
packages/component-library/src/SingleToggleGroup/index.stories.tsx

@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { SingleToggleGroup as SingleToggleGroupComponent } from "./index.js";
+
+const meta = {
+  component: SingleToggleGroupComponent,
+  argTypes: {
+    items: {
+      table: {
+        disable: true,
+      },
+    },
+    onSelectionChange: {
+      table: {
+        category: "Behavior",
+      },
+    },
+  },
+} satisfies Meta<typeof SingleToggleGroupComponent>;
+export default meta;
+
+export const SingleToggleGroup = {
+  args: {
+    items: [
+      { id: "foo", children: "Foo" },
+      { id: "bar", children: "Bar" },
+    ],
+  },
+} satisfies StoryObj<typeof SingleToggleGroupComponent>;

+ 60 - 0
packages/component-library/src/SingleToggleGroup/index.tsx

@@ -0,0 +1,60 @@
+"use client";
+
+import clsx from "clsx";
+import { motion } from "motion/react";
+import { type ComponentProps, useId } from "react";
+import { ToggleButtonGroup, ToggleButton } from "react-aria-components";
+
+import styles from "./index.module.scss";
+import buttonStyles from "../Button/index.module.scss";
+
+type OwnProps = {
+  items: ComponentProps<typeof ToggleButton>[];
+};
+type Props = Omit<
+  ComponentProps<typeof ToggleButtonGroup>,
+  keyof OwnProps | "selectionMode"
+> &
+  OwnProps;
+
+export const SingleToggleGroup = ({ className, items, ...props }: Props) => {
+  const id = useId();
+
+  return (
+    <ToggleButtonGroup
+      className={clsx(styles.singleToggleGroup, className)}
+      selectionMode="single"
+      {...props}
+    >
+      {items.map(({ className: tabClassName, children, ...toggleButton }) => (
+        <ToggleButton
+          key={toggleButton.id}
+          className={clsx(
+            styles.toggleButton,
+            buttonStyles.button,
+            tabClassName,
+          )}
+          data-size="sm"
+          data-variant="ghost"
+          {...toggleButton}
+        >
+          {(args) => (
+            <>
+              {args.isSelected && (
+                <motion.span
+                  layoutId={`${id}-bubble`}
+                  className={styles.bubble}
+                  transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
+                  style={{ originY: "top" }}
+                />
+              )}
+              <span className={buttonStyles.text}>
+                {typeof children === "function" ? children(args) : children}
+              </span>
+            </>
+          )}
+        </ToggleButton>
+      ))}
+    </ToggleButtonGroup>
+  );
+};

+ 4 - 0
packages/component-library/src/theme.scss

@@ -653,6 +653,10 @@ $button-sizes: (
   @return map-get-strict($button-sizes, $size, "icon-size");
 }
 
+@function button-border-radius($size) {
+  @return map-get-strict($button-sizes, $size, "border-radius");
+}
+
 @mixin sr-only {
   position: absolute;
   width: 1px;