mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat: layout toggle (#7515)
* feat: implement fixed layout * feat: track layout * fix
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
33
apps/v4/components/site-config.tsx
Normal file
33
apps/v4/components/site-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
160
apps/v4/hooks/use-layout.tsx
Normal file
160
apps/v4/hooks/use-layout.tsx
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
4
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user