From 16938021d42b5669729942f47e0cbb8e8070f284 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Thu, 21 Nov 2024 19:07:21 +0100 Subject: [PATCH] chore: add Toggle and ToggleGroup --- package.json | 2 + src/app/explorer/_components/StickyBar.tsx | 125 +++++++++++++++++++++ src/app/explorer/atoms.ts | 4 + src/app/explorer/constants.ts | 2 +- src/components/ui/Toggle.tsx | 51 +++++++++ src/components/ui/ToggleGroup.tsx | 64 +++++++++++ src/hooks/useIsStuck.ts | 48 ++++++++ src/hooks/useScrollSpy.ts | 48 ++++++++ 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 src/app/explorer/_components/StickyBar.tsx create mode 100644 src/components/ui/Toggle.tsx create mode 100644 src/components/ui/ToggleGroup.tsx create mode 100644 src/hooks/useIsStuck.ts create mode 100644 src/hooks/useScrollSpy.ts diff --git a/package.json b/package.json index 1ddedfcd9d..1caacdcb5f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", "@t3-oss/env-nextjs": "^0.11.1", "@tanstack/react-query": "^5.60.2", "@tanstack/react-query-devtools": "^5.61.0", diff --git a/src/app/explorer/_components/StickyBar.tsx b/src/app/explorer/_components/StickyBar.tsx new file mode 100644 index 0000000000..c551ef5ee4 --- /dev/null +++ b/src/app/explorer/_components/StickyBar.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { AuthBoundary } from "@/components/AuthBoundary"; +//import { useWeb3ConnectionManager } from "@/components/Web3ConnectionManager/hooks/useWeb3ConnectionManager" +import { buttonVariants } from "@/components/ui/Button"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/ToggleGroup"; +import useIsStuck from "@/hooks/useIsStuck"; +import useScrollspy from "@/hooks/useScrollSpy"; +import { cn } from "@/lib/cssUtils"; +import { Plus } from "@phosphor-icons/react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import Link from "next/link"; +import { useEffect } from "react"; +import { activeSectionAtom, isNavStuckAtom, isSearchStuckAtom } from "../atoms"; +import { ACTIVE_SECTION } from "../constants"; + +export const smoothScrollTo = (id: string) => { + const target = document.getElementById(id); + + if (!target) return; + + window.scrollTo({ + behavior: "smooth", + top: target.offsetTop, + }); +}; + +const Nav = () => { + const isNavStuck = useAtomValue(isNavStuckAtom); + const isSearchStuck = useAtomValue(isSearchStuckAtom); + const [activeSection, setActiveSection] = useAtom(activeSectionAtom); + const spyActiveSection = useScrollspy(Object.values(ACTIVE_SECTION), 100); + useEffect(() => { + if (!spyActiveSection) return; + setActiveSection( + spyActiveSection as (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION], + ); + }, [spyActiveSection, setActiveSection]); + + return ( + + value && + setActiveSection( + value as (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION], + ) + } + value={activeSection} + > + smoothScrollTo(ACTIVE_SECTION.yourGuilds)} + > + Your guilds + + smoothScrollTo(ACTIVE_SECTION.exploreGuilds)} + > + Explore guilds + + + ); +}; + +const CreateGuildLink = () => { + const isNavStuck = useAtomValue(isNavStuckAtom); + return ( + + + Create guild + + ); +}; + +export const StickyBar = () => { + //const { isWeb3Connected } = useWeb3ConnectionManager() + const setIsNavStuck = useSetAtom(isNavStuckAtom); + const isSearchStuck = useAtomValue(isSearchStuckAtom); + const { ref: navToggleRef } = useIsStuck(setIsNavStuck); + + return ( +
+
+
+
+ ); +}; diff --git a/src/app/explorer/atoms.ts b/src/app/explorer/atoms.ts index cc0ef0d59c..f354b9ac36 100644 --- a/src/app/explorer/atoms.ts +++ b/src/app/explorer/atoms.ts @@ -1,5 +1,9 @@ import { atom } from "jotai"; +import { ACTIVE_SECTION } from "./constants"; export const searchAtom = atom(undefined); export const isNavStuckAtom = atom(false); export const isSearchStuckAtom = atom(false); +export const activeSectionAtom = atom< + (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION] +>(ACTIVE_SECTION.yourGuilds); diff --git a/src/app/explorer/constants.ts b/src/app/explorer/constants.ts index 3c0383f14b..0548d43ce2 100644 --- a/src/app/explorer/constants.ts +++ b/src/app/explorer/constants.ts @@ -2,4 +2,4 @@ export const PAGE_SIZE = 24; export const ACTIVE_SECTION = { yourGuilds: "your-guilds", exploreGuilds: "explore-guilds", -}; +} as const; diff --git a/src/components/ui/Toggle.tsx b/src/components/ui/Toggle.tsx new file mode 100644 index 0000000000..38139c9378 --- /dev/null +++ b/src/components/ui/Toggle.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { cn } from "@/lib/cssUtils"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { type VariantProps, cva } from "class-variance-authority"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, +} from "react"; + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground font-medium", + { + variants: { + variant: { + secondary: + "text-secondary-foreground hover:bg-secondary data-[state=on]:bg-secondary active:bg-secondary-hover", + primary: + "hover:bg-secondary-hover active:bg-secondary-active data-[state=on]:bg-primary data-[state=on]:text-primary-foreground bg-secondary text-secondary-foreground", + mono: "text-white hover:bg-white/10 data-[state=on]:bg-white/15 hover:text-white data-[state=on]:text-white", + }, + size: { + sm: "h-8 px-2.5", + md: "h-10 px-3", + lg: "h-11 px-5 font-semibold text-base", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "secondary", + size: "md", + }, + }, +); + +const Toggle = forwardRef< + ElementRef, + ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)); + +Toggle.displayName = TogglePrimitive.Root.displayName; + +export { Toggle, toggleVariants }; diff --git a/src/components/ui/ToggleGroup.tsx b/src/components/ui/ToggleGroup.tsx new file mode 100644 index 0000000000..89f7baade1 --- /dev/null +++ b/src/components/ui/ToggleGroup.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { toggleVariants } from "@/components/ui/Toggle"; +import { cn } from "@/lib/cssUtils"; +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import type { VariantProps } from "class-variance-authority"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + createContext, + forwardRef, + useContext, +} from "react"; + +const ToggleGroupContext = createContext>({ + size: "md", + variant: "secondary", +}); + +const ToggleGroup = forwardRef< + ElementRef, + ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/hooks/useIsStuck.ts b/src/hooks/useIsStuck.ts new file mode 100644 index 0000000000..9497b81f35 --- /dev/null +++ b/src/hooks/useIsStuck.ts @@ -0,0 +1,48 @@ +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useEffect, + useRef, + useState, +} from "react"; + +/** + * The IntersectionObserver triggers if the element is off the viewport, so we have + * to set top="-1px" or bottom="-1px" on the sticky element instead of 0 + */ +const useIsStuck = ( + setIsStuck?: Dispatch>, +): { ref: MutableRefObject; isStuck?: boolean } => { + const ref = useRef(null); + const [isStuck, setIsStuckLocal] = useState(false); + const setIsStuckActive = setIsStuck ?? setIsStuckLocal; + + useEffect(() => { + if (!ref.current) return; + const cachedRef = ref.current; + const topOffsetPx = Number.parseInt(getComputedStyle(cachedRef).top) + 1; + const bottomOffsetPx = + Number.parseInt(getComputedStyle(cachedRef).bottom) + 1; + + const observer = new IntersectionObserver( + ([e]) => { + setIsStuckActive( + !e.isIntersecting && + (e.boundingClientRect.top < topOffsetPx || + e.boundingClientRect.bottom > bottomOffsetPx), + ); + }, + { + threshold: [1], + rootMargin: `-${topOffsetPx || 0}px 0px 0px ${bottomOffsetPx || 0}px`, + }, + ); + observer.observe(cachedRef); + return () => observer.unobserve(cachedRef); + }, [ref]); + + return { ref, isStuck: setIsStuck ? undefined : isStuck }; +}; + +export default useIsStuck; diff --git a/src/hooks/useScrollSpy.ts b/src/hooks/useScrollSpy.ts new file mode 100644 index 0000000000..7fb5b65864 --- /dev/null +++ b/src/hooks/useScrollSpy.ts @@ -0,0 +1,48 @@ +import { useLayoutEffect, useState } from "react"; + +// Restrict value to be between the range [0, value] +const clamp = (value: number) => Math.max(0, value); + +// Check if number is between two values +const isBetween = (value: number, floor: number, ceil: number) => + value >= floor && value <= ceil; + +const useScrollspy = (ids: string[], offset = 0) => { + const [activeId, setActiveId] = useState(""); + + useLayoutEffect(() => { + const listener = () => { + const scroll = window.scrollY; + + const position = ids + .map((id) => { + const element = document.getElementById(id); + + if (!element) return { id, top: -1, bottom: -1 }; + + const rect = element.getBoundingClientRect(); + const top = clamp(rect.top + scroll - offset); + const bottom = clamp(rect.bottom + scroll - offset); + + return { id, top, bottom }; + }) + .find(({ top, bottom }) => isBetween(scroll, top, bottom)); + + setActiveId(position?.id || ""); + }; + + listener(); + + window.addEventListener("resize", listener); + window.addEventListener("scroll", listener); + + return () => { + window.removeEventListener("resize", listener); + window.removeEventListener("scroll", listener); + }; + }, [ids, offset]); + + return activeId; +}; + +export default useScrollspy;