Menu

Change language

GitHub repo

Product Comparison Drawer

blockcommerce

Floating multi-product specification comparison bar with attribute diff highlighting, expanded full-matrix drawer comparison, and sticky buy triggers.

Installation

$npx uipkge-ng@latest add product-comparison-drawer
Manifest:https://uipkge.dev/r/angular/product-comparison-drawer.jsonInstalls to:src/app/components/blocks/product-comparison-drawer/

Variants

Loading interactive previews…

Schema

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

ComparisonSpec
interface ComparisonSpec {
  price: string
  driverSize: string
  batteryLife: string
  ancType: string
  waterResistance: string
  weight: string
  accessories: string
  connectivity?: string
  fastCharging?: string
  microphone?: string
  warranty?: string
}
ComparisonProduct
interface ComparisonProduct {
  id: string
  name: string
  tagline: string
  badge: string
  badgeVariant?: 'default' | 'secondary' | 'outline'
  price: string
  numericPrice: number
  rating: number
  reviewsCount: number
  image: string
  inStock: boolean
  shipping: string
  specs: ComparisonSpec
}
SpecField
interface SpecField {
  key: keyof ComparisonSpec
  label: string
  category: 'Key Specifications' | 'Audio & Connectivity' | 'Package & Warranty'
  description?: string
}
SpecCategory
interface SpecCategory {
  name: string
  fields: SpecField[]
}

