App y Dashboard appdashboardsaastopbarheadernotificationsuser-menu
App Topbar
Barra superior con búsqueda ⌘K, notificaciones con no leídas, cambio de tema y menú de usuario.
Vista Previa en Vivo
Notifications
- New booking from Booking.com Deluxe King · 3 nights · €612
- Room 204 reported maintenance Air conditioning not cooling
- Payout sent €18,240 to your bank account
Lucía Ortega lucia@mirador.com
Uso
Copia y pega este código directamente en tu página de Astro:
astro
---
import { AppTopbar } from 'astro-component-kit/app';
---
<AppTopbar user={{ name: 'Lucía Ortega', role: 'General manager' }} /> ---
import { AppTopbar } from 'astro-component-kit/app';
---
<AppTopbar user={{ name: 'Lucía Ortega', role: 'General manager' }} /> Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/lib/AppTopbar.astro:
astro
---
/**
* AppTopbar — SaaS app top bar: mobile menu button, search (⌘K / Ctrl+K), notifications dropdown
* with unread count and "mark all read", light/dark toggle and a user menu.
*
* @param {string} searchPlaceholder - Search placeholder. Default 'Search guests, bookings, rooms…'.
* @param {string} searchAction - Search form action. Default '#'.
* @param {Array<{title: string, text?: string, time: string, unread?: boolean, tone?: 'info'|'success'|'warning'|'danger'}>} notifications - Dropdown items.
* @param {{name: string, role?: string, email?: string}} user - Signed-in user.
* @param {Array<{label: string, href: string}>} userLinks - User menu links.
* @param {string} signOutHref - Sign-out link. Default '#'.
* @param {string} sidebarId - id of the AppSidebar the menu button opens on mobile. Default 'app-sidebar'.
* @param {boolean} themeToggle - Show the light/dark toggle. Default true.
* @param {string} variant - Style (blur|solid|transparent). Default 'blur'.
* @param {string} className - Extra class.
*
* Slots: `start` (e.g. page title or breadcrumb next to the menu button), `actions` (extra buttons).
*
* Accessibility: dropdowns are disclosure buttons closed with Escape or an outside click;
* the unread count is part of the bell's label; ⌘K moves focus to the search field.
*/
import { appIcon, initials, hueFrom } from './app-icons';
interface Notice { title: string; text?: string; time: string; unread?: boolean; tone?: 'info' | 'success' | 'warning' | 'danger' }
interface Props {
searchPlaceholder?: string;
searchAction?: string;
notifications?: Notice[];
user?: { name: string; role?: string; email?: string };
userLinks?: Array<{ label: string; href: string }>;
signOutHref?: string;
sidebarId?: string;
themeToggle?: boolean;
variant?: 'blur' | 'solid' | 'transparent';
className?: string;
}
const {
searchPlaceholder = 'Search guests, bookings, rooms…',
searchAction = '#',
notifications = [
{ title: 'New booking from Booking.com', text: 'Deluxe King · 3 nights · €612', time: '2 min ago', unread: true, tone: 'success' },
{ title: 'Room 204 reported maintenance', text: 'Air conditioning not cooling', time: '18 min ago', unread: true, tone: 'warning' },
{ title: 'Payout sent', text: '€18,240 to your bank account', time: '1 h ago', tone: 'info' },
],
user = { name: 'Lucía Ortega', role: 'General manager', email: 'lucia@mirador.com' },
userLinks = [{ label: 'Your profile', href: '#' }, { label: 'Settings', href: '#' }, { label: 'Billing', href: '#' }],
signOutHref = '#',
sidebarId = 'app-sidebar',
themeToggle = true,
variant = 'blur',
className = '',
} = Astro.props;
const unread = notifications.filter((n) => n.unread).length;
const hue = hueFrom(user.name);
---
<header class:list={['app-tb', `app-tb--${variant}`, className]} data-app-topbar>
<button class="app-tb__icon app-tb__menu" type="button" aria-label="Open navigation" aria-controls={sidebarId} aria-expanded="false" data-sidebar-open={sidebarId}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><Fragment set:html={appIcon('menu')} /></svg>
</button>
<div class="app-tb__start"><slot name="start" /></div>
<form class="app-tb__search" role="search" action={searchAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><Fragment set:html={appIcon('search')} /></svg>
<label class="sr-only" for="app-tb-q">{searchPlaceholder}</label>
<input id="app-tb-q" name="q" type="search" placeholder={searchPlaceholder} autocomplete="off" data-search />
<kbd aria-hidden="true">⌘K</kbd>
</form>
<div class="app-tb__actions">
<slot name="actions" />
{themeToggle && (
<button class="app-tb__icon" type="button" aria-label="Switch theme" data-theme-toggle>
<svg class="i-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><Fragment set:html={appIcon('sun')} /></svg>
<svg class="i-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('moon')} /></svg>
</button>
)}
<div class="app-tb__menu-wrap">
<button class="app-tb__icon" type="button" aria-expanded="false" aria-controls="app-tb-notes" aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'} data-dd>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('bell')} /></svg>
{unread > 0 && <span class="app-tb__dot" aria-hidden="true" data-unread>{unread}</span>}
</button>
<div class="app-tb__panel app-tb__panel--notes" id="app-tb-notes" hidden data-panel>
<div class="app-tb__panel-head">
<strong>Notifications</strong>
<button type="button" class="app-tb__link" data-mark-read>Mark all as read</button>
</div>
<ul>
{notifications.map((n) => (
<li class:list={['app-tb__note', `app-tb__note--${n.tone ?? 'info'}`, { 'is-unread': n.unread }]}>
<span class="app-tb__note-dot" aria-hidden="true"></span>
<span class="app-tb__note-body">
<strong>{n.title}</strong>
{n.text && <span>{n.text}</span>}
<time>{n.time}</time>
</span>
</li>
))}
</ul>
</div>
</div>
<div class="app-tb__menu-wrap">
<button class="app-tb__user" type="button" aria-expanded="false" aria-controls="app-tb-user" data-dd>
<span class="app-tb__avatar" style={`--h: ${hue}`} aria-hidden="true">{initials(user.name)}</span>
<span class="app-tb__who"><strong>{user.name}</strong>{user.role && <small>{user.role}</small>}</span>
<span class="sr-only">Account menu</span>
</button>
<div class="app-tb__panel app-tb__panel--user" id="app-tb-user" hidden data-panel>
<div class="app-tb__panel-head app-tb__panel-head--user">
<strong>{user.name}</strong>
{user.email && <span>{user.email}</span>}
</div>
<ul>
{userLinks.map((l) => <li><a href={l.href}>{l.label}</a></li>)}
<li class="app-tb__sep"><a href={signOutHref}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('logout')} /></svg>
Sign out</a></li>
</ul>
</div>
</div>
</div>
</header>
<script>
document.querySelectorAll<HTMLElement>('[data-app-topbar]:not([data-bound])').forEach((bar) => {
bar.dataset.bound = '';
const toggles = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-dd]'));
const panel = (b: HTMLButtonElement) => document.getElementById(b.getAttribute('aria-controls') || '');
const closeAll = (except?: HTMLButtonElement) => toggles.forEach((b) => {
if (b === except) return;
b.setAttribute('aria-expanded', 'false');
const p = panel(b);
if (p) p.hidden = true;
});
toggles.forEach((b) => b.addEventListener('click', (e) => {
e.stopPropagation();
const open = b.getAttribute('aria-expanded') !== 'true';
closeAll(b);
b.setAttribute('aria-expanded', String(open));
const p = panel(b);
if (p) p.hidden = !open;
}));
document.addEventListener('click', (e) => { if (!bar.contains(e.target as Node)) closeAll(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const open = toggles.find((b) => b.getAttribute('aria-expanded') === 'true');
closeAll();
open?.focus();
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
bar.querySelector<HTMLInputElement>('[data-search]')?.focus();
}
});
bar.querySelector('[data-mark-read]')?.addEventListener('click', () => {
bar.querySelectorAll('.app-tb__note.is-unread').forEach((n) => n.classList.remove('is-unread'));
bar.querySelector('[data-unread]')?.remove();
bar.querySelector('[aria-controls="app-tb-notes"]')?.setAttribute('aria-label', 'Notifications');
});
bar.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'light' ? 'dark' : 'light';
root.dataset.theme = next;
try { window.localStorage.setItem('ack-theme', next); } catch { /* ignore */ }
});
});
</script>
<style>
.app-tb {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: var(--sp-3, 0.75rem);
min-height: 64px;
padding: 0 var(--sp-6, 1.5rem);
border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08));
}
.app-tb--blur { background: rgb(var(--paper-rgb, 5 5 5) / 0.8); backdrop-filter: blur(14px) saturate(1.3); }
.app-tb--solid { background: var(--c-bg-1, #0a0a0a); }
.app-tb--transparent { background: transparent; border-bottom-color: transparent; }
.app-tb__start { min-width: 0; }
.app-tb__start:empty { display: none; }
.app-tb__search {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-2, 0.5rem);
flex: 1;
max-width: 440px;
height: 40px;
padding: 0 var(--sp-3, 0.75rem);
border-radius: 10px;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08));
background: rgb(var(--ink-rgb, 255 255 255) / 0.03);
color: var(--c-text-2, #a3a3a3);
}
.app-tb__search:focus-within { border-color: var(--app-accent, var(--c-primary-light, #d4a017)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 18%, transparent); }
.app-tb__search input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; color: var(--c-text-1, #fff); font: inherit; font-size: 0.85rem; }
.app-tb__search input::placeholder { color: var(--c-text-3, #737373); }
.app-tb__search kbd { padding: 1px 6px; border-radius: 6px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); font-family: var(--font-mono, monospace); font-size: 0.7rem; color: var(--c-text-3, #737373); }
.app-tb__actions { display: flex; align-items: center; gap: var(--sp-1, 0.25rem); margin-left: auto; }
.app-tb__icon {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--c-text-2, #a3a3a3);
cursor: pointer;
}
.app-tb__icon:hover, .app-tb__icon[aria-expanded='true'] { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); color: var(--c-text-1, #fff); }
.app-tb__menu { display: none; }
.app-tb .i-moon { display: none; }
:global([data-theme='light']) .app-tb .i-sun { display: none; }
:global([data-theme='light']) .app-tb .i-moon { display: block; }
.app-tb__dot { position: absolute; top: 4px; right: 4px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--c-danger, #f87171); color: #fff; font-size: 0.62rem; font-weight: 800; line-height: 16px; text-align: center; box-shadow: 0 0 0 2px var(--c-bg-1, #0a0a0a); }
.app-tb__menu-wrap { position: relative; }
.app-tb__user { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); min-height: 44px; padding: 0 var(--sp-2, 0.5rem) 0 4px; margin-left: var(--sp-1, 0.25rem); border: 0; border-radius: 999px; background: transparent; color: var(--c-text-1, #fff); font: inherit; cursor: pointer; text-align: left; }
.app-tb__user:hover, .app-tb__user[aria-expanded='true'] { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); }
.app-tb__avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: hsl(var(--h) 55% 45%); color: #fff; font-size: 0.75rem; font-weight: 700; }
.app-tb__who { display: grid; line-height: 1.2; }
.app-tb__who strong { font-size: 0.82rem; }
.app-tb__who small { font-size: 0.7rem; color: var(--c-text-2, #a3a3a3); }
.app-tb :is(a, button, input):focus-visible { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.app-tb__panel {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 340px;
max-width: calc(100vw - 24px);
border-radius: 14px;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
background: var(--c-bg-1, #0a0a0a);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: tb-pop 0.16s ease-out;
}
.app-tb__panel--user { width: 240px; }
.app-tb__panel[hidden] { display: none; }
@keyframes tb-pop { from { opacity: 0; transform: translateY(-4px); } }
.app-tb__panel-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); color: var(--c-text-1, #fff); font-size: 0.88rem; }
.app-tb__panel-head--user { display: grid; gap: 2px; }
.app-tb__panel-head--user span { font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.app-tb__link { border: 0; background: none; color: var(--app-accent, var(--c-primary-light, #d4a017)); font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; min-height: 32px; }
.app-tb__panel ul { margin: 0; padding: 4px; list-style: none; }
.app-tb__note { display: flex; gap: var(--sp-3, 0.75rem); padding: var(--sp-3, 0.75rem); border-radius: 10px; }
.app-tb__note:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.04); }
.app-tb__note-dot { flex-shrink: 0; width: 8px; height: 8px; margin-top: 6px; border-radius: 50%; background: transparent; }
.app-tb__note.is-unread .app-tb__note-dot { background: var(--tone, var(--app-accent, var(--c-primary-light, #d4a017))); }
.app-tb__note--success { --tone: var(--c-success, #34d399); }
.app-tb__note--warning { --tone: var(--c-warning, #fbbf24); }
.app-tb__note--danger { --tone: var(--c-danger, #f87171); }
.app-tb__note-body { display: grid; gap: 2px; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); }
.app-tb__note-body strong { font-size: 0.85rem; color: var(--c-text-1, #fff); font-weight: 600; }
.app-tb__note-body time { font-size: 0.72rem; color: var(--c-text-3, #737373); }
.app-tb__panel--user a { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); min-height: 40px; padding: 0 var(--sp-3, 0.75rem); border-radius: 8px; color: var(--c-text-1, #fff); font-size: 0.85rem; text-decoration: none; }
.app-tb__panel--user a:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
.app-tb__sep { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-width: 960px) { .app-tb__menu { display: grid; } }
@media (max-width: 720px) {
.app-tb { padding: 0 var(--sp-3, 0.75rem); }
.app-tb__search kbd, .app-tb__who { display: none; }
}
@media (max-width: 480px) { .app-tb__search { display: none; } }
@media (prefers-reduced-motion: reduce) { .app-tb__panel { animation: none; } }
</style> ---
/**
* AppTopbar — SaaS app top bar: mobile menu button, search (⌘K / Ctrl+K), notifications dropdown
* with unread count and "mark all read", light/dark toggle and a user menu.
*
* @param {string} searchPlaceholder - Search placeholder. Default 'Search guests, bookings, rooms…'.
* @param {string} searchAction - Search form action. Default '#'.
* @param {Array<{title: string, text?: string, time: string, unread?: boolean, tone?: 'info'|'success'|'warning'|'danger'}>} notifications - Dropdown items.
* @param {{name: string, role?: string, email?: string}} user - Signed-in user.
* @param {Array<{label: string, href: string}>} userLinks - User menu links.
* @param {string} signOutHref - Sign-out link. Default '#'.
* @param {string} sidebarId - id of the AppSidebar the menu button opens on mobile. Default 'app-sidebar'.
* @param {boolean} themeToggle - Show the light/dark toggle. Default true.
* @param {string} variant - Style (blur|solid|transparent). Default 'blur'.
* @param {string} className - Extra class.
*
* Slots: `start` (e.g. page title or breadcrumb next to the menu button), `actions` (extra buttons).
*
* Accessibility: dropdowns are disclosure buttons closed with Escape or an outside click;
* the unread count is part of the bell's label; ⌘K moves focus to the search field.
*/
import { appIcon, initials, hueFrom } from './app-icons';
interface Notice { title: string; text?: string; time: string; unread?: boolean; tone?: 'info' | 'success' | 'warning' | 'danger' }
interface Props {
searchPlaceholder?: string;
searchAction?: string;
notifications?: Notice[];
user?: { name: string; role?: string; email?: string };
userLinks?: Array<{ label: string; href: string }>;
signOutHref?: string;
sidebarId?: string;
themeToggle?: boolean;
variant?: 'blur' | 'solid' | 'transparent';
className?: string;
}
const {
searchPlaceholder = 'Search guests, bookings, rooms…',
searchAction = '#',
notifications = [
{ title: 'New booking from Booking.com', text: 'Deluxe King · 3 nights · €612', time: '2 min ago', unread: true, tone: 'success' },
{ title: 'Room 204 reported maintenance', text: 'Air conditioning not cooling', time: '18 min ago', unread: true, tone: 'warning' },
{ title: 'Payout sent', text: '€18,240 to your bank account', time: '1 h ago', tone: 'info' },
],
user = { name: 'Lucía Ortega', role: 'General manager', email: 'lucia@mirador.com' },
userLinks = [{ label: 'Your profile', href: '#' }, { label: 'Settings', href: '#' }, { label: 'Billing', href: '#' }],
signOutHref = '#',
sidebarId = 'app-sidebar',
themeToggle = true,
variant = 'blur',
className = '',
} = Astro.props;
const unread = notifications.filter((n) => n.unread).length;
const hue = hueFrom(user.name);
---
<header class:list={['app-tb', `app-tb--${variant}`, className]} data-app-topbar>
<button class="app-tb__icon app-tb__menu" type="button" aria-label="Open navigation" aria-controls={sidebarId} aria-expanded="false" data-sidebar-open={sidebarId}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><Fragment set:html={appIcon('menu')} /></svg>
</button>
<div class="app-tb__start"><slot name="start" /></div>
<form class="app-tb__search" role="search" action={searchAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><Fragment set:html={appIcon('search')} /></svg>
<label class="sr-only" for="app-tb-q">{searchPlaceholder}</label>
<input id="app-tb-q" name="q" type="search" placeholder={searchPlaceholder} autocomplete="off" data-search />
<kbd aria-hidden="true">⌘K</kbd>
</form>
<div class="app-tb__actions">
<slot name="actions" />
{themeToggle && (
<button class="app-tb__icon" type="button" aria-label="Switch theme" data-theme-toggle>
<svg class="i-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><Fragment set:html={appIcon('sun')} /></svg>
<svg class="i-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('moon')} /></svg>
</button>
)}
<div class="app-tb__menu-wrap">
<button class="app-tb__icon" type="button" aria-expanded="false" aria-controls="app-tb-notes" aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'} data-dd>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('bell')} /></svg>
{unread > 0 && <span class="app-tb__dot" aria-hidden="true" data-unread>{unread}</span>}
</button>
<div class="app-tb__panel app-tb__panel--notes" id="app-tb-notes" hidden data-panel>
<div class="app-tb__panel-head">
<strong>Notifications</strong>
<button type="button" class="app-tb__link" data-mark-read>Mark all as read</button>
</div>
<ul>
{notifications.map((n) => (
<li class:list={['app-tb__note', `app-tb__note--${n.tone ?? 'info'}`, { 'is-unread': n.unread }]}>
<span class="app-tb__note-dot" aria-hidden="true"></span>
<span class="app-tb__note-body">
<strong>{n.title}</strong>
{n.text && <span>{n.text}</span>}
<time>{n.time}</time>
</span>
</li>
))}
</ul>
</div>
</div>
<div class="app-tb__menu-wrap">
<button class="app-tb__user" type="button" aria-expanded="false" aria-controls="app-tb-user" data-dd>
<span class="app-tb__avatar" style={`--h: ${hue}`} aria-hidden="true">{initials(user.name)}</span>
<span class="app-tb__who"><strong>{user.name}</strong>{user.role && <small>{user.role}</small>}</span>
<span class="sr-only">Account menu</span>
</button>
<div class="app-tb__panel app-tb__panel--user" id="app-tb-user" hidden data-panel>
<div class="app-tb__panel-head app-tb__panel-head--user">
<strong>{user.name}</strong>
{user.email && <span>{user.email}</span>}
</div>
<ul>
{userLinks.map((l) => <li><a href={l.href}>{l.label}</a></li>)}
<li class="app-tb__sep"><a href={signOutHref}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('logout')} /></svg>
Sign out</a></li>
</ul>
</div>
</div>
</div>
</header>
<script>
document.querySelectorAll<HTMLElement>('[data-app-topbar]:not([data-bound])').forEach((bar) => {
bar.dataset.bound = '';
const toggles = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-dd]'));
const panel = (b: HTMLButtonElement) => document.getElementById(b.getAttribute('aria-controls') || '');
const closeAll = (except?: HTMLButtonElement) => toggles.forEach((b) => {
if (b === except) return;
b.setAttribute('aria-expanded', 'false');
const p = panel(b);
if (p) p.hidden = true;
});
toggles.forEach((b) => b.addEventListener('click', (e) => {
e.stopPropagation();
const open = b.getAttribute('aria-expanded') !== 'true';
closeAll(b);
b.setAttribute('aria-expanded', String(open));
const p = panel(b);
if (p) p.hidden = !open;
}));
document.addEventListener('click', (e) => { if (!bar.contains(e.target as Node)) closeAll(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const open = toggles.find((b) => b.getAttribute('aria-expanded') === 'true');
closeAll();
open?.focus();
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
bar.querySelector<HTMLInputElement>('[data-search]')?.focus();
}
});
bar.querySelector('[data-mark-read]')?.addEventListener('click', () => {
bar.querySelectorAll('.app-tb__note.is-unread').forEach((n) => n.classList.remove('is-unread'));
bar.querySelector('[data-unread]')?.remove();
bar.querySelector('[aria-controls="app-tb-notes"]')?.setAttribute('aria-label', 'Notifications');
});
bar.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'light' ? 'dark' : 'light';
root.dataset.theme = next;
try { window.localStorage.setItem('ack-theme', next); } catch { /* ignore */ }
});
});
</script>
<style>
.app-tb {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: var(--sp-3, 0.75rem);
min-height: 64px;
padding: 0 var(--sp-6, 1.5rem);
border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08));
}
.app-tb--blur { background: rgb(var(--paper-rgb, 5 5 5) / 0.8); backdrop-filter: blur(14px) saturate(1.3); }
.app-tb--solid { background: var(--c-bg-1, #0a0a0a); }
.app-tb--transparent { background: transparent; border-bottom-color: transparent; }
.app-tb__start { min-width: 0; }
.app-tb__start:empty { display: none; }
.app-tb__search {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-2, 0.5rem);
flex: 1;
max-width: 440px;
height: 40px;
padding: 0 var(--sp-3, 0.75rem);
border-radius: 10px;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08));
background: rgb(var(--ink-rgb, 255 255 255) / 0.03);
color: var(--c-text-2, #a3a3a3);
}
.app-tb__search:focus-within { border-color: var(--app-accent, var(--c-primary-light, #d4a017)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 18%, transparent); }
.app-tb__search input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; color: var(--c-text-1, #fff); font: inherit; font-size: 0.85rem; }
.app-tb__search input::placeholder { color: var(--c-text-3, #737373); }
.app-tb__search kbd { padding: 1px 6px; border-radius: 6px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); font-family: var(--font-mono, monospace); font-size: 0.7rem; color: var(--c-text-3, #737373); }
.app-tb__actions { display: flex; align-items: center; gap: var(--sp-1, 0.25rem); margin-left: auto; }
.app-tb__icon {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--c-text-2, #a3a3a3);
cursor: pointer;
}
.app-tb__icon:hover, .app-tb__icon[aria-expanded='true'] { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); color: var(--c-text-1, #fff); }
.app-tb__menu { display: none; }
.app-tb .i-moon { display: none; }
:global([data-theme='light']) .app-tb .i-sun { display: none; }
:global([data-theme='light']) .app-tb .i-moon { display: block; }
.app-tb__dot { position: absolute; top: 4px; right: 4px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--c-danger, #f87171); color: #fff; font-size: 0.62rem; font-weight: 800; line-height: 16px; text-align: center; box-shadow: 0 0 0 2px var(--c-bg-1, #0a0a0a); }
.app-tb__menu-wrap { position: relative; }
.app-tb__user { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); min-height: 44px; padding: 0 var(--sp-2, 0.5rem) 0 4px; margin-left: var(--sp-1, 0.25rem); border: 0; border-radius: 999px; background: transparent; color: var(--c-text-1, #fff); font: inherit; cursor: pointer; text-align: left; }
.app-tb__user:hover, .app-tb__user[aria-expanded='true'] { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); }
.app-tb__avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: hsl(var(--h) 55% 45%); color: #fff; font-size: 0.75rem; font-weight: 700; }
.app-tb__who { display: grid; line-height: 1.2; }
.app-tb__who strong { font-size: 0.82rem; }
.app-tb__who small { font-size: 0.7rem; color: var(--c-text-2, #a3a3a3); }
.app-tb :is(a, button, input):focus-visible { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.app-tb__panel {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 340px;
max-width: calc(100vw - 24px);
border-radius: 14px;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
background: var(--c-bg-1, #0a0a0a);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: tb-pop 0.16s ease-out;
}
.app-tb__panel--user { width: 240px; }
.app-tb__panel[hidden] { display: none; }
@keyframes tb-pop { from { opacity: 0; transform: translateY(-4px); } }
.app-tb__panel-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); color: var(--c-text-1, #fff); font-size: 0.88rem; }
.app-tb__panel-head--user { display: grid; gap: 2px; }
.app-tb__panel-head--user span { font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.app-tb__link { border: 0; background: none; color: var(--app-accent, var(--c-primary-light, #d4a017)); font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; min-height: 32px; }
.app-tb__panel ul { margin: 0; padding: 4px; list-style: none; }
.app-tb__note { display: flex; gap: var(--sp-3, 0.75rem); padding: var(--sp-3, 0.75rem); border-radius: 10px; }
.app-tb__note:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.04); }
.app-tb__note-dot { flex-shrink: 0; width: 8px; height: 8px; margin-top: 6px; border-radius: 50%; background: transparent; }
.app-tb__note.is-unread .app-tb__note-dot { background: var(--tone, var(--app-accent, var(--c-primary-light, #d4a017))); }
.app-tb__note--success { --tone: var(--c-success, #34d399); }
.app-tb__note--warning { --tone: var(--c-warning, #fbbf24); }
.app-tb__note--danger { --tone: var(--c-danger, #f87171); }
.app-tb__note-body { display: grid; gap: 2px; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); }
.app-tb__note-body strong { font-size: 0.85rem; color: var(--c-text-1, #fff); font-weight: 600; }
.app-tb__note-body time { font-size: 0.72rem; color: var(--c-text-3, #737373); }
.app-tb__panel--user a { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); min-height: 40px; padding: 0 var(--sp-3, 0.75rem); border-radius: 8px; color: var(--c-text-1, #fff); font-size: 0.85rem; text-decoration: none; }
.app-tb__panel--user a:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
.app-tb__sep { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-width: 960px) { .app-tb__menu { display: grid; } }
@media (max-width: 720px) {
.app-tb { padding: 0 var(--sp-3, 0.75rem); }
.app-tb__search kbd, .app-tb__who { display: none; }
}
@media (max-width: 480px) { .app-tb__search { display: none; } }
@media (prefers-reduced-motion: reduce) { .app-tb__panel { animation: none; } }
</style>
Información Rápida
- Categoría
- App y Dashboard
- Nombre de archivo
AppTopbar.astro- Dependencias
- Ninguna — Astro nativo + CSS puro
- Etiquetas
- appdashboardsaastopbarheadernotificationsuser-menu