Menu

Change language

GitHub repo

Announcement Banner

blockmarketing

Interactive release, security, and maintenance broadcast center with rotating live ticker, CLI copy button, and floating pill / top-bar modes.

Installation

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

Variants

Loading interactive previews…

Schema

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

AnnouncementItem
interface AnnouncementItem {
  id: string
  category: AnnouncementCategory
  badgeText: string
  title: string
  actionLabel: string
  actionUrl?: string
  cliSnippet?: string
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/announcement-banner/announcement-banner.component.ts11.7 kB
    // Deprecated alias of the announcement broadcast center (use ui-announcement). rotating live ticker with CLI copy and prev/next
    // controls, plus floating-pill and top-bar modes. Port of the Vue/React block 1:1 —
    // same three announcements, same 6s autoplay that pauses on hover, same category
    // badge tones. `variant` picks the placement; `autoplay` starts the rotation.
    import {
      ChangeDetectionStrategy,
      Component,
      DestroyRef,
      Input,
      OnDestroy,
      OnInit,
      computed,
      inject,
      signal,
    } from '@angular/core'
    import { ArrowRight, ChevronLeft, ChevronRight, LucideAngularModule, Megaphone, Terminal, X } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    
    export type AnnouncementVariant = 'floating-pill' | 'top-bar' | 'interactive-ticker'
    export type AnnouncementCategory = 'release' | 'security' | 'maintenance' | 'feature'
    
    export interface AnnouncementItem {
      id: string
      category: AnnouncementCategory
      badgeText: string
      title: string
      actionLabel: string
      actionUrl?: string
      cliSnippet?: string
    }
    
    const announcements: AnnouncementItem[] = [
      {
        id: 'ann-1',
        category: 'release',
        badgeText: 'v2.4.0 Release',
        title: 'Tailwind CSS v4 OKLCH tokens & 281+ production workbenches are live.',
        actionLabel: 'Read Changelog',
        actionUrl: 'https://uipkge.dev/changelog',
        cliSnippet: 'npx shadcn-vue@latest add https://uipkge.dev/r/vue/init.json',
      },
      {
        id: 'ann-2',
        category: 'feature',
        badgeText: 'Dual-Framework',
        title: '100% Vue 3.5 & React 19 component parity achieved with zero runtime CSS.',
        actionLabel: 'Explore Parity Matrix',
        actionUrl: 'https://uipkge.dev/components',
        cliSnippet: 'npx shadcn@latest add https://uipkge.dev/r/react/init.json',
      },
      {
        id: 'ann-3',
        category: 'security',
        badgeText: 'Security Notice',
        title: 'Upstream Reka UI & Radix dependency security patches verified and updated.',
        actionLabel: 'Security Advisory',
        actionUrl: 'https://uipkge.dev/security',
      },
    ]
    
    const categoryBadgeClass: Record<AnnouncementCategory, string> = {
      release: 'bg-primary/10 text-primary border-primary/20',
      security: 'border-warning/20 bg-warning/10 text-warning',
      maintenance: '',
      feature: 'border-success/20 bg-success/10 text-success',
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-announcement-banner, [ui-announcement-banner]',
      standalone: true,
      // The block renders its own root <div>: keep this wrapper out of the layout.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent],
      template: `
        @if (!dismissed()) {
          <div
            data-slot="announcement-banner"
            [attr.data-variant]="variant"
            role="region"
            aria-label="Announcement"
            [class]="rootClass"
            (mouseenter)="isPaused.set(true)"
            (mouseleave)="isPaused.set(false)"
          >
            @if (variant === 'interactive-ticker') {
              <div
                class="bg-muted/70 border-border text-foreground flex items-center justify-between gap-3 overflow-hidden border-b px-4 py-2 text-xs shadow-xs backdrop-blur-md"
              >
                <div class="mx-auto flex w-full max-w-6xl items-center justify-between gap-4">
                  <!-- Left: Status Beacon & Item Indicator -->
                  <div class="flex shrink-0 items-center gap-3">
                    <div class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs">
                      <span class="bg-primary size-2 animate-pulse rounded-full"></span>
                      <span class="text-foreground font-semibold">Live Dispatch</span>
                    </div>
                    <span ui-badge variant="secondary" [class]="badgeClass()">{{ current().badgeText }}</span>
                  </div>
    
                  <!-- Center: Dynamic Headline & Action Link -->
                  <div class="flex flex-1 items-center justify-center gap-2 truncate">
                    <p class="text-foreground truncate font-medium">{{ current().title }}</p>
                    @if (current().actionUrl; as actionUrl) {
                      <a
                        [href]="actionUrl"
                        class="text-primary hidden shrink-0 items-center gap-1 font-semibold hover:underline sm:inline-flex"
                      >
                        {{ current().actionLabel }} <lucide-icon [img]="ArrowRight" class="size-3" />
                      </a>
                    }
                  </div>
    
                  <!-- Right: Ticker Navigation & CLI / Dismiss -->
                  <div class="flex shrink-0 items-center gap-2">
                    @if (current().cliSnippet; as snippet) {
                      <button
                        type="button"
                        class="bg-background border-border text-muted-foreground hover:text-foreground hidden items-center gap-1.5 rounded border px-2 py-0.5 font-mono text-xs transition-colors md:inline-flex"
                        (click)="copyCliSnippet(snippet)"
                      >
                        <lucide-icon [img]="Terminal" class="text-primary size-3" />
                        <span>{{ copiedCli() ? 'Copied CLI!' : 'Copy CLI' }}</span>
                      </button>
                    }
                    <div class="border-border bg-background flex items-center overflow-hidden rounded-md border">
                      <button
                        type="button"
                        class="hover:bg-muted text-muted-foreground hover:text-foreground p-1 transition-colors"
                        aria-label="Previous announcement"
                        (click)="prevAnnouncement()"
                      >
                        <lucide-icon [img]="ChevronLeft" class="size-3.5" />
                      </button>
                      <span class="text-muted-foreground px-1.5 font-mono text-xs">
                        {{ currentIndex() + 1 }}/{{ announcementCount }}
                      </span>
                      <button
                        type="button"
                        class="hover:bg-muted text-muted-foreground hover:text-foreground p-1 transition-colors"
                        aria-label="Next announcement"
                        (click)="nextAnnouncement()"
                      >
                        <lucide-icon [img]="ChevronRight" class="size-3.5" />
                      </button>
                    </div>
                    <button
                      type="button"
                      aria-label="Dismiss banner"
                      class="text-muted-foreground hover:text-foreground hover:bg-muted rounded-md p-1 transition-colors"
                      (click)="dismiss()"
                    >
                      <lucide-icon [img]="X" class="size-3.5" />
                    </button>
                  </div>
                </div>
              </div>
            } @else if (variant === 'floating-pill') {
              <div class="mx-auto w-fit max-w-2xl px-4 py-2">
                <div
                  class="bg-card/90 border-border text-foreground hover:border-primary/40 flex items-center gap-3 rounded-full border py-1.5 pr-2 pl-3 text-xs shadow-md backdrop-blur-md transition-colors"
                >
                  <span ui-badge variant="secondary" [class]="pillBadgeClass">{{ current().badgeText }}</span>
                  <p class="text-foreground max-w-sm truncate font-medium">{{ current().title }}</p>
                  @if (current().actionUrl; as actionUrl) {
                    <a
                      [href]="actionUrl"
                      class="text-primary inline-flex shrink-0 items-center gap-1 font-semibold hover:underline"
                    >
                      {{ current().actionLabel }} <lucide-icon [img]="ArrowRight" class="size-3" />
                    </a>
                  }
                  <button
                    type="button"
                    aria-label="Dismiss announcement"
                    class="text-muted-foreground hover:text-foreground hover:bg-muted rounded-full p-1 transition-colors"
                    (click)="dismiss()"
                  >
                    <lucide-icon [img]="X" class="size-3.5" />
                  </button>
                </div>
              </div>
            } @else {
              <div
                class="bg-primary text-primary-foreground flex items-center justify-between gap-3 px-4 py-2 text-xs shadow-xs"
              >
                <div class="mx-auto flex w-full max-w-6xl items-center justify-between gap-4">
                  <div class="flex items-center gap-2 truncate">
                    <lucide-icon [img]="Megaphone" class="size-4 shrink-0" />
                    <span class="font-mono font-bold">[{{ current().badgeText }}]</span>
                    <span class="truncate">{{ current().title }}</span>
                  </div>
                  <div class="flex shrink-0 items-center gap-3">
                    @if (current().actionUrl; as actionUrl) {
                      <a
                        [href]="actionUrl"
                        class="inline-flex items-center gap-1 font-semibold underline-offset-4 hover:underline"
                      >
                        {{ current().actionLabel }} <lucide-icon [img]="ArrowRight" class="size-3" />
                      </a>
                    }
                    <button
                      type="button"
                      aria-label="Dismiss banner"
                      class="text-primary-foreground/80 hover:text-primary-foreground hover:bg-primary-foreground/15 rounded p-1 transition-colors"
                      (click)="dismiss()"
                    >
                      <lucide-icon [img]="X" class="size-3.5" />
                    </button>
                  </div>
                </div>
              </div>
            }
          </div>
        }
      `,
    })
    export class UiAnnouncementBannerComponent implements OnInit, OnDestroy {
      @Input() variant: AnnouncementVariant = 'interactive-ticker'
      @Input() autoplay = true
      @Input('class') className?: string
    
      readonly currentIndex = signal(0)
      readonly dismissed = signal(false)
      readonly isPaused = signal(false)
      readonly copiedCli = signal(false)
    
      protected readonly announcementCount = announcements.length
      protected readonly pillBadgeClass = 'bg-primary/10 text-primary border-primary/20 px-2 py-0.5 font-mono text-xs'
    
      protected readonly ArrowRight = ArrowRight
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly Megaphone = Megaphone
      protected readonly Terminal = Terminal
      protected readonly X = X
    
      private timer: ReturnType<typeof setInterval> | undefined
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          clearInterval(this.timer)
          clearTimeout(this.copyTimer)
        })
      }
    
      ngOnInit(): void {
        this.startAutoplay()
      }
    
      ngOnDestroy(): void {
        this.stopAutoplay()
      }
    
      readonly current = computed(() => announcements[this.currentIndex()]!)
    
      readonly badgeClass = computed(() => cn('px-2 py-0.5 font-mono text-xs', categoryBadgeClass[this.current().category]))
    
      get rootClass(): string {
        return cn('relative z-50 transition-colors duration-200', this.className)
      }
    
      nextAnnouncement(): void {
        this.currentIndex.update((i) => (i + 1) % announcements.length)
      }
    
      prevAnnouncement(): void {
        this.currentIndex.update((i) => (i - 1 + announcements.length) % announcements.length)
      }
    
      copyCliSnippet(snippet?: string): void {
        if (!snippet) return
        try {
          void navigator.clipboard.writeText(snippet).catch(() => {})
        } catch {
          // Blocked clipboard: the label flip below still confirms the intent.
        }
        this.copiedCli.set(true)
        clearTimeout(this.copyTimer)
        this.copyTimer = setTimeout(() => this.copiedCli.set(false), 2000)
      }
    
      dismiss(): void {
        this.dismissed.set(true)
      }
    
      private startAutoplay(): void {
        if (!this.autoplay) return
        this.stopAutoplay()
        this.timer = setInterval(() => {
          if (!this.isPaused()) this.nextAnnouncement()
        }, 6000)
      }
    
      private stopAutoplay(): void {
        clearInterval(this.timer)
        this.timer = undefined
      }
    }
    
  • src/app/components/blocks/announcement-banner/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/announcement-banner.json