Menu

Change language

GitHub repo

Case Studies — Filterable List

blockmarketing

Filterable index of customer stories with industry chips above rows carrying the headline result, stack, and read time, plus an empty state.

Installation

$npx uipkge-ng@latest add case-study-filterable-list
Manifest:https://uipkge.dev/r/angular/case-study-filterable-list.jsonInstalls to:src/app/components/blocks/case-study-filterable-list/

Variants

Loading interactive previews…

Schema

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

CaseStudyListStudy
interface CaseStudyListStudy {
  company: string
  industry: string
  result: string
  stack: string
  readTime: string
}

Files installed (2)

  • src/app/components/blocks/case-study-filterable-list/case-study-filterable-list.component.ts6.6 kB
    // Filterable index of customer stories: industry chips narrow rows carrying the headline
    // result, stack, and read time, with an empty state covering no matches. Port of the React
    // block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { ArrowUpRight, LucideAngularModule } 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'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiToggleGroupComponent, UiToggleGroupItemComponent } from '@/ui/toggle-group/toggle-group.component'
    
    export interface CaseStudyListStudy {
      company: string
      industry: string
      result: string
      stack: string
      readTime: string
    }
    
    const defaultStudies: CaseStudyListStudy[] = [
      {
        company: 'Northwind Logistics',
        industry: 'Logistics',
        result: '5 days off the monthly close',
        stack: 'Snowflake · dbt · Okta',
        readTime: '8 min',
      },
      {
        company: 'Halden Health',
        industry: 'Healthcare',
        result: '71% fewer ad-hoc data requests',
        stack: 'BigQuery · Airflow',
        readTime: '6 min',
      },
      {
        company: 'Verity Retail Group',
        industry: 'Retail',
        result: '2.4pp tighter forecast band',
        stack: 'Redshift · dbt',
        readTime: '7 min',
      },
      {
        company: 'Calder Manufacturing',
        industry: 'Manufacturing',
        result: '11 spreadsheets retired',
        stack: 'Postgres · Dagster',
        readTime: '5 min',
      },
      {
        company: 'Ridgeway Freight',
        industry: 'Logistics',
        result: 'Per-shipment landed cost',
        stack: 'Snowflake · Fivetran',
        readTime: '9 min',
      },
      {
        company: 'Ashford Clinics',
        industry: 'Healthcare',
        result: 'PHI-scoped self-serve reporting',
        stack: 'BigQuery · Entra ID',
        readTime: '6 min',
      },
    ]
    
    const ALL = 'All'
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-case-study-filterable-list, [ui-case-study-filterable-list]',
      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,
        UiSeparatorComponent,
        UiToggleGroupComponent,
        UiToggleGroupItemComponent,
      ],
      template: `
        <section data-slot="case-study-filterable-list" [class]="rootClass">
          <div class="mx-auto max-w-4xl px-6 py-20 lg:py-28">
            <div class="flex flex-wrap items-end justify-between gap-6">
              <div>
                <span ui-badge variant="secondary">Customer stories</span>
                <h2 class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Read one close to your own</h2>
              </div>
    
              <div
                ui-toggle-group
                type="single"
                variant="outline"
                size="sm"
                class="flex-nowrap overflow-x-auto"
                aria-label="Filter case studies by industry"
                [value]="active()"
                (valueChange)="onFilterChange($event)"
              >
                @for (industry of industries(); track industry) {
                  <button ui-toggle-group-item [value]="industry">{{ industry }}</button>
                }
              </div>
            </div>
    
            <div ui-card class="mt-8">
              <div ui-card-content class="p-2">
                @if (visible().length === 0) {
                  <div class="px-4 py-16 text-center">
                    <p class="text-sm font-medium">No stories in that industry yet.</p>
                    <button ui-button variant="outline" size="sm" class="mt-4" (click)="showAll()">Show all</button>
                  </div>
                } @else {
                  <ul>
                    @for (study of visible(); track study.company; let last = $last) {
                      <li>
                        <a
                          href="#"
                          class="hover:bg-muted focus-visible:ring-ring group flex items-center gap-4 rounded-lg p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                          (click)="$event.preventDefault()"
                        >
                          <div class="min-w-0 flex-1">
                            <div class="flex flex-wrap items-center gap-2">
                              <p class="text-sm font-semibold">{{ study.company }}</p>
                              <span ui-badge variant="outline">{{ study.industry }}</span>
                            </div>
                            <p class="mt-1 text-sm">{{ study.result }}</p>
                            <p class="text-muted-foreground mt-1 font-mono text-xs">{{ study.stack }}</p>
                          </div>
                          <span class="text-muted-foreground shrink-0 text-xs">{{ study.readTime }}</span>
                          <lucide-icon
                            [img]="ArrowUpRight"
                            class="text-muted-foreground size-4 shrink-0 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
                            aria-hidden="true"
                          />
                        </a>
                      </li>
                      @if (!last) {
                        <ui-separator />
                      }
                    }
                  </ul>
                }
              </div>
            </div>
          </div>
        </section>
      `,
    })
    export class UiCaseStudyFilterableListComponent {
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly allLabel = ALL
    
      @Input() set initialStudies(v: CaseStudyListStudy[] | undefined) {
        this.studies.set((v ?? defaultStudies).map((s) => ({ ...s })))
      }
      @Input() set defaultIndustry(v: string | undefined) {
        this.active.set(v ?? ALL)
      }
      @Input('class') className?: string
    
      readonly studies = signal<CaseStudyListStudy[]>(defaultStudies.map((s) => ({ ...s })))
      readonly active = signal<string>(ALL)
    
      readonly industries = computed(() => [ALL, ...new Set(this.studies().map((s) => s.industry))])
      readonly visible = computed(() => {
        const active = this.active()
        return active === ALL ? this.studies() : this.studies().filter((s) => s.industry === active)
      })
    
      get rootClass(): string {
        return cn('bg-background', this.className)
      }
    
      // Single-select emits '' when the pressed chip is pressed again; fall back to ALL so the
      // list is never empty by accident.
      onFilterChange(value: string | string[]): void {
        const next = Array.isArray(value) ? (value[0] ?? '') : value
        this.active.set(next || ALL)
      }
    
      showAll(): void {
        this.active.set(ALL)
      }
    }
    
  • src/app/components/blocks/case-study-filterable-list/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/case-study-filterable-list.json