Skip to content
Merged
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
95 changes: 95 additions & 0 deletions apps/docs-tests/docs/copy-react.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { expect, test } from '@playwright/test';

/**
* The component page header's Copy React button and its menu. The button
* reads the demo's control store through the copy source bridge, so the
* copied snippet has to carry the current params, and the menu has to line
* up with the button box rather than the chevron inside it.
*/

// Wide enough that the shader column reaches the 4xl cap the header shares,
// so the button box, the menu, and the shader all end on one edge.
test.use({ viewport: { width: 1728, height: 1000 } });

test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
});

test('Copy React copies the demo with its current params', async ({ page }) => {
await page.goto('/components/vignette');
await page.waitForLoadState('networkidle');

const button = page.getByRole('button', { name: 'Copy React' });

await expect(button).toBeEnabled();

// A slider write lands in the store immediately, so the copied snippet
// has to show the new value rather than the page's initial one.
const intensity = page.getByRole('slider', { name: 'Intensity' });

await intensity.focus();
await page.keyboard.press('End');

await button.click();

await expect(button).toHaveAttribute('data-copied', 'true');
await expect(page.getByText('Copied', { exact: true })).toBeAttached();

const copied = await page.evaluate(() => navigator.clipboard.readText());

expect(copied).toContain(
"import { LinearGradient, ShaderScene, Vignette } from '@camp-dev/shaders'",
);
expect(copied).toContain('<LinearGradient />');
expect(copied).toContain('intensity={1}');

// The feedback clears on its own.
await expect(button).not.toHaveAttribute('data-copied', 'true', { timeout: 3000 });
});

test('a two-word page copies its PascalCase tag, not its label', async ({ page }) => {
await page.goto('/components/conic-gradient');
await page.waitForLoadState('networkidle');

const button = page.getByRole('button', { name: 'Copy React' });

await expect(button).toBeEnabled();
await button.click();
await expect(button).toHaveAttribute('data-copied', 'true');

const copied = await page.evaluate(() => navigator.clipboard.readText());

expect(copied).toContain("import { ConicGradient, ShaderScene } from '@camp-dev/shaders'");
expect(copied).toContain('<ConicGradient');
expect(copied).not.toContain('Conic Gradient');
});

test('the menu lines up with the button box', async ({ page }) => {
await page.goto('/components/aurora');
await page.waitForLoadState('networkidle');

const chevron = page.getByRole('button', { name: 'More copy options' });

await chevron.click();

const menu = page.getByRole('menu');

await expect(menu).toBeVisible();

const [boxRight, menuRight, shaderRight] = await Promise.all([
chevron.evaluate((element) => element.parentElement!.getBoundingClientRect().right),
menu.evaluate((element) => element.getBoundingClientRect().right),
page.locator('[data-shader-demo]').evaluate((element) => element.getBoundingClientRect().right),
]);

expect(menuRight).toBe(boxRight);
expect(shaderRight).toBe(boxRight);

// The left half of the button is Copy React, so the menu holds only the
// markdown rows, both disabled until the export ships.
const rows = page.getByRole('menuitem');

await expect(rows).toHaveText(['Copy as markdown', 'View as markdown']);
await expect(rows.first()).toHaveAttribute('aria-disabled', 'true');
await expect(rows.last()).toHaveAttribute('aria-disabled', 'true');
});
15 changes: 15 additions & 0 deletions apps/docs/src/app/components/[slug]/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@
margin: var(--spacing-4) 0 0;
}

/* The header is two columns: the title and description on the left, the
copy actions on the right, bottom-aligned so the button sits on the
description's line as in the mock. The text column wraps below a
comfortable reading width (the sm container) rather than squeezing, which
drops the actions onto their own line under it on a narrow viewport. */
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--spacing-4) var(--spacing-6);
max-width: var(--container-4xl);
margin: var(--spacing-6) 0 0;
}

.heading {
flex: 1 1 var(--container-sm);
min-width: 0;
}

.title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-semibold);
Expand Down
26 changes: 18 additions & 8 deletions apps/docs/src/app/components/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Shared shell for every converted component page, in the mock's section
* order: breadcrumbs and header above the demo, Usage and API Reference
* below it, then prev/next pagination. Titles and descriptions come from the
* below it, then prev/next pagination. The header carries the page's copy
* actions beside the title (page-actions/). Titles and descriptions come from the
* catalog (content/components.ts) and page order from its taxonomy tree; the
* interactive demo and Usage content come from the demo registry, which
* every component page has an entry in.
Expand All @@ -17,7 +18,9 @@ import { notFound } from 'next/navigation';

