feat(ui): add app shell primitives

This commit is contained in:
Saravana Achu Mac 2026-05-06 13:32:17 -07:00
parent 5af6154e80
commit 5e7b349a7c
2 changed files with 290 additions and 0 deletions

View File

@ -0,0 +1,270 @@
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(
'min-h-screen bg-[var(--bl-bg-canvas)] text-[var(--bl-text-primary)]',
className
)}
style={shellStyle}
{...props}
>
{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(
'fixed left-3 top-3 z-[38] inline-flex 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(
'fixed inset-0 z-[39] bg-[var(--bl-overlay-scrim,rgba(0,0,0,0.5))]',
open ? 'block' : 'hidden',
'lg:hidden',
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(
'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',
open ? 'translate-x-0' : '-translate-x-full',
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,
children,
...props
}: AppShellMainProps) {
return (
<main
id={id}
tabIndex={-1}
aria-labelledby={labelledBy}
className={clsx(
'min-h-screen min-w-0 p-5 pt-16 lg:pl-[var(--bl-app-sidebar-width)] lg:pt-8',
className
)}
{...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>
);
}

View File

@ -1,4 +1,24 @@
export { Button, type ButtonProps } from './components/Button';
export {
AppShell,
AppShellMain,
AppShellMobileToggle,
AppShellNav,
AppShellNavItem,
AppShellOverlay,
AppShellPageHeader,
AppShellSidebar,
AppShellSkipLink,
type AppShellMainProps,
type AppShellMobileToggleProps,
type AppShellNavItemProps,
type AppShellNavProps,
type AppShellOverlayProps,
type AppShellPageHeaderProps,
type AppShellProps,
type AppShellSidebarProps,
type AppShellSkipLinkProps,
} from './components/AppShell';
export { IconButton, type IconButtonProps } from './components/IconButton';
export {
Toast,