Menu

Change language

GitHub repo

Code Snippet Playground

blockdevops

Multi-file interactive code runner, editor tabs, and terminal console output for components with live render sandbox, syntax highlighting, and test execution.

Installation

$npx uipkge-ng@latest add code-snippet-playground
Manifest:https://uipkge.dev/r/angular/code-snippet-playground.jsonInstalls to:src/app/components/blocks/code-snippet-playground/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CodeSnippetPlaygroundFile
interface CodeSnippetPlaygroundFile {
  id: string
  name: string
  path: string
  language: string
  size: string
  content: string
}
CodeSnippetPlaygroundTestLog
interface CodeSnippetPlaygroundTestLog {
  id: string
  name: string
  duration: string
  status: 'pass' | 'fail'
}

Files installed (2)

  • src/app/components/blocks/code-snippet-playground/code-snippet-playground.component.ts34.9 kB
    // Edit this file to change the files shown in the editor, the test log or the copy.
    // Everything is mocked: "Run Code" (or Cmd/Ctrl+Enter) replays the Vitest run after 480ms.
    import { Component, DestroyRef, Input, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      Braces,
      Check,
      CheckCircle2,
      Code2,
      Copy,
      Eye,
      FileCode,
      Layers,
      Loader2,
      LucideAngularModule,
      Play,
      RotateCcw,
      Terminal,
      Zap,
      type LucideIconData,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTabsComponent,
      UiTabsContentComponent,
      UiTabsListComponent,
      UiTabsTriggerComponent,
    } from '@/ui/tabs/tabs.component'
    
    export interface CodeSnippetPlaygroundFile {
      id: string
      name: string
      path: string
      language: string
      size: string
      content: string
    }
    
    export interface CodeSnippetPlaygroundTestLog {
      id: string
      name: string
      duration: string
      status: 'pass' | 'fail'
    }
    
    const files: CodeSnippetPlaygroundFile[] = [
      {
        id: 'button-tsx',
        name: 'Button.tsx',
        path: 'packages/registry-react/components/button/Button.tsx',
        language: 'typescript',
        size: '1.1 KB',
        content: `import * as React from 'react'
    import { Slot } from '@radix-ui/react-slot'
    import { cn } from '@/lib/utils'
    import { buttonVariants, type ButtonVariants } from './button.variants'
    
    export interface ButtonProps
      extends React.ButtonHTMLAttributes<HTMLButtonElement>,
        ButtonVariants {
      asChild?: boolean
    }
    
    const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
      ({ className, variant, size, asChild = false, type = 'button', ...props }, ref) => {
        const Comp = asChild ? Slot : 'button'
        return (
          <Comp
            data-slot="button"
            data-variant={variant ?? undefined}
            data-size={size ?? undefined}
            className={cn(buttonVariants({ variant, size }), className)}
            {...(asChild ? {} : { type })}
            ref={ref}
            {...props}
          />
        )
      },
    )
    Button.displayName = 'Button'
    
    export { Button }`,
      },
      {
        id: 'button-variants',
        name: 'button.variants.ts',
        path: 'packages/registry-react/components/button/button.variants.ts',
        language: 'typescript',
        size: '1.4 KB',
        content: `import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    export const buttonVariants = cva(
      'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0',
      {
        variants: {
          variant: {
            default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
            destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
            outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
            secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
            ghost: 'hover:bg-accent hover:text-accent-foreground',
            link: 'text-primary underline-offset-4 hover:underline',
          },
          size: {
            default: 'h-9 px-4 py-2',
            sm: 'h-8 rounded-md px-3 text-xs',
            lg: 'h-10 rounded-md px-6',
            icon: 'size-9',
          },
        },
        defaultVariants: {
          variant: 'default',
          size: 'default',
        },
      },
    )
    
    export type ButtonVariants = VariantProps<typeof buttonVariants>`,
      },
      {
        id: 'index-ts',
        name: 'index.ts',
        path: 'packages/registry-react/components/button/index.ts',
        language: 'typescript',
        size: '220 B',
        content: `export { Button, type ButtonProps } from './Button'
    export { buttonVariants, type ButtonVariants } from './button.variants'`,
      },
      {
        id: 'button-test',
        name: 'button.test.tsx',
        path: 'packages/registry-react/components/button/__tests__/button.spec.tsx',
        language: 'typescript',
        size: '1.7 KB',
        content: `import { describe, it, expect, vi } from 'vitest'
    import { render, screen, fireEvent } from '@testing-library/react'
    import { Button } from '../Button'
    
    describe('Button Primitive', () => {
      it('renders default button with children text', () => {
        render(<Button>Submit</Button>)
        const btn = screen.getByRole('button', { name: /submit/i })
        expect(btn).toBeInTheDocument()
        expect(btn).toHaveAttribute('data-slot', 'button')
      })
    
      it('applies variant classes correctly', () => {
        render(<Button variant="destructive">Delete</Button>)
        const btn = screen.getByRole('button', { name: /delete/i })
        expect(btn).toHaveAttribute('data-variant', 'destructive')
        expect(btn.className).toContain('bg-destructive')
      })
    
      it('applies size styles correctly', () => {
        render(<Button size="sm">Small</Button>)
        const btn = screen.getByRole('button', { name: /small/i })
        expect(btn).toHaveAttribute('data-size', 'sm')
        expect(btn.className).toContain('h-8')
      })
    
      it('handles click events when active', () => {
        const handleClick = vi.fn()
        render(<Button onClick={handleClick}>Trigger</Button>)
        fireEvent.click(screen.getByRole('button', { name: /trigger/i }))
        expect(handleClick).toHaveBeenCalledTimes(1)
      })
    
      it('respects disabled state attribute', () => {
        render(<Button disabled>Disabled</Button>)
        expect(screen.getByRole('button', { name: /disabled/i })).toBeDisabled()
      })
    
      it('forwards custom class names with cn()', () => {
        render(<Button className="custom-btn">Custom</Button>)
        const btn = screen.getByRole('button', { name: /custom/i })
        expect(btn.className).toContain('custom-btn')
      })
    })`,
      },
    ]
    
    const testLogs: CodeSnippetPlaygroundTestLog[] = [
      { id: '1', name: 'renders default button with children text', duration: '4ms', status: 'pass' },
      { id: '2', name: 'applies variant classes correctly', duration: '7ms', status: 'pass' },
      { id: '3', name: 'applies size styles correctly', duration: '5ms', status: 'pass' },
      { id: '4', name: 'handles click events when active', duration: '8ms', status: 'pass' },
      { id: '5', name: 'respects disabled state attribute', duration: '6ms', status: 'pass' },
      { id: '6', name: 'forwards custom class names with cn()', duration: '12ms', status: 'pass' },
    ]
    
    export type CodeSnippetPlaygroundButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
    export type CodeSnippetPlaygroundButtonSize = 'default' | 'sm' | 'lg' | 'icon'
    function highlightSyntaxLine(line: string): string {
      if (!line.trim()) return '&nbsp;'
    
      const escaped = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    
      // Single line comments
      if (/^\s*\/\//.test(escaped) || /^\s*\/\*/.test(escaped) || /^\s*\*/.test(escaped) || /^\s*&lt;!--/.test(escaped)) {
        return `<span class="text-zinc-500 italic">${escaped}</span>`
      }
    
      // Every emitted <span> is parked behind a letter-only placeholder so later
      // passes cannot match inside the markup they already produced.
      const parked: string[] = []
      const park = (html: string) => {
        const key = String(parked.length)
          .split('')
          .map((d) => String.fromCharCode(97 + Number(d)))
          .join('')
        parked.push(html)
        return `\u0000${key}\u0000`
      }
    
      let out = escaped
    
      // Strings
      out = out.replace(/(["'`])(?:(?=(\\?))\2.)*?\1/g, (m) => park(`<span class="text-success font-normal">${m}</span>`))
    
      // Trailing comments
      out = out.replace(/(\/\/.*$)/, (m) => park(`<span class="text-zinc-500 italic">${m}</span>`))
    
      // Keywords
      out = out.replace(
        /\b(import|export|from|const|let|var|function|return|interface|type|default|as|typeof|withDefaults|defineProps|defineEmits|describe|it|expect|test|async|await|extends|new|true|false|null|undefined)\b/g,
        (m) => park(`<span class="text-chart-1 font-semibold">${m}</span>`),
      )
    
      // Types
      out = out.replace(
        /\b(string|boolean|number|void|HTMLAttributes|VariantProps|ButtonVariants|ButtonProps|Props|Variant|Size|HTMLButtonElement|Record)\b/g,
        (m) => park(`<span class="text-warning font-medium">${m}</span>`),
      )
    
      // Functions / methods
      out = out.replace(
        /\b(cn|buttonVariants|cva|mount|render|screen|getByRole|fireEvent|vi|trigger|classes|attributes|emitted|toBe|toContain|toHaveProperty|toBeDefined|toBeInTheDocument|toHaveAttribute|toHaveBeenCalledTimes|toHaveBeenCalled|forwardRef|displayName|ref|computed|onMounted|onUnmounted|fn)\b/g,
        (m) => park(`<span class="text-info">${m}</span>`),
      )
    
      // Vue/TSX tags
      out = out.replace(
        /(&lt;\/?(?:template|script|Primitive|Button|Slot|Comp|slot|div|span|button)\b(?:\s|\/|&gt;)?)/g,
        (m) => park(`<span class="text-info font-medium">${m}</span>`),
      )
    
      // Attributes / directives
      out = out.replace(
        /(\b(?:data-slot|data-variant|data-size|className|variant|size|asChild|as|type|class|lang|setup|ref|onClick|disabled)\b|:[a-zA-Z0-9_-]+)/g,
        (m) => park(`<span class="text-chart-2">${m}</span>`),
      )
    
      // Numbers
      out = out.replace(/\b(\d+)\b/g, (m) => park(`<span class="text-warning">${m}</span>`))
    
      // Restore every parked span
      return out.replace(/\u0000([a-j]+)\u0000/g, (_, key: string) => {
        const idx = Number(
          key
            .split('')
            .map((c: string) => String(c.charCodeAt(0) - 97))
            .join(''),
        )
        return parked[idx] ?? ''
      })
    }
    
    const variants: CodeSnippetPlaygroundButtonVariant[] = [
      'default',
      'secondary',
      'destructive',
      'outline',
      'ghost',
      'link',
    ]
    const sizes: CodeSnippetPlaygroundButtonSize[] = ['sm', 'default', 'lg', 'icon']
    
    /**
     * Angular port of the React CodeSnippetPlayground block: a two-column studio with file
     * tabs and a syntax-coloured editor on the left, and a Live Preview / Terminal tab pair on
     * the right. Signals for state; Cmd/Ctrl+Enter runs the (mock) suite from anywhere.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-code-snippet-playground, [ui-code-snippet-playground]',
      standalone: true,
      // React renders the Card itself: keep this wrapper out of the layout.
      host: { class: 'contents', '(window:keydown)': 'onWindowKeydown($event)' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiSeparatorComponent,
        UiTabsComponent,
        UiTabsContentComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
      ],
      template: `
        <div ui-card data-slot="code-snippet-playground" [class]="rootClass">
          <!-- Top Toolbar -->
          <div
            ui-card-header
            class="border-border bg-muted/20 flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:justify-between"
          >
            <div class="flex items-center gap-3">
              <div class="bg-primary/10 text-primary flex size-9 items-center justify-center rounded-lg">
                <lucide-icon [img]="Code2" class="size-4.5" />
              </div>
              <div>
                <div class="flex items-center gap-2">
                  <h3 ui-card-title class="text-base font-semibold tracking-tight">Button.tsx · Radix UI Primitive</h3>
                  <span ui-badge variant="outline" class="text-muted-foreground font-mono text-xs">React 19</span>
                  <span ui-badge variant="secondary" class="font-mono text-xs">TypeScript 5.6</span>
                </div>
                <p ui-card-description class="text-muted-foreground text-xs">
                  Multi-file component studio with live preview, syntax engine, and Vitest suite
                </p>
              </div>
            </div>
    
            <!-- Action Buttons -->
            <div class="flex flex-wrap items-center gap-2">
              <div
                class="border-border/80 bg-background/80 flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs"
              >
                <span
                  [class]="
                    cn('size-2 rounded-full transition-colors', isRunning() ? 'bg-warning animate-pulse' : 'bg-success')
                  "
                ></span>
                <span class="text-muted-foreground font-mono">{{
                  isRunning() ? 'Running suite...' : '6 tests passing'
                }}</span>
              </div>
    
              <button
                ui-button
                variant="outline"
                size="sm"
                class="h-8 gap-1.5 text-xs shadow-none"
                title="Reset Playground"
                (click)="handleResetPlayground()"
              >
                <lucide-icon [img]="RotateCcw" class="size-3.5" />
                <span class="hidden sm:inline">Reset</span>
              </button>
    
              <button
                ui-button
                variant="outline"
                size="sm"
                class="h-8 gap-1.5 text-xs shadow-none"
                title="Copy active file contents"
                (click)="handleCopyFile()"
              >
                @if (isCopied()) {
                  <lucide-icon [img]="Check" class="text-success size-3.5" />
                } @else {
                  <lucide-icon [img]="Copy" class="size-3.5" />
                }
                <span>{{ isCopied() ? 'Copied!' : 'Copy File' }}</span>
              </button>
    
              <button
                ui-button
                size="sm"
                class="h-8 gap-1.5 text-xs font-medium"
                [disabled]="isRunning()"
                (click)="handleRunCode()"
              >
                @if (isRunning()) {
                  <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" />
                } @else {
                  <lucide-icon [img]="Play" class="size-3.5 fill-current" />
                }
                <span>Run Code</span>
                <kbd
                  class="border-primary-foreground/30 bg-primary-foreground/10 hidden rounded border px-1 font-mono text-xs sm:inline"
                >
                  ⌘↵
                </kbd>
              </button>
            </div>
          </div>
    
          <!-- Main 2-Column Studio Grid -->
          <div class="divide-border grid grid-cols-1 divide-y lg:grid-cols-12 lg:divide-x lg:divide-y-0">
            <!-- Left Column: File Tabs & Dark Code Editor (7/12 cols) -->
            <section class="flex min-h-[560px] flex-col bg-zinc-950 text-zinc-100 lg:col-span-7">
              <!-- File Navigation Tabs -->
              <div class="flex items-center justify-between border-b border-zinc-800/80 bg-zinc-900/70 px-2">
                <div class="flex scrollbar-none items-center gap-1 overflow-x-auto py-1.5">
                  @for (file of files; track file.id) {
                    <button
                      type="button"
                      [class]="
                        cn(
                          'group flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 font-mono text-xs transition-colors',
                          activeFileId() === file.id
                            ? 'border border-zinc-800 bg-zinc-950 text-zinc-100 shadow-xs'
                            : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
                        )
                      "
                      (click)="activeFileId.set(file.id)"
                    >
                      <lucide-icon
                        [img]="fileIcon(file)"
                        [class]="
                          cn('size-3.5 transition-colors', activeFileId() === file.id ? fileTone(file) : 'text-zinc-500')
                        "
                      />
                      <span>{{ file.name }}</span>
                    </button>
                  }
                </div>
    
                <span class="hidden font-mono text-xs text-zinc-500 xl:inline">{{ activeFile().size }}</span>
              </div>
    
              <!-- Breadcrumb / Path Info -->
              <div
                class="flex items-center justify-between border-b border-zinc-800/60 bg-zinc-950/80 px-4 py-1.5 font-mono text-xs text-zinc-400"
              >
                <div class="flex items-center gap-1.5 overflow-hidden text-ellipsis whitespace-nowrap">
                  <span class="text-zinc-600">src /</span>
                  <span>{{ activeFile().path }}</span>
                </div>
                <span class="text-zinc-500">{{ activeFileLines().length }} lines</span>
              </div>
    
              <!-- Code Editor Body -->
              <div
                class="relative flex flex-1 overflow-x-auto bg-zinc-950 py-3 font-mono text-xs leading-relaxed select-text"
              >
                <!-- Gutter Line Numbers -->
                <div class="flex flex-col border-r border-zinc-800/60 px-3 text-right text-zinc-600 select-none">
                  @for (line of activeFileLines(); track $index) {
                    <span
                      [class]="
                        cn(
                          'h-5 leading-5 transition-colors',
                          hoveredLine() === $index + 1 ? 'font-semibold text-zinc-300' : ''
                        )
                      "
                      >{{ $index + 1 }}</span
                    >
                  }
                </div>
    
                <!-- Code Lines with Syntax Coloring -->
                <div class="flex-1 px-4 whitespace-pre">
                  @for (html of highlightedLines(); track $index) {
                    <div
                      [class]="
                        cn(
                          'group flex h-5 items-center rounded-xs px-1 leading-5 transition-colors',
                          hoveredLine() === $index + 1 ? 'bg-zinc-800/40' : ''
                        )
                      "
                      (mouseenter)="hoveredLine.set($index + 1)"
                      (mouseleave)="hoveredLine.set(null)"
                    >
                      <span [innerHTML]="html"></span>
                    </div>
                  }
                </div>
              </div>
    
              <!-- Editor Status Bar -->
              <div
                class="flex items-center justify-between border-t border-zinc-800/60 bg-zinc-900/90 px-3 py-1 font-mono text-xs text-zinc-400"
              >
                <div class="flex items-center gap-3">
                  <span>UTF-8</span>
                  <span>2 Spaces</span>
                  <span class="text-zinc-500">React TSX</span>
                </div>
                <div class="flex items-center gap-3">
                  <span>Ln {{ hoveredLine() ?? 1 }}, Col 1</span>
                  <span class="text-success">Prettier ✓</span>
                </div>
              </div>
            </section>
    
            <!-- Right Column: Live Sandbox & Terminal Console (5/12 cols) -->
            <section class="bg-background flex min-h-[560px] flex-col lg:col-span-5">
              <ui-tabs [value]="activeRightTab()" (valueChange)="activeRightTab.set($event)" class="flex h-full flex-col">
                <!-- Right Tab Header -->
                <div class="border-border bg-muted/30 border-b px-3 py-2">
                  <ui-tabs-list class="grid w-full grid-cols-2">
                    <button ui-tabs-trigger value="preview" class="gap-1.5 text-xs">
                      <lucide-icon [img]="Eye" class="size-3.5" />
                      <span>Live Preview</span>
                    </button>
                    <button ui-tabs-trigger value="console" class="gap-1.5 text-xs">
                      <lucide-icon [img]="Terminal" class="size-3.5" />
                      <span>Terminal</span>
                      <span ui-badge variant="secondary" class="text-success ml-1 px-1 py-0 font-mono text-xs">PASS</span>
                    </button>
                  </ui-tabs-list>
                </div>
    
                <!-- TAB 1: Live Component Sandbox -->
                <ui-tabs-content value="preview" class="m-0 flex flex-1 flex-col gap-4 p-4">
                  <!-- Variant & Style Controls -->
                  <div class="border-border bg-card space-y-3 rounded-lg border p-3 shadow-xs">
                    <div class="flex items-center justify-between">
                      <span class="text-foreground text-xs font-semibold">Variant</span>
                      <span class="text-muted-foreground font-mono text-xs">{{ selectedVariant() }}</span>
                    </div>
                    <div class="flex flex-wrap gap-1.5">
                      @for (v of variants; track v) {
                        <button
                          type="button"
                          [class]="
                            cn(
                              'min-h-6 cursor-pointer rounded-md px-2.5 py-1 font-mono text-xs capitalize transition-colors',
                              selectedVariant() === v
                                ? 'bg-primary text-primary-foreground font-medium shadow-xs'
                                : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'
                            )
                          "
                          (click)="selectedVariant.set(v)"
                        >
                          {{ v }}
                        </button>
                      }
                    </div>
    
                    <ui-separator class="my-2" />
    
                    <div class="flex items-center justify-between">
                      <span class="text-foreground text-xs font-semibold">Size &amp; Options</span>
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5">
                      @for (s of sizes; track s) {
                        <button
                          type="button"
                          [class]="
                            cn(
                              'min-h-6 cursor-pointer rounded-md px-2 py-0.5 font-mono text-xs uppercase transition-colors',
                              selectedSize() === s
                                ? 'bg-secondary text-secondary-foreground font-semibold'
                                : 'bg-muted/40 text-muted-foreground hover:bg-muted'
                            )
                          "
                          (click)="selectedSize.set(s)"
                        >
                          {{ s }}
                        </button>
                      }
    
                      <div class="ml-auto flex items-center gap-2">
                        <label class="text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none">
                          <input
                            type="checkbox"
                            [checked]="isDisabled()"
                            (change)="isDisabled.set(checked($event))"
                            class="accent-primary"
                          />
                          <span>Disabled</span>
                        </label>
                        <label class="text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none">
                          <input
                            type="checkbox"
                            [checked]="isLoading()"
                            (change)="isLoading.set(checked($event))"
                            class="accent-primary"
                          />
                          <span>Loading</span>
                        </label>
                        <label class="text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none">
                          <input
                            type="checkbox"
                            [checked]="withIcon()"
                            (change)="withIcon.set(checked($event))"
                            class="accent-primary"
                          />
                          <span>Icon</span>
                        </label>
                      </div>
                    </div>
                  </div>
    
                  <!-- Render Stage Canvas -->
                  <div
                    class="border-border bg-muted/20 relative flex min-h-[160px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-6"
                  >
                    <div class="flex flex-col items-center gap-3">
                      <button
                        ui-button
                        [variant]="selectedVariant()"
                        [size]="selectedSize()"
                        [disabled]="isDisabled() || isLoading()"
                        class="transition-[color,background-color,border-color,box-shadow,opacity,transform,scale,translate,rotate] active:scale-95"
                        (click)="handleButtonClick()"
                      >
                        @if (isLoading()) {
                          <lucide-icon [img]="Loader2" class="size-4 animate-spin" />
                        } @else if (withIcon() || selectedSize() === 'icon') {
                          <lucide-icon [img]="Zap" class="size-4" />
                        }
                        @if (selectedSize() !== 'icon') {
                          <span>Interactive Button</span>
                        }
                      </button>
    
                      <p class="text-muted-foreground font-mono text-xs">
                        Clicks: <span class="text-foreground font-semibold">{{ clickCount() }}</span>
                      </p>
                    </div>
    
                    <!-- Last Interaction Status -->
                    <div
                      class="bg-background/80 text-muted-foreground absolute right-2 bottom-2 left-2 flex items-center justify-between rounded-md px-2 py-1 text-xs backdrop-blur-xs"
                    >
                      <span class="truncate font-mono">{{ lastInteraction() }}</span>
                      <span class="text-success shrink-0 font-medium">Rendered OK</span>
                    </div>
                  </div>
    
                  <!-- Dynamic Snippet Output -->
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between text-xs">
                      <span class="text-foreground font-medium">Usage Code</span>
                      <button
                        type="button"
                        class="text-muted-foreground hover:text-foreground flex min-h-6 cursor-pointer items-center gap-1"
                        (click)="handleCopySnippet()"
                      >
                        @if (isSnippetCopied()) {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ isSnippetCopied() ? 'Copied' : 'Copy' }}</span>
                      </button>
                    </div>
                    <pre
                      class="border-border bg-muted/40 text-foreground overflow-x-auto rounded-md border p-2.5 font-mono text-xs leading-relaxed"
                    ><code>{{ generatedSnippet() }}</code></pre>
                  </div>
                </ui-tabs-content>
    
                <!-- TAB 2: Terminal Console Execution Output -->
                <ui-tabs-content
                  value="console"
                  class="m-0 flex flex-1 flex-col bg-zinc-950 p-4 font-mono text-xs text-zinc-300"
                >
                  <!-- Console Toolbar -->
                  <div class="mb-3 flex items-center justify-between border-b border-zinc-800 pb-2 text-zinc-400">
                    <div class="flex items-center gap-2">
                      <span class="font-semibold text-zinc-200">Vitest Test Runner</span>
                      <span ui-badge variant="outline" class="border-zinc-700 bg-zinc-900 font-mono text-xs text-zinc-300"
                        >v2.1.8</span
                      >
                    </div>
                    <div class="flex items-center gap-3">
                      <span class="text-zinc-500">Run #{{ executionCount() }} ({{ lastRunTime() }})</span>
                      <button
                        type="button"
                        class="cursor-pointer text-zinc-400 hover:text-zinc-100"
                        title="Re-run tests"
                        (click)="handleRunCode()"
                      >
                        <lucide-icon [img]="RotateCcw" class="size-3.5" />
                      </button>
                    </div>
                  </div>
    
                  <!-- Terminal Execution Logs -->
                  <div class="flex-1 space-y-2 overflow-y-auto pr-1">
                    <div class="text-zinc-500">
                      $ vitest run packages/registry-react/components/button/__tests__/button.spec.tsx
                    </div>
    
                    <div class="text-success flex items-center gap-2 font-semibold">
                      <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" />
                      <span>PASS packages/registry-react/components/button/__tests__/button.spec.tsx (6 tests)</span>
                      <span class="font-normal text-zinc-500">42ms</span>
                    </div>
    
                    <div class="ml-4 space-y-1.5 border-l border-zinc-800 pl-3">
                      @for (log of testLogs; track log.id) {
                        <div class="flex items-center justify-between text-zinc-300">
                          <div class="flex items-center gap-2">
                            <lucide-icon [img]="Check" class="text-success size-3" />
                            <span>{{ log.name }}</span>
                          </div>
                          <span class="text-zinc-500">{{ log.duration }}</span>
                        </div>
                      }
                    </div>
    
                    <!-- Summary Card -->
                    <div class="mt-4 space-y-1 rounded-md border border-zinc-800/80 bg-zinc-900/60 p-2.5 text-zinc-400">
                      <div class="flex justify-between">
                        <span>Test Files</span>
                        <span class="text-success font-medium">1 passed (1)</span>
                      </div>
                      <div class="flex justify-between">
                        <span>Tests</span>
                        <span class="text-success font-medium">6 passed (6)</span>
                      </div>
                      <div class="flex justify-between">
                        <span>Start at</span>
                        <span class="text-zinc-300">{{ lastRunTime() }}</span>
                      </div>
                      <div class="flex justify-between">
                        <span>Duration</span>
                        <span class="text-zinc-300">42ms (transform 12ms, setup 0ms, collect 8ms, tests 42ms)</span>
                      </div>
                    </div>
                  </div>
    
                  <!-- Bottom Console Status Banner -->
                  <div
                    class="border-success/30 bg-success/10 text-success mt-3 flex items-center justify-between rounded-md border px-3 py-2"
                  >
                    <div class="flex items-center gap-2 font-semibold">
                      <lucide-icon [img]="CheckCircle2" class="size-4" />
                      <span>PASS · 6 passed, 0 failed in 42ms</span>
                    </div>
                    <span class="text-success/80 text-xs">Exit code: 0</span>
                  </div>
                </ui-tabs-content>
              </ui-tabs>
            </section>
          </div>
        </div>
      `,
    })
    export class UiCodeSnippetPlaygroundComponent {
      /** Extra classes for the root Card (React `className`). */
      @Input('class') className?: string
    
      protected readonly cn = cn
      protected readonly files = files
      protected readonly testLogs = testLogs
      protected readonly variants = variants
      protected readonly sizes = sizes
      protected readonly Code2 = Code2
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Copy = Copy
      protected readonly Eye = Eye
      protected readonly Loader2 = Loader2
      protected readonly Play = Play
      protected readonly RotateCcw = RotateCcw
      protected readonly Terminal = Terminal
      protected readonly Zap = Zap
    
      readonly activeFileId = signal('button-tsx')
      readonly activeRightTab = signal('preview')
      readonly hoveredLine = signal<number | null>(null)
      readonly isCopied = signal(false)
      readonly isSnippetCopied = signal(false)
      readonly isRunning = signal(false)
      readonly lastRunTime = signal('14:32:08')
      readonly executionCount = signal(1)
    
      // Interactive Workbench State
      readonly selectedVariant = signal<CodeSnippetPlaygroundButtonVariant>('default')
      readonly selectedSize = signal<CodeSnippetPlaygroundButtonSize>('default')
      readonly isDisabled = signal(false)
      readonly isLoading = signal(false)
      readonly withIcon = signal(true)
      readonly clickCount = signal(0)
      readonly lastInteraction = signal('Ready for test interactions')
    
      readonly activeFile = computed(() => files.find((f) => f.id === this.activeFileId()) ?? files[0]!)
      readonly activeFileLines = computed(() => this.activeFile().content.split('\n'))
      readonly highlightedLines = computed(() => this.activeFileLines().map(highlightSyntaxLine))
    
      readonly generatedSnippet = computed(() => {
        const variant = this.selectedVariant()
        const size = this.selectedSize()
        const v = variant !== 'default' ? ` variant="${variant}"` : ''
        const s = size !== 'default' ? ` size="${size}"` : ''
        const d = this.isDisabled() ? ' disabled' : ''
    
        if (size === 'icon') {
          return `<Button${v}${s}${d}>\n  <Zap className="size-4" />\n</Button>`
        }
    
        if (this.isLoading()) {
          return `<Button${v}${s}${d}>\n  <Loader2 className="size-4 animate-spin" />\n  Please wait\n</Button>`
        }
    
        if (this.withIcon()) {
          return `<Button${v}${s}${d}>\n  <Zap className="size-4" />\n  Interactive Button\n</Button>`
        }
    
        return `<Button${v}${s}${d}>Interactive Button</Button>`
      })
    
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t)))
      }
    
      get rootClass(): string {
        return cn('border-border bg-card w-full overflow-hidden shadow-xs', this.className)
      }
    
      fileIcon(file: CodeSnippetPlaygroundFile): LucideIconData {
        if (file.name.endsWith('.tsx')) return FileCode
        if (file.name.endsWith('.variants.ts')) return Braces
        if (file.name === 'index.ts') return Layers
        return CheckCircle2
      }
    
      fileTone(file: CodeSnippetPlaygroundFile): string {
        if (file.name.endsWith('.tsx')) return 'text-info'
        if (file.name.endsWith('.variants.ts')) return 'text-chart-1'
        if (file.name === 'index.ts') return 'text-warning'
        return 'text-success'
      }
    
      checked(event: Event): boolean {
        return (event.target as HTMLInputElement).checked
      }
    
      handleRunCode(): void {
        if (this.isRunning()) return
        this.isRunning.set(true)
        this.activeRightTab.set('console')
    
        this.later(() => {
          this.isRunning.set(false)
          this.executionCount.update((prev) => prev + 1)
          this.lastRunTime.set(new Date().toTimeString().split(' ')[0]!)
        }, 480)
      }
    
      handleCopyFile(): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(this.activeFile().content)
          this.isCopied.set(true)
          this.later(() => this.isCopied.set(false), 2000)
        }
      }
    
      handleCopySnippet(): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(this.generatedSnippet())
          this.isSnippetCopied.set(true)
          this.later(() => this.isSnippetCopied.set(false), 2000)
        }
      }
    
      handleResetPlayground(): void {
        this.activeFileId.set('button-tsx')
        this.selectedVariant.set('default')
        this.selectedSize.set('default')
        this.isDisabled.set(false)
        this.isLoading.set(false)
        this.withIcon.set(true)
        this.clickCount.set(0)
        this.lastInteraction.set('Playground reset to initial state')
      }
    
      handleButtonClick(): void {
        if (this.isDisabled() || this.isLoading()) return
        const next = this.clickCount() + 1
        this.clickCount.set(next)
        this.lastInteraction.set(`Dispatched onClick event #${next} at ${new Date().toLocaleTimeString()}`)
      }
    
      onWindowKeydown(e: KeyboardEvent): void {
        if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
          e.preventDefault()
          this.handleRunCode()
        }
      }
    
      private later(fn: () => void, ms: number): void {
        const t = setTimeout(() => {
          this.timers.delete(t)
          fn()
        }, ms)
        this.timers.add(t)
      }
    }
    
  • src/app/components/blocks/code-snippet-playground/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/code-snippet-playground.json