Skip to content

Commit

Permalink
chore: add Toggle and ToggleGroup
Browse files Browse the repository at this point in the history
  • Loading branch information
dominik-stumpf committed Nov 21, 2024
1 parent 30cfe33 commit 1693802
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 1 deletion.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
125 changes: 125 additions & 0 deletions src/app/explorer/_components/StickyBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToggleGroup
type="single"
className="gap-2"
size={isSearchStuck ? "sm" : "lg"}
variant={isNavStuck ? "secondary" : "mono"}
onValueChange={(value) =>
value &&
setActiveSection(
value as (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION],
)
}
value={activeSection}
>
<ToggleGroupItem
value={ACTIVE_SECTION.yourGuilds}
className={cn("rounded-xl transition-all", {
"rounded-lg": isSearchStuck,
})}
onClick={() => smoothScrollTo(ACTIVE_SECTION.yourGuilds)}
>
Your guilds
</ToggleGroupItem>
<ToggleGroupItem
value={ACTIVE_SECTION.exploreGuilds}
className={cn("rounded-xl transition-all", {
"rounded-lg": isSearchStuck,
})}
onClick={() => smoothScrollTo(ACTIVE_SECTION.exploreGuilds)}
>
Explore guilds
</ToggleGroupItem>
</ToggleGroup>
);
};

const CreateGuildLink = () => {
const isNavStuck = useAtomValue(isNavStuckAtom);
return (
<Link
href="/create-guild"
aria-label="Create guild"
prefetch={false}
className={buttonVariants({
variant: "ghost",
size: "sm",
className: [
// Temporarily, until we don't migrate the scrollable Tabs component
"min-h-11 w-11 gap-1.5 px-0 sm:min-h-0 sm:w-auto sm:px-3",
{
"text-white": !isNavStuck,
},
],
})}
>
<Plus />
<span className="hidden sm:inline-block">Create guild</span>
</Link>
);
};

export const StickyBar = () => {
//const { isWeb3Connected } = useWeb3ConnectionManager()
const setIsNavStuck = useSetAtom(isNavStuckAtom);
const isSearchStuck = useAtomValue(isSearchStuckAtom);
const { ref: navToggleRef } = useIsStuck(setIsNavStuck);

return (
<div
className={cn(
"sticky top-0 z-10 flex h-16 w-full items-center transition-all",
{
"h-12": isSearchStuck,
},
)}
ref={navToggleRef}
>
<div className="relative flex w-full items-center justify-between">
<Nav />
<AuthBoundary fallback={null}>
<CreateGuildLink />
</AuthBoundary>
</div>
</div>
);
};
4 changes: 4 additions & 0 deletions src/app/explorer/atoms.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { atom } from "jotai";
import { ACTIVE_SECTION } from "./constants";

export const searchAtom = atom<string | undefined>(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);
2 changes: 1 addition & 1 deletion src/app/explorer/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ export const PAGE_SIZE = 24;
export const ACTIVE_SECTION = {
yourGuilds: "your-guilds",
exploreGuilds: "explore-guilds",
};
} as const;
51 changes: 51 additions & 0 deletions src/components/ui/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof TogglePrimitive.Root>,
ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));

Toggle.displayName = TogglePrimitive.Root.displayName;

export { Toggle, toggleVariants };
64 changes: 64 additions & 0 deletions src/components/ui/ToggleGroup.tsx
Original file line number Diff line number Diff line change
@@ -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<VariantProps<typeof toggleVariants>>({
size: "md",
variant: "secondary",
});

const ToggleGroup = forwardRef<
ElementRef<typeof ToggleGroupPrimitive.Root>,
ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex w-max items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));

ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;

const ToggleGroupItem = forwardRef<
ElementRef<typeof ToggleGroupPrimitive.Item>,
ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = useContext(ToggleGroupContext);

return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});

ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;

export { ToggleGroup, ToggleGroupItem };
48 changes: 48 additions & 0 deletions src/hooks/useIsStuck.ts
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<boolean>>,
): { ref: MutableRefObject<null>; isStuck?: boolean } => {
const ref = useRef(null);
const [isStuck, setIsStuckLocal] = useState(false);
const setIsStuckActive = setIsStuck ?? setIsStuckLocal;

useEffect(() => {

Check warning on line 21 in src/hooks/useIsStuck.ts

View workflow job for this annotation

GitHub Actions / quality-assurance

lint/correctness/useExhaustiveDependencies

This hook does not specify all of its dependencies: setIsStuckActive

Check warning on line 21 in src/hooks/useIsStuck.ts

View workflow job for this annotation

GitHub Actions / quality-assurance

lint/correctness/useExhaustiveDependencies

This hook specifies more dependencies than necessary: ref
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;
48 changes: 48 additions & 0 deletions src/hooks/useScrollSpy.ts
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 1693802

Please sign in to comment.