314 lines
7.7 KiB
TypeScript
314 lines
7.7 KiB
TypeScript
import * as React from 'react';
|
|
import { clsx } from 'clsx';
|
|
import { Menu, X } from 'lucide-react';
|
|
|
|
type ShellStyle = React.CSSProperties & {
|
|
'--bl-app-sidebar-width'?: string;
|
|
};
|
|
|
|
export interface AppShellProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
sidebarWidth?: number;
|
|
}
|
|
|
|
export function AppShell({
|
|
sidebarWidth = 280,
|
|
className,
|
|
style,
|
|
children,
|
|
...props
|
|
}: AppShellProps) {
|
|
const shellStyle: ShellStyle = {
|
|
'--bl-app-sidebar-width': `${sidebarWidth}px`,
|
|
...style,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'bl-app-shell min-h-screen bg-[var(--bl-bg-canvas)] text-[var(--bl-text-primary)]',
|
|
className
|
|
)}
|
|
style={shellStyle}
|
|
{...props}
|
|
>
|
|
<style>
|
|
{`
|
|
.bl-app-shell-mobile-toggle {
|
|
display: inline-flex;
|
|
}
|
|
|
|
.bl-app-shell-overlay {
|
|
display: none;
|
|
}
|
|
|
|
.bl-app-shell-overlay[data-open="true"] {
|
|
display: block;
|
|
}
|
|
|
|
.bl-app-shell-sidebar {
|
|
transform: translateX(-100%);
|
|
transition: transform 200ms ease-out;
|
|
}
|
|
|
|
.bl-app-shell-sidebar[data-open="true"] {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.bl-app-shell-main {
|
|
padding: 4rem 1.25rem 1.25rem;
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.bl-app-shell-mobile-toggle,
|
|
.bl-app-shell-overlay {
|
|
display: none !important;
|
|
}
|
|
|
|
.bl-app-shell-sidebar {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.bl-app-shell-main {
|
|
padding: 2rem 2rem 2rem calc(var(--bl-app-sidebar-width, 280px) + 2rem);
|
|
}
|
|
}
|
|
`}
|
|
</style>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface AppShellSkipLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
label?: string;
|
|
}
|
|
|
|
export function AppShellSkipLink({
|
|
href = '#main-content',
|
|
label = 'Skip to main content',
|
|
className,
|
|
children,
|
|
...props
|
|
}: AppShellSkipLinkProps) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
className={clsx(
|
|
'sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-50',
|
|
'focus:rounded-md focus:border focus:border-[var(--bl-accent)]',
|
|
'focus:bg-[var(--bl-bg-elevated)] focus:px-4 focus:py-3 focus:text-sm',
|
|
'focus:font-semibold focus:text-[var(--bl-accent)]',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children ?? label}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export interface AppShellMobileToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
open: boolean;
|
|
openLabel?: string;
|
|
closeLabel?: string;
|
|
}
|
|
|
|
export function AppShellMobileToggle({
|
|
open,
|
|
openLabel = 'Open menu',
|
|
closeLabel = 'Close menu',
|
|
className,
|
|
children,
|
|
...props
|
|
}: AppShellMobileToggleProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={open ? closeLabel : openLabel}
|
|
className={clsx(
|
|
'bl-app-shell-mobile-toggle fixed left-3 top-3 z-[38] h-10 w-10 items-center justify-center',
|
|
'rounded-md border border-[var(--bl-border)] bg-[var(--bl-bg-elevated)]',
|
|
'text-[var(--bl-text-primary)] shadow-sm transition-colors',
|
|
'hover:bg-[var(--bl-surface-muted)] focus-visible:outline-none',
|
|
'focus-visible:ring-2 focus-visible:ring-[var(--bl-accent)] lg:hidden',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children ??
|
|
(open ? <X size={20} aria-hidden="true" /> : <Menu size={20} aria-hidden="true" />)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export interface AppShellOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
open: boolean;
|
|
}
|
|
|
|
export function AppShellOverlay({ open, className, ...props }: AppShellOverlayProps) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
data-open={open ? 'true' : 'false'}
|
|
className={clsx(
|
|
'bl-app-shell-overlay fixed inset-0 z-[39] bg-[var(--bl-overlay-scrim,rgba(0,0,0,0.5))]',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export interface AppShellSidebarProps extends React.HTMLAttributes<HTMLElement> {
|
|
open: boolean;
|
|
width?: number;
|
|
label?: string;
|
|
}
|
|
|
|
export function AppShellSidebar({
|
|
open,
|
|
width = 280,
|
|
label = 'Primary',
|
|
className,
|
|
style,
|
|
children,
|
|
...props
|
|
}: AppShellSidebarProps) {
|
|
return (
|
|
<aside
|
|
aria-label={label}
|
|
data-open={open ? 'true' : 'false'}
|
|
className={clsx(
|
|
'bl-app-shell-sidebar fixed left-0 top-0 z-40 flex h-screen flex-col overflow-y-auto',
|
|
'border-r border-[var(--bl-border)] bg-[var(--bl-surface-sidebar,var(--bl-bg-elevated))]',
|
|
'transition-transform duration-200 ease-out lg:translate-x-0',
|
|
className
|
|
)}
|
|
style={{
|
|
width,
|
|
minWidth: width,
|
|
...style,
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export interface AppShellMainProps extends React.HTMLAttributes<HTMLElement> {
|
|
labelledBy?: string;
|
|
}
|
|
|
|
export function AppShellMain({
|
|
id = 'main-content',
|
|
labelledBy,
|
|
className,
|
|
style,
|
|
children,
|
|
...props
|
|
}: AppShellMainProps) {
|
|
return (
|
|
<main
|
|
id={id}
|
|
tabIndex={-1}
|
|
aria-labelledby={labelledBy}
|
|
className={clsx('bl-app-shell-main min-h-screen min-w-0', className)}
|
|
style={style}
|
|
{...props}
|
|
>
|
|
<div className="grid gap-6">{children}</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export interface AppShellPageHeaderProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
|
|
title: React.ReactNode;
|
|
description?: React.ReactNode;
|
|
actions?: React.ReactNode;
|
|
titleId?: string;
|
|
}
|
|
|
|
export function AppShellPageHeader({
|
|
title,
|
|
description,
|
|
actions,
|
|
titleId = 'page-title',
|
|
className,
|
|
children,
|
|
...props
|
|
}: AppShellPageHeaderProps) {
|
|
return (
|
|
<header className={className} {...props}>
|
|
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-6 shadow-sm">
|
|
<div className="grid max-w-3xl gap-2">
|
|
<h1
|
|
id={titleId}
|
|
className="m-0 font-[var(--bl-font-display,var(--font-display,inherit))] text-2xl font-bold"
|
|
>
|
|
{title}
|
|
</h1>
|
|
{description ? (
|
|
<div className="text-sm leading-6 text-[var(--bl-text-secondary)]">{description}</div>
|
|
) : null}
|
|
{children}
|
|
</div>
|
|
{actions ? <div aria-label="Page actions">{actions}</div> : null}
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
export interface AppShellNavProps extends React.HTMLAttributes<HTMLElement> {
|
|
label?: string;
|
|
}
|
|
|
|
export function AppShellNav({
|
|
label = 'Primary navigation',
|
|
className,
|
|
children,
|
|
...props
|
|
}: AppShellNavProps) {
|
|
return (
|
|
<nav aria-label={label} className={clsx('grid gap-2', className)} {...props}>
|
|
{children}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
export interface AppShellNavItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
active?: boolean;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
export function AppShellNavItem({
|
|
active,
|
|
icon,
|
|
className,
|
|
children,
|
|
...props
|
|
}: AppShellNavItemProps) {
|
|
return (
|
|
<a
|
|
aria-current={active ? 'page' : undefined}
|
|
className={clsx(
|
|
'flex items-center gap-3 rounded-md border px-3.5 py-3 text-sm no-underline',
|
|
'transition-colors focus-visible:outline-none focus-visible:ring-2',
|
|
'focus-visible:ring-[var(--bl-accent)]',
|
|
active
|
|
? 'border-[var(--bl-accent)] bg-[var(--bl-accent-muted)] text-[var(--bl-text-primary)]'
|
|
: 'border-[var(--bl-border)] bg-[var(--bl-surface-muted)] text-[var(--bl-text-secondary)] hover:text-[var(--bl-text-primary)]',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{icon ? (
|
|
<span className="shrink-0" aria-hidden="true">
|
|
{icon}
|
|
</span>
|
|
) : null}
|
|
<span>{children}</span>
|
|
</a>
|
|
);
|
|
}
|