diff --git a/js/package.json b/js/package.json index 04cc97713..7170cd88f 100644 --- a/js/package.json +++ b/js/package.json @@ -35,6 +35,7 @@ "dayjs": "^1.11.13", "immer": "^10.1.3", "mantine-form-zod-resolver": "^1.3.0", + "motion": "^12.34.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 00f14965b..03a2b094b 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: mantine-form-zod-resolver: specifier: ^1.3.0 version: 1.3.0(@mantine/form@8.1.2(react@18.3.1))(zod@3.25.76) + motion: + specifier: ^12.34.2 + version: 12.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -81,7 +84,7 @@ importers: version: 7.5.2 type-fest: specifier: github:tahminator/type-fest#main - version: https://codeload.github.com/tahminator/type-fest/tar.gz/fa55f482694f080ad051297bbb53f42bbbe4ae4b + version: https://codeload.github.com/tahminator/type-fest/tar.gz/35da6f774e9b44fa2a91c8a127de3bda32a13543 use-debounce: specifier: ^10.0.4 version: 10.0.4(react@18.3.1) @@ -1906,6 +1909,20 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + framer-motion@12.34.2: + resolution: {integrity: sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2601,6 +2618,26 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + motion-dom@12.34.2: + resolution: {integrity: sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + + motion@12.34.2: + resolution: {integrity: sha512-QAthwCtW6N0TpZ+bBmBMzdwuftoay2yFV2DT44jRcUQhPbFPdAX+pjzmIUNM3sMYDD5OAraJagRGAKE8q5OsmA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3373,13 +3410,13 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-fest@5.4.1: - resolution: {integrity: sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==} + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} - type-fest@https://codeload.github.com/tahminator/type-fest/tar.gz/fa55f482694f080ad051297bbb53f42bbbe4ae4b: - resolution: {tarball: https://codeload.github.com/tahminator/type-fest/tar.gz/fa55f482694f080ad051297bbb53f42bbbe4ae4b} - version: 5.4.1 + type-fest@https://codeload.github.com/tahminator/type-fest/tar.gz/35da6f774e9b44fa2a91c8a127de3bda32a13543: + resolution: {tarball: https://codeload.github.com/tahminator/type-fest/tar.gz/35da6f774e9b44fa2a91c8a127de3bda32a13543} + version: 5.4.4 engines: {node: '>=20'} typed-array-buffer@1.0.3: @@ -5542,6 +5579,15 @@ snapshots: dependencies: is-callable: 1.2.7 + framer-motion@12.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.34.2 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fsevents@2.3.3: optional: true @@ -6369,6 +6415,20 @@ snapshots: dependencies: minipass: 7.1.2 + motion-dom@12.34.2: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + framer-motion: 12.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + ms@2.1.3: {} msw@2.12.7(@types/node@24.0.10)(typescript@5.6.3): @@ -6388,7 +6448,7 @@ snapshots: statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.0 - type-fest: 5.4.1 + type-fest: 5.4.4 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -7245,11 +7305,11 @@ snapshots: type-fest@4.41.0: {} - type-fest@5.4.1: + type-fest@5.4.4: dependencies: tagged-tag: 1.0.0 - type-fest@https://codeload.github.com/tahminator/type-fest/tar.gz/fa55f482694f080ad051297bbb53f42bbbe4ae4b: + type-fest@https://codeload.github.com/tahminator/type-fest/tar.gz/35da6f774e9b44fa2a91c8a127de3bda32a13543: dependencies: tagged-tag: 1.0.0 diff --git a/js/src/app/_component/AboutUs.tsx b/js/src/app/_component/AboutUs.tsx index b499b0b2b..8706a1160 100644 --- a/js/src/app/_component/AboutUs.tsx +++ b/js/src/app/_component/AboutUs.tsx @@ -39,14 +39,6 @@ export default function AboutUs() { direction={"column"} w={"50%"} > - - Celebrating CodeBloom's 1 Year Anniversary! 🎉 - Level Up Your Coding with diff --git a/js/src/components/ui/auth/AvatarButton.tsx b/js/src/components/ui/auth/AvatarButton.tsx index 9808d7d8e..d9a6f4adf 100644 --- a/js/src/components/ui/auth/AvatarButton.tsx +++ b/js/src/components/ui/auth/AvatarButton.tsx @@ -20,24 +20,20 @@ export default function AvatarDropdown({ {showInitial ? - + {initial} - : } + : } {schoolFF && ( <> {userId && ( - + My Profile )} - + Settings @@ -47,7 +43,6 @@ export default function AvatarDropdown({ component={Link} to={"/api/auth/logout"} reloadDocument - w-full color="red" > Logout diff --git a/js/src/components/ui/button/transitonal/TransitionalButtons.module.css b/js/src/components/ui/button/transitonal/TransitionalButtons.module.css new file mode 100644 index 000000000..328805df8 --- /dev/null +++ b/js/src/components/ui/button/transitonal/TransitionalButtons.module.css @@ -0,0 +1,20 @@ +.navGroup { + position: relative; +} + +.hoverIndicator, +.activeIndicator { + position: absolute; + height: 100%; + top: 0; + border-radius: var(--mantine-radius-md); + pointer-events: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; +} + +.navButton { + position: relative; + z-index: 1; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/js/src/components/ui/button/transitonal/TransitionalButtons.test.tsx b/js/src/components/ui/button/transitonal/TransitionalButtons.test.tsx new file mode 100644 index 000000000..688345fa0 --- /dev/null +++ b/js/src/components/ui/button/transitonal/TransitionalButtons.test.tsx @@ -0,0 +1,263 @@ +import TransitionalButtons from "@/components/ui/button/transitonal/TransitionalButtons"; +import { TestUtils, TestUtilTypes } from "@/lib/test"; +import { theme } from "@/lib/theme"; +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; + +describe("TransitionalButtons", () => { + afterEach(() => { + cleanup(); + }); + + let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null; + beforeEach(() => { + renderProviderFn = TestUtils.getRenderWithAllProvidersFn(); + }); + + it("should render both buttons with links and hrefs", () => { + const buttons = [ + { + to: "/", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.(); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + expect(element.getAttribute("href")?.includes(buttons[0]?.to)).toBe(true); + expect(element.innerHTML.includes(buttons[0]?.label)).toBe(true); + + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.getAttribute("href")?.includes(buttons[1]?.to)).toBe(true); + expect(element2.innerHTML.includes(buttons[1]?.label)).toBe(true); + }); + + it("hovering on first button should make the hover text black", () => { + const buttons = [ + { + to: "/", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.(); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + + fireEvent.mouseEnter(element); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + + expect(element.style.color.includes("rgb(16, 17, 19)")).toBe(true); + + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.style.color.includes("rgb(16, 17, 19)")).toBe(false); + }); + + it("hovering on second button should make hover text black", async () => { + const buttons = [ + { + to: "/home", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.(); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + await waitFor(() => { + fireEvent.mouseEnter(element2); + }); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + expect(element.style.color.includes("rgb(16, 17, 19)")).toBe(false); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.style.color.includes("rgb(16, 17, 19)")).toBe(true); + }); + + it("hovering and unhovering on first button", async () => { + const buttons = [ + { + to: "/home", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.(); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + await waitFor(() => { + fireEvent.mouseEnter(element); + }); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + expect(element.style.color.includes("rgb(16, 17, 19)")).toBe(true); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.style.color.includes("rgb(16, 17, 19)")).toBe(false); + + await waitFor(() => { + fireEvent.mouseLeave(element); + }); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + await waitFor(() => { + expect(element.style.color.includes("rgb(16, 17, 19)")).toBe(false); + }); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + await waitFor(() => { + expect(element2.style.color.includes("rgb(16, 17, 19)")).toBe(false); + }); + }); + + it("clicking on first button should make the select text black", () => { + const buttons = [ + { + to: "/", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.(); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + + fireEvent.mouseDown(element); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + + expect(element.style.color.includes("rgb(16, 17, 19)")).toBe(true); + + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.style.color.includes("rgb(16, 17, 19)")).toBe(false); + }); + + it("clicking on second button should make the select text black", () => { + const buttons = [ + { + to: "/", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.(); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + + fireEvent.click(element2); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + + expect(element.style.color.includes("rgb(16, 17, 19)")).toBe(false); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.style.color.includes("rgb(16, 17, 19)")).toBe(true); + }); + + it("custom select style should surface in the UI", () => { + const buttons = [ + { + to: "/", + label: "Home", + }, + { + to: "/leaderboard", + label: "Leaderboard", + }, + ]; + + renderProviderFn?.( + , + ); + const element = screen.getByTestId( + `transitional-button-${buttons[0]?.label}`, + ); + const element2 = screen.getByTestId( + `transitional-button-${buttons[1]?.label}`, + ); + + fireEvent.click(element2); + + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + + expect(element.style.color.includes("rgb(77, 171, 247)")).toBe(false); + + expect(element2).toBeInTheDocument(); + expect(element2).toBeVisible(); + expect(element2.style.color.includes("rgb(77, 171, 247)")).toBe(true); + }); +}); diff --git a/js/src/components/ui/button/transitonal/TransitionalButtons.tsx b/js/src/components/ui/button/transitonal/TransitionalButtons.tsx new file mode 100644 index 000000000..75d4301e8 --- /dev/null +++ b/js/src/components/ui/button/transitonal/TransitionalButtons.tsx @@ -0,0 +1,184 @@ +import classes from "@/components/ui/button/transitonal/TransitionalButtons.module.css"; +import { theme } from "@/lib/theme"; +import { Box, Button, ButtonProps, Group } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { useEffect, useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { useImmer } from "use-immer"; + +interface StateProps { + background?: string; + color?: string; +} + +interface IndicatorState { + left: number; + width: number; + opacity: number; + textColor?: string; +} + +type LinkLabel = string; +type LinkTo = string; + +interface Links { + to: LinkTo; + label: LinkLabel; +} + +interface TransitionalButtonsProps { + buttons: Links[]; + buttonProps?: ButtonProps; + active?: StateProps; + hover?: StateProps; + default?: StateProps; +} + +export default function TransitionalButtons({ + buttons, + buttonProps, + active = { background: theme.colors.patina[8], color: theme.colors.dark[9] }, + hover = { background: "white", color: theme.colors.dark[9] }, + default: defaultStyle = { color: "white" }, +}: TransitionalButtonsProps) { + // mobile doesnt use these buttons, so when we navigate on mobile + // and then go back to desktop view, it thinks the bounds of the background boxes + // are 0. We can fix that by re-rendering one more time when this + // isMobile boolean changes. + const isMobile = useMediaQuery("(max-width: 768px)"); + const location = useLocation(); + + const [hoverStyle, setHoverStyle] = useImmer({ + left: 0, + width: 0, + opacity: 0, + textColor: defaultStyle.color, + }); + const [activeStyle, setActiveStyle] = useImmer({ + left: 0, + width: 0, + opacity: 0, + textColor: defaultStyle.color, + }); + const [hoveredButton, setHoveredButton] = useState(null); + const parentRef = useRef(null); + const buttonRefs = useRef>(new Map()); + + const handleMouseEnter = ( + e: React.MouseEvent, + to: LinkTo, + ) => { + const button = e.currentTarget; + const parent = parentRef.current; + if (!parent) return; + + setHoveredButton(to); + + const buttonRect = button.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + + setHoverStyle((prev) => { + prev.left = buttonRect.left - parentRect.left; + prev.width = buttonRect.width; + prev.opacity = 1; + prev.textColor = hover.color; + }); + }; + + const handleParentMouseLeave = () => { + setHoveredButton(null); + setHoverStyle((prev) => { + prev.opacity = 0; + prev.textColor = defaultStyle.color; + }); + }; + + useEffect(() => { + const parent = parentRef.current; + if (!parent) return; + + const activeButton = buttonRefs.current.get(location.pathname); + if (activeButton) { + const buttonRect = activeButton.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + + setActiveStyle((prev) => { + prev.left = buttonRect.left - parentRect.left; + prev.width = buttonRect.width; + prev.opacity = 1; + prev.textColor = active.color; + }); + } else { + setActiveStyle((prev) => { + prev.opacity = 0; + prev.textColor = defaultStyle.color; + }); + } + }, [ + location.pathname, + setActiveStyle, + active.background, + active.color, + defaultStyle.color, + isMobile, + ]); + + return ( + + + + {buttons.map(({ to, label }) => { + const isActive = location.pathname === to; + const isHovered = hoveredButton === to; + + const textColor = (() => { + if (isActive) return activeStyle.textColor; + if (isHovered) return hoverStyle.textColor; + return defaultStyle.color; + })(); + + return ( + + ); + })} + + ); +} diff --git a/js/src/components/ui/dropdown/FilterDropdownItem.tsx b/js/src/components/ui/dropdown/FilterDropdownItem.tsx index 222c0157a..50345ae42 100644 --- a/js/src/components/ui/dropdown/FilterDropdownItem.tsx +++ b/js/src/components/ui/dropdown/FilterDropdownItem.tsx @@ -40,7 +40,7 @@ export default function FilterDropdownItem({ disabled={disabled} withThumbIndicator={false} /> - : + : } >
(null); + const { scrollYProgress } = useScroll({ + target: containerRef, + offset: ["start end", "end end"], + }); + + const y = useTransform(scrollYProgress, [0, 1], ["-100%", "0%"]); + return ( -
-
-
- Logo -
- - - { - "CodeBloom is a LeetCode motivation site for Patina Network members." - } - -
- - - - - - - - - - - Privacy Policy - +
+ +
+
+ Logo + +
+ + CodeBloom is a LeetCode motivation site for Patina Network members. + +
+ + + + + + + + + + + Privacy Policy + +
-
+
); } diff --git a/js/src/components/ui/header/Header.module.css b/js/src/components/ui/header/Header.module.css index a329e4089..114aae6c2 100644 --- a/js/src/components/ui/header/Header.module.css +++ b/js/src/components/ui/header/Header.module.css @@ -1,9 +1,9 @@ -header { +.header { display: flex; align-items: center; justify-content: space-between; - border-bottom: solid 1px grey; - padding: 10px; + border-radius: var(--mantine-radius-lg); + gap: 1rem; } header > :nth-child(2) { diff --git a/js/src/components/ui/header/Header.tsx b/js/src/components/ui/header/Header.tsx index e4b57be1a..66077f460 100644 --- a/js/src/components/ui/header/Header.tsx +++ b/js/src/components/ui/header/Header.tsx @@ -1,6 +1,8 @@ import AvatarButton from "@/components/ui/auth/AvatarButton"; import SkeletonButton from "@/components/ui/auth/SkeletonButton"; -import classes from "@/components/ui/header/Header.module.css"; +import TransitionalButtons from "@/components/ui/button/transitonal/TransitionalButtons"; +import HeaderContainer from "@/components/ui/header/container/HeaderContainer"; +import { MM } from "@/components/wrapper"; import { useAuthQuery } from "@/lib/api/queries/auth"; import { Box, @@ -13,8 +15,15 @@ import { Title, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; +import { motion } from "motion/react"; import { Link } from "react-router-dom"; +const navButtons = [ + { to: "/", label: "Home" }, + { to: "/dashboard", label: "Dashboard" }, + { to: "/leaderboard", label: "Leaderboard" }, +]; + export default function Header() { const { data, status } = useAuthQuery(); const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] = @@ -29,7 +38,7 @@ export default function Header() { return Sorry, something went wrong.; } - if (data?.user && data?.session) { + if (data && data.user && data.session) { const profileUrl = data.user.profileUrl; const initial = data.user.nickname ? data.user.nickname.charAt(0).toUpperCase() : "?"; @@ -50,45 +59,52 @@ export default function Header() { }; return ( - -
- - - Logo - - <Text - gradient={{ from: "rgb(75,233,167)", to: "white" }} - variant="gradient" - size="lg" - > - CodeBloom - </Text> - - - - - - - - - - - - - - - {renderButton()} - -
+ <> + + {({ logoSize, textOpacity, textWidth, fontSize }) => ( + <> + + + + + + <MM.Text + gradient={{ from: "patina.4", to: "patina.8" }} + variant="gradient" + size="lg" + style={{ + fontSize, + }} + > + CodeBloom + </MM.Text> + + + + + + + + {renderButton()} + + + )} + - - - Home - - - Dashboard - - - Leaderboard - + + {navButtons.map(({ to, label }) => ( + + ))} {renderButton("w-full")} -
+ ); } diff --git a/js/src/components/ui/header/container/HeaderContainer.test.tsx b/js/src/components/ui/header/container/HeaderContainer.test.tsx new file mode 100644 index 000000000..de62a5647 --- /dev/null +++ b/js/src/components/ui/header/container/HeaderContainer.test.tsx @@ -0,0 +1,57 @@ +import HeaderContainer from "@/components/ui/header/container/HeaderContainer"; +import { TestUtils, TestUtilTypes } from "@/lib/test"; +import { cleanup, screen } from "@testing-library/react"; + +describe("HeaderContainer", () => { + afterEach(() => { + cleanup(); + }); + + let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null; + beforeEach(() => { + renderProviderFn = TestUtils.getRenderWithAllProvidersFn(); + }); + + it("should render header and children", () => { + renderProviderFn?.( + + {() =>
Hello
} +
, + ); + + const header = screen.getByRole("banner"); + expect(header).toBeInTheDocument(); + expect(header).toBeVisible(); + expect(screen.getByTestId("header-child")).toBeInTheDocument(); + screen.debug(); + expect(header.style.background.includes("rgb(48, 48, 48)")).toBe(true); + }); + + it("should pass animation motion values to children", () => { + renderProviderFn?.( + + {(animations) => ( +
+ + {String(animations.logoSize.get())} + + + {String(animations.textOpacity.get())} + + + {String(animations.textWidth.get())} + + + {String(animations.fontSize.get())} + +
+ )} +
, + ); + + expect(screen.getByTestId("logo-size").textContent).toBe("45"); + expect(screen.getByTestId("text-opacity").textContent).toBe("1"); + expect(screen.getByTestId("text-width").textContent).toBe("20rem"); + expect(screen.getByTestId("font-size").textContent).toBe("16px"); + }); +}); diff --git a/js/src/components/ui/header/container/HeaderContainer.tsx b/js/src/components/ui/header/container/HeaderContainer.tsx new file mode 100644 index 000000000..4a6ca97eb --- /dev/null +++ b/js/src/components/ui/header/container/HeaderContainer.tsx @@ -0,0 +1,57 @@ +import classes from "@/components/ui/header/Header.module.css"; +import { theme } from "@/lib/theme"; +import { Box } from "@mantine/core"; +import { motion, MotionValue, useScroll, useTransform } from "motion/react"; +import { ReactNode } from "react"; + +const SCROLL_RANGE: [number, number] = [0, 150]; + +interface HeaderContainerProps { + children: (animations: { + logoSize: MotionValue; + textOpacity: MotionValue; + textWidth: MotionValue; + fontSize: MotionValue; + }) => ReactNode; +} + +export default function HeaderContainer({ children }: HeaderContainerProps) { + const { scrollY } = useScroll(); + + const headerPadding = useTransform(scrollY, SCROLL_RANGE, ["1rem", "1rem"]); + const headerMarginHorizontal = useTransform(scrollY, SCROLL_RANGE, [ + "2%", + "15%", + ]); + const logoSize = useTransform(scrollY, SCROLL_RANGE, [45, 35]); + const textOpacity = useTransform(scrollY, SCROLL_RANGE, [1, 0]); + const textWidth = useTransform(scrollY, SCROLL_RANGE, ["20rem", "0rem"]); + const fontSize = useTransform(scrollY, SCROLL_RANGE, ["16px", "0px"]); + + return ( + + + {children({ logoSize, textOpacity, textWidth, fontSize })} + + + ); +} diff --git a/js/src/components/wrapper/index.tsx b/js/src/components/wrapper/index.tsx new file mode 100644 index 000000000..65ea9a58b --- /dev/null +++ b/js/src/components/wrapper/index.tsx @@ -0,0 +1,9 @@ +import { TextRef } from "@/components/wrapper/text"; +import { motion } from "motion/react"; + +export const Text = motion(TextRef); + +// eslint-disable-next-line react-refresh/only-export-components +export const MM = { + Text, +}; diff --git a/js/src/components/wrapper/text/index.tsx b/js/src/components/wrapper/text/index.tsx new file mode 100644 index 000000000..d9bcbdc89 --- /dev/null +++ b/js/src/components/wrapper/text/index.tsx @@ -0,0 +1,16 @@ +import { Text, TextProps } from "@mantine/core"; +import { forwardRef } from "react"; + +interface ITextProps + extends TextProps, + Omit, keyof TextProps> {} + +export const TextRef = forwardRef( + ({ children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); diff --git a/js/src/lib/theme.tsx b/js/src/lib/theme.tsx index c1b599bb7..ce050a3a9 100644 --- a/js/src/lib/theme.tsx +++ b/js/src/lib/theme.tsx @@ -1,12 +1,13 @@ import { Card, - createTheme, CSSVariablesResolver, DEFAULT_THEME, + MantineThemeOverride, mergeMantineTheme, rem, Tooltip, } from "@mantine/core"; +import { MergeDeep } from "type-fest"; /** * Custom mantine theme override @@ -22,7 +23,7 @@ import { * {@link https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/core/src/core/MantineProvider/default-theme.ts Default Theme} * {@link https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/core/src/core/MantineProvider/default-colors.ts Default Colors} */ -export const themeOverride = createTheme({ +export const themeOverride = { components: { Tooltip: Tooltip.extend({ defaultProps: { @@ -117,6 +118,7 @@ export const themeOverride = createTheme({ xl: "88em", // 1408px - Anything not fullscreen }, other: { + codebloomGray: "#303030", patinaGreenDark: "#03664D", patinaGreenLight: "#4DFFB0", patinaBlueDark: "#1550C4", @@ -125,9 +127,13 @@ export const themeOverride = createTheme({ patinaRedLight: "#FF3D3D", contentContainerWidth: 1200, }, -}); +} as const satisfies MantineThemeOverride; -export const theme = mergeMantineTheme(DEFAULT_THEME, themeOverride); +// mantine types suck :( +export const theme = mergeMantineTheme( + DEFAULT_THEME, + themeOverride, +) as unknown as MergeDeep; /** * Add custom CSS variables to keep consistency across the codebase.