Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions preview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
ConfirmDialog,
Dialog,
EmptyState,
ErrorState,
FormPanelSkeleton,
GridListbox,
Icon,
LinesSkeleton,
ListSkeleton,
Menu,
OutageGate,
PageHeader,
Pagination,
Popover,
Sidebar,
Expand Down Expand Up @@ -242,14 +244,13 @@ function Preview() {
</button>
</div>
{dialog === 'plain' && (
<Dialog eyebrow="preview" onClose={() => setDialog(null)} title="A plain dialog">
<Dialog onClose={() => setDialog(null)} title="A plain dialog">
<p className="mt-4 text-muted">On a phone this opens as a bottom sheet.</p>
</Dialog>
)}
{dialog === 'confirm' && (
<ConfirmDialog
confirmLabel="Delete"
eyebrow="preview"
onClose={() => setDialog(null)}
onConfirm={() => Promise.reject(new Error('This preview cannot delete anything.'))}
title="Delete this note?"
Expand Down Expand Up @@ -293,7 +294,7 @@ function Preview() {
{ active: false, badge: 4, href: '#workspace', icon: 'bell', label: 'Inbox' },
],
},
{ label: 'spaces', items: [{ active: false, href: '#workspace', label: 'Friends trip' }] },
{ label: 'Spaces', items: [{ active: false, href: '#workspace', label: 'Friends trip' }] },
]}
/>
</div>
Expand Down Expand Up @@ -329,7 +330,23 @@ function Preview() {
</div>
</Section>

<Section title="Empty and loading">
<Section title="Page header">
<PageHeader
actions={
<button className="secondary-button px-3 py-2 text-sm" type="button">
<Icon name="plus" size={16} />
New
</button>
}
eyebrow="Shared space · owner"
title="Home"
/>
</Section>

<Section title="Empty, error and loading">
<ErrorState onRetry={() => {}} title="This didn’t load">
The server answered with an error.
</ErrorState>
<EmptyState title="Nothing here yet">
<p className="mt-2 mb-0 text-muted">Empty states sit in a dashed box.</p>
</EmptyState>
Expand Down
169 changes: 108 additions & 61 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CSSProperties, ReactNode } from 'react';
import { createContext, useContext, useState, type CSSProperties, type ReactNode } from 'react';
import { useLink } from '../UiProvider.js';
import { Icon, type IconName } from './Icon.js';
import { Skeleton } from './Skeleton.js';
Expand All @@ -14,6 +14,30 @@ export interface ShellTab extends ShellNavItem {
loading?: boolean;
}

const collapseKey = 'graphite-sidebar-collapsed';

const ShellContext = createContext({ collapsed: false });

export function useShell() {
return useContext(ShellContext);
}

function storedCollapsed() {
try {
return localStorage.getItem(collapseKey) === '1';
} catch {
return false;
}
}

function storeCollapsed(collapsed: boolean) {
try {
localStorage.setItem(collapseKey, collapsed ? '1' : '0');
} catch {
return;
}
}

