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
77 changes: 75 additions & 2 deletions preview/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { StrictMode, useState, type ReactNode } from 'react';
import { StrictMode, useCallback, useState, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import {
AppShell,
Avatar,
CodeInput,
CommandPalette,
ConfirmDialog,
Dialog,
EmptyState,
Expand All @@ -14,13 +17,19 @@ import {
OutageGate,
Pagination,
Popover,
Sidebar,
SmoothImage,
SnackbarProvider,
Tabs,
TagChip,
tagColors,
TextAreaField,
TextField,
Toggle,
timeAgo,
useCommandShortcut,
useSnackbar,
type CommandItem,
type IconName,
} from '../src/index.ts';
import './styles.css';
Expand All @@ -38,7 +47,13 @@ const colors = [
['wip', 'bg-wip'],
['dormant', 'bg-dormant'],
];
const icons: IconName[] = ['bell', 'compass', 'filter', 'gamepad', 'home', 'library', 'user'];
const icons: IconName[] = ['bell', 'compass', 'filter', 'gamepad', 'home', 'library', 'search', 'user'];
const commands: CommandItem[] = [
{ group: 'Go to', href: '#workspace', id: 'notes', label: 'Notes', hint: 'g n' },
{ group: 'Go to', href: '#workspace', id: 'agenda', label: 'Agenda', hint: 'g a' },
{ group: 'Actions', id: 'task', label: 'New task', onSelect: () => {} },
{ group: 'Actions', id: 'note', label: 'New note', onSelect: () => {} },
];
const outageListeners = new Set<() => void>();

function Section({ children, title }: { children: ReactNode; title: string }) {
Expand All @@ -65,6 +80,10 @@ function SnackbarDemo() {

function Preview() {
const [dialog, setDialog] = useState<'plain' | 'confirm' | null>(null);
const [palette, setPalette] = useState(false);
const [query, setQuery] = useState('');
const openPalette = useCallback(() => setPalette(true), []);
useCommandShortcut(openPalette);
const [checked, setChecked] = useState(true);
const [page, setPage] = useState(3);

Expand Down Expand Up @@ -240,6 +259,60 @@ function Preview() {
)}
</Section>

<Section title="Workspace">
<Tabs
items={[
{ active: true, href: '#workspace', label: 'Profile' },
{ active: false, href: '#workspace', label: 'Security' },
{ active: false, href: '#workspace', label: 'Sessions' },
]}
label="Settings"
/>
<div className="chip-row">
{tagColors.map((color) => (
<TagChip color={color} key={color} label={color} />
))}
<TagChip color="blue" href="#workspace" label="linked" />
<TagChip color="green" label="removable" onRemove={() => {}} />
</div>
<div className="flex items-center gap-3">
<Avatar name="Amr" present />
<Avatar name="Sam" />
<Avatar name="Lee" present size="sm" />
</div>
<div className="max-w-xs">
<CodeInput hint="From your authenticator app." label="Six-digit code" name="code" />
</div>
<div className="h-72 max-w-60 overflow-hidden rounded-xl border border-line">
<Sidebar
label="Areas"
sections={[
{
items: [
{ active: true, href: '#workspace', icon: 'home', label: 'Home' },
{ active: false, badge: 4, href: '#workspace', icon: 'bell', label: 'Inbox' },
],
},
{ label: 'spaces', items: [{ active: false, href: '#workspace', label: 'Friends trip' }] },
]}
/>
</div>
<button className="secondary-button inline-flex w-fit" onClick={openPalette} type="button">
Open command palette (Ctrl+K)
</button>
{palette && (
<CommandPalette
items={commands.filter((command) => command.label.toLowerCase().includes(query.toLowerCase()))}
onClose={() => {
setPalette(false);
setQuery('');
}}
onQueryChange={setQuery}
query={query}
/>
)}
</Section>

<Section title="Snackbar">
<SnackbarDemo />
</Section>
Expand Down
17 changes: 14 additions & 3 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function AppShell({
children,
footer,
nav,
sidebar,
tabs,
}: {
actions?: ReactNode;
Expand All @@ -29,6 +30,7 @@ export function AppShell({
children: ReactNode;
footer?: ReactNode;
nav: ShellNavItem[];
sidebar?: ReactNode;
tabs: ShellTab[];
}) {
const Link = useLink();
Expand Down Expand Up @@ -62,9 +64,18 @@ export function AppShell({
</div>
{alert}
</header>
<main className="shell flex-1 py-8 sm:py-14" id="content">
{children}
</main>
{sidebar ? (
<div className="flex flex-1">
<aside className="sidebar-panel hidden md:block">{sidebar}</aside>
<main className="shell min-w-0 flex-1 py-8 sm:py-14" id="content">
{children}
</main>
</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) =>
Expand Down
16 changes: 16 additions & 0 deletions src/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function Avatar({
name,
present,
size = 'md',
}: {
name: string;
present?: boolean;
size?: 'sm' | 'md';
}) {
return (
<span aria-label={present ? `${name}, here now` : name} className={`avatar avatar-${size}`} role="img">
<span aria-hidden="true">{name.trim().charAt(0).toUpperCase()}</span>
{present && <span aria-hidden="true" className="presence-dot" />}
</span>
);
}
25 changes: 25 additions & 0 deletions src/components/CodeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { TextField } from './Field.js';

export function CodeInput({
length = 6,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'pattern' | 'type'> & {
error?: string;
hint?: ReactNode;
label: ReactNode;
length?: number;
}) {
return (
<TextField
autoComplete="one-time-code"
className="code-input"
inputMode="numeric"
maxLength={length}
pattern={`\\d{${length}}`}
spellCheck={false}
type="text"
{...props}
/>
);
}
161 changes: 161 additions & 0 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { useLink } from '../UiProvider.js';
import { Icon } from './Icon.js';

export interface Command {
group: string;
hint?: string;
id: string;
label: string;
}

export type CommandItem = Command & ({ href: string } | { onSelect: () => void });

export function useCommandShortcut(open: () => void) {
useEffect(() => {
const listen = (event: globalThis.KeyboardEvent) => {
if (event.key.toLowerCase() === 'k' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
open();
}
};
document.addEventListener('keydown', listen);
return () => document.removeEventListener('keydown', listen);
}, [open]);
}

export function CommandPalette({
emptyLabel = 'Nothing matches',
items,
label = 'Search and commands',
onClose,
onQueryChange,
placeholder = 'Search or run a command',
query,
}: {
emptyLabel?: string;
items: CommandItem[];
label?: string;
onClose: () => void;
onQueryChange: (query: string) => void;
placeholder?: string;
query: string;
}) {
const Link = useLink();
const dialog = useRef<HTMLDialogElement>(null);
const listId = useId();
const [active, setActive] = useState(0);
const groups = [...new Set(items.map((item) => item.group))];
const ordered = groups.flatMap((group) => items.filter((item) => item.group === group));
const activeItem = ordered[Math.min(active, ordered.length - 1)];
const optionId = (item: CommandItem) => `${listId}-${item.id}`;

useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.current?.showModal();
return () => opener?.focus();
}, []);

useEffect(() => setActive(0), [query]);

useEffect(() => {
document.getElementById(`${listId}-${activeItem?.id}`)?.scrollIntoView?.({ block: 'nearest' });
}, [activeItem?.id, listId]);

function move(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (ordered.length === 0) return;
const step = event.key === 'ArrowDown' ? 1 : -1;
setActive((current) => (Math.min(current, ordered.length - 1) + step + ordered.length) % ordered.length);
} else if (event.key === 'Enter' && activeItem) {
event.preventDefault();
document.getElementById(optionId(activeItem))?.click();
}
}

return (
<dialog
aria-label={label}
className="command-palette"
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
ref={dialog}
>
<div className="flex items-center gap-3 border-b border-line px-4">
<span className="text-faint">
<Icon name="search" size={18} />
</span>
<input
aria-activedescendant={activeItem ? optionId(activeItem) : undefined}
aria-autocomplete="list"
aria-controls={listId}
aria-expanded="true"
aria-label={label}
autoFocus
className="command-input"
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={move}
placeholder={placeholder}
role="combobox"
type="text"
value={query}
/>
<kbd className="mono-sm text-faint">esc</kbd>
</div>
<div className="command-list" id={listId} role="listbox">
{ordered.length === 0 ? (
<p className="m-0 px-4 py-6 text-center text-sm text-muted">{emptyLabel}</p>
) : (
groups.map((group) => (
<div aria-label={group} className="py-1.5" key={group} role="group">
<p aria-hidden="true" className="eyebrow m-0 px-4 py-1.5">
{group}
</p>
{ordered
.filter((item) => item.group === group)
.map((item) => {
const props = {
'aria-selected': item === activeItem,
className: 'command-option',
id: optionId(item),
onMouseMove: () => setActive(ordered.indexOf(item)),
role: 'option',
tabIndex: -1,
};
const content = (
<>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="mono-sm shrink-0 text-faint">{item.hint}</span>}
</>
);
return 'href' in item ? (
<Link {...props} href={item.href} key={item.id} onClick={onClose}>
{content}
</Link>
) : (
<button
{...props}
key={item.id}
onClick={() => {
onClose();
item.onSelect();
}}
type="button"
>
{content}
</button>
);
})}
</div>
))
)}
</div>
</dialog>
);
}
1 change: 1 addition & 0 deletions src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const paths = {
'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',
library: 'M5 4h3v16H5zM10 4h3v16h-3zM15.2 4.6l2.9-.8 3.5 15.6-2.9.8z',
search: 'M21 21l-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
user: 'M20 21a8 8 0 0 0-16 0M12 13a5 5 0 1 0 0-10 5 5 0 0 0 0 10z',
};

Expand Down
Loading
Loading