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
134 changes: 134 additions & 0 deletions src/components/docs/CopyCodeButton.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
/**
* CopyCodeButton - framework-free "copy to clipboard" affordance for inline
* CDL/code snippets.
*
* Renders a small icon button that copies its `code` prop to the clipboard
* and swaps to a checkmark + "Copied" state briefly on success. Follows the
* same pattern as ThemeToggle.astro: a single delegated click listener bound
* once per page (guarded by a `window` flag), so this can be rendered dozens
* of times on one page (e.g. one per example card) without adding dozens of
* listeners.
*/
interface Props {
code: string;
label?: string;
class?: string;
}

const { code, label = 'Copy CDL to clipboard', class: className = '' } = Astro.props;
---

<button
type="button"
class:list={['copy-code-btn', className]}
data-copy-code={code}
data-copy-label={label}
aria-label={label}
>
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3a2.25 2.25 0 00-2.166 1.638m7.332 0c.055.194.084.4.084.612v.75c0 .414-.336.75-.75.75h-5.25a.75.75 0 01-.75-.75v-.75c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185.639-.075 1.281-.135 1.927-.184"
></path>
</svg>
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.5 12.75l6 6 9-13.5"></path>
</svg>
<span class="copy-code-btn-status" aria-live="polite"></span>
</button>

<style>
.copy-code-btn {
@apply inline-flex items-center justify-center rounded-md p-1.5;
@apply text-slate-400 hover:text-crystal-700 hover:bg-slate-100;
@apply dark:text-cream-muted dark:hover:text-crystal-300 dark:hover:bg-coffee-raised2;
@apply transition-colors;
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-1 dark:focus-visible:ring-crystal-400;
}

.copy-code-btn .copy-icon,
.copy-code-btn .check-icon {
@apply w-4 h-4;
}

.copy-code-btn .check-icon {
display: none;
}

.copy-code-btn.copied {
@apply text-emerald-600 dark:text-emerald-400;
}

.copy-code-btn.copied .copy-icon {
display: none;
}

.copy-code-btn.copied .check-icon {
display: inline-flex;
}

.copy-code-btn-status {
@apply sr-only;
}
</style>

<script is:inline>
(function () {
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
// Fallback for browsers/contexts without the async Clipboard API
// (e.g. insecure contexts).
return new Promise(function (resolve, reject) {
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
resolve();
} catch (err) {
reject(err);
}
});
}

function showCopied(btn) {
var status = btn.querySelector('.copy-code-btn-status');
window.clearTimeout(btn.__copyResetTimer);
btn.classList.add('copied');
btn.setAttribute('aria-label', 'Copied to clipboard');
if (status) status.textContent = 'Copied to clipboard';
btn.__copyResetTimer = window.setTimeout(function () {
btn.classList.remove('copied');
btn.setAttribute('aria-label', btn.getAttribute('data-copy-label') || 'Copy CDL to clipboard');
if (status) status.textContent = '';
}, 1600);
}

