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 shadcn@latest add https://uipkge.dev/r/lit/changelog.json
Manifest:https://uipkge.dev/r/lit/changelog.jsonInstalls to:src/uip/blocks/changelog/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
releasesRelease[]—optional
headingstringChangelogoptional
descriptionstringNew features, improvements, and fixes — newest first.optional
subscribebooleantrueoptional
subscribe-variant'default' | 'outline'outlineoptional
scrollablebooleanfalseoptional

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[]
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-changelog': UipChangelog
  }
}

Files installed (1)

  • src/uip/blocks/changelog/changelog.ts7.4 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { Bell } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/section-card/section-card'
    
    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, 'success' | 'info' | 'warning' | 'destructive'> = {
      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' })
    
    function formatDate(date: 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`))
    }
    
    /**
     * <uip-changelog> — the registry Changelog block as a web component.
     *
     * Class strings are React's Changelog verbatim. `heading` is React's `title`
     * (`title` is taken by HTMLElement). `releases` is a property (arrays don't
     * cross attributes); the remaining props are kebab-case attributes. The
     * SectionCard chrome is a <uip-section-card> with the Subscribe action in
     * its header-action slot.
     */
    export class UipChangelog extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        releases: {},
        heading: {},
        description: {},
        subscribe: { type: Boolean },
        subscribeVariant: { attribute: 'subscribe-variant' },
        scrollable: { type: Boolean },
      }
    
      releases?: Release[]
      heading = 'Changelog'
      description = 'New features, improvements, and fixes — newest first.'
      subscribe = true
      subscribeVariant: 'default' | 'outline' = 'outline'
      scrollable = false
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'changelog')
      }
    
      protected updated() {
        // React passes data-slot="changelog" through SectionCard; <uip-section-card>
        // labels itself "section-card" on connect, so re-label it for DOM parity.
        this.renderRoot.querySelector('uip-section-card')?.setAttribute('data-slot', 'changelog')
      }
    
      render() {
        const source = this.releases ?? stubReleases
        return html`
          <uip-section-card data-slot="changelog" heading=${this.heading} description=${this.description}>
            ${this.subscribe
              ? html`<span slot="header-action"
                  ><uip-button variant=${this.subscribeVariant} size="sm"
                    >${icon(Bell, 'bell')} Subscribe</uip-button
                  ></span
                >`
              : nothing}
            ${source.length === 0
              ? html`<p class="text-muted-foreground py-6 text-center text-sm">No releases yet.</p>`
              : html`<ol class=${cn('space-y-8', this.scrollable && 'max-h-96 overflow-y-auto pr-1')}>
                  ${source.map(
                    (release, ri) => html`<li>
                      <div class="flex items-center gap-2.5">
                        <h3 class="font-mono text-sm font-semibold tracking-tight break-all">${release.version}</h3>
                        ${(release.latest ?? ri === 0) ? html`<uip-badge>Latest</uip-badge>` : nothing}
                        <time dateTime=${release.date} class="text-muted-foreground ml-auto text-xs">
                          ${formatDate(release.date)}
                        </time>
                      </div>
    
                      <ol class="mt-3">
                        ${release.entries.map(
                          (entry) => html`<li
                            class="border-border border-l pb-5 pl-6 last:pb-0"
                          >
                            <div class="flex items-start gap-3">
                              <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">
                                  <uip-badge variant=${entryTone[entry.type]}>${entryLabel[entry.type]}</uip-badge>
                                  <p class="text-sm font-medium">${entry.title}</p>
                                </div>
                                ${entry.description
                                  ? html`<p class="text-muted-foreground mt-1 text-sm leading-relaxed">
                                      ${entry.description}
                                    </p>`
                                  : nothing}
                              </div>
                            </div>
                          </li>`,
                        )}
                      </ol>
                    </li>`,
                  )}
                </ol>`}
          </uip-section-card>
        `
      }
    }
    
    customElements.get('uip-changelog') || customElements.define('uip-changelog', UipChangelog)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-changelog': UipChangelog
      }
    }
    

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