1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
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

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