if (!window.__copyCodeButtonBound) {
window.__copyCodeButtonBound = true;

document.addEventListener('click', function (event) {
var btn = event.target.closest ? event.target.closest('.copy-code-btn') : null;
if (!btn) return;
var text = btn.getAttribute('data-copy-code') || '';
copyText(text)
.then(function () {
showCopied(btn);
})
.catch(function () {
/* Clipboard write failed silently - no destructive fallback needed. */
});
});
}
})();
</script>
93 changes: 93 additions & 0 deletions src/components/docs/DocsLayout.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
/**
* DocsLayout - shared chrome for every page under `/docs`.
*
* Wraps `BaseLayout` and adds the persistent `DocsSidebar`, an automatic
* `Breadcrumb` + `PageNav` (both derived from the single nav tree in
* `src/lib/docs/nav.ts` for the current `Astro.url.pathname`), and
* optionally `DocsTOC` for long reference pages.
*
* Usage - replace a page's `<BaseLayout title=... description=...>` +
* its own `<Container>`/`<Breadcrumb>`/`<PageNav>` with:
*
* <DocsLayout title="..." description="...">
* <article>...page content, unchanged...</article>
* </DocsLayout>
*
* Pass `showToc` for the long reference pages (CDL spec, CLI options,
* Components, the 4 API pages) to render `DocsTOC` in a third rail at xl+.
* Pass `wide` for pages whose content is a card grid / component gallery
* rather than a prose article (index, components) so the content column
* isn't capped to reading width.
*
* Layout/responsive approach:
* - Mobile (<lg): single column. `DocsSidebar` renders its `<details>`
* disclosure inline above the content; `DocsTOC` (if present) renders
* its own floating button + bottom sheet, independent of scroll
* position.
* - lg+: two-column CSS grid, `[240px_minmax(0,1fr)]` - sidebar rail then
* content. Both the sidebar's and (at xl+, see below) the TOC's grid
* cells get `lg:self-stretch`/`xl:self-stretch` so the cell spans the
* full row height. This is required for `position: sticky` to have any
* travel room - a cell that shrink-wraps its sticky child's own height
* gives the sticky rule nothing to do, which was the exact bug just
* fixed on `/learn`. Verified by measuring the sidebar's
* `getBoundingClientRect()` after a ~1500px scroll (see verification
* notes in the PR/commit for this change).
* - xl+ (only when `showToc`): a third `240px` column is added for
* `DocsTOC`, so long pages become sidebar | content | TOC.
*/
import BaseLayout from '../../layouts/BaseLayout.astro';
import DocsSidebar from './DocsSidebar.astro';
import DocsTOC from './DocsTOC.astro';
import Breadcrumb from './Breadcrumb.astro';
import PageNav from './PageNav.astro';
import { Container } from '../ui-astro';
import { getDocsBreadcrumb, getDocsPageNav } from '../../lib/docs/nav';

interface Props {
title: string;
description?: string;
/** Render the in-page "On this page" TOC rail (long reference pages only). */
showToc?: boolean;
/** Card-grid/gallery pages that want the full content-column width instead of a reading-width cap. */
wide?: boolean;
}

const { title, description, showToc = false, wide = false } = Astro.props;

const pathname = Astro.url.pathname;
const breadcrumbItems = getDocsBreadcrumb(pathname);
const { prev, next } = getDocsPageNav(pathname);
---

<BaseLayout title={title} description={description}>
<Container size="xl" padding="lg">
<div
class:list={[
'lg:grid lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10 lg:items-start',
showToc && 'xl:grid-cols-[240px_minmax(0,1fr)_240px]',
]}
>
<div class="lg:col-start-1 lg:row-start-1 lg:self-stretch">
<DocsSidebar />
</div>

<div class:list={['min-w-0 lg:col-start-2 lg:row-start-1', !wide && 'max-w-4xl']}>
{breadcrumbItems && <Breadcrumb items={breadcrumbItems} />}

<div id="docs-content">
<slot />
</div>

<PageNav prev={prev} next={next} />
</div>

{showToc && (
<div class="hidden xl:block xl:col-start-3 xl:row-start-1 xl:self-stretch">
<DocsTOC />
</div>
)}
</div>
</Container>
</BaseLayout>
113 changes: 113 additions & 0 deletions src/components/docs/DocsSidebar.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
/**
* DocsSidebar - persistent cross-page navigation for the /docs section.
*
* Renders the grouped nav tree from `src/lib/docs/nav.ts` (single source of
* truth, shared with the flat prev/next ordering and breadcrumb lookup).
*
* Two variants, each responsible for hiding itself at the "wrong" breakpoint
* (mirrors the pattern in `src/components/learn/TableOfContents.astro`):
* - Mobile (<lg): a `<details>` "Documentation menu" disclosure sitting
* inline at the top of the content column. A `<details>` (rather than
* the floating-button+dialog pattern also used on this site for
* `DocsTOC`) was chosen deliberately: this is a full page tree, not a
* handful of in-page anchors, and long docs pages already get a floating
* "On this page" button from `DocsTOC` — a second floating trigger on
* the same screen would be redundant and visually competing. A simple
* inline disclosure avoids that collision entirely.
* - Desktop (lg+): a sticky aside rail. `DocsLayout.astro` places this
* component's wrapper in a grid cell with `lg:self-stretch` so the cell
* spans the full row height - without that, `position: sticky` has no
* room to travel and the rail scrolls off with the page (the bug fixed
* in `learn/TableOfContents.astro`; do not reintroduce it here).
*/
import { docsNav, isDocsNavActive } from '../../lib/docs/nav';

