Product Comparison Drawer
blockcommerceFloating multi-product specification comparison bar with attribute diff highlighting, expanded full-matrix drawer comparison, and sticky buy triggers.
Installation
$pnpm dlx uipkge-ng@latest add product-comparison-drawer$npx uipkge-ng@latest add product-comparison-drawer$yarn dlx uipkge-ng@latest add product-comparison-drawer$bunx uipkge-ng@latest add product-comparison-drawerManifest:
https://uipkge.dev/r/angular/product-comparison-drawer.jsonInstalls to:src/app/components/blocks/product-comparison-drawer/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ComparisonSpecinterface ComparisonSpec {
price: string
driverSize: string
batteryLife: string
ancType: string
waterResistance: string
weight: string
accessories: string
connectivity?: string
fastCharging?: string
microphone?: string
warranty?: string
}ComparisonProductinterface 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
}SpecFieldinterface SpecField {
key: keyof ComparisonSpec
label: string
category: 'Key Specifications' | 'Audio & Connectivity' | 'Package & Warranty'
description?: string
}SpecCategoryinterface SpecCategory {
name: string
fields: SpecField[]
}npm dependencies
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
export { UiProductComparisonDrawerComponent, DEFAULT_COMPARISON_PRODUCTS, SPEC_FIELDS, type ComparisonProduct, type ComparisonSpec, type ProductComparisonDrawerMode, type SpecField, } from './product-comparison-drawer.component'
Raw manifest:https://uipkge.dev/r/angular/product-comparison-drawer.json