1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
App y Dashboardappdashboardsaasheadertoolbartabs

Page Header

Cabecera de página de app con migas, descripción, selector de periodo, botones y pestañas.

Vista Previa en Vivo

Dashboard

Good morning, Lucía — here is what is happening at Hotel Mirador today.

New booking

Uso

Copia y pega este código directamente en tu componente React:

tsx
import { PageHeader } from 'react-component-kit-ui/app';

<PageHeader title="Reports" periods={['7d', '30d', 'Year']} actions={[{ label: 'Export', icon: 'download' }]} />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/PageHeader.tsx + PageHeader.css:

PageHeader.tsx
'use client';

import { useEffect, useRef } from 'react';
import { appIcon } from './app-icons';
import './PageHeader.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/**
 * PageHeader — App page heading: breadcrumb, title, description, period switcher and action
 * buttons, with optional underline tabs below.
 *
 * @param {string} title - Page title. Default 'Dashboard'.
 * @param {string} description - Supporting text.
 * @param {Array<{label: string, href?: string}>} breadcrumb - Trail (optional).
 * @param {Array<{label: string, href?: string, icon?: string, primary?: boolean}>} actions - Buttons on the right.
 * @param {string[]} periods - Segmented period options (e.g. ['Today','7d','30d']); empty hides it.
 * @param {number} activePeriod - Selected period index. Default 0.
 * @param {Array<{label: string, href: string, count?: number}>} tabs - Underline tabs (optional).
 * @param {string} currentTab - href of the active tab.
 * @param {string} size - Title scale (sm|md|lg). Default 'md'.
 * @param {string} className - Extra class.
 *
 * Accessibility: h1 title; the period switcher is a radio group (arrow keys) that dispatches
 * `app:period` with `{ index, label }`; tabs are links with aria-current.
 */