import { Breadcrumbs } from '@/components/breadcrumbs/breadcrumbs';
import { CodeBlock } from '@/components/code-block/code-block';
import { CopySourceProvider } from '@/components/controls';
import { ChevronDownIcon } from '@/components/icons/chevron-down';
import { PageActions } from '@/components/page-actions/page-actions';
import { PageToc, type PageTocSection } from '@/components/page-toc/page-toc';
import { PropsTable } from '@/components/props-table/props-table';
import { getComponentsCatalog, getComponentsTree } from '@/content/catalog';
Expand Down Expand Up @@ -86,13 +89,20 @@ export default async function ComponentPage({ params }: PageProps) {
return (
<main>
<Breadcrumbs className={styles.breadcrumbs} crumbs={crumbs} />
<div id={slug}>
<header className={styles.header}>
<h1 className={styles.title}>{record.label}</h1>
<p className={styles.description}>{record.description}</p>
</header>
<Island />
</div>
{/* The header's Copy React reads the island's control store through
this provider; see the copy source in controls/context.tsx. */}
<CopySourceProvider>
<div id={slug}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>{record.label}</h1>
<p className={styles.description}>{record.description}</p>
</div>
<PageActions componentName={record.componentName} siblings={entry.copySiblings} />
</header>
<Island />
</div>
</CopySourceProvider>
{/* Sits right after the demo grid so its lines can measure back up to
the shader's center; see .dock in page-toc.module.css. */}
<PageToc sections={sections} />
Expand Down
13 changes: 13 additions & 0 deletions apps/docs/src/app/components/demo-registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface ComponentPageEntry {
* component tag used in the snippet lands in the import automatically.
*/
usageSnippet: string;
/**
* Layers the demo scene renders under the component, as JSX, for pages
* whose scene composes a background. Copy React (page-actions/) emits them
* ahead of the component so the copied snippet reproduces the demo. Absent
* when the component fills the scene by itself.
*/
copySiblings?: readonly string[];
/**
* Optional prose above the snippet. Single-paragraph notes are bare
* content; multi-paragraph notes bring their own <p> tags.
Expand All @@ -58,6 +65,9 @@ export const COMPONENT_PAGES: Record<string, ComponentPageEntry> = {
},
blobs: {
Island: BlobsIsland,
copySiblings: [
"<LinearGradient angle={90} speed={0} stops={[{ color: 'oklch(0.18 0.02 265)' }, { color: 'oklch(0.26 0.04 300)' }]} />",
],
usageSnippet: `<ShaderScene>
<LinearGradient />
<Blobs />
Expand All @@ -77,6 +87,7 @@ export const COMPONENT_PAGES: Record<string, ComponentPageEntry> = {
},
dither: {
Island: DitherIsland,
copySiblings: ['<MeshGradient />'],
usageSnippet: `<ShaderScene>
<MeshGradient />
<Dither pattern="bayer-8x8" pixelSize={4} levels={4} />
Expand Down Expand Up @@ -127,6 +138,7 @@ export const COMPONENT_PAGES: Record<string, ComponentPageEntry> = {
},
grain: {
Island: GrainIsland,
copySiblings: ['<LinearGradient />'],
usageSnippet: `<ShaderScene>
<LinearGradient />
<Grain intensity={0.45} speed={1} blend="additive" />
Expand Down Expand Up @@ -179,6 +191,7 @@ export const COMPONENT_PAGES: Record<string, ComponentPageEntry> = {
},
vignette: {
Island: VignetteIsland,
copySiblings: ['<LinearGradient />'],
usageSnippet: `<ShaderScene>
<LinearGradient />
<Vignette intensity={0.5} radius={0.6} feather={0.5} />
Expand Down
39 changes: 3 additions & 36 deletions apps/docs/src/components/controls/ColorPopoverContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { oklchInGamut, oklchToGamut } from '@camp-dev/shaders/color';
import { useDisplayGamut } from '@camp-dev/shaders/gamut';

import { CopyIcon } from '@/components/icons/copy';
import { COPY_ANNOUNCEMENTS, useClipboardCopy } from '@/lib/use-clipboard-copy';

import { ChannelSlider } from './color/ChannelSlider';
import { formatOklch, type OklchColor, parseToOklch } from './color/oklch';
Expand Down Expand Up @@ -139,16 +140,6 @@ export function ColorPopoverContents({ path, label }: { path: PathInput; label:
);
}

const COPIED_FEEDBACK_MS = 1200;

type CopyStatus = 'idle' | 'copied' | 'failed';

const COPY_ANNOUNCEMENTS: Record<CopyStatus, string> = {
idle: '',
copied: 'Copied',
failed: 'Copy failed',
};

/**
* Copies the current color string. The glyph turns lime for a moment as the
* only visible feedback, and a live region says "Copied" for screen readers,
Expand All @@ -158,39 +149,15 @@ const COPY_ANNOUNCEMENTS: Record<CopyStatus, string> = {
* way rather than left as a silent rejection.
*/
function CopyButton({ label, text }: { label: string; text: string }) {
const [status, setStatus] = useState<CopyStatus>('idle');
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (feedbackTimeoutRef.current !== null) clearTimeout(feedbackTimeoutRef.current);
};
}, []);

const copy = () => {
// The write starts inside a promise chain: `navigator.clipboard` is
// undefined outside a secure context, and a plain call would throw out
// of the click handler, whereas here the throw lands in the rejection
// path with every other failure.
void Promise.resolve()
.then(() => navigator.clipboard.writeText(text))
.then(
() => setStatus('copied'),
() => setStatus('failed'),
);

if (feedbackTimeoutRef.current !== null) clearTimeout(feedbackTimeoutRef.current);

feedbackTimeoutRef.current = setTimeout(() => setStatus('idle'), COPIED_FEEDBACK_MS);
};
const { status, copy } = useClipboardCopy();

return (
<>
<button
aria-label={`Copy ${label}`}
className={styles.copyButton}
data-copied={status === 'copied' || undefined}
onClick={copy}
onClick={() => copy(text)}
title="Copy"
type="button"
>
Expand Down
55 changes: 52 additions & 3 deletions apps/docs/src/components/controls/context.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,56 @@
'use client';

/**
* Three contexts. ControlsProvider carries the page's store down to every
* Four contexts. ControlsProvider carries the page's store down to every
* control. PathPrefixProvider is how list rows work: ListInput wraps each row
* in a prefix like ['stops', 2], so the ColorInput inside that row can say
* path="color" and land on stops[2].color without knowing its own index.
* ListRowProvider carries the row's name ("stop 2") alongside, so a control
* inside a row can drop its own visible label and still name itself fully
* for a screen reader.
* for a screen reader. CopySourceProvider runs the other way: it sits above
* the island, ControlsProvider publishes its store up into it, and the page
* header's Copy React button (page-actions/) reads the store from there.
*/
import { createContext, type ReactNode, useContext, useMemo } from 'react';
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react';

import type { ControlPath, ControlStore, PathSegment } from './store';

const StoreContext = createContext<ControlStore<object> | null>(null);
const PathPrefixContext = createContext<ControlPath>([]);

// ----------------------------------------------------------------------------
// The copy source
// ----------------------------------------------------------------------------

/**
* The page's store as seen from outside the island. The component page
* template renders the header and the demo island as siblings, and only
* the island creates the store, so the header can't reach it through
* ordinary context. This pair of contexts bridges the gap: the outer one
* hands ControlsProvider a setter to publish into, the inner one hands the
* header whatever was published. Null until an island has mounted, and on
* pages with no island.
*/
const CopySourceContext = createContext<ControlStore<object> | null>(null);
const PublishCopySourceContext = createContext<
((store: ControlStore<object> | null) => void) | null
>(null);

export function CopySourceProvider({ children }: { children: ReactNode }) {
const [store, setStore] = useState<ControlStore<object> | null>(null);

return (
<PublishCopySourceContext.Provider value={setStore}>
<CopySourceContext.Provider value={store}>{children}</CopySourceContext.Provider>
</PublishCopySourceContext.Provider>
);
}

/** The mounted island's store, or null before it mounts. */
export function useCopySource(): ControlStore<object> | null {
return useContext(CopySourceContext);
}

/**
* Trail of ancestor row labels, outermost first ("line 5", then "stop 2").
* Empty outside any list. Controls read it for two things: whether they are
Expand All @@ -35,6 +70,20 @@ export function ControlsProvider({
store: ControlStore<object>;
children: ReactNode;
}) {
const publish = useContext(PublishCopySourceContext);

// Publishes the store to the page header for as long as this island is
// mounted. Islands render inside CopySourceProvider only on the component
// pages; the dev playgrounds have no header and no provider, so there is
// nothing to publish to there.
useEffect(() => {
if (publish === null) return;

publish(store);

return () => publish(null);
}, [publish, store]);

return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}

Expand Down
9 changes: 8 additions & 1 deletion apps/docs/src/components/controls/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export { ColorInput } from './ColorInput';
export { ControlsProvider, ListRowProvider, PathPrefixProvider, useListRowTrail } from './context';
export {
ControlsProvider,
CopySourceProvider,
ListRowProvider,
PathPrefixProvider,
useCopySource,
useListRowTrail,
} from './context';
export { ControlPanel } from './ControlPanel';
export { formatJsx, formatParams } from './copy';
export type { CopyConfig } from './copy';
Expand Down
Loading
Loading