Menu

Change language

GitHub repo

Changelog

blockmarketing

Release-notes timeline in a SectionCard: Subscribe header action, version groups with a monospace tag, Latest badge and right-aligned date, and entries pairing a typed badge (New / Improved / Fixed / Breaking) with a title and optional description along a continuous border-l rail with dot markers. Pass `releases` to replace the stub; first entry is treated as latest unless a release sets `latest`.

Installation

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

Variants

Loading interactive previews…

Schema

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

ChangelogEntry
interface ChangelogEntry {
  type: ChangelogEntryType
  title: string
  description?: string
}
Release
interface Release {
  version: string
  /** ISO date string, e.g. '2026-08-14'. */
  date: string
  /** Overrides the default "first release in the array is the latest" rule. */
  latest?: boolean
  entries: ChangelogEntry[]
}

Files installed (2)

  • src/app/components/blocks/changelog/changelog.component.ts7.9 kB
    // Release-notes timeline in a SectionCard: Subscribe header action, version groups with a
    // monospace tag, Latest badge and right-aligned date, and entries pairing a typed badge
    // (New / Improved / Fixed / Breaking) with a title and optional description along a
    // continuous border-l rail with dot markers. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { Bell, LucideAngularModule } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent, type BadgeVariant } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    
    export type ChangelogEntryType = 'new' | 'improved' | 'fixed' | 'breaking'
    
    export interface ChangelogEntry {
      type: ChangelogEntryType
      title: string
      description?: string
    }
    
    export interface Release {
      version: string
      /** ISO date string, e.g. '2026-08-14'. */
      date: string
      /** Overrides the default "first release in the array is the latest" rule. */
      latest?: boolean
      entries: ChangelogEntry[]
    }
    
    const entryTone: Record<ChangelogEntryType, BadgeVariant> = {
      new: 'success',
      improved: 'info',
      fixed: 'warning',
      breaking: 'destructive',
    }
    
    const entryLabel: Record<ChangelogEntryType, string> = {
      new: 'New',
      improved: 'Improved',
      fixed: 'Fixed',
      breaking: 'Breaking',
    }
    
    const stubReleases: Release[] = [
      {
        version: 'v2.4.0',
        date: '2026-08-14',
        entries: [
          {
            type: 'new',
            title: 'Command palette',
            description: 'Global ⌘K palette with fuzzy search and full keyboard navigation.',
          },
          { type: 'improved', title: 'Table virtualization', description: 'Tables stay at 60fps past 10k rows.' },
          { type: 'fixed', title: 'Dark mode contrast', description: 'Sidebar badges now meet AA contrast in dark mode.' },
        ],
      },
      {
        version: 'v2.3.0',
        date: '2026-07-30',
        entries: [
          {
            type: 'new',
            title: 'Saved views',
            description: 'Pin filtered table views to the sidebar and share them with your team.',
          },
          {
            type: 'improved',
            title: 'CSV import',
            description: 'Merged cells and formula columns are flattened on import.',
          },
          { type: 'fixed', title: 'Scheduled exports', description: 'Exports no longer drift across DST boundaries.' },
        ],
      },
      {
        version: 'v2.2.1',
        date: '2026-07-08',
        entries: [
          {
            type: 'breaking',
            title: 'Node 20+ required',
            description: 'Node 18 reached end of life; the CLI now targets Node 20 and newer.',
          },
          {
            type: 'fixed',
            title: 'Safari login loop',
            description: 'Session cookies set on a redirect were dropped by ITP.',
          },
          {
            type: 'fixed',
            title: 'Duplicate webhooks',
            description: 'Retries after a timeout no longer double-deliver events.',
          },
        ],
      },
    ]
    
    const dateFormatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-changelog, [ui-changelog]',
      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, UiSectionCardComponent],
      template: `
        <div data-slot="changelog" [class]="rootClass">
          <div ui-section-card data-slot="changelog" [title]="title()" [description]="description()">
            @if (subscribe()) {
              <button ui-button [variant]="subscribeVariant()" size="sm" slot="header-action">
                <lucide-icon [img]="Bell" class="size-3.5" aria-hidden="true" />
                Subscribe
              </button>
            }
    
            @if (allReleases().length === 0) {
              <p class="text-muted-foreground py-6 text-center text-sm">No releases yet.</p>
            } @else {
              <!-- Version groups. Each entry li carries its own border-l segment so the
                   rail stays continuous through row spacing but breaks at version headers. -->
              <ol [class]="listClass">
                @for (release of allReleases(); track release.version; let ri = $index) {
                  <li>
                    <div class="flex items-center gap-2.5">
                      <h3 class="font-mono text-sm font-semibold tracking-tight break-all">{{ release.version }}</h3>
                      @if (isLatest(release, ri)) {
                        <span ui-badge>Latest</span>
                      }
                      <time [attr.datetime]="release.date" class="text-muted-foreground ml-auto text-xs">
                        {{ formatDate(release.date) }}
                      </time>
                    </div>
    
                    <ol class="mt-3">
                      @for (entry of release.entries; track entry.title; let ei = $index) {
                        <li class="border-border border-l pb-5 pl-6 last:pb-0">
                          <div class="flex items-start gap-3">
                            <!-- In-flow dot pulled back over the rail; ring punches out the line
                                 behind it (same trick as TimelineMedia's connector). -->
                            <span
                              aria-hidden="true"
                              class="bg-muted-foreground/40 ring-background mt-1.5 -ml-[30px] size-2.5 shrink-0 rounded-full ring-4"
                            ></span>
                            <div class="min-w-0">
                              <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
                                <span ui-badge [variant]="tone(entry.type)">{{ label(entry.type) }}</span>
                                <p class="text-sm font-medium">{{ entry.title }}</p>
                              </div>
                              @if (entry.description) {
                                <p class="text-muted-foreground mt-1 text-sm leading-relaxed">{{ entry.description }}</p>
                              }
                            </div>
                          </div>
                        </li>
                      }
                    </ol>
                  </li>
                }
              </ol>
            }
          </div>
        </div>
      `,
    })
    export class UiChangelogComponent {
      protected readonly Bell = Bell
    
      private readonly _releases = signal<Release[] | undefined>(undefined)
      @Input() set releases(v: Release[] | undefined) {
        this._releases.set(v)
      }
      readonly title = signal('Changelog')
      @Input('title') set titleInput(v: string | undefined) {
        if (v !== undefined) this.title.set(v)
      }
      readonly description = signal('New features, improvements, and fixes — newest first.')
      @Input('description') set descriptionInput(v: string | undefined) {
        if (v !== undefined) this.description.set(v)
      }
      readonly subscribe = signal(true)
      @Input('subscribe') set subscribeInput(v: boolean) {
        this.subscribe.set(v)
      }
      readonly subscribeVariant = signal<'default' | 'outline'>('outline')
      @Input('subscribeVariant') set subscribeVariantInput(v: 'default' | 'outline') {
        if (v) this.subscribeVariant.set(v)
      }
      readonly scrollable = signal(false)
      @Input('scrollable') set scrollableInput(v: boolean) {
        this.scrollable.set(v)
      }
      @Input('class') className?: string
    
      readonly allReleases = computed(() => this._releases() ?? stubReleases)
    
      get rootClass(): string {
        return cn('w-full', this.className)
      }
    
      get listClass(): string {
        return cn('space-y-8', this.scrollable() && 'max-h-96 overflow-y-auto pr-1')
      }
    
      tone(t: ChangelogEntryType): BadgeVariant {
        return entryTone[t]
      }
    
      label(t: ChangelogEntryType): string {
        return entryLabel[t]
      }
    
      isLatest(release: Release, index: number): boolean {
        return release.latest ?? index === 0
      }
    
      formatDate(date: string): string {
        // Pin the time so a UTC-stored ISO date cannot shift a day in negative offsets.
        return dateFormatter.format(new Date(`${date}T00:00:00`))
      }
    }
    
  • src/app/components/blocks/changelog/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/changelog.json