Приложение и дашбордappdashboardsaasheadertoolbartabs
Page Header
Заголовок страницы приложения: хлебные крошки, описание, выбор периода, кнопки и вкладки.
Живое превью
Dashboard
Good morning, Lucía — here is what is happening at Hotel Mirador today.
Использование
Скопируйте и вставьте этот код в ваш React-компонент:
tsx
import { PageHeader } from 'react-component-kit-ui/app';
<PageHeader title="Reports" periods={['7d', '30d', 'Year']} actions={[{ label: 'Export', icon: 'download' }]} />Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: 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; }
}Краткая информация
- Категория
- Приложение и дашборд
- Имя файла
PageHeader.tsx- Зависимости
- Нет — чистый React + CSS
- Теги
- appdashboardsaasheadertoolbartabs