diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx index 8bc4be7c1..2cb70ce03 100644 --- a/packages/widget/src/App.tsx +++ b/packages/widget/src/App.tsx @@ -7,12 +7,11 @@ import { createRef, useImperativeHandle, useState } from "react"; import ReactDOM from "react-dom/client"; import { createMemoryRouter, RouterProvider } from "react-router"; import { preloadImages } from "./assets/images"; -import { Box } from "./components/atoms/box"; import { Dashboard } from "./Dashboard"; import { Providers } from "./providers"; +import { AppContainerProvider } from "./providers/app-container"; import { SettingsContextProvider, useSettings } from "./providers/settings"; import type { SettingsProps, VariantProps } from "./providers/settings/types"; -import { appContainer } from "./style.css"; import { useLoadErrorTranslations } from "./translation"; import { Widget } from "./Widget"; @@ -46,13 +45,11 @@ export const SKApp = (props: SKAppProps) => { return ( - - + ); }; diff --git a/packages/widget/src/components/atoms/virtual-list/index.tsx b/packages/widget/src/components/atoms/virtual-list/index.tsx index 7bbce42e6..a13a6cce2 100644 --- a/packages/widget/src/components/atoms/virtual-list/index.tsx +++ b/packages/widget/src/components/atoms/virtual-list/index.tsx @@ -46,7 +46,7 @@ type VirtualGroupListProps = { estimateSize: VirtualizerOptions["estimateSize"]; } & InfiniteScrollProps; -export const VirtualList = ({ +export const VirtualList = ({ data, itemContent, className, diff --git a/packages/widget/src/hooks/use-split-collapsed.ts b/packages/widget/src/hooks/use-split-collapsed.ts new file mode 100644 index 000000000..ae906ed62 --- /dev/null +++ b/packages/widget/src/hooks/use-split-collapsed.ts @@ -0,0 +1,13 @@ +import { useAppContainerWidth } from "../providers/app-container"; +import { isSplitCollapsedWidth } from "../styles/tokens/breakpoints"; + +/** + * JS counterpart of `splitExpandedContainerQuery`. Both must resolve against + * the app container width so the layout stays consistent when the dashboard is + * embedded in a host that is narrower than the viewport. + */ +export const useSplitCollapsed = () => { + const appContainerWidth = useAppContainerWidth(); + + return appContainerWidth !== null && isSplitCollapsedWidth(appContainerWidth); +}; diff --git a/packages/widget/src/pages-dashboard/common/components/split-view/index.tsx b/packages/widget/src/pages-dashboard/common/components/split-view/index.tsx new file mode 100644 index 000000000..194859aba --- /dev/null +++ b/packages/widget/src/pages-dashboard/common/components/split-view/index.tsx @@ -0,0 +1,90 @@ +import clsx from "clsx"; +import { type ReactNode, useState } from "react"; +import { Box } from "../../../../components/atoms/box"; +import { CaretLeftIcon } from "../../../../components/atoms/icons/caret-left"; +import { useSplitCollapsed } from "../../../../hooks/use-split-collapsed"; +import { VerticalDivider } from "../divider"; +import * as styles from "./styles.css"; + +type Side = "primary" | "secondary"; + +type SplitViewProps = { + primary: ReactNode; + secondary: ReactNode; + primaryBarLabel: string; + secondaryBarLabel: string; +}; + +export const SplitView = ({ + primary, + secondary, + primaryBarLabel, + secondaryBarLabel, +}: SplitViewProps) => { + const isCollapsed = useSplitCollapsed(); + const [activeSide, setActiveSide] = useState("primary"); + + if (!primary || !secondary) { + return {primary || secondary}; + } + + const showPrimary = !isCollapsed || activeSide === "primary"; + const showSecondary = !isCollapsed || activeSide === "secondary"; + const revealLabel = + activeSide === "primary" ? secondaryBarLabel : primaryBarLabel; + + const primaryClass = !isCollapsed + ? styles.panelWrapContents + : showPrimary + ? styles.panelWrapActiveFromLeft + : styles.panelWrapHidden; + + const secondaryClass = !isCollapsed + ? styles.panelWrapContents + : showSecondary + ? styles.panelWrapActiveFromRight + : styles.panelWrapHidden; + + return ( + + {primary} + + {isCollapsed ? ( + + setActiveSide((side) => + side === "primary" ? "secondary" : "primary" + ) + } + > + + + + + + {revealLabel} + + + ) : ( + + )} + + {secondary} + + ); +}; diff --git a/packages/widget/src/pages-dashboard/common/components/split-view/styles.css.ts b/packages/widget/src/pages-dashboard/common/components/split-view/styles.css.ts new file mode 100644 index 000000000..26b2aa272 --- /dev/null +++ b/packages/widget/src/pages-dashboard/common/components/split-view/styles.css.ts @@ -0,0 +1,122 @@ +import { keyframes, style } from "@vanilla-extract/css"; +import { atoms } from "../../../../styles/theme/atoms.css"; +import { vars } from "../../../../styles/theme/contract.css"; +import { OUTLET_PADDING } from "../styles.css"; + +export const container = style({ + alignItems: "stretch", + display: "flex", + flexDirection: "row", + gap: "24px", + justifyContent: "center", + minWidth: 0, + width: "100%", +}); + +export const panelWrapContents = style({ + display: "contents", +}); + +export const panelWrapHidden = style({ + display: "none", +}); + +const reduceMotion = "(prefers-reduced-motion: reduce)"; + +const slideInFromLeft = keyframes({ + from: { opacity: 0, transform: "translateX(-16px)" }, + to: { opacity: 1, transform: "translateX(0)" }, +}); + +const slideInFromRight = keyframes({ + from: { opacity: 0, transform: "translateX(16px)" }, + to: { opacity: 1, transform: "translateX(0)" }, +}); + +const panelWrapActiveBase = style({ + display: "flex", + flex: 1, + flexDirection: "row", + minWidth: 0, +}); + +export const panelWrapActiveFromLeft = style([ + panelWrapActiveBase, + { + animation: `${slideInFromLeft} 260ms ease`, + "@media": { + [reduceMotion]: { animation: "none" }, + }, + }, +]); + +export const panelWrapActiveFromRight = style([ + panelWrapActiveBase, + { + animation: `${slideInFromRight} 260ms ease`, + "@media": { + [reduceMotion]: { animation: "none" }, + }, + }, +]); + +export const bar = style([ + atoms({ + background: "stakeSectionBackground", + }), + { + alignItems: "center", + alignSelf: "stretch", + border: 0, + borderRadius: "10px", + cursor: "pointer", + display: "flex", + flexDirection: "column", + flexShrink: 0, + font: "inherit", + gap: "8px", + justifyContent: "center", + padding: "12px 0", + transition: "background 150ms ease", + width: "30px", + selectors: { + "&:hover": { + background: vars.color.backgroundMuted, + }, + }, + }, +]); + +/** + * Bleed the bar outward into the dashboard outlet padding, leaving an 8px gap + * to the card edge on whichever side it currently lives. + */ +export const barBleedRight = style({ + marginRight: `calc(8px - ${OUTLET_PADDING})`, +}); + +export const barBleedLeft = style({ + marginLeft: `calc(8px - ${OUTLET_PADDING})`, +}); + +export const barIcon = style({ + alignItems: "center", + display: "flex", + justifyContent: "center", + transition: "transform 200ms ease", + "@media": { + [reduceMotion]: { transition: "none" }, + }, +}); + +export const barIconFlipped = style({ + transform: "rotate(180deg)", +}); + +export const barLabel = style({ + fontSize: "13px", + fontWeight: 600, + letterSpacing: "0.02em", + whiteSpace: "nowrap", + writingMode: "vertical-rl", +}); diff --git a/packages/widget/src/pages-dashboard/common/components/styles.css.ts b/packages/widget/src/pages-dashboard/common/components/styles.css.ts index bf83fa854..386c1d9b5 100644 --- a/packages/widget/src/pages-dashboard/common/components/styles.css.ts +++ b/packages/widget/src/pages-dashboard/common/components/styles.css.ts @@ -11,7 +11,9 @@ export const wrapper = recipe({ borderWidth: "1px", borderStyle: "solid", boxShadow: "0px 15px 40px 0px #0000000D", - width: "1000px", + maxWidth: "1000px", + width: "100%", + minWidth: "100%", }, ], variants: { @@ -59,16 +61,6 @@ export const outletWrapper = recipe({ }, }); -export const tabPageContainer = recipe({ - base: { - display: "flex", - flexDirection: "row", - gap: "24px", - alignItems: "stretch", - justifyContent: "center", - }, -}); - export const tabPageDivider = recipe({ base: { alignSelf: "stretch", diff --git a/packages/widget/src/pages-dashboard/common/components/tab-page-container.tsx b/packages/widget/src/pages-dashboard/common/components/tab-page-container.tsx deleted file mode 100644 index d0e4d84ea..000000000 --- a/packages/widget/src/pages-dashboard/common/components/tab-page-container.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import clsx from "clsx"; -import { Box } from "../../../components/atoms/box"; -import { tabPageContainer } from "./styles.css"; - -export const TabPageContainer = ({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) => { - return {children}; -}; diff --git a/packages/widget/src/pages-dashboard/overview/index.tsx b/packages/widget/src/pages-dashboard/overview/index.tsx index 85a4edac7..b91fceba6 100644 --- a/packages/widget/src/pages-dashboard/overview/index.tsx +++ b/packages/widget/src/pages-dashboard/overview/index.tsx @@ -1,38 +1,43 @@ +import { useTranslation } from "react-i18next"; import { Outlet } from "react-router"; import { Box } from "../../components/atoms/box"; import { AnimationPage } from "../../navigation/containers/animation-page"; import { BackButtonProvider } from "../common/components/back-button"; -import { VerticalDivider } from "../common/components/divider"; -import { TabPageContainer } from "../common/components/tab-page-container"; +import { SplitView } from "../common/components/split-view"; import { EarnDetails } from "./earn-details"; import { earnDetailsWrapper } from "./earn-details/styles.css"; import { overviewPageContainer } from "./styles.css"; export const OverviewPage = () => { + const { t } = useTranslation(); + return ( - - - - - - - - - - - - - + + + + + + } + secondary={ + + + + } + /> ); diff --git a/packages/widget/src/pages-dashboard/overview/styles.css.ts b/packages/widget/src/pages-dashboard/overview/styles.css.ts index 7b03c1d32..86cce6ae8 100644 --- a/packages/widget/src/pages-dashboard/overview/styles.css.ts +++ b/packages/widget/src/pages-dashboard/overview/styles.css.ts @@ -1,5 +1,10 @@ import { style } from "@vanilla-extract/css"; +import { splitExpandedContainerQuery } from "../../styles/tokens/breakpoints"; export const overviewPageContainer = style({ - maxWidth: "380px", + "@container": { + [splitExpandedContainerQuery]: { + maxWidth: "380px", + }, + }, }); diff --git a/packages/widget/src/pages-dashboard/position-details/index.tsx b/packages/widget/src/pages-dashboard/position-details/index.tsx index 12d622481..df734cf19 100644 --- a/packages/widget/src/pages-dashboard/position-details/index.tsx +++ b/packages/widget/src/pages-dashboard/position-details/index.tsx @@ -9,8 +9,7 @@ import { BackButton, BackButtonProvider, } from "../common/components/back-button"; -import { VerticalDivider } from "../common/components/divider"; -import { TabPageContainer } from "../common/components/tab-page-container"; +import { SplitView } from "../common/components/split-view"; import { positionDetailsActionsHasContent, positionDetailsStakeHasContent, @@ -53,6 +52,7 @@ const PositionBreadcrumb = ({ }; const PositionDetailsPageComponent = () => { + const { t } = useTranslation(); const positionDetails = usePositionDetails(); const shouldShowActions = positionDetailsActionsHasContent(positionDetails) || @@ -64,45 +64,48 @@ const PositionDetailsPageComponent = () => { return ( - - {shouldShowActions ? ( + + + + + + + + ) : null + } + secondary={ - + {shouldShowActions ? null : ( + + )} - - - + - ) : null} - - {shouldShowActions ? : null} - - - {shouldShowActions ? null : ( - - )} - - - - + } + /> ); }; diff --git a/packages/widget/src/pages-dashboard/position-details/styles.css.ts b/packages/widget/src/pages-dashboard/position-details/styles.css.ts index b4d05886f..503c43740 100644 --- a/packages/widget/src/pages-dashboard/position-details/styles.css.ts +++ b/packages/widget/src/pages-dashboard/position-details/styles.css.ts @@ -1,5 +1,6 @@ import { style } from "@vanilla-extract/css"; import { atoms } from "../../styles/theme/atoms.css"; +import { splitExpandedContainerQuery } from "../../styles/tokens/breakpoints"; export const posistionDetailsInfoContainer = style([ atoms({ @@ -8,12 +9,20 @@ export const posistionDetailsInfoContainer = style([ width: "0", }), { - maxWidth: "600px", + "@container": { + [splitExpandedContainerQuery]: { + maxWidth: "600px", + }, + }, }, ]); export const positionDetailsActionsContainer = style({ - maxWidth: "380px", + "@container": { + [splitExpandedContainerQuery]: { + maxWidth: "380px", + }, + }, }); export const breadcrumb = style([ diff --git a/packages/widget/src/providers/app-container/index.tsx b/packages/widget/src/providers/app-container/index.tsx new file mode 100644 index 000000000..731612d01 --- /dev/null +++ b/packages/widget/src/providers/app-container/index.tsx @@ -0,0 +1,62 @@ +import type { PropsWithChildren } from "react"; +import { createContext, useContext, useLayoutEffect, useState } from "react"; +import { Box } from "../../components/atoms/box"; +import { appContainer } from "../../style.css"; + +type AppContainerVariant = "widget" | "dashboard"; + +/** + * Inline size of the app container, or `null` while it is not measurable yet. + * Layout decisions taken in JS must use this instead of the window size, since + * the app can be embedded in a host that gives it less room than the viewport. + */ +const AppContainerWidthContext = createContext( + undefined +); + +export const AppContainerProvider = ({ + variant, + children, +}: PropsWithChildren<{ variant: AppContainerVariant }>) => { + const [element, setElement] = useState(null); + const [width, setWidth] = useState(null); + + useLayoutEffect(() => { + if (!element || typeof ResizeObserver === "undefined") { + setWidth(null); + return; + } + + setWidth(element.getBoundingClientRect().width); + + const observer = new ResizeObserver(([entry]) => { + if (entry) setWidth(entry.contentRect.width); + }); + + observer.observe(element); + + return () => observer.disconnect(); + }, [element]); + + return ( + + + {children} + + + ); +}; + +export const useAppContainerWidth = () => { + const value = useContext(AppContainerWidthContext); + + if (value === undefined) { + throw new Error("AppContainerProvider not found in the tree"); + } + + return value; +}; diff --git a/packages/widget/src/style.css.ts b/packages/widget/src/style.css.ts index 42be2717b..ec3fb09d6 100644 --- a/packages/widget/src/style.css.ts +++ b/packages/widget/src/style.css.ts @@ -1,10 +1,12 @@ -import { createContainer, style } from "@vanilla-extract/css"; +import { style } from "@vanilla-extract/css"; import { recipe } from "@vanilla-extract/recipes"; import { atoms } from "./styles/theme/atoms.css"; import { minContainerWidth } from "./styles/tokens/breakpoints"; +import { + appContainerName, + widgetContainerName, +} from "./styles/tokens/containers.css"; -const appContainerName = createContainer(); -const widgetContainerName = createContainer(); const widgetContainerMaxWidth = 400; export const animationContainer = style([ diff --git a/packages/widget/src/styles/theme/global.css.ts b/packages/widget/src/styles/theme/global.css.ts index 23b2b73a6..1416e9379 100644 --- a/packages/widget/src/styles/theme/global.css.ts +++ b/packages/widget/src/styles/theme/global.css.ts @@ -1,6 +1,6 @@ import { globalStyle, layer } from "@vanilla-extract/css"; import { vars } from "./contract.css"; -import { rootSelector } from "./ids"; +import { dashboardLayoutSelector, rootSelector } from "./ids"; const reset = layer("reset"); @@ -12,6 +12,13 @@ globalStyle(rootSelector, { }, }); +globalStyle(`${dashboardLayoutSelector} ${rootSelector}`, { + marginLeft: "auto", + marginRight: "auto", + maxWidth: "1000px", + width: "100%", +}); + // Resets globalStyle(`${rootSelector} *`, { "@layer": { diff --git a/packages/widget/src/styles/theme/ids.ts b/packages/widget/src/styles/theme/ids.ts index 51b7c1590..c5eee43b1 100644 --- a/packages/widget/src/styles/theme/ids.ts +++ b/packages/widget/src/styles/theme/ids.ts @@ -1,2 +1,5 @@ export const id = "stakekit"; export const rootSelector = `[data-rk="${id}"]`; + +const layoutAttribute = "data-sk-layout"; +export const dashboardLayoutSelector = `[${layoutAttribute}="dashboard"]`; diff --git a/packages/widget/src/styles/tokens/breakpoints.ts b/packages/widget/src/styles/tokens/breakpoints.ts index 124690d41..db3840df3 100644 --- a/packages/widget/src/styles/tokens/breakpoints.ts +++ b/packages/widget/src/styles/tokens/breakpoints.ts @@ -1,3 +1,5 @@ +import { appContainerName } from "./containers.css"; + export const breakpoints = { mobile: 0, tablet: 520, @@ -6,11 +8,24 @@ export const breakpoints = { export type Breakpoint = keyof typeof breakpoints; -export const minMediaQuery = (breakpoint: Breakpoint) => - `screen and (min-width: ${breakpoints[breakpoint]}px)`; +const SPLIT_COLLAPSE_BREAKPOINT = 800; + +const toPx = (breakpoint: Breakpoint | number) => + typeof breakpoint === "number" ? breakpoint : breakpoints[breakpoint]; + +export const minMediaQuery = (breakpoint: Breakpoint | number) => + `screen and (min-width: ${toPx(breakpoint)}px)`; export const minContainerWidth = ( containerName: string, breakpoint: Breakpoint | number -) => - `${containerName} (min-width: ${typeof breakpoint === "number" ? breakpoint : breakpoints[breakpoint]}px)`; +) => `${containerName} (min-width: ${toPx(breakpoint)}px)`; + + +export const splitExpandedContainerQuery = minContainerWidth( + appContainerName, + SPLIT_COLLAPSE_BREAKPOINT + 1 +); + +export const isSplitCollapsedWidth = (appContainerWidth: number) => + appContainerWidth <= SPLIT_COLLAPSE_BREAKPOINT; diff --git a/packages/widget/src/styles/tokens/containers.css.ts b/packages/widget/src/styles/tokens/containers.css.ts new file mode 100644 index 000000000..085b0bd72 --- /dev/null +++ b/packages/widget/src/styles/tokens/containers.css.ts @@ -0,0 +1,5 @@ +import { createContainer } from "@vanilla-extract/css"; + +export const appContainerName = createContainer(); + +export const widgetContainerName = createContainer(); diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json index cac12c715..a0cf90cd8 100644 --- a/packages/widget/src/translation/English/translations.json +++ b/packages/widget/src/translation/English/translations.json @@ -239,6 +239,11 @@ } }, "dashboard": { + "split_view": { + "earn": "Earn", + "details": "Details", + "actions": "Actions" + }, "details": { "tabs": { "earn": "Earn", diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json index a508d49a0..5be0f9ee0 100644 --- a/packages/widget/src/translation/French/translations.json +++ b/packages/widget/src/translation/French/translations.json @@ -682,6 +682,11 @@ }, "chain_modal_disclaimer": "Alimenté par Yield.xyz", "dashboard": { + "split_view": { + "earn": "Earn", + "details": "Détails", + "actions": "Actions" + }, "details": { "tabs": { "earn": "Earn",