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