export function AppShell({
actions,
alert,
Expand All @@ -34,71 +58,94 @@ export function AppShell({
tabs: ShellTab[];
}) {
const Link = useLink();
const [collapsed, setCollapsed] = useState(storedCollapsed);
const toggle = () => {
setCollapsed(!collapsed);
storeCollapsed(!collapsed);
};

return (
<div className="flex min-h-screen flex-col pb-[calc(var(--tab-bar-height)+env(safe-area-inset-bottom))] md:pb-0">
<a className="skip-link" href="#content">
skip to content
</a>
<header className="sticky top-0 z-20 border-b border-line-soft bg-paper/90 backdrop-blur-md">
<div className="shell flex h-16 items-center gap-6">
<Link
className="flex shrink-0 items-center gap-2.5 text-lg font-semibold tracking-tight text-ink no-underline"
href={brand.href}
>
{brand.mark}
{brand.name}
</Link>
<nav aria-label="Primary" className="mono-sm hidden items-center gap-6 md:flex">
{nav.map((item) => (
<Link
aria-current={item.active ? 'page' : undefined}
className={`nav-link whitespace-nowrap no-underline ${item.active ? 'text-ink' : 'text-muted hover:text-ink'}`}
href={item.href}
key={item.href}
<ShellContext.Provider value={{ collapsed: Boolean(sidebar) && collapsed }}>
<div className="flex min-h-screen flex-col pb-[calc(var(--tab-bar-height)+env(safe-area-inset-bottom))] md:pb-0">
<a className="skip-link" href="#content">
skip to content
</a>
<header className="sticky top-0 z-20 border-b border-line-soft bg-paper/90 backdrop-blur-md">
<div className={sidebar ? 'shell-bar flex h-16 items-center gap-2' : 'shell flex h-16 items-center gap-6'}>
{sidebar && (
<button
aria-controls="app-sidebar"
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand the sidebar' : 'Collapse the sidebar'}
className="sidebar-toggle hidden md:inline-flex"
onClick={toggle}
title={collapsed ? 'Expand the sidebar' : 'Collapse the sidebar'}
type="button"
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-2">{actions}</div>
</div>
{alert}
</header>
{sidebar ? (
<div className="flex flex-1">
<aside className="sidebar-panel hidden md:block">{sidebar}</aside>
<div className="min-w-0 flex-1">
<main className="shell py-8 sm:py-14" id="content">
{children}
</main>
</div>
</div>
) : (
<main className="shell flex-1 py-8 sm:py-14" id="content">
{children}
</main>
)}
{footer}
<nav aria-label="Main" className="tab-bar md:hidden" style={{ '--tab-count': tabs.length } as CSSProperties}>
{tabs.map((tab) =>
tab.loading ? (
<span aria-hidden="true" className="tab-bar-item" key={tab.label}>
<Skeleton className="h-[22px] w-[22px] rounded-full" />
<Skeleton className="h-2.5 w-10" />
</span>
) : (
<Icon name="panel" size={18} />
</button>
)}
<Link
aria-current={tab.active ? 'page' : undefined}
className="tab-bar-item"
href={tab.href}
key={tab.label}
className="flex shrink-0 items-center gap-2.5 text-lg font-semibold tracking-tight text-ink no-underline"
href={brand.href}
>
<Icon name={tab.icon} size={22} />
<span>{tab.label}</span>
{brand.mark}
{brand.name}
</Link>
),
<nav aria-label="Primary" className="mono-sm hidden items-center gap-6 md:flex">
{nav.map((item) => (
<Link
aria-current={item.active ? 'page' : undefined}
className={`nav-link whitespace-nowrap no-underline ${item.active ? 'text-ink' : 'text-muted hover:text-ink'}`}
href={item.href}
key={item.href}
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-2">{actions}</div>
</div>
{alert}
</header>
{sidebar ? (
<div className="flex flex-1">
<aside className="sidebar-panel hidden md:block" data-collapsed={collapsed} id="app-sidebar">
{sidebar}
</aside>
<div className="min-w-0 flex-1">
<main className="shell py-8 sm:py-14" id="content">
{children}
</main>
</div>
</div>
) : (
<main className="shell flex-1 py-8 sm:py-14" id="content">
{children}
</main>
)}
</nav>
</div>
{footer}
<nav aria-label="Main" className="tab-bar md:hidden" style={{ '--tab-count': tabs.length } as CSSProperties}>
{tabs.map((tab) =>
tab.loading ? (
<span aria-hidden="true" className="tab-bar-item" key={tab.label}>
<Skeleton className="h-[22px] w-[22px] rounded-full" />
<Skeleton className="h-2.5 w-10" />
</span>
) : (
<Link
aria-current={tab.active ? 'page' : undefined}
className="tab-bar-item"
href={tab.href}
key={tab.label}
>
<Icon name={tab.icon} size={22} />
<span>{tab.label}</span>
</Link>
),
)}
</nav>
</div>
</ShellContext.Provider>
);
}
2 changes: 1 addition & 1 deletion src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function ConfirmDialog({
children: ReactNode;
confirmLabel: string;
errorFallback?: string;
eyebrow: string;
eyebrow?: string;
onClose: () => void;
onConfirm: () => Promise<void>;
title: string;
Expand Down
11 changes: 6 additions & 5 deletions src/components/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { Icon } from './Icon.js';

export function Dialog({
children,
Expand All @@ -7,7 +8,7 @@ export function Dialog({
title,
}: {
children: ReactNode;
eyebrow: string;
eyebrow?: string;
onClose: () => void;
title: string;
}) {
Expand Down Expand Up @@ -36,13 +37,13 @@ export function Dialog({
<div className="p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:p-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="eyebrow">{eyebrow}</p>
<h2 className="mt-2 mb-0 text-2xl font-medium" id={titleId}>
{eyebrow && <p className="eyebrow mb-2">{eyebrow}</p>}
<h2 className="m-0 text-xl font-medium" id={titleId}>
{title}
</h2>
</div>
<button aria-label="Close" className="text-button text-xl" onClick={onClose} type="button">
×
<button aria-label="Close" className="icon-button -mt-1.5 -mr-2 shrink-0" onClick={onClose} type="button">
<Icon name="x" size={18} />
</button>
</div>
{children}
Expand Down
35 changes: 35 additions & 0 deletions src/components/ErrorState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { Icon } from './Icon.js';

export function ErrorState({
action,
children,
onRetry,
title,
}: {
action?: ReactNode;
children?: ReactNode;
onRetry?: () => void;
title: string;
}) {
return (
<div className="error-state" role="alert">
<span className="error-state-icon">
<Icon name="alert" size={22} />
</span>
<h2 className="m-0 text-lg font-medium">{title}</h2>
{children && <div className="max-w-md text-sm text-muted">{children}</div>}
{(onRetry || action) && (
<div className="mt-2 flex flex-wrap justify-center gap-2">
{onRetry && (
<button className="secondary-button px-3 py-2 text-sm" onClick={onRetry} type="button">
<Icon name="refresh" size={16} />
Try again
</button>
)}
{action}
</div>
)}
</div>
);
}
4 changes: 3 additions & 1 deletion src/components/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ function FieldMessage({ error, hint, id }: { error?: string; hint?: ReactNode; i
<span className="field-hint" id={`${id}-hint`}>
{hint}
</span>
) : null;
) : (
<span aria-hidden="true" className="field-slot" />
);
}

export function TextField({
Expand Down
19 changes: 19 additions & 0 deletions src/components/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
const paths = {
alert: 'M12 4 21 20H3zM12 10v4M12 17.5h.01',
bell: 'M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0',
calendar: 'M8 3v4M16 3v4M4 10h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z',
check: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM8.5 12.5l2.5 2.5 4.5-5',
compass: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM15.5 8.5l-2 5-5 2 2-5z',
filter: 'M4 6h16M7 12h10M10 18h4',
folder: 'M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z',
gamepad:
'M6 11h4M8 9v4M15 12h.01M18 10h.01M17.3 5H6.7a4 4 0 0 0-3.96 3.43l-.73 5.12A2.8 2.8 0 0 0 4.8 16.8c.9 0 1.7-.5 2.1-1.3L8 14h8l1.1 1.5c.4.8 1.2 1.3 2.1 1.3a2.8 2.8 0 0 0 2.79-3.25l-.73-5.12A4 4 0 0 0 17.3 5z',
home: 'M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1z',
key: 'M14.5 12.5a4.5 4.5 0 1 0-4-4L3 16v4h4v-2h2v-2h2l1.5-1.5M16 8h.01',
library: 'M5 4h3v16H5zM10 4h3v16h-3zM15.2 4.6l2.9-.8 3.5 15.6-2.9.8z',
lock: 'M6 11h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1zM8 11V8a4 4 0 0 1 8 0v3',
mail: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zM3.5 7l8.5 6 8.5-6',
monitor: 'M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM8 21h8M12 17v4',
panel: 'M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 4v16',
pencil: 'M4 20h4L19 9l-4-4L4 16zM14 6l4 4',
plus: 'M12 5v14M5 12h14',
refresh: 'M20 11a8 8 0 0 0-14.5-4.5L4 8M4 4v4h4M4 13a8 8 0 0 0 14.5 4.5L20 16M20 20v-4h-4',
repeat: 'M17 3l3 3-3 3M4 11V9a3 3 0 0 1 3-3h13M7 21l-3-3 3-3M20 13v2a3 3 0 0 1-3 3H4',
search: 'M21 21l-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
settings:
'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
shield: 'M12 3 5 6v5c0 4.4 3 8.3 7 10 4-1.7 7-5.6 7-10V6zM9 12l2 2 4-4',
sparkles: 'M10 3l1.6 4.4L16 9l-4.4 1.6L10 15l-1.6-4.4L4 9l4.4-1.6zM18 14l.9 2.1L21 17l-2.1.9L18 20l-.9-2.1L15 17l2.1-.9z',
trash: 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3',
user: 'M20 21a8 8 0 0 0-16 0M12 13a5 5 0 1 0 0-10 5 5 0 0 0 0 10z',
x: 'M6 6l12 12M18 6 6 18',
};

export type IconName = keyof typeof paths;
Expand Down
Loading
Loading