feat: layout toggle (#7515)

* feat: implement fixed layout

* feat: track layout

* fix
This commit is contained in:
shadcn
2025-05-31 14:21:19 +04:00
committed by GitHub
parent 16ee16b053
commit 65223896da
16 changed files with 246 additions and 48 deletions

View File

@@ -48,15 +48,23 @@ export default async function ChartPage({ params }: ChartPageProps) {
{type.charAt(0).toUpperCase() + type.slice(1)} Charts
</h2>
<div className="grid flex-1 scroll-mt-20 items-stretch gap-10 md:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:gap-10">
{chartList.map((chart) => (
<ChartDisplay
key={chart.id}
name={chart.id}
className={cn(chart.fullWidth && "md:col-span-2 lg:col-span-3")}
>
<chart.component />
</ChartDisplay>
))}
{Array.from({ length: 12 }).map((_, index) => {
const chart = chartList[index]
return chart ? (
<ChartDisplay
key={chart.id}
name={chart.id}
className={cn(chart.fullWidth && "md:col-span-2 lg:col-span-3")}
>
<chart.component />
</ChartDisplay>
) : (
<div
key={`empty-${index}`}
className="hidden aspect-square w-full rounded-lg border border-dashed xl:block"
/>
)
})}
</div>
</div>
)

View File

@@ -47,7 +47,7 @@ export default function ChartsLayout({
children: React.ReactNode
}) {
return (
<div>
<>
<PageHeader>
<Announcement />
<PageHeaderHeading>{title}</PageHeaderHeading>
@@ -65,11 +65,11 @@ export default function ChartsLayout({
<ChartsNav />
<ThemeSelector className="mr-4 hidden md:flex" />
</PageNav>
<div className="container-wrapper section-soft">
<div className="container-wrapper section-soft flex-1">
<div className="container pb-6">
<section className="theme-container">{children}</section>
</div>
</div>
</div>
</>
)
}

View File

@@ -8,8 +8,8 @@ export default function DocsLayout({
children: React.ReactNode
}) {
return (
<div className="container-wrapper flex flex-1 flex-col">
<SidebarProvider className="min-h-min flex-1 items-start px-0 [--sidebar-width:220px] [--top-spacing:0] lg:grid lg:grid-cols-[var(--sidebar-width)_minmax(0,1fr)] lg:[--sidebar-width:240px] lg:[--top-spacing:calc(var(--spacing)*4)]">
<div className="container-wrapper flex flex-1 flex-col px-2">
<SidebarProvider className="3xl:fixed:container 3xl:fixed:px-3 min-h-min flex-1 items-start px-0 [--sidebar-width:220px] [--top-spacing:0] lg:grid lg:grid-cols-[var(--sidebar-width)_minmax(0,1fr)] lg:[--sidebar-width:240px] lg:[--top-spacing:calc(var(--spacing)*4)]">
<DocsSidebar tree={source.pageTree} />
<div className="h-full w-full">{children}</div>
</SidebarProvider>

View File

@@ -1,9 +1,9 @@
import type { Metadata } from "next"
import { cookies } from "next/headers"
import { META_THEME_COLORS, siteConfig } from "@/lib/config"
import { fontVariables } from "@/lib/fonts"
import { cn } from "@/lib/utils"
import { LayoutProvider } from "@/hooks/use-layout"
import { ActiveThemeProvider } from "@/components/active-theme"
import { Analytics } from "@/components/analytics"
import { TailwindIndicator } from "@/components/tailwind-indicator"
@@ -63,9 +63,6 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
const cookieStore = await cookies()
const activeThemeValue = cookieStore.get("active_theme")?.value
return (
<html lang="en" suppressHydrationWarning>
<head>
@@ -76,6 +73,9 @@ export default async function RootLayout({
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
}
if (localStorage.layout) {
document.documentElement.classList.add('layout-' + localStorage.layout)
}
} catch (_) {}
`,
}}
@@ -84,18 +84,19 @@ export default async function RootLayout({
</head>
<body
className={cn(
"text-foreground group/body overscroll-none font-sans antialiased [--footer-height:calc(var(--spacing)*14)] [--header-height:calc(var(--spacing)*14)]",
activeThemeValue ? `theme-${activeThemeValue}` : "",
"text-foreground group/body overscroll-none font-sans antialiased [--footer-height:calc(var(--spacing)*14)] [--header-height:calc(var(--spacing)*14)] xl:[--footer-height:calc(var(--spacing)*24)]",
fontVariables
)}
>
<ThemeProvider>
<ActiveThemeProvider initialTheme={activeThemeValue}>
{children}
<TailwindIndicator />
<Toaster position="top-center" />
<Analytics />
</ActiveThemeProvider>
<LayoutProvider>
<ActiveThemeProvider>
{children}
<TailwindIndicator />
<Toaster position="top-center" />
<Analytics />
</ActiveThemeProvider>
</LayoutProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -8,15 +8,8 @@ import {
useState,
} from "react"
const COOKIE_NAME = "active_theme"
const DEFAULT_THEME = "default"
function setThemeCookie(theme: string) {
if (typeof window === "undefined") return
document.cookie = `${COOKIE_NAME}=${theme}; path=/; max-age=31536000; SameSite=Lax; ${window.location.protocol === "https:" ? "Secure;" : ""}`
}
type ThemeContextType = {
activeTheme: string
setActiveTheme: (theme: string) => void
@@ -36,8 +29,6 @@ export function ActiveThemeProvider({
)
useEffect(() => {
setThemeCookie(activeTheme)
Array.from(document.body.classList)
.filter((className) => className.startsWith("theme-"))
.forEach((className) => {

View File

@@ -51,7 +51,7 @@ export function ChartsNav({
<Link
href={link.href}
key={link.href}
data-active={pathname === link.href}
data-active={link.href.startsWith(pathname)}
className={cn(
"text-muted-foreground hover:text-primary data-[active=true]:text-primary flex h-7 shrink-0 items-center justify-center px-4 text-center text-base font-medium transition-colors"
)}

View File

@@ -44,7 +44,7 @@ export function DocsSidebar({
<SidebarMenuButton
asChild
isActive={item.url === pathname}
className="data-[active=true]:bg-accent data-[active=true]:border-accent relative h-[30px] w-fit overflow-visible border border-transparent text-[0.8rem] font-medium after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md"
className="data-[active=true]:bg-accent data-[active=true]:border-accent 3xl:fixed:w-full 3xl:fixed:max-w-48 relative h-[30px] w-fit overflow-visible border border-transparent text-[0.8rem] font-medium after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md"
>
<Link href={item.url}>{item.name}</Link>
</SidebarMenuButton>

View File

@@ -24,6 +24,7 @@ export function ModeSwitcher() {
size="icon"
className="group/toggle extend-touch-target size-8"
onClick={toggleTheme}
title="Toggle theme"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { GalleryHorizontalIcon } from "lucide-react"
import { trackEvent } from "@/lib/events"
import { cn } from "@/lib/utils"
import { useLayout } from "@/hooks/use-layout"
import { Button } from "@/registry/new-york-v4/ui/button"
export function SiteConfig({ className }: React.ComponentProps<typeof Button>) {
const { layout, setLayout } = useLayout()
return (
<Button
variant="ghost"
size="icon"
onClick={() => {
const newLayout = layout === "fixed" ? "full" : "fixed"
setLayout(newLayout)
trackEvent({
name: "set_layout",
properties: { layout: newLayout },
})
}}
className={cn("size-8", className)}
title="Toggle layout"
>
<span className="sr-only">Toggle layout</span>
<GalleryHorizontalIcon />
</Button>
)
}

View File

@@ -2,7 +2,7 @@ import { siteConfig } from "@/lib/config"
export function SiteFooter() {
return (
<footer className="group-has-[.section-soft]/body:bg-surface/40 dark:bg-transparent">
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent dark:bg-transparent">
<div className="container-wrapper px-4 xl:px-6">
<div className="flex h-(--footer-height) items-center justify-between">
<div className="text-muted-foreground w-full text-center text-xs leading-loose sm:text-sm">

View File

@@ -12,14 +12,16 @@ import { ModeSwitcher } from "@/components/mode-switcher"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { SiteConfig } from "./site-config"
export function SiteHeader() {
const colors = getColors()
const pageTree = source.pageTree
return (
<header className="bg-background sticky top-0 z-50 w-full">
<div className="container-wrapper px-6">
<div className="flex h-(--header-height) items-center gap-2 **:data-[slot=separator]:!h-4">
<div className="container-wrapper 3xl:fixed:px-0 px-6">
<div className="3xl:fixed:container flex h-(--header-height) items-center gap-2 **:data-[slot=separator]:!h-4">
<MobileNav
tree={pageTree}
items={siteConfig.navItems}
@@ -46,6 +48,8 @@ export function SiteHeader() {
className="ml-2 hidden lg:block"
/>
<GitHubLink />
<Separator orientation="vertical" className="3xl:flex hidden" />
<SiteConfig className="3xl:flex hidden" />
<Separator orientation="vertical" />
<ModeSwitcher />
</div>

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
type Layout = "fixed" | "full"
interface LayoutProviderProps {
children: React.ReactNode
defaultLayout?: Layout
forcedLayout?: Layout
storageKey?: string
attribute?: string | string[]
value?: Record<string, string>
}
interface LayoutProviderState {
layout: Layout
setLayout: (layout: Layout | ((prev: Layout) => Layout)) => void
forcedLayout?: Layout
}
const isServer = typeof window === "undefined"
const LayoutContext = React.createContext<LayoutProviderState | undefined>(
undefined
)
const saveToLS = (storageKey: string, value: string) => {
try {
localStorage.setItem(storageKey, value)
} catch {
// Unsupported
}
}
const useLayout = () => {
const context = React.useContext(LayoutContext)
if (context === undefined) {
throw new Error("useLayout must be used within a LayoutProvider")
}
return context
}
const Layout = ({
forcedLayout,
storageKey = "layout",
defaultLayout = "full",
attribute = "class",
value,
children,
}: LayoutProviderProps) => {
const [layout, setLayoutState] = React.useState<Layout>(() => {
if (isServer) return defaultLayout
try {
const saved = localStorage.getItem(storageKey)
if (saved === "fixed" || saved === "full") {
return saved
}
return defaultLayout
} catch {
return defaultLayout
}
})
const attrs = !value ? ["layout-fixed", "layout-full"] : Object.values(value)
const applyLayout = React.useCallback(
(layout: Layout) => {
if (!layout) return
const name = value ? value[layout] : `layout-${layout}`
const d = document.documentElement
const handleAttribute = (attr: string) => {
if (attr === "class") {
d.classList.remove(...attrs)
if (name) d.classList.add(name)
} else if (attr.startsWith("data-")) {
if (name) {
d.setAttribute(attr, name)
} else {
d.removeAttribute(attr)
}
}
}
if (Array.isArray(attribute)) attribute.forEach(handleAttribute)
else handleAttribute(attribute)
},
[attrs, attribute, value]
)
const setLayout = React.useCallback(
(value: Layout | ((prev: Layout) => Layout)) => {
if (typeof value === "function") {
setLayoutState((prevLayout) => {
const newLayout = value(prevLayout)
saveToLS(storageKey, newLayout)
return newLayout
})
} else {
setLayoutState(value)
saveToLS(storageKey, value)
}
},
[storageKey]
)
// localStorage event handling
React.useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== storageKey) return
if (!e.newValue) {
setLayout(defaultLayout)
} else if (e.newValue === "fixed" || e.newValue === "full") {
setLayoutState(e.newValue)
}
}
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
}, [setLayout, storageKey, defaultLayout])
// Apply layout on mount and when it changes
React.useEffect(() => {
const currentLayout = forcedLayout ?? layout
applyLayout(currentLayout)
}, [forcedLayout, layout, applyLayout])
// Prevent layout changes during hydration
const [isHydrated, setIsHydrated] = React.useState(false)
React.useEffect(() => {
setIsHydrated(true)
}, [])
const providerValue = React.useMemo(
() => ({
layout: isHydrated ? layout : defaultLayout,
setLayout,
forcedLayout,
}),
[layout, setLayout, forcedLayout, isHydrated, defaultLayout]
)
return (
<LayoutContext.Provider value={providerValue}>
{children}
</LayoutContext.Provider>
)
}
const LayoutProvider = (props: LayoutProviderProps) => {
const context = React.useContext(LayoutContext)
// Ignore nested context providers, just passthrough children
if (context) return <>{props.children}</>
return <Layout {...props} />
}
export { useLayout, LayoutProvider }

View File

@@ -15,6 +15,7 @@ const eventSchema = z.object({
"copy_chart_theme",
"copy_chart_data",
"copy_color",
"set_layout",
]),
// declare type AllowedPropertyValues = string | number | boolean | null
properties: z

View File

@@ -33,6 +33,7 @@ export default defineConfig({
export const docs = defineDocs({
dir: "content/docs",
docs: {
// @ts-expect-error - TODO: fix the type.
schema: frontmatterSchema.extend({
links: z
.object({

View File

@@ -5,6 +5,8 @@
@custom-variant dark (&:is(.dark *));
@custom-variant fixed (&:is(.layout-fixed *));
@theme inline {
--breakpoint-3xl: 1600px;
--breakpoint-4xl: 2000px;
@@ -172,7 +174,7 @@
}
@utility section-soft {
@apply from-background to-surface/40 dark:bg-background bg-gradient-to-b;
@apply from-background to-surface/40 dark:bg-background 3xl:fixed:bg-none bg-gradient-to-b;
}
@utility theme-container {
@@ -180,11 +182,7 @@
}
@utility container-wrapper {
@apply mx-auto w-full px-2;
.fixed-width & {
@apply mx-auto w-full max-w-screen-2xl px-2;
}
@apply 3xl:fixed:max-w-[calc(var(--breakpoint-2xl)+2rem)] mx-auto w-full px-2;
}
@utility container {

4
pnpm-lock.yaml generated
View File

@@ -10045,7 +10045,7 @@ snapshots:
'@types/node': 20.5.1
chalk: 4.1.2
cosmiconfig: 8.3.6(typescript@5.7.3)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.17.16)(typescript@5.7.3))(typescript@5.7.3)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.7.3))(typescript@5.7.3)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@@ -14530,7 +14530,7 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.17.16)(typescript@5.7.3))(typescript@5.7.3):
cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.7.3))(typescript@5.7.3):
dependencies:
'@types/node': 20.5.1
cosmiconfig: 8.3.6(typescript@5.7.3)