feat(ui): add app shell primitives
This commit is contained in:
parent
5af6154e80
commit
5e7b349a7c
270
packages/ui/src/components/AppShell.tsx
Normal file
270
packages/ui/src/components/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,24 @@
|
|||||||
export { Button, type ButtonProps } from './components/Button';
|
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 { IconButton, type IconButtonProps } from './components/IconButton';
|
||||||
export {
|
export {
|
||||||
Toast,
|
Toast,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user