Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"test": "tsc"
},
"dependencies": {
"@codesandbox/sandpack-react": "^2.20.0",
"@faker-js/faker": "^10.1.0",
"@headlessui/react": "2.2.9",
"@headlessui/tailwindcss": "^0.2.2",
Expand Down
11 changes: 10 additions & 1 deletion packages/docs/src/app/playground/(demos)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { QuerySpy } from '@/src/components/query-spy'
import { QuerystringSkeleton } from '@/src/components/querystring'
import { DemoTabsWrapper } from '@/src/components/codesandbox/demo-tabs-wrapper'
import React, { Suspense } from 'react'

const DEMO_PATHS: Record<string, string> = {
'basic-counter': 'basic-counter/client.tsx',
'batching': 'batching/client.tsx',
'hex-colors': 'hex-colors/client.tsx',
// 'pagination': 'pagination/pagination-controls.client.tsx', // Not included because it's server component
'tic-tac-toe': 'tic-tac-toe/client.tsx',
}

export default function PlaygroundDemoLayout({
children
}: {
Expand All @@ -12,7 +21,7 @@ export default function PlaygroundDemoLayout({
<Suspense fallback={<QuerystringSkeleton>&nbsp;</QuerystringSkeleton>}>
<QuerySpy />
</Suspense>
{children}
<DemoTabsWrapper demoPaths={DEMO_PATHS}>{children}</DemoTabsWrapper>
</>
)
}
46 changes: 46 additions & 0 deletions packages/docs/src/components/codesandbox/demo-tabs-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

import { Suspense, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { LiveTab } from './live-tab'

export interface DemoPaths {
[demoName: string]: string
}

export interface DemoTabsWrapperProps {
children: ReactNode
demoPaths: DemoPaths
demoTabLabel?: string
liveTabLabel?: string
getDemoName?: (pathname: string) => string
}

export function DemoTabsWrapper({
children,
demoPaths,
demoTabLabel = 'Demo',
liveTabLabel = 'Live',
getDemoName = (pathname) => pathname?.split('/').pop() ?? '',
}: DemoTabsWrapperProps) {
const pathname = usePathname()
const demoName = getDemoName(pathname ?? '')
const clientPath = demoPaths[demoName]

if (!clientPath) return <>{children}</>

return (
<Tabs key={demoName} items={[demoTabLabel, liveTabLabel]} defaultIndex={0}>
<Tab value={demoTabLabel}>
<Suspense>{children}</Suspense>
</Tab>
<Tab value={liveTabLabel}>
<Suspense fallback={<div>Loading...</div>}>
<LiveTab demoPath={clientPath} />
</Suspense>
</Tab>
</Tabs>
)
}

279 changes: 279 additions & 0 deletions packages/docs/src/components/codesandbox/files/components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/**
* Component source code for Sandpack demos
* These components are injected into the Sandpack environment
*/

/**
* Default demo code showing basic nuqs usage
* A simple example with a single query parameter
*/
export const INITIAL_CODE = `import React from 'react';
import { useQueryState } from "nuqs";

export default function Demo() {
const [hello, setHello] = useQueryState("hello", { defaultValue: "" });

return (
<>
<input
className="border text-black border-gray-300 rounded-md px-4 py-2"
value={hello}
placeholder="Enter your name"
onChange={(e) => setHello(e.target.value || null)}
/>
<p>Hello, {hello || "anonymous visitor"}!</p>
</>
);
}
`

/**
* Compact querystring display component
* Displays URL search parameters in a formatted, readable way
*/
export const QUERYSTRING_COMPONENT = `import React, { Fragment, useMemo } from 'react';

export type QuerystringProps = React.ComponentProps<'pre'> & {
value: string | URLSearchParams
keepKeys?: string[]
}

function filterKeys(query: string | URLSearchParams, keys?: string[]) {
const src = new URLSearchParams(query)
if (!keys?.length) return src

const dest = new URLSearchParams()
for (const [k, v] of src.entries()) {
if (keys.includes(k)) dest.append(k, v)
}
return dest
}

export function Querystring({ value, keepKeys, className, ...props }: QuerystringProps) {
const search = useMemo(() => filterKeys(value, keepKeys), [value, keepKeys])

return (
<pre
aria-label="Querystring spy"
className="block w-full overflow-x-auto rounded-lg px-4 py-3 text-xs sm:text-sm font-mono"
style={{ background: 'hsl(var(--muted))', border: '1px solid hsl(var(--border))', color: 'hsl(var(--foreground))' }}
{...props}
>
{Array.from(search.entries()).map(([k, v], i) => (
<Fragment key={k + i}>
<span style={{ color: 'hsl(var(--muted-foreground))' }}>
{i === 0 ? '?' : <><wbr />&</>}
</span>
<span className="text-[#005CC5] dark:text-[#79B8FF]">{k}</span>=
<span className="text-[#D73A49] dark:text-[#F97583]">{v}</span>
</Fragment>
))}
{search.size === 0 && (
<span style={{ color: 'hsl(var(--muted-foreground))', fontStyle: 'italic' }}>
{'<empty query>'}
</span>
)}
</pre>
)
}
`

/**
* Query spy with iframe-parent URL sync
* Handles bidirectional communication between Sandpack iframe and parent window
*/
export const QUERY_SPY_COMPONENT = `import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Querystring, QuerystringProps } from './querystring';

export function QuerySpy(props: Omit<QuerystringProps, 'value'>) {
const [searchParams] = useSearchParams();
const [current, setCurrent] = useState(() => new URLSearchParams(window.location.search));
const isUpdating = useRef(false);
const lastSearch = useRef('');

const sendToParent = (search: string) => {
if (window.parent !== window) {
window.parent.postMessage({ type: 'nuqs-url-update', search }, '*');
}
};

// Sync React Router params to parent
useEffect(() => {
const params = searchParams.toString();
if (params !== lastSearch.current && !isUpdating.current) {
lastSearch.current = params;
sendToParent(params);
}
isUpdating.current = false;
}, [searchParams]);

// Poll for immediate URL changes
useEffect(() => {
let lastUrl = window.location.search;

const check = () => {
const curr = window.location.search;
if (curr !== lastUrl) {
lastUrl = curr;
const params = new URLSearchParams(curr);
setCurrent(params);
const str = params.toString();
if (str !== lastSearch.current && !isUpdating.current) {
lastSearch.current = str;
sendToParent(str);
}
}
};

let rafId: number;
const poll = () => {
check();
rafId = requestAnimationFrame(poll);
};
rafId = requestAnimationFrame(poll);

return () => cancelAnimationFrame(rafId);
}, []);

// Listen for parent URL and theme updates
useEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (e.data?.type === 'nuqs-parent-url-update') {
const newSearch = e.data.search;
if (new URLSearchParams(window.location.search).toString() !== newSearch) {
isUpdating.current = true;
window.history.replaceState({}, '', newSearch ? \`?\${newSearch}\` : window.location.pathname);
setCurrent(new URLSearchParams(newSearch));
window.dispatchEvent(new PopStateEvent('popstate'));
lastSearch.current = newSearch;
}
} else if (e.data?.type === 'nuqs-theme-update') {
const theme = e.data.theme;
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.body.style.backgroundColor = 'hsl(var(--background))';
document.body.style.color = 'hsl(var(--foreground))';
} else {
document.documentElement.classList.remove('dark');
document.body.style.backgroundColor = 'hsl(var(--background))';
document.body.style.color = 'hsl(var(--foreground))';
}
}
};

window.addEventListener('message', handleMessage);
if (window.parent !== window) {
window.parent.postMessage({ type: 'nuqs-child-ready' }, '*');
}

return () => window.removeEventListener('message', handleMessage);
}, []);

return <Querystring value={current} {...props} />;
}
`

/**
* App wrapper component
* Main application layout for demos
*/
export const APP_COMPONENT = `import React from 'react';
import Demo from './Demo';
import { QuerySpy } from './QuerySpy';
import "/globals.css";

function App() {
return (
<section className="my-5 flex flex-col items-start gap-4 p-5">
<Demo />
<div className="mt-6">
<div className="text-xs font-semibold uppercase tracking-wider mb-3 text-muted-foreground">
Query String
</div>
<QuerySpy />
</div>
</section>
);
}

export default App;
`

/**
* Entry point with router and nuqs setup
* Configures React Router, NuqsAdapter, and Tailwind
*/
export const INDEX_COMPONENT = `import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6';
import App from './App';

// Configure Tailwind CDN with custom colors
if (typeof window !== 'undefined' && !document.getElementById('tailwind-config')) {
const script = document.createElement('script');
script.id = 'tailwind-config';
script.innerHTML = \`
tailwind.config = {
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
};
\`;
document.head.insertBefore(script, document.head.firstChild);
}

const root = ReactDOM.createRoot(document.getElementById('root')!);

root.render(
<React.StrictMode>
<BrowserRouter>
<NuqsAdapter>
<App />
</NuqsAdapter>
</BrowserRouter>
</React.StrictMode>
);
`
36 changes: 36 additions & 0 deletions packages/docs/src/components/codesandbox/files/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { SandpackFiles } from '@codesandbox/sandpack-react'
import {
APP_COMPONENT,
INDEX_COMPONENT,
QUERY_SPY_COMPONENT,
QUERYSTRING_COMPONENT,
} from './components'

// Main exports - these are the primary public API
export const SANDPACK_FILES: SandpackFiles = {
'/App.tsx': {
code: APP_COMPONENT,
hidden: true,
},
'/QuerySpy.tsx': {
code: QUERY_SPY_COMPONENT,
hidden: true,
},
'/querystring.tsx': {
code: QUERYSTRING_COMPONENT,
hidden: true,
},
'/index.tsx': {
code: INDEX_COMPONENT,
hidden: true,
},
}

// Component exports - available if needed for customization
export {
INITIAL_CODE,
APP_COMPONENT,
INDEX_COMPONENT,
QUERY_SPY_COMPONENT,
QUERYSTRING_COMPONENT,
} from './components'
Loading