diff --git a/apps/insights/src/components/PublisherTag/index.module.scss b/apps/insights/src/components/PublisherTag/index.module.scss
index ed4e0612ce..439a20e797 100644
--- a/apps/insights/src/components/PublisherTag/index.module.scss
+++ b/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);
+ }
+ }
}
diff --git a/apps/insights/src/components/PublisherTag/index.tsx b/apps/insights/src/components/PublisherTag/index.tsx
index 3553f6a5b9..a5347fc8f9 100644
--- a/apps/insights/src/components/PublisherTag/index.tsx
+++ b/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) => (
{props.isLoading ? (
) : (
{props.icon ?? }
)}
- {props.isLoading ? (
-
- ) : (
- <>
- {props.name ? (
-
- ) : (
-
- )}
- >
- )}
+
);
@@ -52,3 +44,38 @@ const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => (
);
+
+const Contents = (props: Props) => {
+ if (props.isLoading) {
+ return ;
+ } else if (props.compact) {
+ return props.name ? (
+ {props.name}
+ ) : (
+
+ );
+ } else if (props.name) {
+ return (
+
+ );
+ } else {
+ return ;
+ }
+};
+
+const omitKeys = >(
+ obj: T,
+ keys: string[],
+) => {
+ const omitSet = new Set(keys);
+ return Object.fromEntries(
+ Object.entries(obj).filter(([key]) => !omitSet.has(key)),
+ );
+};
diff --git a/apps/insights/src/components/Root/index.tsx b/apps/insights/src/components/Root/index.tsx
index d3d62d4969..6ef9c3a0aa 100644
--- a/apps/insights/src/components/Root/index.tsx
+++ b/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) => (
-
-
-
-
- {children}
-
-
-
-
-);
+export const Root = async ({ children }: Props) => {
+ const [data, publishers] = await Promise.all([getData(), getPublishers()]);
+
+ return (
+
+ ({
+ id: feed.symbol,
+ key: toHex(feed.product.price_account),
+ displaySymbol: feed.product.display_symbol,
+ icon: ,
+ 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),
+ }),
+ };
+ })}
+ >
+
+
+
+ {children}
+
+
+
+
+
+ );
+};
diff --git a/apps/insights/src/components/Root/search-button.tsx b/apps/insights/src/components/Root/search-button.tsx
index cd2f8f7435..d099ba7431 100644
--- a/apps/insights/src/components/Root/search-button.tsx
+++ b/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 = () => (
-
-);
+import { useToggleSearchDialog } from "./search-dialog";
+
+export const SearchButton = () => {
+ const toggleSearchDialog = useToggleSearchDialog();
+ return (
+
+ );
+};
const SearchText = () => {
const isSSR = useIsSSR();
diff --git a/apps/insights/src/components/Root/search-dialog.module.scss b/apps/insights/src/components/Root/search-dialog.module.scss
new file mode 100644
index 0000000000..0f9027c408
--- /dev/null
+++ b/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;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/insights/src/components/Root/search-dialog.tsx b/apps/insights/src/components/Root/search-dialog.tsx
new file mode 100644
index 0000000000..403888bf3b
--- /dev/null
+++ b/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 | 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("");
+ const collator = useCollator();
+ const filter = useFilter({ sensitivity: "base", usage: "search" });
+
+ const updateSelectedType = useCallback((set: Set) => {
+ 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 (
+ <>
+
+ {children}
+
+
+
+
+
+
+
+
+
+ (
+ {
+ setSearch("");
+ }}
+ />
+ )}
+ >
+ {(result) => (
+
+
+
+ {result.type === ResultType.PriceFeed
+ ? "PRICE FEED"
+ : "PUBLISHER"}
+
+
+ {result.type === ResultType.PriceFeed ? (
+ <>
+
+
+ {result.assetClass.toUpperCase()}
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ )}
+
+
+
+
+ >
+ );
+};
+
+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 ");
+ this.name = "NotInitializedError";
+ }
+}
diff --git a/packages/component-library/src/Badge/index.module.scss b/packages/component-library/src/Badge/index.module.scss
index 5e044f619e..7dba861745 100644
--- a/packages/component-library/src/Badge/index.module.scss
+++ b/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);
diff --git a/packages/component-library/src/MainNavTabs/index.tsx b/packages/component-library/src/MainNavTabs/index.tsx
index 9582d8fdde..11282aea82 100644
--- a/packages/component-library/src/MainNavTabs/index.tsx
+++ b/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, keyof OwnProps> & OwnProps;
-export const MainNavTabs = ({ className, pathname, ...props }: Props) => (
-
- {({ className: tabClassName, children, ...tab }) => (
-
- {(args) => (
- <>
- {args.isSelected && (
-
- )}
-
- {typeof children === "function" ? children(args) : children}
-
- >
- )}
-
- )}
-
-);
+export const MainNavTabs = ({ className, pathname, ...props }: Props) => {
+ const id = useId();
+ return (
+
+ {({ className: tabClassName, children, ...tab }) => (
+
+ {(args) => (
+ <>
+ {args.isSelected && (
+
+ )}
+
+ {typeof children === "function" ? children(args) : children}
+
+ >
+ )}
+
+ )}
+
+ );
+};
diff --git a/packages/component-library/src/ModalDialog/index.tsx b/packages/component-library/src/ModalDialog/index.tsx
index 9e4e9e17c2..b74254c055 100644
--- a/packages/component-library/src/ModalDialog/index.tsx
+++ b/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) => {
+ const [animation, setAnimation] = useState("unmounted");
+
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ setAnimation(isOpen ? "visible" : "hidden");
+ },
+ [setAnimation],
+ );
+
+ return (
+
+
+
+ );
+};
+
const ModalAnimationContext = createContext<
[AnimationState, Dispatch>] | undefined
>(undefined);
@@ -113,7 +131,7 @@ export const ModalDialog = ({
{...(overlayClassName && { className: overlayClassName })}
{...(isOpen !== undefined && { isOpen })}
>
-
+
{(...args) => (
{typeof children === "function" ? children(...args) : children}
diff --git a/packages/component-library/src/SearchInput/index.tsx b/packages/component-library/src/SearchInput/index.tsx
index 6d8c8b4c95..815789217e 100644
--- a/packages/component-library/src/SearchInput/index.tsx
+++ b/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";
diff --git a/packages/component-library/src/SingleToggleGroup/index.module.scss b/packages/component-library/src/SingleToggleGroup/index.module.scss
new file mode 100644
index 0000000000..5bc1f0d035
--- /dev/null
+++ b/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"
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/packages/component-library/src/SingleToggleGroup/index.stories.tsx b/packages/component-library/src/SingleToggleGroup/index.stories.tsx
new file mode 100644
index 0000000000..8aa9219291
--- /dev/null
+++ b/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;
+export default meta;
+
+export const SingleToggleGroup = {
+ args: {
+ items: [
+ { id: "foo", children: "Foo" },
+ { id: "bar", children: "Bar" },
+ ],
+ },
+} satisfies StoryObj;
diff --git a/packages/component-library/src/SingleToggleGroup/index.tsx b/packages/component-library/src/SingleToggleGroup/index.tsx
new file mode 100644
index 0000000000..a445f37c10
--- /dev/null
+++ b/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[];
+};
+type Props = Omit<
+ ComponentProps,
+ keyof OwnProps | "selectionMode"
+> &
+ OwnProps;
+
+export const SingleToggleGroup = ({ className, items, ...props }: Props) => {
+ const id = useId();
+
+ return (
+
+ {items.map(({ className: tabClassName, children, ...toggleButton }) => (
+
+ {(args) => (
+ <>
+ {args.isSelected && (
+
+ )}
+
+ {typeof children === "function" ? children(args) : children}
+
+ >
+ )}
+
+ ))}
+
+ );
+};
diff --git a/packages/component-library/src/theme.scss b/packages/component-library/src/theme.scss
index a524b37659..53d53f36cf 100644
--- a/packages/component-library/src/theme.scss
+++ b/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;