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 {
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user