diff --git a/.gitignore b/.gitignore index d6866a933..86225fb78 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ plan/* node_modules/ .pnp .pnp.js +.pnpm-store # Build outputs dist/ diff --git a/packages/apollo-wind/.storybook/main.ts b/packages/apollo-wind/.storybook/main.ts index 572e831ba..5050846f6 100644 --- a/packages/apollo-wind/.storybook/main.ts +++ b/packages/apollo-wind/.storybook/main.ts @@ -34,7 +34,19 @@ if (isDev) { const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-docs'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-docs', + '@storybook/addon-a11y', + '@storybook/addon-mcp', + ], + + // Enable the component manifest so Storybook MCP can expose + // machine-readable component metadata (APIs, props, usage examples) + // to AI agents and coding tools. + features: { + experimentalComponentsManifest: true, + }, // Serve only public/ (e.g. ui-path.svg for sidebar logo); single dir to avoid route conflicts staticDirs: ['../public'], @@ -52,6 +64,7 @@ const config: StorybookConfig = { async viteFinal(config) { return { ...config, + envDir: resolve(__dirname, '..'), resolve: { ...config.resolve, alias: { diff --git a/packages/apollo-wind/.storybook/manager.js b/packages/apollo-wind/.storybook/manager.js deleted file mode 100644 index d1ff96415..000000000 --- a/packages/apollo-wind/.storybook/manager.js +++ /dev/null @@ -1,9 +0,0 @@ -import { addons } from 'storybook/manager-api'; -import theme from './theme'; - -addons.setConfig({ - theme, - sidebar: { - collapsedRoots: ['theme', 'forms'], - }, -}); diff --git a/packages/apollo-wind/.storybook/manager.ts b/packages/apollo-wind/.storybook/manager.ts index d1ff96415..3205a54cb 100644 --- a/packages/apollo-wind/.storybook/manager.ts +++ b/packages/apollo-wind/.storybook/manager.ts @@ -3,7 +3,8 @@ import theme from './theme'; addons.setConfig({ theme, + showPanel: false, sidebar: { - collapsedRoots: ['theme', 'forms'], + collapsedRoots: ['components', 'forms', 'experiments'], }, }); diff --git a/packages/apollo-wind/.storybook/preview-head.html b/packages/apollo-wind/.storybook/preview-head.html index 8180973ce..23bd16877 100644 --- a/packages/apollo-wind/.storybook/preview-head.html +++ b/packages/apollo-wind/.storybook/preview-head.html @@ -1,3 +1,11 @@ + + + + + ; +}; + +const ChartTooltip = RechartsPrimitive.Tooltip; + +function ChartTooltipContent({ + active, + payload, + className, + indicator = 'dot', + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, +}: React.ComponentProps & + React.ComponentProps<'div'> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: 'line' | 'dot' | 'dashed'; + nameKey?: string; + labelKey?: string; + }) { + const { config } = useChart(); + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null; + } + + const [item] = payload; + const key = `${labelKey || item?.dataKey || item?.name || 'value'}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = + !labelKey && typeof label === 'string' + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label; + + if (labelFormatter) { + return ( +
+ {labelFormatter(value, payload)} +
+ ); + } + + if (!value) { + return null; + } + + return
{value}
; + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]); + + if (!active || !payload?.length) { + return null; + } + + const nestLabel = payload.length === 1 && indicator !== 'dot'; + + return ( +
+ {!nestLabel ? tooltipLabel : null} +
+ {payload + .filter((item) => item.type !== 'none') + .map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || 'value'}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload.fill || item.color; + + return ( +
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5', + indicator === 'dot' && 'items-center', + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + + ) : ( + !hideIndicator && ( +
+ ) + )} +
+
+ {nestLabel ? tooltipLabel : null} + + {itemConfig?.label || item.name} + +
+ {item.value != null && ( + + {item.value.toLocaleString()} + + )} +
+ + )} +
+ ); + })} +
+
+ ); +} + +const ChartLegend = RechartsPrimitive.Legend; + +function ChartLegendContent({ + className, + hideIcon = false, + payload, + verticalAlign = 'bottom', + nameKey, +}: React.ComponentProps<'div'> & + Pick & { + hideIcon?: boolean; + nameKey?: string; + }) { + const { config } = useChart(); + + if (!payload?.length) { + return null; + } + + return ( +
+ {payload + .filter((item) => item.type !== 'none') + .map((item) => { + const key = `${nameKey || item.dataKey || 'value'}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + + return ( +
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3', + )} + > + {itemConfig?.icon && !hideIcon ? ( + + ) : ( +
+ )} + {itemConfig?.label} +
+ ); + })} +
+ ); +} + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string, +) { + if (typeof payload !== 'object' || payload === null) { + return undefined; + } + + const payloadPayload = + 'payload' in payload && + typeof payload.payload === 'object' && + payload.payload !== null + ? payload.payload + : undefined; + + let configLabelKey: string = key; + + if ( + key in payload && + typeof payload[key as keyof typeof payload] === 'string' + ) { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === 'string' + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string; + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config]; +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +}; diff --git a/packages/apollo-wind/src/components/ui/checkbox.stories.tsx b/packages/apollo-wind/src/components/ui/checkbox.stories.tsx index cede3a683..d98169cd1 100644 --- a/packages/apollo-wind/src/components/ui/checkbox.stories.tsx +++ b/packages/apollo-wind/src/components/ui/checkbox.stories.tsx @@ -4,7 +4,7 @@ import { Label } from './label'; import { Row, Column } from './layout'; const meta = { - title: 'Design System/Core/Checkbox', + title: 'Components/Core/Checkbox', component: Checkbox, parameters: { layout: 'centered', diff --git a/packages/apollo-wind/src/components/ui/code-block.stories.tsx b/packages/apollo-wind/src/components/ui/code-block.stories.tsx new file mode 100644 index 000000000..b7bb73870 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/code-block.stories.tsx @@ -0,0 +1,543 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { CodeBlockTheme } from './code-block'; +import { CodeBlock } from './code-block'; + +// ============================================================================ +// Meta +// ============================================================================ + +const meta = { + title: 'Components/Core/Code Block', + component: CodeBlock, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + language: { + control: 'select', + options: ['tsx', 'typescript', 'javascript', 'json', 'css', 'html', 'python', 'bash', 'sql', 'yaml', 'markdown'], + }, + theme: { + control: 'select', + options: [ + 'dark', + 'light', + 'future-dark', + 'future-light', + 'core-dark', + 'core-light', + 'wireframe', + 'vertex', + 'canvas', + 'dark-hc', + 'light-hc', + ], + }, + showLineNumbers: { control: 'boolean' }, + showCopyButton: { control: 'boolean' }, + wrapLines: { control: 'boolean' }, + fileName: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// Sample code snippets +// ============================================================================ + +const tsxSample = ` +import { useState } from 'react'; + +interface User { + id: number; + name: string; + email: string; +} + +export function UserCard({ user }: { user: User }) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+

{user.name}

+ {isExpanded && ( +

{user.email}

+ )} + +
+ ); +} +`.trim(); + +const jsSample = ` +async function fetchUsers(page = 1, limit = 10) { + const url = new URL('/api/users', window.location.origin); + url.searchParams.set('page', String(page)); + url.searchParams.set('limit', String(limit)); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(\`HTTP error — status: \${response.status}\`); + } + + const { users, total } = await response.json(); + return { users, total, page, limit }; +} +`.trim(); + +const jsonSample = ` +{ + "name": "@uipath/apollo-wind", + "version": "0.10.0", + "description": "UiPath Apollo — Tailwind CSS design system", + "dependencies": { + "react": ">=18.0.0", + "tailwindcss": "^4.1.0", + "class-variance-authority": "^0.7.1", + "lucide-react": "^0.555.0" + }, + "scripts": { + "dev": "storybook dev -p 6006", + "build": "rslib build" + } +} +`.trim(); + +const cssSample = ` +/* Apollo Wind — design token utilities */ +@layer base { + :root { + --radius: 0.75rem; + --color-background: var(--color-background); + --color-foreground: var(--color-foreground); + } + + * { + border-color: var(--color-border-de-emp); + box-sizing: border-box; + } + + body { + background: var(--color-background); + color: var(--color-foreground); + font-family: var(--font-sans); + } +} +`.trim(); + +const pythonSample = ` +from typing import Generator + +def fibonacci(n: int) -> Generator[int, None, None]: + """Yield the first n Fibonacci numbers.""" + a, b = 0, 1 + for _ in range(n): + yield a + a, b = b, a + b + +result = list(fibonacci(10)) +print(result) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] +`.trim(); + +const bashSample = ` +#!/bin/bash +# Bootstrap the Apollo UI monorepo + +set -e + +echo "Installing dependencies..." +pnpm install + +echo "Building packages..." +pnpm turbo build --filter=@uipath/apollo-core +pnpm turbo build --filter=@uipath/apollo-wind + +echo "Starting Storybook..." +pnpm --filter @uipath/apollo-wind storybook +`.trim(); + +const sqlSample = ` +SELECT + u.id, + u.name, + COUNT(o.id) AS order_count, + SUM(o.total) AS total_spent +FROM users u +LEFT JOIN orders o ON o.user_id = u.id +WHERE + u.created_at >= '2024-01-01' + AND u.status = 'active' +GROUP BY u.id, u.name +HAVING SUM(o.total) > 500 +ORDER BY total_spent DESC +LIMIT 10; +`.trim(); + +const htmlSample = ` + + + + + + Apollo Wind + + + +
+ + + +`.trim(); + +const longSample = ` +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type SortDirection = 'asc' | 'desc'; + +interface Column { + key: keyof T; + header: string; + sortable?: boolean; + render?: (value: T[keyof T], row: T) => React.ReactNode; +} + +interface TableProps { + data: T[]; + columns: Column[]; + pageSize?: number; + searchable?: boolean; +} + +export function DataTable({ + data, + columns, + pageSize = 10, + searchable = true, +}: TableProps) { + const [query, setQuery] = useState(''); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('asc'); + const [page, setPage] = useState(1); + const inputRef = useRef(null); + + // Reset to page 1 whenever the query or sort changes + useEffect(() => { + setPage(1); + }, [query, sortKey, sortDir]); + + const filtered = useMemo(() => { + if (!query.trim()) return data; + const q = query.toLowerCase(); + return data.filter((row) => + columns.some((col) => String(row[col.key]).toLowerCase().includes(q)) + ); + }, [data, columns, query]); + + const sorted = useMemo(() => { + if (!sortKey) return filtered; + return [...filtered].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [filtered, sortKey, sortDir]); + + const paginated = useMemo(() => { + const start = (page - 1) * pageSize; + return sorted.slice(start, start + pageSize); + }, [sorted, page, pageSize]); + + const totalPages = Math.ceil(sorted.length / pageSize); + + const handleSort = useCallback( + (key: keyof T) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('asc'); + } + }, + [sortKey] + ); + + return ( +
+ {searchable && ( + setQuery(e.target.value)} + /> + )} + + + + + {columns.map((col) => ( + + ))} + + + + {paginated.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
col.sortable && handleSort(col.key)} + > + {col.header} + {col.sortable && sortKey === col.key && ( + {sortDir === 'asc' ? ' ↑' : ' ↓'} + )} +
+ {col.render ? col.render(row[col.key], row) : String(row[col.key])} +
+ + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} +
+ ); +} +`.trim(); + +// ============================================================================ +// Default — no `theme` prop: auto-follows the Apollo page theme +// ============================================================================ + +export const Default: Story = { + args: { + children: tsxSample, + language: 'tsx', + showLineNumbers: true, + showCopyButton: true, + }, +}; + +// ============================================================================ +// With File Name +// ============================================================================ + +export const WithFileName: Story = { + name: 'With File Name', + args: { + children: tsxSample, + language: 'tsx', + fileName: 'UserCard.tsx', + showLineNumbers: true, + showCopyButton: true, + }, +}; + +// ============================================================================ +// No Line Numbers +// ============================================================================ + +export const NoLineNumbers: Story = { + name: 'No Line Numbers', + args: { + children: jsSample, + language: 'javascript', + showLineNumbers: false, + showCopyButton: true, + }, +}; + +// ============================================================================ +// No Copy Button +// ============================================================================ + +export const NoCopyButton: Story = { + name: 'No Copy Button', + args: { + children: jsonSample, + language: 'json', + showLineNumbers: true, + showCopyButton: false, + }, +}; + +// ============================================================================ +// Wrap Long Lines +// ============================================================================ + +export const WrapLongLines: Story = { + name: 'Wrap Long Lines', + args: { + children: `const result = await someVeryLongFunctionName({ parameterOne: 'value', parameterTwo: 42, parameterThree: true, parameterFour: 'another long value that pushes the line past the viewport width' });`, + language: 'javascript', + showLineNumbers: false, + wrapLines: true, + }, +}; + +// ============================================================================ +// Languages +// ============================================================================ + +export const Languages = { + name: 'Languages', + parameters: { layout: 'padded' }, + render: () => ( +
+
+

TypeScript / TSX

+ + {tsxSample.split('\n').slice(0, 8).join('\n')} + +
+ +
+

JavaScript

+ + {jsSample} + +
+ +
+

JSON

+ + {jsonSample} + +
+ +
+

CSS

+ + {cssSample} + +
+ +
+

Python

+ + {pythonSample} + +
+ +
+

Bash

+ + {bashSample} + +
+ +
+

SQL

+ + {sqlSample} + +
+ +
+

HTML

+ + {htmlSample} + +
+
+ ), +}; + +// ============================================================================ +// Long Code +// ============================================================================ + +export const LongCode: Story = { + name: 'Long Code', + args: { + children: longSample, + language: 'tsx', + fileName: 'DataTable.tsx', + showLineNumbers: true, + showCopyButton: true, + }, +}; + +// ============================================================================ +// All Themes +// ============================================================================ + +const THEME_LABELS: Record = { + // Standard + dark: 'Dark', + light: 'Light', + 'dark-hc': 'High Contrast Dark', + 'light-hc': 'High Contrast Light', + // Future design language + 'future-dark': 'Future: Dark', + 'future-light': 'Future: Light', + 'core-dark': 'Core: Dark', + 'core-light': 'Core: Light', + wireframe: 'Wireframe', + vertex: 'Vertex', + canvas: 'Canvas', +}; + +const preview = ` +import { useState } from 'react'; + +export function Counter() { + const [count, setCount] = useState(0); + return ( + + ); +} +`.trim(); + +export const AllThemes = { + name: 'All Themes', + parameters: { layout: 'padded' }, + render: () => ( +
+ {(Object.keys(THEME_LABELS) as CodeBlockTheme[]).map((t) => ( +
+

+ {THEME_LABELS[t]} +

+ + {preview} + +
+ ))} +
+ ), +}; diff --git a/packages/apollo-wind/src/components/ui/code-block.tsx b/packages/apollo-wind/src/components/ui/code-block.tsx new file mode 100644 index 000000000..2f8bfbc01 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/code-block.tsx @@ -0,0 +1,335 @@ +import { Check, Copy } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { + atomDark, + nightOwl, + nord, + oneDark, + oneLight, + prism, + vs, + vscDarkPlus, +} from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import { cn } from '@/lib'; +import { Button } from '@/components/ui/button'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * All Apollo theme variants supported by CodeBlock. + * + * Standard: + * - `'dark'` / `'light'` — Apollo dark / light + * - `'dark-hc'` / `'light-hc'` — high-contrast variants + * + * Future design language (from themes.css): + * - `'future-dark'` / `'future-light'` — Future zinc palette, cyan brand + * - `'core-dark'` / `'core-light'` — Apollo Core blue palette + * - `'wireframe'` — Greyscale / prototyping + * - `'vertex'` — Deep blue-grey, teal brand + * - `'canvas'` — Apollo MUI dark, orange brand + * + * When no theme is passed the component auto-detects from the Apollo + * `` class and switches live when the theme changes. + */ +export type CodeBlockTheme = + | 'dark' + | 'light' + | 'future-dark' + | 'future-light' + | 'core-dark' + | 'core-light' + | 'wireframe' + | 'vertex' + | 'canvas' + | 'dark-hc' + | 'light-hc'; + +export interface CodeBlockProps { + /** The code string to display */ + children: string; + /** Programming language for syntax highlighting (e.g. 'tsx', 'python', 'sql') */ + language?: string; + /** Show line numbers on the left */ + showLineNumbers?: boolean; + /** Show copy-to-clipboard button in the header */ + showCopyButton?: boolean; + /** Optional file name displayed in the header */ + fileName?: string; + /** + * Color scheme. When omitted the component auto-follows the active Apollo + * page theme by watching the class on `` — switches live. + */ + theme?: CodeBlockTheme; + /** Wrap long lines instead of scrolling horizontally */ + wrapLines?: boolean; + className?: string; +} + +// ============================================================================ +// Per-theme configuration +// ============================================================================ + +interface ThemeConfig { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismStyle: Record; + bg: string; + headerBg: string; + labelColor: string; + iconColor: string; + lineNumberColor: string; +} + +const THEME_CONFIG: Record = { + // ── Standard Apollo dark ───────────────────────────────────────────────── + dark: { + prismStyle: oneDark, + bg: '#282c34', + headerBg: '#21252b', + labelColor: '#abb2bf', + iconColor: '#9da5b4', + lineNumberColor: '#495162', + }, + // ── Standard Apollo light ──────────────────────────────────────────────── + light: { + prismStyle: oneLight, + bg: '#fafafa', + headerBg: '#f0f0f0', + labelColor: '#6b7280', + iconColor: '#9ca3af', + lineNumberColor: '#c0c0c0', + }, + // ── Future dark — VS Code Dark+ for a modern zinc feel ─────────────────── + 'future-dark': { + prismStyle: vscDarkPlus, + bg: '#18181b', + headerBg: '#09090b', + labelColor: '#a1a1aa', + iconColor: '#71717a', + lineNumberColor: '#3f3f46', + }, + // ── Future light — VS Code Light for a clean zinc-50 feel ──────────────── + 'future-light': { + prismStyle: vs, + bg: '#fafafa', + headerBg: '#f4f4f5', + labelColor: '#52525b', + iconColor: '#71717a', + lineNumberColor: '#d4d4d8', + }, + // ── Core dark — Nord for the apollo-core blue-grey palette ─────────────── + 'core-dark': { + prismStyle: nord, + bg: '#182027', + headerBg: '#111920', + labelColor: '#8ea1b1', + iconColor: '#6b8899', + lineNumberColor: '#2e3f4c', + }, + // ── Core light — VS Code Light on a clean white surface ────────────────── + 'core-light': { + prismStyle: vs, + bg: '#ffffff', + headerBg: '#f0f4f8', + labelColor: '#374151', + iconColor: '#6b7280', + lineNumberColor: '#c8d4de', + }, + // ── Wireframe — classic Prism on grey-50, minimal ──────────────────────── + wireframe: { + prismStyle: prism, + bg: '#f9fafb', + headerBg: '#f3f4f6', + labelColor: '#6b7280', + iconColor: '#9ca3af', + lineNumberColor: '#d1d5db', + }, + // ── Vertex — Night Owl on deep oklch blue-grey, teal brand ─────────────── + vertex: { + prismStyle: nightOwl, + bg: 'oklch(0.21 0.03 258.5)', + headerBg: 'oklch(0.188 0.028 258.5)', + labelColor: '#a6b5c9', + iconColor: '#7a90a8', + lineNumberColor: 'oklch(0.32 0.03 258.5)', + }, + // ── Canvas — Atom Dark for Apollo MUI dark, UiPath orange brand ────────── + canvas: { + prismStyle: atomDark, + bg: '#182027', + headerBg: '#111920', + labelColor: '#8ea1b1', + iconColor: '#6b8899', + lineNumberColor: '#2e3f4c', + }, + // ── High-contrast dark ─────────────────────────────────────────────────── + 'dark-hc': { + prismStyle: oneDark, + bg: '#0a0a0a', + headerBg: '#141414', + labelColor: '#e4e4e4', + iconColor: '#c8c8c8', + lineNumberColor: '#505050', + }, + // ── High-contrast light ────────────────────────────────────────────────── + 'light-hc': { + prismStyle: oneLight, + bg: '#ffffff', + headerBg: '#e8e8e8', + labelColor: '#111827', + iconColor: '#374151', + lineNumberColor: '#9ca3af', + }, +}; + +// ============================================================================ +// Auto-detect Apollo theme from class +// ============================================================================ + +// Check more specific / longer class names before short ones to avoid +// a class like "dark" matching inside "future-dark" +const BODY_CLASS_PRIORITY: CodeBlockTheme[] = [ + 'future-dark', + 'future-light', + 'core-dark', + 'core-light', + 'dark-hc', + 'light-hc', + 'wireframe', + 'vertex', + 'canvas', + 'dark', + 'light', +]; + +function getBodyTheme(): CodeBlockTheme { + if (typeof document === 'undefined') return 'dark'; + const classList = document.body.classList; + return BODY_CLASS_PRIORITY.find((t) => classList.contains(t)) ?? 'dark'; +} + +function useApolloTheme(): CodeBlockTheme { + const [theme, setTheme] = useState(getBodyTheme); + + useEffect(() => { + const observer = new MutationObserver(() => setTheme(getBodyTheme())); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + return theme; +} + +// ============================================================================ +// CodeBlock +// ============================================================================ + +/** + * Syntax-highlighted code block built on react-syntax-highlighter (Prism engine). + * + * Supports 200+ languages, optional line numbers, a filename header, and a + * one-click copy button. Automatically follows the active Apollo theme by + * watching the body class — or accept an explicit `theme` prop to override. + * + * Supported themes: dark, light, future-dark, future-light, core-dark, + * core-light, wireframe, vertex, canvas, dark-hc, light-hc. + */ +export function CodeBlock({ + children, + language = 'tsx', + showLineNumbers = true, + showCopyButton = true, + fileName, + theme, + wrapLines = false, + className, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + const detectedTheme = useApolloTheme(); + const activeTheme = theme ?? detectedTheme; + const config = THEME_CONFIG[activeTheme]; + + const code = children.trim(); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + timeoutRef.current = setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API not available — silent fail + } + } + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const showHeader = !!(fileName || language || showCopyButton); + + return ( +
+ {/* ── Header ─────────────────────────────────────────────── */} + {showHeader && ( +
+ + {fileName ?? language} + + + {showCopyButton && ( + + )} +
+ )} + + {/* ── Code ───────────────────────────────────────────────── */} + + {code} + +
+ ); +} diff --git a/packages/apollo-wind/src/components/ui/collapsible.stories.tsx b/packages/apollo-wind/src/components/ui/collapsible.stories.tsx deleted file mode 100644 index 863771849..000000000 --- a/packages/apollo-wind/src/components/ui/collapsible.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta } from '@storybook/react-vite'; -import { useState } from 'react'; -import { Button } from './button'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'; -import { Row } from './layout'; - -const meta: Meta = { - title: 'Design System/Data Display/Collapsible', - component: Collapsible, - tags: ['autodocs'], -}; - -export default meta; - -export const Default = { - args: {}, - render: () => { - const [isOpen, setIsOpen] = useState(false); - - return ( - - -

@peduarte starred 3 repositories

- - - -
-
- @radix-ui/primitives -
- -
- @radix-ui/colors -
-
- @stitches/react -
-
-
- ); - }, -}; diff --git a/packages/apollo-wind/src/components/ui/combobox.stories.tsx b/packages/apollo-wind/src/components/ui/combobox.stories.tsx index f9631533c..8d21e43b3 100644 --- a/packages/apollo-wind/src/components/ui/combobox.stories.tsx +++ b/packages/apollo-wind/src/components/ui/combobox.stories.tsx @@ -1,19 +1,32 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState } from 'react'; +import type { Meta } from '@storybook/react-vite'; +import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react'; +import * as React from 'react'; +import { Badge } from './badge'; +import { Button } from './button'; import { Combobox, ComboboxItem } from './combobox'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from './command'; import { Label } from './label'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { cn } from '../../lib'; -const meta = { - title: 'Design System/Core/Combobox', +const meta: Meta = { + title: 'Components/Core/Combobox', component: Combobox, - parameters: { - layout: 'centered', - }, tags: ['autodocs'], -} satisfies Meta; +}; export default meta; -type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Shared data +// --------------------------------------------------------------------------- const frameworks: ComboboxItem[] = [ { value: 'next.js', label: 'Next.js' }, @@ -21,18 +34,30 @@ const frameworks: ComboboxItem[] = [ { value: 'nuxt.js', label: 'Nuxt.js' }, { value: 'remix', label: 'Remix' }, { value: 'astro', label: 'Astro' }, + { value: 'gatsby', label: 'Gatsby' }, + { value: 'angular', label: 'Angular' }, + { value: 'vue', label: 'Vue' }, ]; -const largeList: ComboboxItem[] = Array.from({ length: 50 }, (_, i) => ({ - value: `item-${i + 1}`, - label: `Item ${i + 1}`, -})); +const countries: ComboboxItem[] = [ + { value: 'us', label: 'United States' }, + { value: 'gb', label: 'United Kingdom' }, + { value: 'de', label: 'Germany' }, + { value: 'fr', label: 'France' }, + { value: 'jp', label: 'Japan' }, + { value: 'au', label: 'Australia' }, + { value: 'ca', label: 'Canada' }, + { value: 'br', label: 'Brazil' }, +]; -export const Default: Story = { - // @ts-expect-error - args not needed when using render, but Storybook requires it - args: {}, +// ============================================================================ +// Basic +// ============================================================================ + +export const Basic = { + name: 'Basic', render: () => { - const [value, setValue] = useState(''); + const [value, setValue] = React.useState(''); return ( { - const [value, setValue] = useState(''); - return ( -
- - -
+// ============================================================================ +// Combobox Multi-select +// ============================================================================ + +function MultiSelectCombobox() { + const [open, setOpen] = React.useState(false); + const [selected, setSelected] = React.useState([]); + + const toggle = (val: string) => { + setSelected((prev) => + prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val] ); - }, + }; + + const remove = (val: string) => { + setSelected((prev) => prev.filter((v) => v !== val)); + }; + + return ( +
+ + + + + + + + + No results found. + + {frameworks.map((item) => ( + toggle(item.value)}> + {item.label} + + + ))} + + + + + + {selected.length > 0 && ( +
+ {selected.map((val) => { + const item = frameworks.find((f) => f.value === val); + return ( + + {item?.label} + + + ); + })} +
+ )} +
+ ); +} + +export const ComboboxMultiSelect = { + name: 'Combobox Multi-select', + render: () => , }; -export const WithValue: Story = { - // @ts-expect-error - args not needed when using render, but Storybook requires it - args: {}, - render: () => { - const [value, setValue] = useState('next.js'); +// ============================================================================ +// Combobox with Custom Display +// ============================================================================ - return ( - - ); - }, +function CustomDisplayCombobox() { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + + type EnrichedItem = { value: string; label: string; emoji: string; desc: string }; + + const items: EnrichedItem[] = [ + { value: 'bug', label: 'Bug', emoji: '🐛', desc: 'Something is broken' }, + { value: 'feature', label: 'Feature', emoji: '✨', desc: 'New functionality' }, + { value: 'improvement', label: 'Improvement', emoji: '🔧', desc: 'Enhance existing feature' }, + { value: 'docs', label: 'Documentation', emoji: '📝', desc: 'Update documentation' }, + { value: 'performance', label: 'Performance', emoji: '⚡', desc: 'Speed improvement' }, + ]; + + const selected = items.find((i) => i.value === value); + + return ( + + + + + + + + + No results found. + + {items.map((item) => ( + { setValue(v === value ? '' : v); setOpen(false); }} + > +
+ {item.emoji} +
+

{item.label}

+

{item.desc}

+
+
+ +
+ ))} +
+
+
+
+
+ ); +} + +export const ComboboxWithCustomDisplay = { + name: 'Combobox with Custom Display', + render: () => , +}; + +// ============================================================================ +// Combobox Async +// ============================================================================ + +function AsyncCombobox() { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + const [query, setQuery] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [results, setResults] = React.useState([]); + + const allItems: ComboboxItem[] = [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'angular', label: 'Angular' }, + { value: 'svelte', label: 'Svelte' }, + { value: 'solid', label: 'Solid' }, + { value: 'preact', label: 'Preact' }, + { value: 'lit', label: 'Lit' }, + { value: 'alpine', label: 'Alpine.js' }, + { value: 'htmx', label: 'HTMX' }, + { value: 'qwik', label: 'Qwik' }, + ]; + + React.useEffect(() => { + if (!open) return; + setLoading(true); + const timeout = setTimeout(() => { + const filtered = allItems.filter((item) => + item.label.toLowerCase().includes(query.toLowerCase()) + ); + setResults(filtered); + setLoading(false); + }, 500); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, open]); + + const selected = allItems.find((i) => i.value === value); + + return ( +
+

Simulates a 500ms async search delay.

+ + + + + + + + + {loading ? ( +
+ +
+ ) : results.length === 0 ? ( + No results found. + ) : ( + + {results.map((item) => ( + { setValue(v === value ? '' : v); setOpen(false); }} + > + {item.label} + + + ))} + + )} +
+
+
+
+
+ ); +} + +export const ComboboxAsync = { + name: 'Combobox Async', + render: () => , }; -export const Disabled: Story = { - // @ts-expect-error - args not needed when using render, but Storybook requires it - args: {}, +// ============================================================================ +// Combobox Disabled +// ============================================================================ + +export const ComboboxDisabled = { + name: 'Combobox Disabled', render: () => { - const [value, setValue] = useState('next.js'); + const [value, setValue] = React.useState('next.js'); return ( - +
+
+ + +
+
+ + {}} + placeholder="Select framework..." + disabled + /> +
+
); }, }; -export const LargeList: Story = { - // @ts-expect-error - args not needed when using render, but Storybook requires it - args: {}, +// ============================================================================ +// Examples +// ============================================================================ + +export const Examples = { + name: 'Examples', render: () => { - const [value, setValue] = useState(''); + const [framework, setFramework] = React.useState(''); + const [country, setCountry] = React.useState(''); + const [role, setRole] = React.useState(''); + + const roles: ComboboxItem[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'editor', label: 'Editor' }, + { value: 'viewer', label: 'Viewer' }, + { value: 'developer', label: 'Developer' }, + { value: 'designer', label: 'Designer' }, + ]; return ( - +
+ {/* Form field */} +
+

Form Field

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Inline filters */} +
+

Inline Filters

+
+ {}} + placeholder="Status" + className="w-[150px]" + /> + {}} + placeholder="Role" + className="w-[150px]" + /> + {}} + placeholder="Region" + className="w-[150px]" + /> +
+
+
); }, }; diff --git a/packages/apollo-wind/src/components/ui/command.stories.tsx b/packages/apollo-wind/src/components/ui/command.stories.tsx index 8e1ffe749..27ee078e8 100644 --- a/packages/apollo-wind/src/components/ui/command.stories.tsx +++ b/packages/apollo-wind/src/components/ui/command.stories.tsx @@ -1,14 +1,22 @@ import type { Meta } from '@storybook/react-vite'; import { Calendar, + Calculator, CreditCard, + FileText, + Globe, + Laptop, Mail, MessageSquare, - PlusCircle, + Moon, + Plus, + Search, Settings, + Sun, User, } from 'lucide-react'; import * as React from 'react'; +import { Button } from './button'; import { Command, CommandDialog, @@ -22,7 +30,7 @@ import { } from './command'; const meta = { - title: 'Design System/Navigation/Command', + title: 'Components/Navigation/Command', component: Command, parameters: { layout: 'centered', @@ -32,8 +40,12 @@ const meta = { export default meta; -export const Default = { - args: {}, +// ============================================================================ +// Basic +// ============================================================================ + +export const Basic = { + name: 'Basic', render: () => ( @@ -45,23 +57,134 @@ export const Default = { Calendar - - Search Users + + Search Settings + + + ), +}; + +// ============================================================================ +// Command with Dialog +// ============================================================================ + +export const CommandWithDialog = { + name: 'Command with Dialog', + render: () => { + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((o) => !o); + } + }; + document.addEventListener('keydown', down); + return () => document.removeEventListener('keydown', down); + }, []); + + return ( +
+

+ Press{' '} + + K + {' '} + or click the button below. +

+ + + + + No results found. + + + + Calendar + + + + Search Users + + + + Settings + + + + + + + Send Message + ⌘M + + + + New Chat + ⌘N + + + + +
+ ); + }, +}; + +// ============================================================================ +// Command with Groups +// ============================================================================ + +export const CommandWithGroups = { + name: 'Command with Groups', + render: () => ( + + + + No results found. + + + + Go to Dashboard + + + + Go to Documents + + + + Go to Settings + + - - Create New + + Create New Project - Send Message + Send Invite + + + + + + + Profile + + + + Billing @@ -69,42 +192,58 @@ export const Default = { ), }; -export const WithShortcuts = { - args: {}, +// ============================================================================ +// Command with Shortcuts +// ============================================================================ + +export const CommandWithShortcuts = { + name: 'Command with Shortcuts', render: () => ( No results found. - + - - Calendar + + Search ⌘K + + + New File + ⌘N + + + + Settings + ⌘, + + + + - Search Users - ⌘U + Profile + ⌘P Billing ⌘B - - - Settings - ⌘S - ), }; -export const Dialog = { - args: {}, +// ============================================================================ +// Command Palette Usage +// ============================================================================ + +export const CommandPaletteUsage = { + name: 'Command Palette Usage', render: () => { const [open, setOpen] = React.useState(false); @@ -112,7 +251,7 @@ export const Dialog = { const down = (e: KeyboardEvent) => { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - setOpen((open) => !open); + setOpen((o) => !o); } }; document.addEventListener('keydown', down); @@ -121,47 +260,69 @@ export const Dialog = { return (
-

- Press{' '} - - K - -

- + + + Search commands... + + + K + + - + No results found. - + + + + New Project + ⌘N + + + + New Document + ⌘D + + + + Compose Message + ⌘M + + + + + + + Dashboard + Calendar - Search Users + Team Members + + + - Settings + Preferences + ⌘, - - - - - Send Message - ⌘M + + Light Mode - - New Chat - ⌘N + + Dark Mode @@ -170,3 +331,114 @@ export const Dialog = { ); }, }; + +// ============================================================================ +// Examples +// ============================================================================ + +export const Examples = { + name: 'Examples', + render: () => ( +
+ {/* Search bar */} +
+

Search Bar

+ + + + No results found. + + + + Introduction + + + + Installation + + + + Configuration + + + + + + + Button + + + + Input + + + + Dialog + + + + +
+ + {/* Calculator */} +
+

Calculator Launcher

+ + + + No results found. + + + + Calculator + ⌘= + + + + Unit Converter + + + + Currency Exchange + + + + +
+ + {/* User management */} +
+

User Management

+ + + + No users found. + + + +
+ Alex Johnson + Engineering +
+
+ + +
+ Sarah Williams + Design +
+
+ + +
+ Michael Chen + Product +
+
+
+
+
+
+
+ ), +}; diff --git a/packages/apollo-wind/src/components/ui/component-gallery.stories.tsx b/packages/apollo-wind/src/components/ui/component-gallery.stories.tsx index 8a5406a61..3c9399c31 100644 --- a/packages/apollo-wind/src/components/ui/component-gallery.stories.tsx +++ b/packages/apollo-wind/src/components/ui/component-gallery.stories.tsx @@ -19,7 +19,7 @@ import { Calendar } from './calendar'; import { Search } from 'lucide-react'; const meta = { - title: 'Design System/All Components', + title: 'Components/All Components', parameters: { layout: 'fullscreen', }, @@ -57,7 +57,7 @@ const components: ComponentInfo[] = [ { name: 'Accordion', description: 'Interactive expandable sections', - storyPath: 'design-system-data-display-accordion--docs', + storyPath: 'components-data-display-accordion--docs', category: Category.DataDisplay, preview: ( @@ -71,7 +71,7 @@ const components: ComponentInfo[] = [ { name: 'Alert', description: 'Displays a callout message', - storyPath: 'design-system-feedback-alert--docs', + storyPath: 'components-feedback-alert--docs', category: Category.Feedback, preview: ( @@ -83,14 +83,14 @@ const components: ComponentInfo[] = [ { name: 'Alert Dialog', description: 'Modal dialog for important actions', - storyPath: 'design-system-overlays-alert-dialog--docs', + storyPath: 'components-overlays-alert-dialog--docs', category: Category.Overlays, preview: , }, { name: 'Aspect Ratio', description: 'Content with desired ratio', - storyPath: 'design-system-layout-aspect-ratio--docs', + storyPath: 'components-layout-aspect-ratio--docs', category: Category.Layout, preview: (
@@ -101,7 +101,7 @@ const components: ComponentInfo[] = [ { name: 'Badge', description: 'Small status indicators', - storyPath: 'design-system-data-display-badge--docs', + storyPath: 'components-data-display-badge--docs', category: Category.DataDisplay, preview: (
@@ -113,7 +113,7 @@ const components: ComponentInfo[] = [ { name: 'Breadcrumb', description: 'Navigation hierarchy path', - storyPath: 'design-system-navigation-breadcrumb--docs', + storyPath: 'components-navigation-breadcrumb--docs', category: Category.Navigation, preview: (
@@ -128,7 +128,7 @@ const components: ComponentInfo[] = [ { name: 'Button', description: 'Clickable button element', - storyPath: 'design-system-core-button--docs', + storyPath: 'components-core-button--docs', category: Category.Core, preview: (
@@ -142,14 +142,14 @@ const components: ComponentInfo[] = [ { name: 'Calendar', description: 'Date selection calendar', - storyPath: 'design-system-data-display-calendar--docs', + storyPath: 'components-data-display-calendar--docs', category: Category.DataDisplay, preview: , }, { name: 'Card', description: 'Container with content sections', - storyPath: 'design-system-data-display-card--docs', + storyPath: 'components-data-display-card--docs', category: Category.DataDisplay, preview: ( @@ -163,7 +163,7 @@ const components: ComponentInfo[] = [ { name: 'Checkbox', description: 'Toggle checkbox input', - storyPath: 'design-system-core-checkbox--docs', + storyPath: 'components-core-checkbox--docs', category: Category.Core, preview: (
@@ -174,21 +174,10 @@ const components: ComponentInfo[] = [
), }, - { - name: 'Collapsible', - description: 'Expandable content panel', - storyPath: 'design-system-data-display-collapsible--docs', - category: Category.DataDisplay, - preview: ( - - ), - }, { name: 'Combobox', description: 'Searchable select input', - storyPath: 'design-system-core-combobox--docs', + storyPath: 'components-core-combobox--docs', category: Category.Core, preview: (