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
11 changes: 10 additions & 1 deletion examples/blog/app/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export default function RootLayout({ children }: LayoutProps) {
--accent-text: var(--accent);
--accent-surface: color-mix(in oklch, var(--accent-live) 12%, transparent);
--accent-border: color-mix(in oklch, var(--accent-live) 28%, transparent);
/* Logo mark gradient: the exact warm-orange stops webjs.dev uses, so
the brand mark reads identically here and on the main site (dark
default here; the light overrides below carry the light stops). */
--logo-from: oklch(0.8 0.16 58);
--logo-to: oklch(0.62 0.18 44);
--glow-a: oklch(0.63 0.17 44);
--glow-strength: 0.16;

Expand All @@ -178,6 +183,8 @@ export default function RootLayout({ children }: LayoutProps) {
--accent: oklch(0.54 0.16 52);
--accent-hover: oklch(0.5 0.16 52);
--accent-fg: oklch(1 0 0);
--logo-from: oklch(0.63 0.17 50);
--logo-to: oklch(0.44 0.11 52);
}

/* light: OS preference */
Expand All @@ -195,6 +202,8 @@ export default function RootLayout({ children }: LayoutProps) {
--accent: oklch(0.54 0.16 52);
--accent-hover: oklch(0.5 0.16 52);
--accent-fg: oklch(1 0 0);
--logo-from: oklch(0.63 0.17 50);
--logo-to: oklch(0.44 0.11 52);
}
}

Expand Down Expand Up @@ -251,7 +260,7 @@ export default function RootLayout({ children }: LayoutProps) {

<header class="site-header fixed inset-x-0 top-0 z-20 flex items-center justify-between gap-4 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px] backdrop-saturate-[180%]">
<a href="/" class="inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
<span class="inline-block w-[22px] h-[22px] rounded-md bg-gradient-to-br from-accent to-[color-mix(in_oklch,var(--accent)_55%,var(--fg))] shadow-[inset_0_0_0_1px_oklch(1_0_0/0.15),0_1px_4px_var(--accent-tint)]"></span>
<span class="inline-block w-[22px] h-[22px] rounded-[7px] bg-gradient-to-br from-[var(--logo-from)] to-[var(--logo-to)] shadow-[inset_0_0_0_1px_oklch(1_0_0/0.15),0_2px_10px_var(--accent-tint)]"></span>
<span>webjs</span>
<span class="text-fg-subtle mx-1 font-normal">/</span>
<span>blog</span>
Expand Down
89 changes: 89 additions & 0 deletions packages/ui/packages/website/app/_components/preview-tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { WebComponent, html, css, signal } from '@webjsdev/core';

/**
* `<preview-tabs>`: a Preview / Code segmented toggle wrapping a live
* component demo and its source snippet, the way shadcn's docs let you flip a
* preview to the markup that produced it.
*
* Why shadow DOM plus slots (not a light-DOM re-render): the live demo is
* projected through `slot="preview"`, so it stays in light DOM (Tailwind and
* the shadcn preview tokens still apply) and, crucially, it is projected once
* and never rebuilt. A WebComponent that emitted the demo from its own
* `render()` would tear down and re-instantiate every `ui-*` custom element on
* each toggle (their `connectedCallback` captures `innerHTML`, so a rebuild is
* destructive). The shadow root owns only the segmented control and hides the
* inactive slot; both slots stay in the tree so the projected demo is assigned
* exactly once.
*
* Progressive enhancement: with JS off the DSD-rendered shadow shows the
* default Preview slot and the buttons are inert. The full source is also
* printed at the bottom of every component page, so no information is lost.
*/
export class PreviewTabs extends WebComponent {
static shadow = true;

/** Which pane is visible. Instance signal, so each toggle is component-local. */
mode = signal<'preview' | 'code'>('preview');

static styles = css`
:host { display: block; }
.bar {
display: inline-flex;
gap: 2px;
padding: 3px;
margin-bottom: 10px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg-elev);
}
.tab {
font: 500 12.5px/1 var(--font-sans, system-ui, sans-serif);
color: var(--fg-muted);
background: transparent;
border: 0;
border-radius: 6px;
padding: 6px 13px;
cursor: pointer;
transition: color 140ms ease, background 140ms ease;
}
.tab:hover { color: var(--fg); }
.tab[data-active='true'] {
color: var(--fg);
background: var(--bg-subtle);
}
.tab:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-tint);
}
[hidden] { display: none !important; }
`;

render() {
const mode = this.mode.get();
const isPreview = mode === 'preview';
return html`
<div class="bar" role="tablist" aria-label="Preview and code">
<button
type="button"
class="tab"
role="tab"
data-active=${String(isPreview)}
aria-selected=${isPreview ? 'true' : 'false'}
@click=${() => this.mode.set('preview')}
>Preview</button>
<button
type="button"
class="tab"
role="tab"
data-active=${String(!isPreview)}
aria-selected=${!isPreview ? 'true' : 'false'}
@click=${() => this.mode.set('code')}
>Code</button>
</div>
<slot name="preview" ?hidden=${!isPreview}></slot>
<slot name="code" ?hidden=${isPreview}></slot>
`;
}
}

PreviewTabs.register('preview-tabs');
Loading
Loading