const pathname = Astro.url.pathname;
---

<!-- Mobile: collapsible disclosure -->
<details class="docs-nav-mobile lg:hidden mb-8 group rounded-xl border border-slate-200 bg-white dark:border-coffee-border dark:bg-coffee-raised">
<summary class="flex cursor-pointer select-none list-none items-center justify-between px-4 py-3 text-sm font-semibold text-slate-900 dark:text-cream-primary [&::-webkit-details-marker]:hidden">
<span class="flex items-center gap-2">
<svg class="h-4 w-4 shrink-0 text-slate-500 dark:text-cream-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
Documentation menu
</span>
<svg class="docs-nav-chevron h-4 w-4 shrink-0 text-slate-400 transition-transform group-open:rotate-180 dark:text-cream-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</summary>
<nav class="border-t border-slate-200 px-4 py-4 dark:border-coffee-border" aria-label="Documentation sections">
{docsNav.map((group) => (
<div class="mt-5 first:mt-0">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-cream-muted">{group.title}</p>
<ul class="m-0 list-none space-y-1 pl-0">
{group.items.map((item) => (
<li>
<a
href={item.href}
aria-current={isDocsNavActive(item.href, pathname) ? 'page' : undefined}
class="docs-nav-link block rounded-lg px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-100 hover:text-crystal-700 dark:text-cream-secondary dark:hover:bg-coffee-raised2 dark:hover:text-crystal-400"
>
{item.label}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</details>

<!-- Desktop: sticky rail -->
<aside class="docs-sidebar hidden lg:block lg:sticky lg:top-20 lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto" aria-label="Documentation navigation">
<nav>
{docsNav.map((group) => (
<div class="mb-6 last:mb-0">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-cream-muted">{group.title}</p>
<ul class="m-0 list-none space-y-1 border-l border-slate-200 pl-0 dark:border-coffee-border">
{group.items.map((item) => (
<li>
<a
href={item.href}
aria-current={isDocsNavActive(item.href, pathname) ? 'page' : undefined}
class="docs-nav-link -ml-px block border-l-2 border-transparent py-1.5 pl-3 text-sm text-slate-600 transition-colors hover:text-crystal-700 dark:text-cream-secondary dark:hover:text-crystal-400"
>
{item.label}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>

<style>
.docs-nav-link[aria-current='page'] {
color: #0369a1; /* crystal-700, matches the learn TOC active ramp */
border-left-color: #0369a1;
font-weight: 600;
}
:global([data-theme='dark']) .docs-nav-link[aria-current='page'] {
color: #38bdf8; /* crystal-400 */
border-left-color: #38bdf8;
}

/* Mobile disclosure rows are borderless pill rows (no left-rail), so the
active state there needs its own background tint instead - the
color/font-weight rule above still applies via the shared selector. */
.docs-nav-mobile .docs-nav-link[aria-current='page'] {
background-color: rgb(3 105 161 / 0.08); /* crystal-700 tint */
}
:global([data-theme='dark']) .docs-nav-mobile .docs-nav-link[aria-current='page'] {
background-color: rgb(56 189 248 / 0.12); /* crystal-400 tint */
}
</style>
Loading
Loading