Selaa lähdekoodia

chore(staking): migrate to react-aria

Connor Prussin 1 vuosi sitten
vanhempi
sitoutus
24017becb6

+ 1 - 3
apps/staking/package.json

@@ -24,7 +24,6 @@
     "@amplitude/analytics-browser": "^2.9.3",
     "@amplitude/plugin-autocapture-browser": "^0.9.0",
     "@bonfida/spl-name-service": "^3.0.0",
-    "@headlessui/react": "^2.1.2",
     "@heroicons/react": "^2.1.4",
     "@next/third-parties": "^14.2.5",
     "@pythnetwork/staking-sdk": "workspace:*",
@@ -42,8 +41,7 @@
     "react-aria-components": "^1.3.3",
     "react-dom": "^18.3.1",
     "recharts": "^2.12.7",
-    "swr": "^2.2.5",
-    "zod": "^3.23.8"
+    "swr": "^2.2.5"
   },
   "devDependencies": {
     "@axe-core/react": "^4.9.1",

+ 11 - 0
apps/staking/router.d.ts

@@ -0,0 +1,11 @@
+import "react-aria-components";
+
+declare module "react-aria-components" {
+  import { useRouter } from "next/navigation";
+
+  export type RouterConfig = {
+    routerOptions: NonNullable<
+      Parameters<ReturnType<typeof useRouter>["push"]>[1]
+    >;
+  };
+}

+ 27 - 20
apps/staking/src/components/AccountSummary/index.tsx

@@ -1,11 +1,15 @@
 import Image from "next/image";
-import type { ComponentProps, ReactNode } from "react";
+import { type ComponentProps, type ReactNode, useCallback } from "react";
+import {
+  DialogTrigger,
+  Button as ReactAriaButton,
+} from "react-aria-components";
 
 import background from "./background.png";
 import { deposit, withdraw, claim } from "../../api";
 import { StateType, useTransfer } from "../../hooks/use-transfer";
 import { Button } from "../Button";
-import { Modal, ModalButton, ModalPanel } from "../Modal";
+import { ModalDialog } from "../ModalDialog";
 import { Tokens } from "../Tokens";
 import { TransferButton } from "../TransferButton";
 
@@ -71,14 +75,11 @@ export const AccountSummary = ({
               <Tokens>{locked}</Tokens>
               <div>locked included</div>
             </div>
-            <Modal>
-              <ModalButton
-                as="button"
-                className="mt-1 text-sm text-pythpurple-400 hover:underline"
-              >
+            <DialogTrigger>
+              <ReactAriaButton className="mt-1 text-sm text-pythpurple-400 hover:underline focus:outline-none focus-visible:underline focus-visible:outline-none">
                 Show Unlock Schedule
-              </ModalButton>
-              <ModalPanel
+              </ReactAriaButton>
+              <ModalDialog
                 title="Unlock Schedule"
                 description="Your tokens will become available for withdrawal and for participation in Integrity Staking according to this schedule"
               >
@@ -104,8 +105,8 @@ export const AccountSummary = ({
                     </tbody>
                   </table>
                 </div>
-              </ModalPanel>
-            </Modal>
+              </ModalDialog>
+            </DialogTrigger>
           </>
         )}
         <div className="mt-3 flex flex-row items-center gap-4 sm:mt-8">
@@ -124,13 +125,13 @@ export const AccountSummary = ({
           description="The amount of unlocked tokens that are not staked in either program"
           action={
             <TransferButton
-              small
-              secondary
+              size="small"
+              variant="secondary"
               actionDescription="Move funds from your account back to your wallet"
               actionName="Withdraw"
               max={availableToWithdraw}
               transfer={withdraw}
-              disabled={availableToWithdraw === 0n}
+              isDisabled={availableToWithdraw === 0n}
             />
           }
         />
@@ -138,7 +139,7 @@ export const AccountSummary = ({
           name="Available Rewards"
           amount={availableRewards}
           description="Rewards you have earned from OIS"
-          action={<ClaimButton disabled={availableRewards === 0n} />}
+          action={<ClaimButton isDisabled={availableRewards === 0n} />}
           {...(expiringRewards !== undefined &&
             expiringRewards.amount > 0n && {
               warning: (
@@ -194,13 +195,19 @@ const ClaimButton = (
 ) => {
   const { state, execute } = useTransfer(claim);
 
+  const doClaim = useCallback(() => {
+    execute().catch(() => {
+      /* TODO figure out a better UI treatment for when claim fails */
+    });
+  }, [execute]);
+
   return (
     <Button
-      small
-      secondary
-      onClick={execute}
-      disabled={state.type !== StateType.Base}
-      loading={state.type === StateType.Submitting}
+      size="small"
+      variant="secondary"
+      onPress={doClaim}
+      isDisabled={state.type !== StateType.Base}
+      isLoading={state.type === StateType.Submitting}
       {...props}
     >
       Claim

+ 73 - 23
apps/staking/src/components/Button/index.tsx

@@ -1,33 +1,83 @@
-import type { ButtonHTMLAttributes } from "react";
+"use client";
 
-import { Styled } from "../Styled";
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+import { Button as ReactAriaButton } from "react-aria-components";
 
-type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
-  loading?: boolean | undefined;
-  secondary?: boolean | undefined;
-  small?: boolean | undefined;
-  nopad?: boolean | undefined;
+import { Link } from "../Link";
+
+type VariantProps = {
+  variant?: "secondary" | undefined;
+  size?: "small" | "nopad" | undefined;
 };
 
-const ButtonBase = ({
-  loading,
-  secondary,
-  small,
-  nopad,
-  disabled,
+type ButtonProps = ComponentProps<typeof ReactAriaButton> &
+  VariantProps & {
+    isLoading?: boolean | undefined;
+  };
+
+export const Button = ({
+  isLoading,
+  variant,
+  size,
+  isDisabled,
+  className,
   ...props
-}: Props) => (
-  <button
-    disabled={loading === true || disabled === true}
-    {...(loading && { "data-loading": "" })}
-    {...(secondary && { "data-secondary": "" })}
-    {...(small && { "data-small": "" })}
-    {...(nopad && { "data-nopad": "" })}
+}: ButtonProps) => (
+  <ReactAriaButton
+    isDisabled={isLoading === true || isDisabled === true}
+    className={clsx(
+      "disabled:border-neutral-50/10 disabled:bg-neutral-50/10 disabled:text-white/60",
+      isLoading ? "cursor-wait" : "disabled:cursor-not-allowed",
+      baseClassName({ variant, size }),
+      className,
+    )}
     {...props}
   />
 );
 
-export const Button = Styled(
-  ButtonBase,
-  "border border-pythpurple-600 bg-pythpurple-600/50 data-[small]:text-sm data-[small]:px-6 data-[small]:py-1 data-[secondary]:bg-pythpurple-600/20 px-2 sm:px-4 md:px-8 py-2 data-[nopad]:px-0 data-[nopad]:py-0 disabled:cursor-not-allowed disabled:bg-neutral-50/10 disabled:border-neutral-50/10 disabled:text-white/60 disabled:data-[loading]:cursor-wait hover:bg-pythpurple-600/60 data-[secondary]:hover:bg-pythpurple-600/60 data-[secondary]:disabled:bg-neutral-50/10 focus-visible:ring-1 focus-visible:ring-pythpurple-400 focus:outline-none",
+type LinkButtonProps = ComponentProps<typeof Link> & VariantProps;
+
+export const LinkButton = ({
+  variant,
+  size,
+  className,
+  ...props
+}: LinkButtonProps) => (
+  <Link
+    className={clsx(baseClassName({ variant, size }), className)}
+    {...props}
+  />
 );
+
+const baseClassName = (props: VariantProps) =>
+  clsx(
+    "border border-pythpurple-600 hover:bg-pythpurple-600/60 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400",
+    variantClassName(props.variant),
+    sizeClassName(props.size),
+  );
+
+const variantClassName = (variant: VariantProps["variant"]) => {
+  switch (variant) {
+    case "secondary": {
+      return "bg-pythpurple-600/20";
+    }
+    case undefined: {
+      return "bg-pythpurple-600/50";
+    }
+  }
+};
+
+const sizeClassName = (size: VariantProps["size"]) => {
+  switch (size) {
+    case "small": {
+      return "text-sm px-2 sm:px-3 py-1";
+    }
+    case "nopad": {
+      return "px-0 py-0";
+    }
+    case undefined: {
+      return "px-2 sm:px-4 md:px-8 py-2";
+    }
+  }
+};

+ 44 - 37
apps/staking/src/components/Dashboard/index.tsx

@@ -1,5 +1,5 @@
-import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
 import { type ComponentProps, useMemo } from "react";
+import { Tabs, TabList, Tab, TabPanel } from "react-aria-components";
 
 import { AccountSummary } from "../AccountSummary";
 import { Governance } from "../Governance";
@@ -117,54 +117,55 @@ export const Dashboard = ({
         availableRewards={availableRewards}
         expiringRewards={expiringRewards}
       />
-      <TabGroup as="section">
-        <TabList className="flex w-full flex-row text-sm font-medium sm:text-base">
-          <DashboardTab>Overview</DashboardTab>
-          <DashboardTab>Governance</DashboardTab>
-          <DashboardTab>
+      <Tabs>
+        <TabList
+          className="mb-8 flex w-full flex-row text-sm font-medium sm:text-base"
+          aria-label="Programs"
+        >
+          <DashboardTab id={TabIds.Overview}>Overview</DashboardTab>
+          <DashboardTab id={TabIds.Governance}>Governance</DashboardTab>
+          <DashboardTab id={TabIds.IntegrityStaking}>
             <span className="sm:hidden">Integrity Staking</span>
             <span className="hidden sm:inline">
               Oracle Integrity Staking (OIS)
             </span>
           </DashboardTab>
         </TabList>
-        <TabPanels className="mt-8">
-          <DashboardTabPanel>
-            <section className="py-20">
-              <p className="text-center">
-                This is an overview of the staking programs
-              </p>
-            </section>
-          </DashboardTabPanel>
-          <DashboardTabPanel>
-            <Governance
-              availableToStake={availableToStakeGovernance}
-              warmup={governance.warmup}
-              staked={governance.staked}
-              cooldown={governance.cooldown}
-              cooldown2={governance.cooldown2}
-            />
-          </DashboardTabPanel>
-          <DashboardTabPanel>
-            <OracleIntegrityStaking
-              availableToStake={availableToStakeIntegrity}
-              locked={locked}
-              warmup={integrityStakingWarmup}
-              staked={integrityStakingStaked}
-              cooldown={integrityStakingCooldown}
-              cooldown2={integrityStakingCooldown2}
-              publishers={integrityStakingPublishers}
-            />
-          </DashboardTabPanel>
-        </TabPanels>
-      </TabGroup>
+        <DashboardTabPanel id={TabIds.Overview}>
+          <section className="py-20">
+            <p className="text-center">
+              This is an overview of the staking programs
+            </p>
+          </section>
+        </DashboardTabPanel>
+        <DashboardTabPanel id={TabIds.Governance}>
+          <Governance
+            availableToStake={availableToStakeGovernance}
+            warmup={governance.warmup}
+            staked={governance.staked}
+            cooldown={governance.cooldown}
+            cooldown2={governance.cooldown2}
+          />
+        </DashboardTabPanel>
+        <DashboardTabPanel id={TabIds.IntegrityStaking}>
+          <OracleIntegrityStaking
+            availableToStake={availableToStakeIntegrity}
+            locked={locked}
+            warmup={integrityStakingWarmup}
+            staked={integrityStakingStaked}
+            cooldown={integrityStakingCooldown}
+            cooldown2={integrityStakingCooldown2}
+            publishers={integrityStakingPublishers}
+          />
+        </DashboardTabPanel>
+      </Tabs>
     </div>
   );
 };
 
 const DashboardTab = Styled(
   Tab,
-  "grow basis-0 border-b border-neutral-600/50 px-4 py-2 focus-visible:outline-none data-[selected]:cursor-default data-[selected]:border-pythpurple-400 data-[selected]:data-[hover]:bg-transparent data-[hover]:text-pythpurple-400 data-[selected]:text-pythpurple-400 data-[focus]:outline-none data-[focus]:ring-1 data-[focus]:ring-pythpurple-400",
+  "grow basis-0 border-b border-neutral-600/50 px-4 py-2 focus-visible:outline-none selected:cursor-default selected:border-pythpurple-400 selected:hover:bg-transparent hover:text-pythpurple-400 selected:text-pythpurple-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400 cursor-pointer text-center",
 );
 
 const DashboardTabPanel = Styled(
@@ -187,3 +188,9 @@ const useIntegrityStakingSum = (
 
 // eslint-disable-next-line unicorn/no-array-reduce
 const bigIntMin = (...args: bigint[]) => args.reduce((m, e) => (e < m ? e : m));
+
+enum TabIds {
+  Overview,
+  Governance,
+  IntegrityStaking,
+}

+ 1 - 1
apps/staking/src/components/Error/index.tsx

@@ -27,7 +27,7 @@ export const Error = ({ error, reset }: Props) => {
       <strong className="mb-8 border border-pythpurple-400/20 bg-pythpurple-600/50 px-1 py-0.5 text-sm opacity-50">
         {error.digest ?? error.message}
       </strong>
-      {reset && <Button onClick={reset}>Reset</Button>}
+      {reset && <Button onPress={reset}>Reset</Button>}
     </main>
   );
 };

+ 3 - 2
apps/staking/src/components/Footer/index.tsx

@@ -7,6 +7,7 @@ import LinkedIn from "./linkedin.svg";
 import Telegram from "./telegram.svg";
 import X from "./x.svg";
 import Youtube from "./youtube.svg";
+import { Link } from "../Link";
 import { MaxWidth } from "../MaxWidth";
 
 const SOCIAL_LINKS = [
@@ -46,7 +47,7 @@ export const Footer = ({
         <div>© 2024 Pyth Data Association</div>
         <div className="relative -right-3 flex h-full items-center">
           {SOCIAL_LINKS.map(({ name, icon: Icon, href }) => (
-            <a
+            <Link
               target="_blank"
               href={href}
               key={name}
@@ -55,7 +56,7 @@ export const Footer = ({
             >
               <Icon className="size-4" />
               <span className="sr-only">{name}</span>
-            </a>
+            </Link>
           ))}
         </div>
       </MaxWidth>

+ 1 - 1
apps/staking/src/components/Home/index.tsx

@@ -72,7 +72,7 @@ const NoWalletHome = () => {
         publishers.
       </p>
       <div className="grid w-full place-content-center">
-        <Button onClick={showModal}>Connect your wallet to participate</Button>
+        <Button onPress={showModal}>Connect your wallet to participate</Button>
       </div>
     </main>
   );

+ 3 - 0
apps/staking/src/components/Link/index.tsx

@@ -0,0 +1,3 @@
+"use client";
+
+export { Link } from "react-aria-components";

+ 0 - 145
apps/staking/src/components/Modal/index.tsx

@@ -1,145 +0,0 @@
-import {
-  Dialog,
-  DialogBackdrop,
-  DialogTitle,
-  Description,
-  DialogPanel,
-  CloseButton,
-  Transition,
-} from "@headlessui/react";
-import { XMarkIcon } from "@heroicons/react/24/outline";
-import {
-  type ReactNode,
-  type ComponentProps,
-  type ElementType,
-  type Dispatch,
-  type SetStateAction,
-  useState,
-  useCallback,
-  useContext,
-  createContext,
-} from "react";
-
-import { Button } from "../Button";
-
-const ModalContext = createContext<
-  [boolean, Dispatch<SetStateAction<boolean>>] | undefined
->(undefined);
-
-export const Modal = (
-  props: Omit<ComponentProps<typeof ModalContext.Provider>, "value">,
-) => {
-  const state = useState(false);
-  return <ModalContext.Provider value={state} {...props} />;
-};
-
-const useModalContext = () => {
-  const ctx = useContext(ModalContext);
-  if (ctx === undefined) {
-    throw new ContextNotInitializedError();
-  }
-  return ctx;
-};
-
-class ContextNotInitializedError extends Error {
-  constructor() {
-    super("You cannot use this component outside of a <Modal> parent!");
-  }
-}
-
-type ModalButtonProps<T extends ElementType> = Omit<ComponentProps<T>, "as"> & {
-  as?: T;
-};
-
-export const ModalButton = <T extends ElementType>({
-  as,
-  ...props
-}: ModalButtonProps<T>) => {
-  const Component = as ?? Button;
-  const [, setState] = useModalContext();
-  const toggle = useCallback(() => {
-    setState((cur) => !cur);
-  }, [setState]);
-  return <Component onClick={toggle} {...props} />;
-};
-
-export const ModalPanel = (
-  props: Omit<RawModalProps, "isOpen" | "onClose">,
-) => {
-  const [state, setState] = useModalContext();
-  const onClose = useCallback(() => {
-    setState(false);
-  }, [setState]);
-
-  return <RawModal isOpen={state} onClose={onClose} {...props} />;
-};
-
-type RawModalProps = {
-  isOpen: boolean;
-  onClose: () => void;
-  closeDisabled?: boolean | undefined;
-  afterLeave?: (() => void) | undefined;
-  title: ReactNode | ReactNode[];
-  description?: string;
-  children?:
-    | ((onClose: () => void) => ReactNode | ReactNode[])
-    | ReactNode
-    | ReactNode[]
-    | undefined;
-};
-
-export const RawModal = ({
-  isOpen,
-  onClose,
-  closeDisabled,
-  afterLeave,
-  children,
-  title,
-  description,
-}: RawModalProps) => {
-  const handleClose = useCallback(() => {
-    if (!closeDisabled) {
-      onClose();
-    }
-  }, [closeDisabled, onClose]);
-
-  return (
-    <Transition show={isOpen} {...(afterLeave && { afterLeave })}>
-      <Dialog
-        static
-        open={isOpen}
-        onClose={handleClose}
-        className="relative z-50"
-      >
-        <DialogBackdrop
-          transition
-          className="fixed inset-0 bg-black/30 backdrop-blur duration-300 ease-out data-[closed]:opacity-0"
-        />
-        <div className="fixed inset-0 flex w-screen items-center justify-center p-4">
-          <DialogPanel
-            transition
-            className="relative border border-neutral-600/50 bg-[#100E21] px-6 pb-8 pt-12 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:px-10 sm:pb-12"
-          >
-            <DialogTitle as="h2" className="text-3xl font-light leading-6">
-              {title}
-            </DialogTitle>
-            <CloseButton
-              as={Button}
-              className="absolute right-3 top-3 grid size-10 place-content-center"
-              nopad
-              disabled={closeDisabled ?? false}
-            >
-              <XMarkIcon className="size-6" />
-            </CloseButton>
-            {description && (
-              <Description className="mb-10 mt-2 max-w-96 opacity-60">
-                {description}
-              </Description>
-            )}
-            {typeof children === "function" ? children(handleClose) : children}
-          </DialogPanel>
-        </div>
-      </Dialog>
-    </Transition>
-  );
-};

+ 63 - 0
apps/staking/src/components/ModalDialog/index.tsx

@@ -0,0 +1,63 @@
+import { XMarkIcon } from "@heroicons/react/24/outline";
+import type { ComponentProps, ReactNode } from "react";
+import { Dialog, Heading, Modal, ModalOverlay } from "react-aria-components";
+
+import { Button } from "../Button";
+
+// This type currently isn't exported by react-aria-components, so we reconstruct it here...
+type DialogRenderProps = Parameters<
+  Exclude<ComponentProps<typeof Dialog>["children"], ReactNode>
+>[0];
+
+type ModalDialogProps = Omit<
+  ComponentProps<typeof ModalOverlay>,
+  "children"
+> & {
+  closeDisabled?: boolean | undefined;
+  title: ReactNode | ReactNode[];
+  description?: string;
+  children?:
+    | ((options: DialogRenderProps) => ReactNode | ReactNode[])
+    | ReactNode
+    | ReactNode[]
+    | undefined;
+};
+
+export const ModalDialog = ({
+  closeDisabled,
+  children,
+  title,
+  description,
+  ...props
+}: ModalDialogProps) => (
+  <ModalOverlay
+    isKeyboardDismissDisabled={closeDisabled === true}
+    className="fixed left-0 top-0 z-50 grid h-[var(--visual-viewport-height)] w-screen place-content-center bg-black/30 backdrop-blur data-[entering]:duration-300 data-[exiting]:duration-300 data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:fade-in data-[exiting]:fade-out"
+    isDismissable={!closeDisabled}
+    {...props}
+  >
+    <Modal className="data-[entering]:duration-500 data-[exiting]:duration-300 data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:zoom-in-90 data-[exiting]:zoom-out-110">
+      <Dialog className="relative border border-neutral-600/50 bg-[#100E21] px-6 pb-8 pt-12 focus:outline-none sm:px-10 sm:pb-12">
+        {(options) => (
+          <>
+            <Button
+              onPress={options.close}
+              className="absolute right-3 top-3 grid size-10 place-content-center"
+              size="nopad"
+              isDisabled={closeDisabled ?? false}
+            >
+              <XMarkIcon className="size-6" />
+            </Button>
+            <Heading className="text-3xl font-light leading-6" slot="title">
+              {title}
+            </Heading>
+            {description && (
+              <p className="mb-10 mt-2 max-w-96 opacity-60">{description}</p>
+            )}
+            {typeof children === "function" ? children(options) : children}
+          </>
+        )}
+      </Dialog>
+    </Modal>
+  </ModalOverlay>
+);

+ 5 - 9
apps/staking/src/components/NotFound/index.tsx

@@ -1,17 +1,13 @@
-import Link from "next/link";
-
-import { Button } from "../Button";
+import { LinkButton } from "../Button";
 
 export const NotFound = () => (
   <main className="grid size-full place-content-center text-center">
-    <h1 className="mb-8 text-6xl font-semibold text-pythpurple-400">
+    <h1 className="mb-8 text-4xl font-semibold text-pythpurple-400">
       Not Found
     </h1>
-    <p className="mb-20 text-lg font-medium">
-      {"The page you're looking for isn't here"}
-    </p>
-    <Button as={Link} className="place-self-center px-24 py-3" href="/">
+    <p className="mb-20 text-lg">{"The page you're looking for isn't here"}</p>
+    <LinkButton className="place-self-center px-24 py-3" href="/">
       Go Home
-    </Button>
+    </LinkButton>
   </main>
 );

+ 43 - 38
apps/staking/src/components/OracleIntegrityStaking/index.tsx

@@ -19,6 +19,8 @@ import {
   SearchField,
   Input,
   Button as BaseButton,
+  Meter,
+  Label,
 } from "react-aria-components";
 
 import {
@@ -88,7 +90,7 @@ export const OracleIntegrityStaking = ({
         <div className="relative -mx-4 mt-6 overflow-hidden border-t border-neutral-600/50 pt-6 sm:-mx-10 sm:mt-10">
           <div className="relative w-full overflow-x-auto">
             <h3 className="sticky left-0 mb-4 pl-4 text-2xl font-light sm:pb-4 sm:pl-10 sm:pt-6">
-              You ({self.name})
+              You ({self.name ?? self.publicKey.toBase58()})
             </h3>
 
             <table className="mx-auto border border-neutral-600/50 text-sm">
@@ -323,10 +325,10 @@ const PublisherList = ({
               ) : (
                 <Button
                   key={page}
-                  onClick={() => {
+                  onPress={() => {
                     setPage(page);
                   }}
-                  nopad
+                  size="nopad"
                   className="grid size-8 place-content-center"
                 >
                   {page + 1}
@@ -470,36 +472,39 @@ const Publisher = ({
           </>
         )}
         <PublisherTableCell className="text-center">
-          <div className="relative mx-auto grid h-5 w-52 place-content-center border border-black bg-pythpurple-600/50">
-            <div
-              style={{
-                width: `${utilizationPercent.toString()}%`,
-              }}
-              className={clsx(
-                "absolute inset-0 max-w-full",
-                publisher.poolUtilization > publisher.poolCapacity
-                  ? "bg-fuchsia-900"
-                  : "bg-pythpurple-400",
-              )}
-            />
-            <div
-              className={clsx("isolate text-sm font-medium", {
-                "mix-blend-difference":
-                  publisher.poolUtilization <= publisher.poolCapacity,
-              })}
-            >
-              {utilizationPercent.toString()}%
-            </div>
-          </div>
-          <div className="mt-2 flex flex-row items-center justify-center gap-1 text-sm">
-            <span>
-              <Tokens>{publisher.poolUtilization}</Tokens>
-            </span>
-            <span>/</span>
-            <span>
-              <Tokens>{publisher.poolCapacity}</Tokens>
-            </span>
-          </div>
+          <Meter value={utilizationPercent}>
+            {({ percentage }) => (
+              <>
+                <div className="relative mx-auto grid h-5 w-52 place-content-center border border-black bg-pythpurple-600/50">
+                  <div
+                    style={{
+                      width: `${percentage.toString()}%`,
+                    }}
+                    className={clsx(
+                      "absolute inset-0 max-w-full",
+                      percentage < 100 ? "bg-pythpurple-400" : "bg-fuchsia-900",
+                    )}
+                  />
+                  <div
+                    className={clsx("isolate text-sm font-medium", {
+                      "mix-blend-difference": percentage < 100,
+                    })}
+                  >
+                    {`${utilizationPercent.toString()}%`}
+                  </div>
+                </div>
+                <Label className="mt-1 flex flex-row items-center justify-center gap-1 text-sm">
+                  <span>
+                    <Tokens>{publisher.poolUtilization}</Tokens>
+                  </span>
+                  <span>/</span>
+                  <span>
+                    <Tokens>{publisher.poolCapacity}</Tokens>
+                  </span>
+                </Label>
+              </>
+            )}
+          </Meter>
         </PublisherTableCell>
         <PublisherTableCell className="text-center">
           <div>
@@ -567,8 +572,8 @@ const Publisher = ({
                         })}
                       >
                         <TransferButton
-                          small
-                          secondary
+                          size="small"
+                          variant="secondary"
                           className="w-28"
                           actionDescription={`Cancel tokens that are in warmup for staking to ${publisher.name ?? publisher.publicKey.toBase58()}`}
                           actionName="Cancel"
@@ -594,8 +599,8 @@ const Publisher = ({
                       </td>
                       <td className="py-0.5 text-right">
                         <TransferButton
-                          small
-                          secondary
+                          size="small"
+                          variant="secondary"
                           className="w-28"
                           actionDescription={`Unstake tokens from ${publisher.name ?? publisher.publicKey.toBase58()}`}
                           actionName="Unstake"
@@ -643,7 +648,7 @@ const StakeToPublisherButton = ({
 
   return (
     <TransferButton
-      small
+      size="small"
       actionDescription={`Stake to ${publisherName ?? publisherKey.toBase58()}`}
       actionName="Stake"
       max={availableToStake}

+ 8 - 8
apps/staking/src/components/ProgramSection/index.tsx

@@ -83,7 +83,7 @@ export const ProgramSection = ({
               available > 0n && {
                 actions: (
                   <TransferButton
-                    small
+                    size="small"
                     actionDescription={stakeDescription}
                     actionName="Stake"
                     max={available}
@@ -111,8 +111,8 @@ export const ProgramSection = ({
           ...(cancelWarmup !== undefined && {
             actions: (
               <TransferButton
-                small
-                secondary
+                size="small"
+                variant="secondary"
                 actionDescription={cancelWarmupDescription}
                 actionName="Cancel"
                 submitButtonText="Cancel Warmup"
@@ -134,8 +134,8 @@ export const ProgramSection = ({
           staked > 0n && {
             actions: (
               <TransferButton
-                small
-                secondary
+                size="small"
+                variant="secondary"
                 actionDescription={unstakeDescription}
                 actionName="Unstake"
                 max={staked}
@@ -195,19 +195,19 @@ const Position = ({
 }: PositionProps) => (
   <div
     className={clsx(
-      "w-full overflow-hidden border border-neutral-600/50 bg-pythpurple-800 p-4 sm:p-6",
+      "flex w-full flex-col overflow-hidden border border-neutral-600/50 bg-pythpurple-800 p-4 sm:p-6",
       className,
     )}
   >
     <div
       className={clsx(
-        "mb-2 inline-block border border-neutral-600/50 px-1 py-0.5 text-xs text-neutral-400 sm:px-3 sm:py-1",
+        "mb-2 inline-block flex-none border border-neutral-600/50 px-1 py-0.5 text-xs text-neutral-400 sm:px-3 sm:py-1",
         nameClassName,
       )}
     >
       {name}
     </div>
-    <div className="flex flex-row items-end justify-between gap-6 xl:flex-col xl:items-start">
+    <div className="flex grow flex-row items-end justify-between gap-6 xl:flex-col xl:items-start">
       <div>
         <div>
           <Tokens className="text-xl font-light sm:text-3xl">{children}</Tokens>

+ 33 - 28
apps/staking/src/components/Root/index.tsx

@@ -19,6 +19,7 @@ import { Footer } from "../Footer";
 import { Header } from "../Header";
 import { MaxWidth } from "../MaxWidth";
 import { ReportAccessibility } from "../ReportAccessibility";
+import { RouterProvider } from "../RouterProvider";
 import { WalletProvider } from "../WalletProvider";
 
 const redHatText = Red_Hat_Text({
@@ -36,32 +37,36 @@ type Props = {
 };
 
 export const Root = ({ children }: Props) => (
-  <LoggerProvider>
-    <WalletProvider
-      walletConnectProjectId={WALLETCONNECT_PROJECT_ID}
-      rpc={RPC}
-      network={
-        IS_MAINNET ? WalletAdapterNetwork.Mainnet : WalletAdapterNetwork.Devnet
-      }
-    >
-      <StakeAccountProvider>
-        <html
-          lang="en"
-          dir="ltr"
-          className={clsx(redHatText.variable, redHatMono.variable)}
-        >
-          <body className="grid min-h-dvh grid-rows-[auto_1fr_auto] text-pythpurple-100 [background:radial-gradient(20rem_50rem_at_50rem_10rem,_rgba(119,_49,_234,_0.20)_0%,_rgba(17,_15,_35,_0.00)_100rem),_#0A0814] selection:bg-pythpurple-600/60">
-            <Header className="z-10" />
-            <MaxWidth className="my-4">{children}</MaxWidth>
-            <Footer className="z-10" />
-          </body>
-          {GOOGLE_ANALYTICS_ID && (
-            <GoogleAnalytics gaId={GOOGLE_ANALYTICS_ID} />
-          )}
-          {AMPLITUDE_API_KEY && <Amplitude apiKey={AMPLITUDE_API_KEY} />}
-          {!IS_PRODUCTION_SERVER && <ReportAccessibility />}
-        </html>
-      </StakeAccountProvider>
-    </WalletProvider>
-  </LoggerProvider>
+  <RouterProvider>
+    <LoggerProvider>
+      <WalletProvider
+        walletConnectProjectId={WALLETCONNECT_PROJECT_ID}
+        rpc={RPC}
+        network={
+          IS_MAINNET
+            ? WalletAdapterNetwork.Mainnet
+            : WalletAdapterNetwork.Devnet
+        }
+      >
+        <StakeAccountProvider>
+          <html
+            lang="en"
+            dir="ltr"
+            className={clsx(redHatText.variable, redHatMono.variable)}
+          >
+            <body className="grid min-h-dvh grid-rows-[auto_1fr_auto] text-pythpurple-100 [background:radial-gradient(20rem_50rem_at_50rem_10rem,_rgba(119,_49,_234,_0.20)_0%,_rgba(17,_15,_35,_0.00)_100rem),_#0A0814] selection:bg-pythpurple-600/60">
+              <Header className="z-10" />
+              <MaxWidth className="my-4">{children}</MaxWidth>
+              <Footer className="z-10" />
+            </body>
+            {GOOGLE_ANALYTICS_ID && (
+              <GoogleAnalytics gaId={GOOGLE_ANALYTICS_ID} />
+            )}
+            {AMPLITUDE_API_KEY && <Amplitude apiKey={AMPLITUDE_API_KEY} />}
+            {!IS_PRODUCTION_SERVER && <ReportAccessibility />}
+          </html>
+        </StakeAccountProvider>
+      </WalletProvider>
+    </LoggerProvider>
+  </RouterProvider>
 );

+ 19 - 0
apps/staking/src/components/RouterProvider/index.tsx

@@ -0,0 +1,19 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { type ComponentProps, useCallback } from "react";
+import { RouterProvider as ReactAriaRouterProvider } from "react-aria-components";
+
+export const RouterProvider = (
+  props: Omit<ComponentProps<typeof ReactAriaRouterProvider>, "navigate">,
+) => {
+  const router = useRouter();
+  const navigate = useCallback(
+    (...params: Parameters<typeof router.push>) => {
+      router.push(...params);
+    },
+    [router],
+  );
+
+  return <ReactAriaRouterProvider navigate={navigate} {...props} />;
+};

+ 138 - 142
apps/staking/src/components/TransferButton/index.tsx

@@ -1,24 +1,30 @@
-import { Field, Input, Label } from "@headlessui/react";
 import type { PythStakingClient } from "@pythnetwork/staking-sdk";
 import type { PublicKey } from "@solana/web3.js";
 import {
-  type ChangeEvent,
   type ComponentProps,
   type ReactNode,
+  type FormEvent,
   useCallback,
   useMemo,
   useState,
 } from "react";
+import {
+  DialogTrigger,
+  TextField,
+  Label,
+  Input,
+  Form,
+  Group,
+} from "react-aria-components";
 
-import { useLogger } from "../../hooks/use-logger";
 import { StateType, useTransfer } from "../../hooks/use-transfer";
 import { stringToTokens, tokensToString } from "../../tokens";
 import { Button } from "../Button";
-import { Modal, ModalButton, ModalPanel } from "../Modal";
+import { ModalDialog } from "../ModalDialog";
 import { Tokens } from "../Tokens";
 import PythTokensIcon from "../Tokens/pyth.svg";
 
-type Props = {
+type Props = Omit<ComponentProps<typeof Button>, "children"> & {
   actionName: string;
   actionDescription: string;
   title?: string | undefined;
@@ -34,10 +40,6 @@ type Props = {
     stakingAccount: PublicKey,
     amount: bigint,
   ) => Promise<void>;
-  className?: string | undefined;
-  secondary?: boolean | undefined;
-  small?: boolean | undefined;
-  disabled?: boolean | undefined;
 };
 
 export const TransferButton = ({
@@ -48,128 +50,54 @@ export const TransferButton = ({
   max,
   transfer,
   children,
-  className,
-  secondary,
-  small,
-  disabled,
+  ...props
 }: Props) => {
-  const { amountInput, setAmount, updateAmount, resetAmount, amount } =
-    useAmountInput(max);
-  const doTransfer = useCallback(
-    (client: PythStakingClient, stakingAccount: PublicKey) =>
-      amount.type === AmountType.Valid
-        ? transfer(client, stakingAccount, amount.amount)
-        : Promise.reject(new InvalidAmountError()),
-    [amount, transfer],
-  );
-  const setMax = useCallback(() => {
-    setAmount(tokensToString(max));
-  }, [setAmount, max]);
-
-  const { state, execute } = useTransfer(doTransfer);
-  const isSubmitting = state.type === StateType.Submitting;
+  const [closeDisabled, setCloseDisabled] = useState(false);
 
   return (
-    <Modal>
-      <ModalButton
-        className={className}
-        secondary={secondary}
-        small={small}
-        disabled={disabled}
-      >
-        {actionName}
-      </ModalButton>
-      <ModalPanel
+    <DialogTrigger>
+      <Button {...props}>{actionName}</Button>
+      <ModalDialog
         title={title ?? actionName}
-        closeDisabled={isSubmitting}
+        closeDisabled={closeDisabled}
         description={actionDescription}
-        afterLeave={resetAmount}
       >
-        {(close) => (
-          <>
-            <Field className="mb-8 flex w-full flex-col gap-1 sm:min-w-96">
-              <div className="flex flex-row items-center justify-between">
-                <Label className="text-sm">Amount</Label>
-                <div className="flex flex-row items-center gap-2">
-                  <Tokens>{max}</Tokens>
-                  <span className="text-xs opacity-60">Max</span>
-                </div>
-              </div>
-              <div className="relative w-full">
-                <Input
-                  name="amount"
-                  className="w-full truncate border border-neutral-600/50 bg-transparent py-3 pl-12 pr-24 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400"
-                  value={amountInput}
-                  onChange={updateAmount}
-                  placeholder="0.00"
-                />
-                <div className="pointer-events-none absolute inset-y-0 flex w-full items-center justify-between px-4">
-                  <PythTokensIcon className="size-6" />
-                  <Button
-                    small
-                    secondary
-                    className="pointer-events-auto"
-                    onClick={setMax}
-                    disabled={isSubmitting}
-                  >
-                    max
-                  </Button>
-                </div>
-              </div>
-              {state.type === StateType.Error && (
-                <p className="mt-1 text-red-600">
-                  Uh oh, an error occurred! Please try again
-                </p>
-              )}
-            </Field>
-            {children && (
-              <>
-                {typeof children === "function" ? children(amount) : children}
-              </>
-            )}
-            <ExecuteButton
-              amount={amount}
-              execute={execute}
-              loading={isSubmitting}
-              close={close}
-              className="mt-6 w-full"
-            >
-              {submitButtonText ?? actionName}
-            </ExecuteButton>
-          </>
+        {({ close }) => (
+          <DialogContents
+            max={max}
+            transfer={transfer}
+            setCloseDisabled={setCloseDisabled}
+            submitButtonText={submitButtonText ?? actionName}
+            close={close}
+          >
+            {children}
+          </DialogContents>
         )}
-      </ModalPanel>
-    </Modal>
+      </ModalDialog>
+    </DialogTrigger>
   );
 };
 
-type ExecuteButtonProps = Omit<
-  ComponentProps<typeof Button>,
-  "onClick" | "disabled" | "children"
-> & {
-  children: ReactNode | ReactNode[];
-  amount: Amount;
-  execute: () => Promise<void>;
+type DialogContentsProps = {
+  max: bigint;
+  children: Props["children"];
+  transfer: Props["transfer"];
+  setCloseDisabled: (value: boolean) => void;
+  submitButtonText: string;
   close: () => void;
 };
 
-const ExecuteButton = ({
-  amount,
-  execute,
-  close,
+const DialogContents = ({
+  max,
+  transfer,
   children,
-  ...props
-}: ExecuteButtonProps) => {
-  const logger = useLogger();
-  const handleClick = useCallback(async () => {
-    try {
-      await execute();
-      close();
-    } catch (error: unknown) {
-      logger.error(error);
-    }
-  }, [execute, close, logger]);
-  const contents = useMemo(() => {
+  submitButtonText,
+  setCloseDisabled,
+  close,
+}: DialogContentsProps) => {
+  const { amount, setAmount, setMax, stringValue } = useAmountInput(max);
+
+  const validationError = useMemo(() => {
     switch (amount.type) {
       case AmountType.Empty: {
         return "Enter an amount";
@@ -184,46 +112,114 @@ const ExecuteButton = ({
         return "Enter a valid amount";
       }
       case AmountType.Valid: {
-        return children;
+        return;
       }
     }
-  }, [amount, children]);
+  }, [amount]);
+
+  const doTransfer = useCallback(
+    (client: PythStakingClient, stakingAccount: PublicKey) =>
+      amount.type === AmountType.Valid
+        ? transfer(client, stakingAccount, amount.amount)
+        : Promise.reject(new InvalidAmountError()),
+    [amount, transfer],
+  );
+
+  const { execute, state } = useTransfer(doTransfer);
+
+  const handleSubmit = useCallback(
+    (e: FormEvent<HTMLFormElement>) => {
+      e.preventDefault();
+      setCloseDisabled(true);
+      execute()
+        .then(() => {
+          close();
+        })
+        .catch(() => {
+          /* no-op since this is already handled in the UI using `state` and is logged in useTransfer */
+        })
+        .finally(() => {
+          setCloseDisabled(false);
+        });
+    },
+    [execute, close, setCloseDisabled],
+  );
 
   return (
-    <Button
-      disabled={amount.type !== AmountType.Valid}
-      onClick={handleClick}
-      {...props}
-    >
-      {contents}
-    </Button>
+    <Form onSubmit={handleSubmit}>
+      <TextField
+        // eslint-disable-next-line jsx-a11y/no-autofocus
+        autoFocus
+        isInvalid={validationError !== undefined}
+        value={stringValue}
+        onChange={setAmount}
+        validationBehavior="aria"
+        name="amount"
+        className="mb-8 flex w-full flex-col gap-1 sm:min-w-96"
+      >
+        <div className="flex flex-row items-center justify-between">
+          <Label className="text-sm">Amount</Label>
+          <div className="flex flex-row items-center gap-2">
+            <Tokens>{max}</Tokens>
+            <span className="text-xs opacity-60">Max</span>
+          </div>
+        </div>
+        <Group className="relative w-full">
+          <Input
+            className="focused:outline-none focused:ring-0 focused:border-pythpurple-400 w-full truncate border border-neutral-600/50 bg-transparent py-3 pl-12 pr-24 focus:border-pythpurple-400 focus:outline-none focus:ring-0 focus-visible:border-pythpurple-400 focus-visible:outline-none focus-visible:ring-0"
+            placeholder="0.00"
+          />
+          <div className="pointer-events-none absolute inset-y-0 flex w-full items-center justify-between px-4">
+            <PythTokensIcon className="size-6" />
+            <Button
+              size="small"
+              variant="secondary"
+              className="pointer-events-auto"
+              onPress={setMax}
+              isDisabled={state.type === StateType.Submitting}
+            >
+              max
+            </Button>
+          </div>
+        </Group>
+        {state.type === StateType.Error && (
+          <p className="mt-1 text-red-600">
+            Uh oh, an error occurred! Please try again
+          </p>
+        )}
+      </TextField>
+      {children && (
+        <>{typeof children === "function" ? children(amount) : children}</>
+      )}
+      <Button
+        className="mt-6 w-full"
+        type="submit"
+        isLoading={state.type === StateType.Submitting}
+        isDisabled={amount.type !== AmountType.Valid}
+      >
+        {validationError ?? submitButtonText}
+      </Button>
+    </Form>
   );
 };
 
 const useAmountInput = (max: bigint) => {
-  const [amountInput, setAmountInput] = useState<string>("");
+  const [stringValue, setAmount] = useState<string>("");
 
   return {
-    amountInput,
-
-    setAmount: setAmountInput,
+    stringValue,
 
-    updateAmount: useCallback(
-      (event: ChangeEvent<HTMLInputElement>) => {
-        setAmountInput(event.target.value);
-      },
-      [setAmountInput],
-    ),
+    setAmount,
 
-    resetAmount: useCallback(() => {
-      setAmountInput("");
-    }, [setAmountInput]),
+    setMax: useCallback(() => {
+      setAmount(tokensToString(max));
+    }, [setAmount, max]),
 
     amount: useMemo((): Amount => {
-      if (amountInput === "") {
+      if (stringValue === "") {
         return Amount.Empty();
       } else {
-        const amountAsTokens = stringToTokens(amountInput);
+        const amountAsTokens = stringToTokens(stringValue);
         if (amountAsTokens === undefined) {
           return Amount.Invalid();
         } else if (amountAsTokens > max) {
@@ -234,7 +230,7 @@ const useAmountInput = (max: bigint) => {
           return Amount.Valid(amountAsTokens);
         }
       }
-    }, [amountInput, max]),
+    }, [stringValue, max]),
   };
 };
 

+ 142 - 138
apps/staking/src/components/WalletButton/index.tsx

@@ -1,17 +1,5 @@
 "use client";
 
-import {
-  Menu,
-  MenuButton,
-  MenuItem,
-  MenuItems,
-  MenuSection,
-  MenuSeparator,
-  Listbox,
-  ListboxButton,
-  ListboxOptions,
-  ListboxOption,
-} from "@headlessui/react";
 import {
   WalletIcon,
   ArrowsRightLeftIcon,
@@ -20,6 +8,7 @@ import {
   TableCellsIcon,
   BanknotesIcon,
   ChevronRightIcon,
+  CheckIcon,
 } from "@heroicons/react/24/outline";
 import { useWallet } from "@solana/wallet-adapter-react";
 import { useWalletModal } from "@solana/wallet-adapter-react-ui";
@@ -30,19 +19,26 @@ import {
   type ComponentType,
   type SVGAttributes,
   type ReactNode,
-  type ElementType,
-  type Ref,
   useCallback,
   useMemo,
   useState,
-  forwardRef,
 } from "react";
+import {
+  Menu,
+  MenuItem,
+  MenuTrigger,
+  Popover,
+  Separator,
+  Section,
+  SubmenuTrigger,
+} from "react-aria-components";
 
+import { useLogger } from "../../hooks/use-logger";
 import { usePrimaryDomain } from "../../hooks/use-primary-domain";
 import { StateType, useStakeAccount } from "../../hooks/use-stake-account";
 import { AccountHistory } from "../AccountHistory";
 import { Button } from "../Button";
-import { RawModal } from "../Modal";
+import { ModalDialog } from "../ModalDialog";
 
 type Props = Omit<ComponentProps<typeof Button>, "onClick" | "children">;
 
@@ -56,113 +52,129 @@ export const WalletButton = (props: Props) => {
   );
 };
 
-const ConnectedButton = (props: Props) => {
+const ConnectedButton = ({ className, ...props }: Props) => {
   const [accountHistoryOpen, setAccountHistoryOpen] = useState(false);
-  const openAccountHistory = useCallback(
-    () =>
-      setTimeout(() => {
-        setAccountHistoryOpen(true);
-      }, 300),
-    [setAccountHistoryOpen],
-  );
-  const closeAccountHistory = useCallback(() => {
-    setAccountHistoryOpen(false);
+  const openAccountHistory = useCallback(() => {
+    setAccountHistoryOpen(true);
   }, [setAccountHistoryOpen]);
-
-  const wallet = useWallet();
   const modal = useWalletModal();
   const showModal = useCallback(() => {
     modal.setVisible(true);
   }, [modal]);
   const stakeAccountState = useStakeAccount();
+  const wallet = useWallet();
+  const logger = useLogger();
+  const disconnectWallet = useCallback(() => {
+    wallet.disconnect().catch((error: unknown) => {
+      logger.error(error);
+    });
+  }, [wallet, logger]);
 
   return (
     <>
-      <Menu as="div" className="relative">
-        <MenuButton as="div" className="group">
-          <ButtonComponent
-            className="group-data-[open]:bg-pythpurple-600/60"
-            {...props}
-          >
-            <span className="truncate">
-              <ButtonContent />
-            </span>
-            <ChevronDownIcon className="size-4 flex-none opacity-60 transition duration-300 group-data-[open]:-rotate-180" />
-          </ButtonComponent>
-        </MenuButton>
-        <MenuItems
-          transition
-          anchor="bottom end"
-          className="z-10 flex min-w-[var(--button-width)] origin-top-right flex-col border border-neutral-400 bg-pythpurple-100 py-2 text-sm text-pythpurple-950 shadow shadow-neutral-400 transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] data-[closed]:scale-95 data-[closed]:opacity-0 focus-visible:outline-none"
+      <MenuTrigger>
+        <ButtonComponent
+          className={clsx(
+            "group data-[pressed]:bg-pythpurple-600/60",
+            className,
+          )}
+          {...props}
         >
-          <MenuSection className="flex w-full flex-col">
-            {stakeAccountState.type === StateType.Loaded &&
-              stakeAccountState.allAccounts.length > 1 && (
-                <Listbox
-                  value={stakeAccountState.account}
-                  onChange={stakeAccountState.selectAccount}
+          <span className="truncate">
+            <ButtonContent />
+          </span>
+          <ChevronDownIcon className="size-4 flex-none opacity-60 transition duration-300 group-data-[pressed]:-rotate-180" />
+        </ButtonComponent>
+        <StyledMenu className="min-w-[var(--trigger-width)]">
+          {stakeAccountState.type === StateType.Loaded && (
+            <>
+              <SubmenuTrigger>
+                <WalletMenuItem
+                  icon={BanknotesIcon}
+                  textValue="Select stake account"
                 >
-                  <WalletMenuItem as={ListboxButton} icon={BanknotesIcon}>
-                    <span>Select stake account</span>
-                    <ChevronRightIcon className="size-4" />
-                  </WalletMenuItem>
-                  <ListboxOptions
-                    className="z-10 flex origin-top-right flex-col border border-neutral-400 bg-pythpurple-100 py-2 text-sm text-pythpurple-950 shadow shadow-neutral-400 transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] data-[closed]:scale-95 data-[closed]:opacity-0 focus-visible:outline-none"
-                    anchor="left start"
-                    transition
-                  >
-                    {stakeAccountState.allAccounts.map((account) => (
-                      <WalletMenuItem
-                        as={ListboxOption}
-                        key={account.address.toBase58()}
-                        value={account}
-                        className="cursor-pointer hover:bg-black/5"
-                      >
-                        <pre>{account.address.toBase58()}</pre>
-                      </WalletMenuItem>
-                    ))}
-                  </ListboxOptions>
-                </Listbox>
-              )}
-            <MenuItem>
-              <WalletMenuItem
-                onClick={openAccountHistory}
-                icon={TableCellsIcon}
-              >
-                Account history
-              </WalletMenuItem>
-            </MenuItem>
-          </MenuSection>
-          <MenuSeparator className="mx-2 my-1 h-px bg-black/20" />
-          <MenuSection className="flex w-full flex-col">
-            <MenuItem>
-              <WalletMenuItem onClick={showModal} icon={ArrowsRightLeftIcon}>
-                Change wallet
-              </WalletMenuItem>
-            </MenuItem>
-            <MenuItem>
-              <WalletMenuItem
-                onClick={() => wallet.disconnect()}
-                icon={XCircleIcon}
-              >
-                Disconnect
-              </WalletMenuItem>
-            </MenuItem>
-          </MenuSection>
-        </MenuItems>
-      </Menu>
-      <RawModal
-        isOpen={accountHistoryOpen}
-        onClose={closeAccountHistory}
-        title="Account history"
-        description="A history of events that have affected your account balances"
-      >
-        <AccountHistory />
-      </RawModal>
+                  <span>Select stake account</span>
+                  <ChevronRightIcon className="size-4" />
+                </WalletMenuItem>
+                <StyledMenu
+                  items={stakeAccountState.allAccounts.map((account) => ({
+                    account,
+                    id: account.address.toBase58(),
+                  }))}
+                >
+                  {(item) => (
+                    <WalletMenuItem
+                      onAction={() => {
+                        stakeAccountState.selectAccount(item.account);
+                      }}
+                      className={clsx({
+                        "font-semibold":
+                          item.account === stakeAccountState.account,
+                      })}
+                      isDisabled={item.account === stakeAccountState.account}
+                    >
+                      <CheckIcon
+                        className={clsx("size-4 text-pythpurple-600", {
+                          invisible: item.account !== stakeAccountState.account,
+                        })}
+                      />
+                      <pre>
+                        <TruncatedKey>{item.account.address}</TruncatedKey>
+                      </pre>
+                    </WalletMenuItem>
+                  )}
+                </StyledMenu>
+              </SubmenuTrigger>
+              <Section className="flex w-full flex-col">
+                <WalletMenuItem
+                  onAction={openAccountHistory}
+                  icon={TableCellsIcon}
+                >
+                  Account history
+                </WalletMenuItem>
+              </Section>
+              <Separator className="mx-2 my-1 h-px bg-black/20" />
+            </>
+          )}
+          <Section className="flex w-full flex-col">
+            <WalletMenuItem onAction={showModal} icon={ArrowsRightLeftIcon}>
+              Change wallet
+            </WalletMenuItem>
+            <WalletMenuItem onAction={disconnectWallet} icon={XCircleIcon}>
+              Disconnect
+            </WalletMenuItem>
+          </Section>
+        </StyledMenu>
+      </MenuTrigger>
+      {stakeAccountState.type === StateType.Loaded && (
+        <ModalDialog
+          isOpen={accountHistoryOpen}
+          onOpenChange={setAccountHistoryOpen}
+          title="Account history"
+          description="A history of events that have affected your account balances"
+        >
+          <AccountHistory />
+        </ModalDialog>
+      )}
     </>
   );
 };
 
+const StyledMenu = <T extends object>({
+  className,
+  ...props
+}: ComponentProps<typeof Menu<T>>) => (
+  <Popover className="data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:fade-in data-[exiting]:fade-out focus:outline-none focus-visible:outline-none focus-visible:ring-0">
+    <Menu
+      className={clsx(
+        "flex origin-top-right flex-col border border-neutral-400 bg-pythpurple-100 py-2 text-sm text-pythpurple-950 shadow shadow-neutral-400 focus:outline-none focus-visible:outline-none focus-visible:ring-0",
+        className,
+      )}
+      {...props}
+    />
+  </Popover>
+);
+
 const ButtonContent = () => {
   const wallet = useWallet();
   const primaryDomain = usePrimaryDomain();
@@ -185,34 +197,30 @@ const TruncatedKey = ({ children }: { children: PublicKey | `0x${string}` }) =>
     return asString.slice(0, isHex ? 6 : 4) + ".." + asString.slice(-4);
   }, [children]);
 
-type WalletMenuItemProps<T extends ElementType> = Omit<
-  ComponentProps<T>,
-  "as" | "icon"
-> & {
-  as?: T;
+type WalletMenuItemProps = Omit<ComponentProps<typeof MenuItem>, "children"> & {
   icon?: ComponentType<SVGAttributes<SVGSVGElement>>;
+  children: ReactNode;
 };
 
-const WalletMenuItemImpl = <T extends ElementType>(
-  { as, children, icon: Icon, className, ...props }: WalletMenuItemProps<T>,
-  ref: Ref<HTMLButtonElement>,
-) => {
-  const Component = as ?? "button";
-  return (
-    <Component
-      className={clsx(
-        "flex items-center gap-2 whitespace-nowrap px-4 py-2 text-left data-[focus]:bg-pythpurple-800/20 hover:bg-pythpurple-800/20",
-        className,
-      )}
-      ref={ref}
-      {...props}
-    >
-      {Icon && <Icon className="size-4 text-pythpurple-600" />}
-      {children}
-    </Component>
-  );
-};
-const WalletMenuItem = forwardRef(WalletMenuItemImpl);
+const WalletMenuItem = ({
+  children,
+  icon: Icon,
+  className,
+  textValue,
+  ...props
+}: WalletMenuItemProps) => (
+  <MenuItem
+    textValue={textValue ?? (typeof children === "string" ? children : "")}
+    className={clsx(
+      "flex cursor-pointer items-center gap-2 whitespace-nowrap px-4 py-2 text-left data-[disabled]:cursor-default data-[focused]:bg-pythpurple-800/20 data-[has-submenu]:data-[open]:bg-pythpurple-800/10 data-[has-submenu]:data-[open]:data-[focused]:bg-pythpurple-800/20 focus:outline-none focus-visible:outline-none",
+      className,
+    )}
+    {...props}
+  >
+    {Icon && <Icon className="size-4 text-pythpurple-600" />}
+    {children}
+  </MenuItem>
+);
 
 const DisconnectedButton = (props: Props) => {
   const modal = useWalletModal();
@@ -221,17 +229,13 @@ const DisconnectedButton = (props: Props) => {
   }, [modal]);
 
   return (
-    <ButtonComponent onClick={showModal} {...props}>
+    <ButtonComponent onPress={showModal} {...props}>
       <span>Connect wallet</span>
     </ButtonComponent>
   );
 };
 
-type ButtonComponentProps = Omit<
-  ComponentProps<typeof Button>,
-  "children" | "className"
-> & {
-  className?: string | undefined;
+type ButtonComponentProps = Omit<ComponentProps<typeof Button>, "children"> & {
   children: ReactNode | ReactNode[];
 };
 

+ 4 - 1
apps/staking/src/hooks/use-transfer.ts

@@ -5,6 +5,7 @@ import { useSWRConfig } from "swr";
 
 import { getCacheKey as getAccountHistoryCacheKey } from "./use-account-history";
 import { getCacheKey as getDashboardDataCacheKey } from "./use-dashboard-data";
+import { useLogger } from "./use-logger";
 import { useSelectedStakeAccount } from "./use-stake-account";
 
 export const useTransfer = (
@@ -14,6 +15,7 @@ export const useTransfer = (
   ) => Promise<void>,
 ) => {
   const { client, account } = useSelectedStakeAccount();
+  const logger = useLogger();
   const [state, setState] = useState<State>(State.Base());
   const { mutate } = useSWRConfig();
 
@@ -33,10 +35,11 @@ export const useTransfer = (
       ]);
       setState(State.Complete());
     } catch (error: unknown) {
+      logger.error(error);
       setState(State.ErrorState(error));
       throw error;
     }
-  }, [state, client, account.address, transfer, setState, mutate]);
+  }, [state, client, account.address, transfer, setState, mutate, logger]);
 
   return { state, execute };
 };

+ 61 - 70
pnpm-lock.yaml

@@ -111,7 +111,7 @@ importers:
         version: 4.9.1
       '@cprussin/eslint-config':
         specifier: ^3.0.0
-        version: 3.0.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(typescript@5.5.2))(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))(typescript@5.5.2)
+        version: 3.0.0(@typescript-eslint/eslint-plugin@7.13.1(eslint@9.5.0)(typescript@5.5.2))(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))(typescript@5.5.2)
       '@cprussin/jest-config':
         specifier: ^1.4.1
         version: 1.4.1(@babel/core@7.24.7)(@jest/globals@29.7.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(@types/jest@29.5.12)(@types/node@20.14.7)(babel-jest@29.7.0(@babel/core@7.24.7))(bufferutil@4.0.8)(eslint@9.5.0)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))(utf-8-validate@5.0.10)
@@ -327,9 +327,6 @@ importers:
       '@bonfida/spl-name-service':
         specifier: ^3.0.0
         version: 3.0.1(@solana/web3.js@1.92.3(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10)
-      '@headlessui/react':
-        specifier: ^2.1.2
-        version: 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@heroicons/react':
         specifier: ^2.1.4
         version: 2.1.4(react@18.3.1)
@@ -384,16 +381,13 @@ importers:
       swr:
         specifier: ^2.2.5
         version: 2.2.5(react@18.3.1)
-      zod:
-        specifier: ^3.23.8
-        version: 3.23.8
     devDependencies:
       '@axe-core/react':
         specifier: ^4.9.1
         version: 4.9.1
       '@cprussin/eslint-config':
         specifier: ^3.0.0
-        version: 3.0.0(@typescript-eslint/eslint-plugin@7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))(typescript@5.5.4)
+        version: 3.0.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))(typescript@5.5.4)
       '@cprussin/jest-config':
         specifier: ^1.4.1
         version: 1.4.1(@babel/core@7.24.7)(@jest/globals@29.7.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(@types/jest@29.5.12)(@types/node@22.2.0)(babel-jest@29.7.0(@babel/core@7.24.7))(bufferutil@4.0.8)(eslint@9.9.0(jiti@1.21.0))(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))(utf-8-validate@5.0.10)
@@ -4360,13 +4354,6 @@ packages:
       react: ^18
       react-dom: ^18
 
-  '@headlessui/react@2.1.3':
-    resolution: {integrity: sha512-Nt+NlnQbVvMHVZ/QsST6DNyfG8VWqjOYY3eZpp0PrRKpmZw+pzhwQ1F6wtNaW4jnudeC2a5MJC70vbGVcETNIg==}
-    engines: {node: '>=10'}
-    peerDependencies:
-      react: ^18
-      react-dom: ^18
-
   '@heroicons/react@2.1.4':
     resolution: {integrity: sha512-ju0wj0wwrUTMQ2Yceyrma7TKuI3BpSjp+qKqV81K9KGcUHdvTMdiwfRc2cwXBp3uXtKuDZkh0v03nWOQnJFv2Q==}
     peerDependencies:
@@ -7806,12 +7793,6 @@ packages:
       react: '>=16'
       react-dom: '>=16'
 
-  '@tanstack/react-virtual@3.10.4':
-    resolution: {integrity: sha512-Y2y1QJN3e5gNTG4wlZcoW2IAFrVCuho80oyeODKKFVSbAhJAXmkDNH3ZztM6EQij5ueqpqgz5FlsgKP9TGjImA==}
-    peerDependencies:
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-      react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
-
   '@tanstack/react-virtual@3.5.0':
     resolution: {integrity: sha512-rtvo7KwuIvqK9zb0VZ5IL7fiJAEnG+0EiFZz8FUOs+2mhGqdGmjKIaT1XU7Zq0eFqL0jonLlhbayJI/J2SA/Bw==}
     peerDependencies:
@@ -7822,9 +7803,6 @@ packages:
     resolution: {integrity: sha512-OvKx7v0qJTyPkMhqsWzVPZnARiqdGvDvEg8tZ/GSQs7t0Vb2sdtkGg7cdzUZ67sIg9o5/gSwvvT84ouSaP8euA==}
     engines: {node: '>=12'}
 
-  '@tanstack/virtual-core@3.10.4':
-    resolution: {integrity: sha512-yHyli4RHVsI+eJ0RjmOsjA9RpHp3/Zah9t+iRjmFa72dq00TeG/NwuLYuCV6CB4RkWD4i5RD421j1eb6BdKgvQ==}
-
   '@tanstack/virtual-core@3.5.0':
     resolution: {integrity: sha512-KnPRCkQTyqhanNC0K63GBG3wA8I+D1fQuVnAvcBF8f13akOKeQp1gSbu6f77zCxhEk727iV5oQnbHLYzHrECLg==}
 
@@ -24343,7 +24321,7 @@ snapshots:
     transitivePeerDependencies:
       - debug
 
-  '@cprussin/eslint-config@3.0.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(typescript@5.5.2))(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))(typescript@5.5.2)':
+  '@cprussin/eslint-config@3.0.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))(typescript@5.5.4)':
     dependencies:
       '@babel/core': 7.24.7
       '@babel/eslint-parser': 7.24.7(@babel/core@7.24.7)(eslint@9.5.0)
@@ -24355,22 +24333,22 @@ snapshots:
       eslint: 9.5.0
       eslint-config-prettier: 9.1.0(eslint@9.5.0)
       eslint-config-turbo: 1.13.4(eslint@9.5.0)
-      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)
-      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(typescript@5.5.2)
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.5.0)
+      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.5.0)(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(typescript@5.5.4)
       eslint-plugin-jest-dom: 5.4.0(eslint@9.5.0)
       eslint-plugin-jsonc: 2.16.0(eslint@9.5.0)
       eslint-plugin-jsx-a11y: 6.8.0(eslint@9.5.0)
       eslint-plugin-n: 17.9.0(eslint@9.5.0)
       eslint-plugin-react: 7.34.2(eslint@9.5.0)
       eslint-plugin-react-hooks: 4.6.2(eslint@9.5.0)
-      eslint-plugin-storybook: 0.8.0(eslint@9.5.0)(typescript@5.5.2)
-      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))
-      eslint-plugin-testing-library: 6.2.2(eslint@9.5.0)(typescript@5.5.2)
+      eslint-plugin-storybook: 0.8.0(eslint@9.5.0)(typescript@5.5.4)
+      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))
+      eslint-plugin-testing-library: 6.2.2(eslint@9.5.0)(typescript@5.5.4)
       eslint-plugin-tsdoc: 0.3.0
       eslint-plugin-unicorn: 53.0.0(eslint@9.5.0)
       globals: 15.6.0
-      tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))
-      typescript-eslint: 7.13.1(eslint@9.5.0)(typescript@5.5.2)
+      tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))
+      typescript-eslint: 7.13.1(eslint@9.5.0)(typescript@5.5.4)
     transitivePeerDependencies:
       - '@testing-library/dom'
       - '@typescript-eslint/eslint-plugin'
@@ -24421,7 +24399,7 @@ snapshots:
       - ts-node
       - typescript
 
-  '@cprussin/eslint-config@3.0.0(@typescript-eslint/eslint-plugin@7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))(typescript@5.5.4)':
+  '@cprussin/eslint-config@3.0.0(@typescript-eslint/eslint-plugin@7.13.1(eslint@9.5.0)(typescript@5.5.2))(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))(typescript@5.5.2)':
     dependencies:
       '@babel/core': 7.24.7
       '@babel/eslint-parser': 7.24.7(@babel/core@7.24.7)(eslint@9.5.0)
@@ -24433,22 +24411,22 @@ snapshots:
       eslint: 9.5.0
       eslint-config-prettier: 9.1.0(eslint@9.5.0)
       eslint-config-turbo: 1.13.4(eslint@9.5.0)
-      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)
-      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.3.0(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.5.0)(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(typescript@5.5.4)
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.5.0)
+      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(typescript@5.5.2)
       eslint-plugin-jest-dom: 5.4.0(eslint@9.5.0)
       eslint-plugin-jsonc: 2.16.0(eslint@9.5.0)
       eslint-plugin-jsx-a11y: 6.8.0(eslint@9.5.0)
       eslint-plugin-n: 17.9.0(eslint@9.5.0)
       eslint-plugin-react: 7.34.2(eslint@9.5.0)
       eslint-plugin-react-hooks: 4.6.2(eslint@9.5.0)
-      eslint-plugin-storybook: 0.8.0(eslint@9.5.0)(typescript@5.5.4)
-      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))
-      eslint-plugin-testing-library: 6.2.2(eslint@9.5.0)(typescript@5.5.4)
+      eslint-plugin-storybook: 0.8.0(eslint@9.5.0)(typescript@5.5.2)
+      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))
+      eslint-plugin-testing-library: 6.2.2(eslint@9.5.0)(typescript@5.5.2)
       eslint-plugin-tsdoc: 0.3.0
       eslint-plugin-unicorn: 53.0.0(eslint@9.5.0)
       globals: 15.6.0
-      tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))
-      typescript-eslint: 7.13.1(eslint@9.5.0)(typescript@5.5.4)
+      tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))
+      typescript-eslint: 7.13.1(eslint@9.5.0)(typescript@5.5.2)
     transitivePeerDependencies:
       - '@testing-library/dom'
       - '@typescript-eslint/eslint-plugin'
@@ -25758,15 +25736,6 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
-  '@headlessui/react@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
-    dependencies:
-      '@floating-ui/react': 0.26.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-      '@react-aria/focus': 3.17.1(react@18.3.1)
-      '@react-aria/interactions': 3.21.3(react@18.3.1)
-      '@tanstack/react-virtual': 3.10.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-      react: 18.3.1
-      react-dom: 18.3.1(react@18.3.1)
-
   '@heroicons/react@2.1.4(react@18.3.1)':
     dependencies:
       react: 18.3.1
@@ -32734,12 +32703,6 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
-  '@tanstack/react-virtual@3.10.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
-    dependencies:
-      '@tanstack/virtual-core': 3.10.4
-      react: 18.3.1
-      react-dom: 18.3.1(react@18.3.1)
-
   '@tanstack/react-virtual@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@tanstack/virtual-core': 3.5.0
@@ -32748,8 +32711,6 @@ snapshots:
 
   '@tanstack/table-core@8.7.8': {}
 
-  '@tanstack/virtual-core@3.10.4': {}
-
   '@tanstack/virtual-core@3.5.0': {}
 
   '@terra-money/terra.js@3.1.10':
@@ -34006,7 +33967,7 @@ snapshots:
   '@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.5.0)(typescript@5.5.4)':
     dependencies:
       '@eslint-community/regexpp': 4.10.0
-      '@typescript-eslint/parser': 7.13.1(eslint@9.5.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
       '@typescript-eslint/scope-manager': 7.13.1
       '@typescript-eslint/type-utils': 7.13.1(eslint@9.5.0)(typescript@5.5.4)
       '@typescript-eslint/utils': 7.13.1(eslint@9.5.0)(typescript@5.5.4)
@@ -34021,6 +33982,25 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)':
+    dependencies:
+      '@eslint-community/regexpp': 4.10.0
+      '@typescript-eslint/parser': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
+      '@typescript-eslint/scope-manager': 7.13.1
+      '@typescript-eslint/type-utils': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
+      '@typescript-eslint/utils': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
+      '@typescript-eslint/visitor-keys': 7.13.1
+      eslint: 9.9.0(jiti@1.21.0)
+      graphemer: 1.4.0
+      ignore: 5.3.1
+      natural-compare: 1.4.0
+      ts-api-utils: 1.3.0(typescript@5.5.4)
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - supports-color
+    optional: true
+
   '@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.3.0(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)':
     dependencies:
       '@eslint-community/regexpp': 4.10.0
@@ -34169,14 +34149,14 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4)':
+  '@typescript-eslint/parser@7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)':
     dependencies:
       '@typescript-eslint/scope-manager': 7.13.1
       '@typescript-eslint/types': 7.13.1
       '@typescript-eslint/typescript-estree': 7.13.1(typescript@5.5.4)
       '@typescript-eslint/visitor-keys': 7.13.1
       debug: 4.3.5
-      eslint: 9.5.0
+      eslint: 9.9.0(jiti@1.21.0)
     optionalDependencies:
       typescript: 5.5.4
     transitivePeerDependencies:
@@ -39528,11 +39508,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-module-utils@2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint@9.5.0):
+  eslint-module-utils@2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.5.0):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
-      '@typescript-eslint/parser': 7.13.1(eslint@9.5.0)(typescript@5.5.2)
+      '@typescript-eslint/parser': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
       eslint: 9.5.0
       eslint-import-resolver-node: 0.3.9
     transitivePeerDependencies:
@@ -39555,7 +39535,7 @@ snapshots:
       eslint: 9.5.0
       eslint-compat-utils: 0.5.1(eslint@9.5.0)
 
-  eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0):
+  eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.5.0):
     dependencies:
       array-includes: 3.1.8
       array.prototype.findlastindex: 1.2.5
@@ -39565,7 +39545,7 @@ snapshots:
       doctrine: 2.1.0
       eslint: 9.5.0
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint@9.5.0)
+      eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.5.0)
       hasown: 2.0.2
       is-core-module: 2.13.1
       is-glob: 4.0.3
@@ -39576,7 +39556,7 @@ snapshots:
       semver: 6.3.1
       tsconfig-paths: 3.15.0
     optionalDependencies:
-      '@typescript-eslint/parser': 7.13.1(eslint@9.5.0)(typescript@5.5.2)
+      '@typescript-eslint/parser': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
     transitivePeerDependencies:
       - eslint-import-resolver-typescript
       - eslint-import-resolver-webpack
@@ -39642,13 +39622,13 @@ snapshots:
       eslint: 9.5.0
       requireindex: 1.2.0
 
-  eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(typescript@5.5.2):
+  eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.5.0)(jest@29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4)))(typescript@5.5.4):
     dependencies:
-      '@typescript-eslint/utils': 7.7.1(eslint@9.5.0)(typescript@5.5.2)
+      '@typescript-eslint/utils': 7.7.1(eslint@9.5.0)(typescript@5.5.4)
       eslint: 9.5.0
     optionalDependencies:
-      '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(typescript@5.5.2)
-      jest: 29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))
+      '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
+      jest: 29.7.0(@types/node@22.2.0)(ts-node@10.9.2(@types/node@22.2.0)(typescript@5.5.4))
     transitivePeerDependencies:
       - supports-color
       - typescript
@@ -39664,6 +39644,17 @@ snapshots:
       - supports-color
       - typescript
 
+  eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(jest@29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2)))(typescript@5.5.2):
+    dependencies:
+      '@typescript-eslint/utils': 7.7.1(eslint@9.5.0)(typescript@5.5.2)
+      eslint: 9.5.0
+    optionalDependencies:
+      '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.2))(eslint@9.5.0)(typescript@5.5.2)
+      jest: 29.7.0(@types/node@20.14.7)(ts-node@10.9.2(@types/node@20.14.7)(typescript@5.5.2))
+    transitivePeerDependencies:
+      - supports-color
+      - typescript
+
   eslint-plugin-jsonc@2.16.0(eslint@9.5.0):
     dependencies:
       '@eslint-community/eslint-utils': 4.4.0(eslint@9.5.0)
@@ -50896,7 +50887,7 @@ snapshots:
   typescript-eslint@7.13.1(eslint@9.5.0)(typescript@5.5.4):
     dependencies:
       '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.5.0)(typescript@5.5.4))(eslint@9.5.0)(typescript@5.5.4)
-      '@typescript-eslint/parser': 7.13.1(eslint@9.5.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 7.13.1(eslint@9.9.0(jiti@1.21.0))(typescript@5.5.4)
       '@typescript-eslint/utils': 7.13.1(eslint@9.5.0)(typescript@5.5.4)
       eslint: 9.5.0
     optionalDependencies: