← Volver al inicio

Announcement Widget

Widget configurable para mostrar anuncios/banners con animación. Solo funciona en proyectos Astro. Requiere astro-icon.

Bloque NPM: astro, astro-icon

registry/widgets/announcement/announcement.astro

---
/**
 * Announcement Widget
 * 
 * Widget configurable para mostrar anuncios/banners en la parte superior del sitio.
 * 
 * Import from '@enlolab/widgets/Announcement/announcement1.astro' to use the widget.
 * 
 * Características:
 * - Texto configurable con animación de desplazamiento
 * - Icono configurable
 * - Background personalizable
 * - Botón de cierre con persistencia en localStorage
 * - Botón de acción opcional
 * - Animación de texto de derecha a izquierda
 * - Responsive y accesible
 */

import { Icon } from "astro-icon/components";

export interface Props {
  /** Texto principal del anuncio */
  text?: string;
  /** Texto del badge/etiqueta (ej: "NEW", "SALE", etc.) */
  badge?: string;
  /** URL del enlace (si se proporciona, el texto será clickeable) */
  href?: string;
  /** Texto del botón de acción (si se proporciona, se mostrará un botón) */
  buttonText?: string;
  /** URL del botón de acción */
  buttonHref?: string;
  /** Nombre del icono (tabler:*, lucide:*, etc.) */
  icon?: string;
  /** Color de fondo (clase Tailwind o color hexadecimal) */
  backgroundColor?: string;
  /** Color de texto (clase Tailwind o color hexadecimal) */
  textColor?: string;
  /** ID único para persistencia en localStorage (por defecto: "announcement") */
  id?: string;
  /** Si es true, el anuncio se oculta automáticamente */
  isHidden?: boolean;
  /** Velocidad de la animación en segundos (por defecto: 20) */
  animationDuration?: number;
  /** Si es true, muestra el botón de cierre */
  showCloseButton?: boolean;
  /** Si es true, persiste el estado de cierre en localStorage (por defecto: true) */
  persistClose?: boolean;
  /** Clases CSS adicionales */
  className?: string;
}

const {
  text = "Astro v5.5 is now available!",
  badge = "NEW",
  href = "https://astro.build/blog/astro-550/",
  buttonText,
  buttonHref,
  icon = "tabler:sparkles",
  backgroundColor = "bg-black dark:bg-transparent dark:border-b dark:border-slate-800",
  textColor = "text-white dark:text-slate-400",
  id = "announcement",
  isHidden = false,
  animationDuration = 20,
  showCloseButton = true,
  persistClose = true,
  className = "",
} = Astro.props;

// Generar ID único basado en el contenido si no se proporciona uno explícito
// Esto asegura que cada announcement diferente tenga su propia persistencia
const generateUniqueId = () => {
  if (id !== "announcement") {
    // Si se proporciona un ID personalizado, usarlo
    return id;
  }
  // Generar ID único basado en el contenido del announcement
  const content = `${text}-${badge}-${href || ""}-${buttonText || ""}`;
  // Crear un hash simple del contenido
  let hash = 0;
  for (let i = 0; i < content.length; i++) {
    const char = content.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash; // Convertir a entero de 32 bits
  }
  // Retornar solo el hash, sin el prefijo "announcement-"
  return Math.abs(hash).toString(36);
};

const uniqueId = generateUniqueId();
const announcementId = `announcement-${uniqueId}`;
const storageKey = `announcement-${uniqueId}-hidden`;
---

<div
  id={announcementId}
  class:list={[
    "announcement-container",
    "relative",
    "overflow-hidden",
    "flex",
    "items-center",
    "gap-2",
    "px-3 py-2",
    backgroundColor,
    textColor,
    className,
    "hidden", // Oculto por defecto, el script lo mostrará si no está en localStorage
    { "!hidden": isHidden },
  ]}
  data-announcement-id={uniqueId}
  data-storage-key={storageKey}
  data-persist-close={String(persistClose)}
  role="banner"
  aria-label="Anuncio"
>
  {/* Badge */}
  {badge && (
    <span
      class="announcement-badge bg-white/40 dark:bg-slate-700 dark:text-slate-300 font-semibold px-2 py-0.5 text-xs rounded whitespace-nowrap shrink-0"
    >
      {badge}
    </span>
  )}

  {/* Icono */}
  {icon && (
    <Icon
      name={icon}
      class="h-4 w-4 shrink-0"
      aria-hidden="true"
    />
  )}

  {/* Contenedor de texto con animación */}
  <div class="announcement-text-wrapper flex-1 overflow-hidden relative">
    <div
      class="announcement-text-inner flex items-center gap-2"
      style={`--animation-duration: ${animationDuration}s;`}
    >
      {href ? (
        <a
          href={href}
          class="announcement-link hover:underline font-medium text-sm whitespace-nowrap"
        >
          {text}
        </a>
      ) : (
        <span class="announcement-text font-medium text-sm whitespace-nowrap">
          {text}
        </span>
      )}
      {/* Duplicar el texto para animación continua */}
      {href ? (
        <a
          href={href}
          class="announcement-link hover:underline font-medium text-sm whitespace-nowrap"
          aria-hidden="true"
        >
          {text}
        </a>
      ) : (
        <span class="announcement-text font-medium text-sm whitespace-nowrap" aria-hidden="true">
          {text}
        </span>
      )}
    </div>
  </div>

  {/* Botón de acción */}
  {buttonText && buttonHref && (
    <a href={buttonHref}>
      <button
        type="button"
        class="announcement-button shrink-0 text-xs h-7 px-3 py-1 rounded-md border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 transition-colors font-medium"
      >
        {buttonText}
      </button>
    </a>
  )}

  {/* Botón de cierre */}
  {showCloseButton && (
    <button
      type="button"
      class="announcement-close shrink-0 p-1 hover:opacity-70 transition-opacity rounded"
      aria-label="Cerrar anuncio"
      data-close-button
    >
      <Icon
        name="tabler:x"
        class="h-4 w-4"
        aria-hidden="true"
      />
    </button>
  )}
</div>

<style>
  /* Animación de texto desplazándose de derecha a izquierda */
  .announcement-text-inner {
    animation: scroll-text var(--animation-duration, 20s) linear infinite;
  }

  @keyframes scroll-text {
    0% {
      transform: translateX(0);
    }
    100% {
      transform: translateX(-50%);
    }
  }

  /* Pausar animación en hover para mejor UX */
  .announcement-container:hover .announcement-text-inner {
    animation-play-state: paused;
  }

  /* Asegurar que el texto no se corte */
  .announcement-text-wrapper {
    min-width: 0;
  }
</style>

<script is:inline define:vars={{ announcementId, storageKey }}>
  (function() {
    // Esperar a que el DOM esté listo
    function initAnnouncement() {
      const container = document.getElementById(announcementId);
      if (!container) {
        // Si no existe, intentar de nuevo después de un breve delay
        setTimeout(initAnnouncement, 100);
        return;
      }

      const persistClose = container.dataset.persistClose === 'true';

      // Verificar si el anuncio está oculto en localStorage (solo si persistClose está activado)
      if (persistClose) {
        const isHidden = localStorage.getItem(storageKey) === 'true';
        if (isHidden) {
          // Ya está oculto por defecto, no hacer nada
          return;
        }
      }

      // Si no está oculto en localStorage (o persistClose es false), mostrar el announcement
      // Remover solo la clase 'hidden' por defecto (no afecta clases de responsive en className)
      container.classList.remove('hidden');

      // Botón de cierre
      const closeButton = container.querySelector('[data-close-button]');
      if (closeButton) {
        closeButton.addEventListener('click', () => {
          // Guardar en localStorage solo si persistClose está activado
          if (persistClose) {
            localStorage.setItem(storageKey, 'true');
          }
          // Ocultar con animación
          container.style.transition = 'opacity 0.3s ease-out, max-height 0.3s ease-out';
          container.style.opacity = '0';
          container.style.maxHeight = '0';
          container.style.overflow = 'hidden';
          container.style.padding = '0';
          container.style.margin = '0';
          
          setTimeout(() => {
            container.classList.add('!hidden');
          }, 300);
        });
      }
    }

    // Inicializar cuando el DOM esté listo
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', initAnnouncement);
    } else {
      initAnnouncement();
    }
  })();
</script>

Instalación

Para instalar este componente, primero configura el registry en tu components.json:

{
  "registries": {
    "@enlolab": {
      "url": "https://ui.enlolab.com/r/{name}.json",
      "headers": {
        "Authorization": "Bearer ${REGISTRY_TOKEN}"
      }
    }
  }
}

Luego instala el componente:

npx shadcn@latest add @enlolab/announcement