Menu

Change language

GitHub repo

Feature Drag Drop Organizer

blockfeature

Interactive dashboard block layout organizer with position reordering, visibility toggling, and JSON schema export.

Installation

$npx uipkge-ng@latest add feature-drag-drop-organizer
Manifest:https://uipkge.dev/r/angular/feature-drag-drop-organizer.jsonInstalls to:src/app/components/blocks/feature-drag-drop-organizer/

Variants

Loading interactive previews…

Schema

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

LayoutWidget
interface LayoutWidget {
  id: string
  name: string
  category: string
  visible: boolean
  colSpan: number
  previewSnippet: string
}

npm dependencies

Files installed (2)

  • src/app/components/blocks/feature-drag-drop-organizer/feature-drag-drop-organizer.component.ts12.4 kB
    // Composable dashboard layout workbench: widget hierarchy with up/down reordering, visibility
    // toggles, a live canvas preview that follows the order, and a generated JSON schema with copy.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Activity,
      ArrowDown,
      ArrowRight,
      ArrowUp,
      Check,
      Copy,
      Eye,
      EyeOff,
      Layers,
      LucideAngularModule,
      RotateCcw,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    
    export interface LayoutWidget {
      id: string
      name: string
      category: string
      visible: boolean
      colSpan: number
      previewSnippet: string
    }
    
    export const INITIAL_WIDGETS: LayoutWidget[] = [
      {
        id: 'widget-kpi',
        name: 'Real-time KPI Metrics',
        category: 'Analytics',
        visible: true,
        colSpan: 12,
        previewSnippet: '3 Cards: $124.5k MRR • 18ms Latency • 99.99% Uptime',
      },
      {
        id: 'widget-chart',
        name: 'Traffic Sparkline Chart',
        category: 'Telemetry',
        visible: true,
        colSpan: 8,
        previewSnippet: 'SVG Real-time stream (35,000 req/s live)',
      },
      {
        id: 'widget-nodes',
        name: 'Edge PoP Status',
        category: 'Infrastructure',
        visible: true,
        colSpan: 4,
        previewSnippet: '8 global edge regions active (optimal)',
      },
      {
        id: 'widget-feed',
        name: 'Deployment Activity Feed',
        category: 'Workflows',
        visible: true,
        colSpan: 12,
        previewSnippet: 'sha-a81f9c deployed to edge via CLI (2 mins ago)',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-feature-drag-drop-organizer, [ui-feature-drag-drop-organizer]',
      standalone: true,
      // React renders the root <section> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent],
      template: `
        <section data-slot="feature-drag-drop-organizer" [class]="rootClass">
          <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
            <!-- Section Header -->
            <div class="mx-auto max-w-3xl space-y-4 text-center">
              <a
                href="#layout-organizer"
                class="group border-border/80 bg-secondary/60 hover:bg-secondary text-foreground inline-flex items-center gap-2 rounded-full border px-3.5 py-1 text-xs font-medium shadow-2xs transition-colors"
              >
                <lucide-icon [img]="Layers" class="text-primary size-3.5" />
                <span>Composable Layout Workbench</span>
                <lucide-icon
                  [img]="ArrowRight"
                  class="text-muted-foreground size-3 transition-transform group-hover:translate-x-0.5"
                />
              </a>
    
              <h2 class="text-foreground text-3xl font-bold tracking-tight sm:text-4xl">{{ title }}</h2>
    
              <p class="text-muted-foreground text-base sm:text-lg">{{ description }}</p>
            </div>
    
            <!-- Workbench Grid (2 Columns: Controls & Live Canvas) -->
            <div class="mt-12 grid grid-cols-1 gap-6 lg:grid-cols-12">
              <!-- Left: Widget Re-ordering Controls (6 Cols) -->
              <div ui-card class="border-border bg-card flex flex-col justify-between shadow-xs lg:col-span-6">
                <div ui-card-content class="space-y-5 p-6">
                  <div class="border-border flex items-center justify-between border-b pb-3">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Layers" class="text-primary size-4" />
                      <span class="text-foreground text-sm font-semibold">Widget Hierarchy</span>
                    </div>
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground h-7 gap-1 px-2 font-mono text-xs active:scale-95"
                      (click)="resetLayout()"
                    >
                      <lucide-icon [img]="RotateCcw" class="size-3" />
                      <span>Reset</span>
                    </button>
                  </div>
    
                  <!-- Reorderable List of Widgets -->
                  <div class="space-y-2">
                    @for (widget of widgets(); track widget.id; let idx = $index) {
                      <div [class]="widgetRowClass(widget)">
                        <div class="flex min-w-0 items-center gap-3">
                          <div class="text-muted-foreground flex items-center gap-1">
                            <button
                              type="button"
                              [disabled]="idx === 0"
                              [attr.aria-label]="'Move ' + widget.name + ' up'"
                              class="hover:bg-muted rounded p-1 transition-[colors,transform] active:scale-90 disabled:opacity-30"
                              (click)="moveWidget(idx, 'up')"
                            >
                              <lucide-icon [img]="ArrowUp" class="size-3.5" />
                            </button>
                            <button
                              type="button"
                              [disabled]="idx === widgets().length - 1"
                              [attr.aria-label]="'Move ' + widget.name + ' down'"
                              class="hover:bg-muted rounded p-1 transition-[colors,transform] active:scale-90 disabled:opacity-30"
                              (click)="moveWidget(idx, 'down')"
                            >
                              <lucide-icon [img]="ArrowDown" class="size-3.5" />
                            </button>
                          </div>
    
                          <div class="min-w-0 space-y-0.5">
                            <div class="flex items-center gap-2">
                              <span class="text-foreground text-xs font-bold">{{ widget.name }}</span>
                              <span ui-badge variant="outline" class="border-border text-muted-foreground text-xs">
                                {{ widget.category }}
                              </span>
                            </div>
                            <div class="text-muted-foreground truncate text-xs">{{ widget.previewSnippet }}</div>
                          </div>
                        </div>
    
                        <button
                          ui-button
                          variant="ghost"
                          size="icon"
                          class="text-muted-foreground hover:text-foreground size-8 shrink-0 active:scale-95"
                          [attr.aria-label]="(widget.visible ? 'Hide ' : 'Show ') + widget.name"
                          (click)="toggleVisibility(idx)"
                        >
                          @if (widget.visible) {
                            <lucide-icon [img]="Eye" class="text-primary size-3.5" />
                          } @else {
                            <lucide-icon [img]="EyeOff" class="size-3.5" />
                          }
                        </button>
                      </div>
                    }
                  </div>
    
                  <!-- Export JSON Box -->
                  <div class="border-border space-y-2 border-t pt-2">
                    <div class="flex items-center justify-between text-xs">
                      <span class="text-foreground font-medium">Generated Layout JSON</span>
                      <button
                        ui-button
                        variant="ghost"
                        size="sm"
                        class="h-6 gap-1 px-2 font-mono text-xs active:scale-95"
                        (click)="copyJson()"
                      >
                        @if (copied()) {
                          <lucide-icon [img]="Check" class="text-primary size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ copied() ? 'Copied' : 'Copy JSON' }}</span>
                      </button>
                    </div>
                    <pre
                      class="border-border bg-muted/40 text-muted-foreground max-h-32 overflow-y-auto rounded-lg border p-2.5 font-mono text-xs"
                    ><code>{{ exportJson() }}</code></pre>
                  </div>
                </div>
              </div>
    
              <!-- Right: Live Canvas Preview (6 Cols) -->
              <div ui-card class="border-border bg-card flex flex-col justify-between shadow-xs lg:col-span-6">
                <div ui-card-content class="space-y-4 p-6">
                  <div class="border-border flex items-center justify-between border-b pb-3">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Activity" class="text-success size-4" />
                      <span class="text-foreground text-sm font-semibold">Rendered Canvas Preview</span>
                    </div>
                    <span class="text-success font-mono text-xs">Live Synchronized</span>
                  </div>
    
                  <!-- Simulated Dashboard Canvas with Reordered Components -->
                  <div
                    data-testid="canvas-preview"
                    class="border-border/80 bg-background/80 space-y-3 rounded-lg border p-4"
                  >
                    @for (widget of widgets(); track widget.id) {
                      @if (widget.visible) {
                        <div class="border-border bg-muted/30 space-y-1 rounded-lg border p-3 transition-colors">
                          <div class="text-foreground flex items-center justify-between text-xs font-semibold">
                            <span>{{ widget.name }}</span>
                            <span class="text-muted-foreground font-mono text-xs">col-span-{{ widget.colSpan }}</span>
                          </div>
                          <div class="text-muted-foreground text-xs">{{ widget.previewSnippet }}</div>
                        </div>
                      }
                    }
                  </div>
                </div>
              </div>
            </div>
          </div>
        </section>
      `,
    })
    export class UiFeatureDragDropOrganizerComponent {
      protected readonly Activity = Activity
      protected readonly ArrowDown = ArrowDown
      protected readonly ArrowRight = ArrowRight
      protected readonly ArrowUp = ArrowUp
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Eye = Eye
      protected readonly EyeOff = EyeOff
      protected readonly Layers = Layers
      protected readonly RotateCcw = RotateCcw
    
      @Input() title = 'Arrange dashboard layouts intuitively with zero boilerplate.'
      @Input() description =
        'Customize widget order, toggle component visibility, and export clean declarative JSON layout schemas for your application.'
      @Input() set initialWidgets(v: LayoutWidget[] | undefined) {
        this.widgets.set((v ?? INITIAL_WIDGETS).map((w) => ({ ...w })))
      }
      @Input('class') className?: string
    
      readonly widgets = signal<LayoutWidget[]>(INITIAL_WIDGETS.map((w) => ({ ...w })))
      readonly copied = signal(false)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly exportJson = computed(() =>
        JSON.stringify(
          this.widgets().map((w, idx) => ({ order: idx, id: w.id, name: w.name, visible: w.visible, colSpan: w.colSpan })),
          null,
          2,
        ),
      )
    
      get rootClass(): string {
        return cn('bg-background relative overflow-hidden py-16 sm:py-24', this.className)
      }
    
      widgetRowClass(widget: LayoutWidget): string {
        return cn(
          'flex items-center justify-between rounded-lg border p-3 transition-colors',
          widget.visible ? 'border-border bg-background' : 'border-border/60 bg-muted/30 opacity-60',
        )
      }
    
      moveWidget(index: number, direction: 'up' | 'down'): void {
        const targetIndex = direction === 'up' ? index - 1 : index + 1
        if (targetIndex < 0 || targetIndex >= this.widgets().length) return
        const updated = [...this.widgets()]
        const [removed] = updated.splice(index, 1)
        updated.splice(targetIndex, 0, removed!)
        this.widgets.set(updated)
      }
    
      toggleVisibility(index: number): void {
        this.widgets.update((prev) => {
          const next = [...prev]
          next[index] = { ...next[index]!, visible: !next[index]!.visible }
          return next
        })
      }
    
      resetLayout(): void {
        this.widgets.set(INITIAL_WIDGETS.map((w) => ({ ...w })))
      }
    
      async copyJson(): Promise<void> {
        try {
          await navigator.clipboard.writeText(this.exportJson())
          this.copied.set(true)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.copied.set(false), 2000)
        } catch {
          // Clipboard unavailable (insecure context / jsdom): leave the label unchanged.
        }
      }
    }
    
  • src/app/components/blocks/feature-drag-drop-organizer/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/feature-drag-drop-organizer.json