Files installed (2)

  • src/app/components/blocks/product-comparison-drawer/product-comparison-drawer.component.ts34.8 kB
    import {
      Component,
      EventEmitter,
      Input,
      Output,
      booleanAttribute,
      computed,
      signal,
      type OnInit,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import {
      Check,
      ChevronDown,
      ChevronUp,
      GitCompare,
      Layers,
      LucideAngularModule,
      RotateCcw,
      ShoppingCart,
      SlidersHorizontal,
      Star,
      Trash2,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    
    export interface ComparisonSpec {
      price: string
      driverSize: string
      batteryLife: string
      ancType: string
      waterResistance: string
      weight: string
      accessories: string
      connectivity?: string
      fastCharging?: string
      microphone?: string
      warranty?: string
    }
    
    export interface ComparisonProduct {
      id: string
      name: string
      tagline: string
      badge: string
      badgeVariant?: 'default' | 'secondary' | 'outline'
      price: string
      numericPrice: number
      rating: number
      reviewsCount: number
      image: string
      inStock: boolean
      shipping: string
      specs: ComparisonSpec
    }
    
    export interface SpecField {
      key: keyof ComparisonSpec
      label: string
      category: 'Key Specifications' | 'Audio & Connectivity' | 'Package & Warranty'
      description?: string
    }
    
    export type ProductComparisonDrawerMode = 'dock' | 'expanded'
    
    interface SpecCategory {
      name: string
      fields: SpecField[]
    }
    
    export const DEFAULT_COMPARISON_PRODUCTS: ComparisonProduct[] = [
      {
        id: 'prod-headphones',
        name: 'Pro Wireless Headphones',
        tagline: 'Flagship Over-Ear Studio Sound',
        badge: 'Best Overall',
        badgeVariant: 'default',
        price: '$299.00',
        numericPrice: 299,
        rating: 4.9,
        reviewsCount: 1240,
        image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&auto=format&fit=crop&q=80',
        inStock: true,
        shipping: 'Free Next-Day Delivery',
        specs: {
          price: '$299.00',
          driverSize: '40mm Graphene',
          batteryLife: '40 Hours',
          ancType: 'Adaptive ANC',
          waterResistance: 'IPX4',
          weight: '245g',
          accessories: 'Hard Travel Case, 3.5mm Cable, USB-C Cable, Airplane Adapter',
          connectivity: 'Bluetooth 5.3 + Multipoint',
          fastCharging: '10 min charge = 3 hrs playback',
          microphone: '6 Mics with Beamforming AI',
          warranty: '2-Year Manufacturer Warranty',
        },
      },
      {
        id: 'prod-earbuds',
        name: 'Aero Studio Earbuds',
        tagline: 'Compact True Wireless In-Ear',
        badge: 'Editor Choice',
        badgeVariant: 'secondary',
        price: '$199.00',
        numericPrice: 199,
        rating: 4.7,
        reviewsCount: 890,
        image: 'https://images.unsplash.com/photo-1590658268037-6bf12165a8df?w=400&auto=format&fit=crop&q=80',
        inStock: true,
        shipping: 'Free 2-Day Delivery',
        specs: {
          price: '$199.00',
          driverSize: '11mm Dynamic',
          batteryLife: '28 Hours',
          ancType: 'Standard ANC',
          waterResistance: 'IPX5',
          weight: '54g',
          accessories: 'Wireless Charging Case, 4 Pairs Silicone Tips (XS/S/M/L), USB-C Cable',
          connectivity: 'Bluetooth 5.3 + Multipoint',
          fastCharging: '10 min charge = 3 hrs playback',
          microphone: '4 Mics with Wind Noise Guard',
          warranty: '2-Year Manufacturer Warranty',
        },
      },
      {
        id: 'prod-sport',
        name: 'Active ANC Sport',
        tagline: 'Rugged Ergonomic Workout Earphones',
        badge: 'Best Value',
        badgeVariant: 'secondary',
        price: '$149.00',
        numericPrice: 149,
        rating: 4.6,
        reviewsCount: 620,
        image: 'https://images.unsplash.com/photo-1572536147248-ac59a8abfa4b?w=400&auto=format&fit=crop&q=80',
        inStock: true,
        shipping: 'Free Standard Delivery',
        specs: {
          price: '$149.00',
          driverSize: '10mm Hybrid',
          batteryLife: '24 Hours',
          ancType: 'Passive Noise Isolation',
          waterResistance: 'IPX7 Waterproof',
          weight: '62g',
          accessories: 'Compact Case, Secure Wingtips, 3 Pairs Memory Foam Tips, USB-C Cable',
          connectivity: 'Bluetooth 5.3',
          fastCharging: '10 min charge = 3 hrs playback',
          microphone: '2 Mics with Environmental Noise Cancellation',
          warranty: '2-Year Manufacturer Warranty',
        },
      },
    ]
    
    export const SPEC_FIELDS: SpecField[] = [
      {
        key: 'price',
        label: 'Price',
        category: 'Key Specifications',
        description: 'Manufacturer suggested retail price',
      },
      {
        key: 'driverSize',
        label: 'Driver Size',
        category: 'Key Specifications',
        description: 'Acoustic transducer diameter and diaphragm material',
      },
      {
        key: 'batteryLife',
        label: 'Battery Life',
        category: 'Key Specifications',
        description: 'Total listening duration with active cancellation enabled',
      },
      {
        key: 'ancType',
        label: 'ANC Type',
        category: 'Key Specifications',
        description: 'Acoustic cancellation and transparency algorithm type',
      },
      {
        key: 'waterResistance',
        label: 'Water Resistance',
        category: 'Key Specifications',
        description: 'Ingress protection rating against moisture and dust',
      },
      {
        key: 'weight',
        label: 'Weight',
        category: 'Key Specifications',
        description: 'Total physical mass including ear cushions/enclosures',
      },
      {
        key: 'accessories',
        label: 'In-Box Accessories',
        category: 'Package & Warranty',
        description: 'Included bundled cables, adapters, and travel protection',
      },
      {
        key: 'connectivity',
        label: 'Connectivity',
        category: 'Audio & Connectivity',
        description: 'Wireless Bluetooth specification and dual-device pairing',
      },
      {
        key: 'fastCharging',
        label: 'Fast Charging',
        category: 'Audio & Connectivity',
        description: 'Quick-boost charging time for emergency playback',
      },
      {
        key: 'microphone',
        label: 'Microphone Array',
        category: 'Audio & Connectivity',
        description: 'Voice pickup sensors and beamforming algorithms',
      },
      {
        key: 'warranty',
        label: 'Warranty',
        category: 'Package & Warranty',
        description: 'Manufacturer warranty coverage period',
      },
    ]
    
    /** SPEC_FIELDS grouped by category, in first-seen order (React's `categories` memo). */
    function groupFields(fields: SpecField[]): SpecCategory[] {
      const cats: SpecCategory[] = []
      for (const field of fields) {
        let group = cats.find((c) => c.name === field.category)
        if (!group) {
          group = { name: field.category, fields: [] }
          cats.push(group)
        }
        group.fields.push(field)
      }
      return cats
    }
    
    /**
     * ProductComparisonDrawer -- the React block, 1:1: a catalog preview with a sticky compare dock
     * (`mode="dock"`) that expands into a full specification matrix, or the matrix on its own
     * (`mode="expanded"`). The matrix groups specs by category, can highlight only the rows whose
     * values differ across the selected products, and ends in a sticky buy-trigger footer.
     * Emits `addToCart`, `removeProduct`, `clearAll`, `restoreProducts` and `openChange` where React
     * calls the matching `on*` prop.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-product-comparison-drawer, [ui-product-comparison-drawer]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiSeparatorComponent, UiSwitchComponent],
      template: `
        <div data-slot="product-comparison-drawer" [class]="rootClass">
          <!-- Background Mock Catalog Page Preview (Context for Dock) -->
          @if (mode === 'dock') {
            <div class="bg-muted/10 flex flex-col p-4 sm:p-6 lg:p-8">
              <div class="mb-6 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
                <div>
                  <div class="flex items-center gap-2">
                    <span
                      class="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs font-semibold tracking-wider uppercase"
                    >
                      Audio Lab
                    </span>
                    <span class="text-muted-foreground text-xs font-medium">Over-Ear & In-Ear Systems</span>
                  </div>
                  <h2 class="text-xl font-bold tracking-tight sm:text-2xl">High-Fidelity Audio Lineup</h2>
                  <p class="text-muted-foreground mt-1 text-xs sm:text-sm">
                    Select flagship models below to compare acoustic architectures, battery runtime, and diffs side by side.
                  </p>
                </div>
    
                <div class="flex items-center gap-2">
                  @if (selectedProducts().length < catalog.length) {
                    <button ui-button variant="outline" size="sm" class="text-xs" (click)="handleRestoreProducts()">
                      <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3.5" />
                      Reset Selection
                    </button>
                  }
                  <button ui-button variant="default" size="sm" class="text-xs" (click)="toggleExpanded()">
                    <lucide-icon [img]="SlidersHorizontal" class="mr-1.5 size-3.5" />
                    {{ isExpanded() ? 'Hide Comparison' : 'Compare (' + selectedProducts().length + ')' }}
                  </button>
                </div>
              </div>
    
              <!-- Mock Catalog Cards Grid -->
              <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
                @for (product of catalog; track product.id) {
                  <div [class]="catalogCardClass(product)">
                    <div>
                      <div class="bg-muted/40 relative aspect-4/3 w-full overflow-hidden rounded-md">
                        <img
                          [src]="product.image"
                          [alt]="product.name"
                          class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
                          loading="lazy"
                        />
                        <div class="absolute top-2.5 left-2.5">
                          <span
                            ui-badge
                            [variant]="product.badgeVariant || 'default'"
                            class="text-xs font-medium shadow-xs"
                          >
                            {{ product.badge }}
                          </span>
                        </div>
                      </div>
    
                      <div class="mt-3.5 space-y-1">
                        <div class="flex items-center justify-between">
                          <div class="text-warning flex items-center gap-1 text-xs">
                            <lucide-icon [img]="Star" class="size-3.5 fill-current" />
                            <span class="text-foreground font-semibold tabular-nums">{{ product.rating }}</span>
                            <span class="text-muted-foreground">({{ formatCount(product.reviewsCount) }})</span>
                          </div>
                          <span class="text-sm font-bold tabular-nums">{{ product.price }}</span>
                        </div>
                        <h3 class="text-foreground text-sm font-semibold tracking-tight">{{ product.name }}</h3>
                        <p class="text-muted-foreground line-clamp-1 text-xs">{{ product.tagline }}</p>
                      </div>
                    </div>
    
                    <div class="mt-4 flex items-center justify-between gap-2 border-t pt-3">
                      <button
                        type="button"
                        class="text-muted-foreground hover:text-foreground flex min-h-6 items-center gap-1.5 text-xs font-medium transition-colors"
                        (click)="toggleCompare(product)"
                      >
                        <div [class]="compareCheckClass(product)">
                          @if (isSelected(product)) {
                            <lucide-icon [img]="Check" class="size-3 stroke-[3]" />
                          }
                        </div>
                        <span>{{ isSelected(product) ? 'Comparing' : 'Add to compare' }}</span>
                      </button>
    
                      <button
                        ui-button
                        size="sm"
                        variant="outline"
                        class="h-8 text-xs"
                        [disabled]="addedProductId() === product.id"
                        (click)="handleAddToCart(product)"
                      >
                        @if (addedProductId() === product.id) {
                          <lucide-icon [img]="Check" class="text-success mr-1 size-3.5" />
                          Added
                        } @else {
                          <lucide-icon [img]="ShoppingCart" class="mr-1 size-3.5" />
                          Add
                        }
                      </button>
                    </div>
                  </div>
                }
              </div>
            </div>
          }
    
          <!-- Floating Sticky Comparison Bar (Dock at Bottom) -->
          @if (mode === 'dock' && !isExpanded()) {
            <div
              class="bg-background/95 supports-[backdrop-filter]:bg-background/85 sticky bottom-0 z-30 w-full border-t p-3 backdrop-blur-md sm:p-4"
            >
              <div class="mx-auto flex max-w-6xl flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center">
                <!-- Selected Products List -->
                <div class="flex items-center gap-2 overflow-x-auto pb-1 sm:gap-3 sm:pb-0">
                  <div class="flex shrink-0 items-center gap-1.5 pr-2">
                    <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                      <lucide-icon [img]="Layers" class="size-4" />
                    </div>
                    <div class="flex flex-col">
                      <span class="text-xs leading-tight font-semibold">Compare Dock</span>
                      <span class="text-muted-foreground text-xs tabular-nums">
                        {{ selectedProducts().length }} / {{ catalog.length }} selected
                      </span>
                    </div>
                  </div>
    
                  <ui-separator orientation="vertical" class="hidden h-8 sm:block" />
    
                  <!-- Selected Thumbnails -->
                  @if (selectedProducts().length > 0) {
                    <div class="flex items-center gap-2">
                      @for (product of selectedProducts(); track product.id) {
                        <div
                          class="bg-card group hover:border-primary/40 relative flex shrink-0 items-center gap-2 rounded-lg border p-1.5 pr-2.5 shadow-2xs transition-colors"
                        >
                          <img [src]="product.image" [alt]="product.name" class="size-9 rounded-md object-cover" />
                          <div class="flex flex-col">
                            <span class="max-w-[110px] truncate text-xs font-medium sm:max-w-[130px]">
                              {{ product.name }}
                            </span>
                            <span class="text-muted-foreground text-xs font-semibold tabular-nums">
                              {{ product.price }}
                            </span>
                          </div>
                          <button
                            type="button"
                            class="bg-background/80 hover:bg-destructive hover:text-destructive-foreground text-muted-foreground absolute -top-1.5 -right-1.5 flex size-4 items-center justify-center rounded-full border opacity-0 shadow-2xs transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
                            [attr.aria-label]="'Remove ' + product.name + ' from comparison'"
                            (click)="$event.stopPropagation(); handleRemoveProduct(product.id)"
                          >
                            <lucide-icon [img]="X" class="size-2.5" />
                          </button>
                        </div>
                      }
                    </div>
                  } @else {
                    <div class="text-muted-foreground flex items-center gap-2 py-1 text-xs">
                      <span>No products in comparison dock.</span>
                      <button
                        type="button"
                        class="text-primary font-medium hover:underline"
                        (click)="handleRestoreProducts()"
                      >
                        Restore 3 Items
                      </button>
                    </div>
                  }
                </div>
    
                <!-- Action Buttons -->
                <div class="flex shrink-0 items-center justify-end gap-2">
                  @if (selectedProducts().length > 0) {
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground h-8 text-xs font-medium"
                      (click)="handleClearAll()"
                    >
                      Clear All
                    </button>
                  }
    
                  <button
                    ui-button
                    variant="default"
                    size="sm"
                    class="h-8 gap-1.5 text-xs font-medium shadow-xs"
                    [disabled]="selectedProducts().length === 0"
                    (click)="toggleExpanded()"
                  >
                    <span>Compare {{ selectedProducts().length }} Items</span>
                    <lucide-icon [img]="ChevronUp" class="size-3.5" />
                  </button>
                </div>
              </div>
            </div>
          }
    
          <!-- Expanded Full Specification Matrix Drawer / Container -->
          @if (isExpanded() || mode === 'expanded') {
            <div
              class="bg-background motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 relative flex w-full flex-col border-t shadow-lg motion-safe:duration-200"
            >
              <!-- Matrix Header Controls Bar -->
              <div
                class="bg-card/90 supports-[backdrop-filter]:bg-card/75 sticky top-0 z-30 border-b px-4 py-3.5 backdrop-blur-md sm:px-6"
              >
                <div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
                  <div class="flex items-center gap-3">
                    <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                      <lucide-icon [img]="SlidersHorizontal" class="size-4" />
                    </div>
                    <div>
                      <div class="flex items-center gap-2">
                        <h3 class="text-base font-semibold tracking-tight">Specification Matrix</h3>
                        <span ui-badge variant="secondary" class="text-xs font-semibold tabular-nums">
                          {{ selectedProducts().length }} Selected
                        </span>
                        @if (highlightDiffs()) {
                          <span ui-badge variant="outline" class="border-primary/40 bg-primary/5 text-primary text-xs">
                            <lucide-icon [img]="GitCompare" class="mr-1 size-3" />
                            Diffs Highlighted ({{ diffCount() }})
                          </span>
                        }
                      </div>
                      <p class="text-muted-foreground text-xs">
                        Side-by-side technical breakdown across drivers, battery, acoustics, and box contents.
                      </p>
                    </div>
                  </div>
    
                  <!-- Controls: Diff Switch & Close -->
                  <div class="flex items-center gap-4">
                    <div class="flex items-center space-x-2">
                      <button
                        ui-switch
                        id="angular-diff-toggle"
                        size="sm"
                        [checked]="highlightDiffs()"
                        (checkedChange)="highlightDiffs.set($event)"
                      ></button>
                      <label for="angular-diff-toggle" class="cursor-pointer text-xs font-medium select-none">
                        Highlight Differences Only
                      </label>
                    </div>
    
                    @if (mode === 'dock') {
                      <button ui-button variant="outline" size="sm" class="h-8 gap-1 text-xs" (click)="toggleExpanded()">
                        <span>Collapse</span>
                        <lucide-icon [img]="ChevronDown" class="size-3.5" />
                      </button>
                    }
                  </div>
                </div>
              </div>
    
              <!-- Specification Matrix Content -->
              @if (selectedProducts().length === 0) {
                <div class="flex flex-col items-center justify-center p-12 text-center">
                  <div class="bg-muted text-muted-foreground flex size-12 items-center justify-center rounded-full">
                    <lucide-icon [img]="Trash2" class="size-6" />
                  </div>
                  <h4 class="mt-3 text-sm font-semibold">No Products Selected</h4>
                  <p class="text-muted-foreground mt-1 max-w-sm text-xs">
                    You have cleared all items from comparison. Restore the baseline audio products to explore differences.
                  </p>
                  <button ui-button variant="default" size="sm" class="mt-4 text-xs" (click)="handleRestoreProducts()">
                    <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3.5" />
                    Restore 3 Items
                  </button>
                </div>
              } @else {
                <div class="w-full overflow-x-auto">
                  <table class="w-full max-w-[760px] min-w-full border-separate border-spacing-0 text-left text-xs">
                    <!-- Sticky Product Card Header Row -->
                    <thead>
                      <tr>
                        <th
                          scope="col"
                          class="bg-background text-muted-foreground sticky top-0 left-0 z-20 w-[200px] min-w-[180px] border-b p-4 align-bottom font-medium"
                        >
                          <div class="space-y-1">
                            <span class="text-foreground text-xs font-semibold tracking-wider uppercase">Attributes</span>
                            <p class="text-muted-foreground text-xs font-normal">
                              {{ selectedProducts().length }} products compared
                            </p>
                          </div>
                        </th>
    
                        <!-- Product Columns -->
                        @for (product of selectedProducts(); track product.id) {
                          <th scope="col" class="bg-background relative w-[240px] min-w-[220px] border-b p-4 align-top">
                            <div class="flex flex-col gap-3">
                              <!-- Remove button -->
                              <div class="flex items-center justify-between gap-2">
                                <span ui-badge [variant]="product.badgeVariant || 'default'" class="text-xs font-medium">
                                  {{ product.badge }}
                                </span>
                                <button
                                  type="button"
                                  class="text-muted-foreground hover:bg-muted hover:text-foreground flex size-6 items-center justify-center rounded-md transition-colors"
                                  [attr.aria-label]="'Remove ' + product.name"
                                  (click)="handleRemoveProduct(product.id)"
                                >
                                  <lucide-icon [img]="X" class="size-3.5" />
                                </button>
                              </div>
    
                              <!-- Product Hero Visual -->
                              <div class="flex items-center gap-3">
                                <img
                                  [src]="product.image"
                                  [alt]="product.name"
                                  class="size-16 rounded-md border object-cover shadow-2xs"
                                />
                                <div class="flex flex-col">
                                  <span class="text-foreground text-xs leading-tight font-semibold">
                                    {{ product.name }}
                                  </span>
                                  <div class="text-warning mt-1 flex items-center gap-1 text-xs">
                                    <lucide-icon [img]="Star" class="size-3 fill-current" />
                                    <span class="text-foreground font-medium tabular-nums">{{ product.rating }}</span>
                                  </div>
                                  <span class="text-foreground mt-1 text-sm font-bold tabular-nums">
                                    {{ product.price }}
                                  </span>
                                </div>
                              </div>
    
                              <!-- Quick Buy Trigger -->
                              <button
                                ui-button
                                size="sm"
                                variant="default"
                                class="w-full gap-1.5 text-xs shadow-xs"
                                [disabled]="addedProductId() === product.id"
                                (click)="handleAddToCart(product)"
                              >
                                @if (addedProductId() === product.id) {
                                  <lucide-icon [img]="Check" class="text-success size-3.5" />
                                  Added to Cart
                                } @else {
                                  <lucide-icon [img]="ShoppingCart" class="size-3.5" />
                                  Add to Cart
                                }
                              </button>
                            </div>
                          </th>
                        }
                      </tr>
                    </thead>
    
                    <!-- Table Body grouped by Category -->
                    @for (category of categories; track category.name) {
                      <tbody>
                        <!-- Category Subheading Header Row -->
                        <tr>
                          <th
                            [attr.colspan]="selectedProducts().length + 1"
                            scope="colgroup"
                            class="bg-muted/60 text-muted-foreground sticky left-0 z-10 border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase"
                          >
                            {{ category.name }}
                          </th>
                        </tr>
    
                        <!-- Specification Rows -->
                        @for (field of category.fields; track field.key) {
                          <tr [class]="rowClass(field)">
                            <!-- Spec Label Cell (Sticky Left) -->
                            <th
                              scope="row"
                              class="bg-background group-hover/row:bg-muted/30 sticky left-0 z-10 border-b p-3.5 align-middle text-xs font-medium"
                            >
                              <div class="flex items-center justify-between gap-1.5">
                                <div class="flex flex-col">
                                  <span class="text-foreground font-medium">{{ field.label }}</span>
                                  @if (field.description) {
                                    <span class="text-muted-foreground text-xs font-normal">{{ field.description }}</span>
                                  }
                                </div>
                                @if (isRowDiff(field.key) && highlightDiffs()) {
                                  <span
                                    class="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-xs font-semibold"
                                  >
                                    Diff
                                  </span>
                                }
                              </div>
                            </th>
    
                            <!-- Spec Values for Each Selected Product -->
                            @for (product of selectedProducts(); track product.id) {
                              <td class="text-foreground border-b p-3.5 align-middle text-xs">
                                <div class="flex items-center gap-1.5">
                                  <span [class]="valueClass(field)">{{ specValue(product, field) }}</span>
                                </div>
                              </td>
                            }
                          </tr>
                        }
                      </tbody>
                    }
    
                    <!-- Sticky Bottom Buy Triggers Footer -->
                    <tfoot>
                      <tr class="bg-card sticky bottom-0 z-20">
                        <th scope="row" class="bg-card sticky left-0 z-20 border-t p-4 align-middle">
                          <div class="space-y-0.5">
                            <span class="text-foreground text-xs font-semibold">Sticky Buy Trigger</span>
                            <p class="text-muted-foreground text-xs">
                              All purchases backed by 30-day money-back guarantee.
                            </p>
                          </div>
                        </th>
    
                        @for (product of selectedProducts(); track product.id) {
                          <td class="border-t p-4 align-middle">
                            <div class="flex flex-col gap-1.5">
                              <div class="flex items-center justify-between">
                                <span class="text-foreground text-xs font-bold tabular-nums">{{ product.price }}</span>
                                <span class="text-muted-foreground text-xs">{{ product.shipping }}</span>
                              </div>
                              <button
                                ui-button
                                size="sm"
                                variant="default"
                                class="w-full gap-1.5 text-xs shadow-xs"
                                [disabled]="addedProductId() === product.id"
                                (click)="handleAddToCart(product)"
                              >
                                @if (addedProductId() === product.id) {
                                  <lucide-icon [img]="Check" class="text-success size-3.5" />
                                  Added
                                } @else {
                                  <lucide-icon [img]="ShoppingCart" class="size-3.5" />
                                  Buy Now
                                }
                              </button>
                            </div>
                          </td>
                        }
                      </tr>
                    </tfoot>
                  </table>
                </div>
              }
            </div>
          }
        </div>
      `,
    })
    export class UiProductComparisonDrawerComponent implements OnInit {
      /** Opens the matrix on first render (dock mode). */
      @Input({ transform: booleanAttribute }) defaultOpen = false
      /** Seeds the "Highlight Differences Only" switch. */
      @Input({ transform: booleanAttribute }) defaultHighlightDiffs = false
      /** Products in the comparison. A new array resets the selection to it (React re-seeds on a `products` change). */
      @Input() set products(value: ComparisonProduct[] | null | undefined) {
        this.selectedProducts.set([...(value ?? DEFAULT_COMPARISON_PRODUCTS)])
      }
      @Input() mode: ProductComparisonDrawerMode = 'dock'
      @Input('class') className?: string
    
      @Output() readonly addToCart = new EventEmitter<ComparisonProduct>()
      @Output() readonly removeProduct = new EventEmitter<string>()
      @Output() readonly clearAll = new EventEmitter<void>()
      @Output() readonly restoreProducts = new EventEmitter<void>()
      @Output() readonly openChange = new EventEmitter<boolean>()
    
      readonly isExpanded = signal(false)
      readonly highlightDiffs = signal(false)
      readonly selectedProducts = signal<ComparisonProduct[]>([...DEFAULT_COMPARISON_PRODUCTS])
      readonly addedProductId = signal<string | null>(null)
    
      /** The catalog preview always lists the full default lineup, as in React. */
      protected readonly catalog = DEFAULT_COMPARISON_PRODUCTS
      protected readonly categories = groupFields(SPEC_FIELDS)
    
      readonly diffCount = computed(() => SPEC_FIELDS.filter((f) => this.isRowDiff(f.key)).length)
    
      protected readonly Check = Check
      protected readonly ChevronDown = ChevronDown
      protected readonly ChevronUp = ChevronUp
      protected readonly GitCompare = GitCompare
      protected readonly Layers = Layers
      protected readonly RotateCcw = RotateCcw
      protected readonly ShoppingCart = ShoppingCart
      protected readonly SlidersHorizontal = SlidersHorizontal
      protected readonly Star = Star
      protected readonly Trash2 = Trash2
      protected readonly X = X
    
      ngOnInit(): void {
        // React seeds its useState once from these props; inputs land after construction.
        this.isExpanded.set(this.defaultOpen || this.mode === 'expanded')
        this.highlightDiffs.set(this.defaultHighlightDiffs)
      }
    
      protected get rootClass(): string {
        return cn(
          'bg-background text-foreground relative flex w-full flex-col overflow-hidden rounded-xl border',
          this.className,
        )
      }
    
      toggleExpanded(): void {
        const next = !this.isExpanded()
        this.isExpanded.set(next)
        this.openChange.emit(next)
      }
    
      handleRemoveProduct(id: string): void {
        this.selectedProducts.update((prev) => prev.filter((p) => p.id !== id))
        this.removeProduct.emit(id)
      }
    
      handleClearAll(): void {
        this.selectedProducts.set([])
        this.clearAll.emit()
      }
    
      handleRestoreProducts(): void {
        this.selectedProducts.set([...DEFAULT_COMPARISON_PRODUCTS])
        this.restoreProducts.emit()
      }
    
      handleAddToCart(product: ComparisonProduct): void {
        this.addedProductId.set(product.id)
        this.addToCart.emit(product)
        setTimeout(() => {
          this.addedProductId.update((current) => (current === product.id ? null : current))
        }, 1800)
      }
    
      /** Catalog card checkbox: removing emits `removeProduct`, adding does not (as in React). */
      toggleCompare(product: ComparisonProduct): void {
        if (this.isSelected(product)) this.handleRemoveProduct(product.id)
        else this.selectedProducts.update((prev) => [...prev, product])
      }
    
      isSelected(product: ComparisonProduct): boolean {
        return this.selectedProducts().some((p) => p.id === product.id)
      }
    
      /** True when the spec's value differs across the currently selected products. */
      isRowDiff(specKey: keyof ComparisonSpec): boolean {
        const selected = this.selectedProducts()
        if (selected.length <= 1) return false
        const firstVal = selected[0]?.specs[specKey]
        return selected.some((p) => p.specs[specKey] !== firstVal)
      }
    
      specValue(product: ComparisonProduct, field: SpecField): string {
        return product.specs[field.key] || '—'
      }
    
      formatCount(value: number): string {
        return value.toLocaleString()
      }
    
      protected catalogCardClass(product: ComparisonProduct): string {
        return cn(
          'bg-card hover:border-border group relative flex flex-col justify-between rounded-lg border p-4 shadow-xs transition-colors',
          this.isSelected(product) && 'ring-primary/20 border-primary/40 ring-1',
        )
      }
    
      protected compareCheckClass(product: ComparisonProduct): string {
        return cn(
          'flex size-4 items-center justify-center rounded border transition-colors',
          this.isSelected(product)
            ? 'border-primary bg-primary text-primary-foreground'
            : 'border-muted-foreground/40 bg-background',
        )
      }
    
      protected rowClass(field: SpecField): string {
        const isDiff = this.isRowDiff(field.key)
        const highlight = this.highlightDiffs()
        return cn(
          'group/row transition-colors',
          isDiff && highlight ? 'bg-primary/[0.04] dark:bg-primary/[0.08]' : 'hover:bg-muted/20',
          !isDiff && highlight ? 'opacity-40' : 'opacity-100',
        )
      }
    
      protected valueClass(field: SpecField): string {
        const isDiff = this.isRowDiff(field.key)
        return cn({
          'font-semibold tabular-nums': field.key === 'price' || field.key === 'weight' || field.key === 'batteryLife',
          'text-foreground font-medium': isDiff,
          'text-muted-foreground': !isDiff,
        })
      }
    }
    
  • src/app/components/blocks/product-comparison-drawer/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/product-comparison-drawer.json