export interface PageHeaderProps {
  title?: string;
  description?: string;
  breadcrumb?: Array<{ label: string; href?: string }>;
  actions?: Array<{ label: string; href?: string; icon?: string; primary?: boolean }>;
  periods?: string[];
  activePeriod?: number;
  tabs?: Array<{ label: string; href: string; count?: number }>;
  currentTab?: string;
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

export function PageHeader({
  title = 'Dashboard',
  description = 'Good morning, Lucía — here is what is happening at Hotel Mirador today.',
  breadcrumb = [],
  actions = [{ label: 'Export', icon: 'download' }, { label: 'New booking', icon: 'plus', primary: true, href: '#' }],
  periods = ['Today', '7 days', '30 days', 'Year'],
  activePeriod = 2,
  tabs = [],
  currentTab,
  size = 'md',
  className = '',
}: PageHeaderProps) {

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    document.querySelectorAll<HTMLElement>('[data-periods]:not([data-bound])').forEach((group) => {
        group.dataset.bound = '';
        const radios = Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
        const select = (i: number, focus = false) => {
          radios.forEach((r, n) => { r.setAttribute('aria-checked', String(n === i)); r.tabIndex = n === i ? 0 : -1; });
          if (focus) radios[i].focus();
          group.dispatchEvent(new CustomEvent('app:period', { bubbles: true, detail: { index: i, label: radios[i].textContent } }));
        };
        radios.forEach((r, i) => {
          r.addEventListener('click', () => select(i));
          r.addEventListener('keydown', (e) => {
            const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
            if (!d) return;
            e.preventDefault();
            select((i + d + radios.length) % radios.length, true);
          });
        });
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className={cx(['app-ph', `app-ph--${size}`, className])} data-rk-7bc64d="">
        {breadcrumb.length > 0 && (
          <nav className="app-ph__crumbs" aria-label="Breadcrumb" data-rk-7bc64d="">
            <ol data-rk-7bc64d="">{breadcrumb.map((b, i) => <li key={i} data-rk-7bc64d="">{b.href && i < breadcrumb.length - 1 ? <a href={b.href} data-rk-7bc64d="">{b.label}</a> : <span aria-current="page" data-rk-7bc64d="">{b.label}</span>}</li>)}</ol>
          </nav>
        )}
        <div className="app-ph__row" data-rk-7bc64d="">
          <div className="app-ph__text" data-rk-7bc64d="">
            <h1 className="app-ph__title" data-rk-7bc64d="">{title}</h1>
            {description && <p className="app-ph__desc" data-rk-7bc64d="">{description}</p>}
          </div>
          <div className="app-ph__tools" data-rk-7bc64d="">
            {periods.length > 0 && (
              <div className="app-ph__periods" role="radiogroup" aria-label="Period" data-periods data-rk-7bc64d="">
                {periods.map((p, i) => (
                  <button key={i} type="button" role="radio" aria-checked={i === activePeriod ? 'true' : 'false'} tabIndex={i === activePeriod ? 0 : -1} data-i={i} data-rk-7bc64d="">{p}</button>
                ))}
              </div>
            )}
            {actions.map((a) => {
              const Tag = a.href ? 'a' : 'button';
              return (
                <Tag className={cx(['app-ph__btn', { 'app-ph__btn--primary': a.primary }])} href={a.href} type={a.href ? undefined : 'button'} data-rk-7bc64d="">
                  {a.icon && <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-7bc64d=""><g dangerouslySetInnerHTML={{ __html: appIcon(a.icon) }} data-rk-7bc64d="" /></svg>}
                  {a.label}
                </Tag>
              );
            })}
          </div>
        </div>
        {tabs.length > 0 && (
          <nav className="app-ph__tabs" aria-label="Sections" data-rk-7bc64d="">
            {tabs.map((tab, __i: number) => (
              <a key={__i} href={tab.href} aria-current={tab.href === currentTab ? 'page' : undefined} data-rk-7bc64d="">
                {tab.label}{tab.count !== undefined && <span data-rk-7bc64d="">{tab.count}</span>}
              </a>
            ))}
          </nav>
        )}
      </div>
    </>
  );
}

export default PageHeader;
PageHeader.css
/* PageHeader — ported from astro-component-kit (app/PageHeader.astro) */

.app-ph:where([data-rk-7bc64d]) { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-4, 1rem); }
.app-ph__crumbs:where([data-rk-7bc64d]) ol:where([data-rk-7bc64d]) { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; font-size: 0.78rem; color: var(--c-text-3, #737373); }
.app-ph__crumbs:where([data-rk-7bc64d]) li:where([data-rk-7bc64d]) + li:where([data-rk-7bc64d])::before { content: '/'; margin-right: 6px; opacity: 0.6; }
.app-ph__crumbs:where([data-rk-7bc64d]) a:where([data-rk-7bc64d]) { color: inherit; text-decoration: none; }
.app-ph__crumbs:where([data-rk-7bc64d]) a:hover:where([data-rk-7bc64d]), .app-ph__crumbs:where([data-rk-7bc64d]) [aria-current]:where([data-rk-7bc64d]) { color: var(--c-text-1, #fff); }
.app-ph__row:where([data-rk-7bc64d]) { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-4, 1rem); }
.app-ph__title:where([data-rk-7bc64d]) { margin: 0; font-size: 1.6rem; font-weight: 800; letter-spacing: -0.025em; color: var(--c-text-1, #fff); }
.app-ph--sm:where([data-rk-7bc64d]) .app-ph__title:where([data-rk-7bc64d]) { font-size: 1.25rem; }
.app-ph--lg:where([data-rk-7bc64d]) .app-ph__title:where([data-rk-7bc64d]) { font-size: 2.1rem; }
.app-ph__desc:where([data-rk-7bc64d]) { margin: 4px 0 0; font-size: 0.9rem; color: var(--c-text-2, #a3a3a3); }
.app-ph__tools:where([data-rk-7bc64d]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--sp-2, 0.5rem); }
.app-ph__periods:where([data-rk-7bc64d]) { display: inline-flex; padding: 3px; 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); }
.app-ph__periods:where([data-rk-7bc64d]) button:where([data-rk-7bc64d]) { min-height: 34px; padding: 0 0.75rem; border: 0; border-radius: 7px; background: transparent; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.8rem; font-weight: 600; cursor: pointer; }
.app-ph__periods:where([data-rk-7bc64d]) [aria-checked='true']:where([data-rk-7bc64d]) { background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 0 0 1px var(--c-border, rgba(255, 255, 255, 0.08)); }
.app-ph__btn:where([data-rk-7bc64d]) {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 40px;
  padding: 0 0.95rem;
  border-radius: 10px;
  border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
  background: var(--c-bg-1, #0a0a0a);
  color: var(--c-text-1, #fff);
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.15s ease, filter 0.15s ease;
}
.app-ph__btn:hover:where([data-rk-7bc64d]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); color: var(--c-text-1, #fff); }
.app-ph__btn--primary:where([data-rk-7bc64d]) { border-color: transparent; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.app-ph__btn--primary:hover:where([data-rk-7bc64d]) { background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); filter: brightness(1.08); }
.app-ph:where([data-rk-7bc64d]) :is(a, button):focus-visible:where([data-rk-7bc64d]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.app-ph__tabs:where([data-rk-7bc64d]) { display: flex; gap: var(--sp-5, 1.25rem); border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); overflow-x: auto; scrollbar-width: none; }
.app-ph__tabs:where([data-rk-7bc64d]) a:where([data-rk-7bc64d]) { display: inline-flex; align-items: center; gap: 6px; min-height: 42px; border-bottom: 2px solid transparent; color: var(--c-text-2, #a3a3a3); font-size: 0.85rem; font-weight: 600; text-decoration: none; white-space: nowrap; }
.app-ph__tabs:where([data-rk-7bc64d]) a:hover:where([data-rk-7bc64d]) { color: var(--c-text-1, #fff); }
.app-ph__tabs:where([data-rk-7bc64d]) a[aria-current]:where([data-rk-7bc64d]) { color: var(--c-text-1, #fff); border-bottom-color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.app-ph__tabs:where([data-rk-7bc64d]) span:where([data-rk-7bc64d]) { padding: 0 6px; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.08); font-size: 0.7rem; }

@container (max-width: 720px) {
  .app-ph__row:where([data-rk-7bc64d]) { flex-direction: column; align-items: stretch; }
  .app-ph__tools:where([data-rk-7bc64d]) { justify-content: flex-start; }
}

Información Rápida

Categoría
App y Dashboard
Nombre de archivo
PageHeader.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
appdashboardsaasheadertoolbartabs