diff --git a/docs/solid-carbon.md b/docs/solid-carbon.md new file mode 100644 index 0000000..6d896be --- /dev/null +++ b/docs/solid-carbon.md @@ -0,0 +1,55 @@ +# SolidJS UI Component Library + +Carbon-inspired component library for Malfestio. + +## Animations + +Presets defined in `src/lib/animations.ts`: + +- `fadeIn`, `fadeOut` - opacity transitions +- `slideInRight`, `slideInUp` - directional entry +- `scaleIn`, `scaleOut` - scale with opacity +- `springConfig` - natural bounce settings +- `staggerDelay(index)` - list animation helper + +## Components + +### Core + +| Component | File | Purpose | +| ---------- | ------------------- | ------------------------------------------------ | +| Dialog | `ui/Dialog.tsx` | Modal with transactional/danger/passive variants | +| TreeView | `ui/TreeView.tsx` | Expandable hierarchy with keyboard nav | +| DataTable | `ui/DataTable.tsx` | Sortable, selectable, expandable rows | +| SidePanel | `ui/SidePanel.tsx` | Collapsible left navigation | +| RightPanel | `ui/RightPanel.tsx` | Slide-in detail panel | +| Tabs | `ui/Tabs.tsx` | Line and contained variants | +| Dropdown | `ui/Dropdown.tsx` | Single/multi select with search | +| Tag | `ui/Tag.tsx` | Read-only, dismissible, selectable | + +### Supporting + +| Component | File | Purpose | +| ----------- | -------------------- | ---------------------------- | +| Skeleton | `ui/Skeleton.tsx` | Loading placeholders | +| EmptyState | `ui/EmptyState.tsx` | Zero-state UI | +| Tooltip | `ui/Tooltip.tsx` | Hover info with positioning | +| ProgressBar | `ui/ProgressBar.tsx` | Determinate/indeterminate | +| Avatar | `ui/Avatar.tsx` | Image with initials fallback | +| Menu | `ui/Menu.tsx` | Context/dropdown actions | + +### Existing + +| Component | File | Purpose | +| --------- | --------------- | ------------------------------ | +| Button | `ui/Button.tsx` | Primary/secondary/danger/ghost | +| Card | `ui/Card.tsx` | Container with optional title | +| Toast | `ui/Toast.tsx` | Notifications | + +## Keyboard Navigation + +- **Dialog**: `Escape` to close +- **TreeView**: Arrow keys, `Enter`/`Space` to expand +- **Tabs**: Left/Right arrows +- **Dropdown**: Arrow keys, `Enter` to select, `Escape` to close +- **Menu**: Arrow keys, `Enter` to select diff --git a/docs/todo.md b/docs/todo.md index 3a29105..eb4aa8e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -50,8 +50,9 @@ - PDS client for `putRecord`, `deleteRecord`, `uploadBlob`. - TID generation and AT-URI builder in core crate. - Database migration for token storage and AT-URI columns. +- **(Done) Milestone E**: Internal component library/UI Foundation + Animations. -### Milestone E - Content Authoring (Notes + Cards + Deck Builder) +### Milestone F - Content Authoring (Notes + Cards + Deck Builder) #### Deliverables diff --git a/web/package.json b/web/package.json index 348a86c..4530f1e 100644 --- a/web/package.json +++ b/web/package.json @@ -16,12 +16,14 @@ "@solidjs/router": "^0.15.4", "@tailwindcss/vite": "^4.1.18", "clsx": "^2.1.1", + "motion": "^12.23.26", "rehype-external-links": "^3.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "solid-js": "^1.9.10", + "solid-motionone": "^1.0.4", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "unified": "^11.0.5" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 62a4251..846dfd3 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + motion: + specifier: ^12.23.26 + version: 12.23.26 rehype-external-links: specifier: ^3.0.0 version: 3.0.0 @@ -41,6 +44,9 @@ importers: solid-js: specifier: ^1.9.10 version: 1.9.10 + solid-motionone: + specifier: ^1.0.4 + version: 1.0.4(solid-js@1.9.10) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -319,6 +325,24 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@motionone/animation@10.18.0': + resolution: {integrity: sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==} + + '@motionone/dom@10.18.0': + resolution: {integrity: sha512-bKLP7E0eyO4B2UaHBBN55tnppwRnaE3KFfh3Ps9HhnAkar3Cb69kUCJY9as8LrccVYKgHA+JY5dOQqJLOPhF5A==} + + '@motionone/easing@10.18.0': + resolution: {integrity: sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==} + + '@motionone/generators@10.18.0': + resolution: {integrity: sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==} + + '@motionone/types@10.17.1': + resolution: {integrity: sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==} + + '@motionone/utils@10.18.0': + resolution: {integrity: sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==} + '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} @@ -415,6 +439,26 @@ packages: '@rolldown/pluginutils@1.0.0-beta.50': resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + '@solid-primitives/props@3.2.2': + resolution: {integrity: sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/refs@1.1.2': + resolution: {integrity: sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/transition-group@1.1.2': + resolution: {integrity: sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@solidjs/meta@0.29.4': resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==} peerDependencies: @@ -980,6 +1024,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + framer-motion@12.23.26: + resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1020,6 +1078,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hey-listen@1.0.8: + resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1328,6 +1389,26 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.26: + resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1516,6 +1597,11 @@ packages: solid-js@1.9.10: resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==} + solid-motionone@1.0.4: + resolution: {integrity: sha512-aqEjgecoO9raDFznu/dEci7ORSmA26Kjj9J4Cn1Gyr0GZuOVdvsNxdxClTL9J40Aq/uYFx4GLwC8n70fMLHiuA==} + peerDependencies: + solid-js: ^1.8.0 + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -2026,6 +2112,41 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@motionone/animation@10.18.0': + dependencies: + '@motionone/easing': 10.18.0 + '@motionone/types': 10.17.1 + '@motionone/utils': 10.18.0 + tslib: 2.8.1 + + '@motionone/dom@10.18.0': + dependencies: + '@motionone/animation': 10.18.0 + '@motionone/generators': 10.18.0 + '@motionone/types': 10.17.1 + '@motionone/utils': 10.18.0 + hey-listen: 1.0.8 + tslib: 2.8.1 + + '@motionone/easing@10.18.0': + dependencies: + '@motionone/utils': 10.18.0 + tslib: 2.8.1 + + '@motionone/generators@10.18.0': + dependencies: + '@motionone/types': 10.17.1 + '@motionone/utils': 10.18.0 + tslib: 2.8.1 + + '@motionone/types@10.17.1': {} + + '@motionone/utils@10.18.0': + dependencies: + '@motionone/types': 10.17.1 + hey-listen: 1.0.8 + tslib: 2.8.1 + '@napi-rs/wasm-runtime@1.1.0': dependencies: '@emnapi/core': 1.7.1 @@ -2083,6 +2204,24 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.50': {} + '@solid-primitives/props@3.2.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/refs@1.1.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/transition-group@1.1.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@solidjs/meta@0.29.4(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 @@ -2672,6 +2811,12 @@ snapshots: flatted@3.3.3: {} + framer-motion@12.23.26: + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + fsevents@2.3.3: optional: true @@ -2717,6 +2862,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hey-listen@1.0.8: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.6.0 @@ -3086,6 +3233,17 @@ snapshots: dependencies: brace-expansion: 2.0.2 + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.26: + dependencies: + framer-motion: 12.23.26 + tslib: 2.8.1 + ms@2.1.3: {} nanoid@3.3.11: {} @@ -3263,6 +3421,16 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.3(seroval@1.3.2) + solid-motionone@1.0.4(solid-js@1.9.10): + dependencies: + '@motionone/dom': 10.18.0 + '@motionone/utils': 10.18.0 + '@solid-primitives/props': 3.2.2(solid-js@1.9.10) + '@solid-primitives/refs': 1.1.2(solid-js@1.9.10) + '@solid-primitives/transition-group': 1.1.2(solid-js@1.9.10) + csstype: 3.2.3 + solid-js: 1.9.10 + solid-refresh@0.6.3(solid-js@1.9.10): dependencies: '@babel/generator': 7.28.5 @@ -3340,8 +3508,7 @@ snapshots: dependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-check@0.4.0: dependencies: diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 5150e43..676fb08 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ import { A } from "@solidjs/router"; import { type Component, Show } from "solid-js"; import { authStore } from "../../lib/store"; +import { Avatar } from "../ui/Avatar"; const Login: Component = () => ( @@ -27,9 +28,7 @@ export const Header: Component = () => { class="text-xs text-red-400 hover:text-red-300 transition-colors"> Logout -
- {authStore.user()?.handle.slice(0, 2).toUpperCase()} -
+ diff --git a/web/src/components/ui/Avatar.test.tsx b/web/src/components/ui/Avatar.test.tsx new file mode 100644 index 0000000..5054996 --- /dev/null +++ b/web/src/components/ui/Avatar.test.tsx @@ -0,0 +1,24 @@ +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it } from "vitest"; +import { Avatar } from "./Avatar"; + +describe("Avatar", () => { + afterEach(cleanup); + + it("renders initials fallback when no src", () => { + render(() => ); + expect(screen.getByText("JD")).toBeInTheDocument(); + }); + + it("renders image when src provided", () => { + render(() => ); + const img = screen.getByRole("img"); + expect(img).toHaveAttribute("src", "/avatar.jpg"); + }); + + it("applies size classes", () => { + render(() => ); + const avatar = screen.getByText("T").closest("div"); + expect(avatar).toHaveClass("w-12"); + }); +}); diff --git a/web/src/components/ui/Avatar.tsx b/web/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..43558fb --- /dev/null +++ b/web/src/components/ui/Avatar.tsx @@ -0,0 +1,65 @@ +import { Show, splitProps } from "solid-js"; +import type { Component } from "solid-js"; + +type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; + +type AvatarProps = { src?: string; alt?: string; name?: string; size?: AvatarSize; class?: string }; + +const sizeStyles = { + xs: "w-6 h-6 text-xs", + sm: "w-8 h-8 text-xs", + md: "w-10 h-10 text-sm", + lg: "w-12 h-12 text-base", + xl: "w-16 h-16 text-lg", +}; + +const getInitials = (name: string): string => { + return name.split(" ").map((part) => part[0]).join("").slice(0, 2).toUpperCase(); +}; + +const stringToColor = (str: string): string => { + const colors = [ + "bg-blue-600", + "bg-green-600", + "bg-purple-600", + "bg-pink-600", + "bg-indigo-600", + "bg-teal-600", + "bg-orange-600", + "bg-cyan-600", + ]; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +}; + +export const Avatar: Component = (props) => { + const [local, others] = splitProps(props, ["src", "alt", "name", "size", "class"]); + const size = () => local.size ?? "md"; + const initials = () => (local.name ? getInitials(local.name) : "?"); + const bgColor = () => (local.name ? stringToColor(local.name) : "bg-gray-700"); + + return ( +
+ + {initials()} + + }> + {local.alt (e.currentTarget as HTMLImageElement).style.display = "none"} /> + +
+ ); +}; diff --git a/web/src/components/ui/DataTable.test.tsx b/web/src/components/ui/DataTable.test.tsx new file mode 100644 index 0000000..75dfe77 --- /dev/null +++ b/web/src/components/ui/DataTable.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type Column, DataTable } from "./DataTable"; + +type TestRow = { id: string; name: string; status: string }; + +const columns: Column[] = [{ key: "name", header: "Name", sortable: true }, { + key: "status", + header: "Status", +}]; + +const data: TestRow[] = [{ id: "1", name: "Alice", status: "Active" }, { id: "2", name: "Bob", status: "Inactive" }, { + id: "3", + name: "Charlie", + status: "Active", +}]; + +describe("DataTable", () => { + afterEach(cleanup); + + it("renders table with data", () => { + render(() => r.id} />); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("sorts by column when clicked", () => { + render(() => r.id} />); + const nameHeader = screen.getByText("Name"); + fireEvent.click(nameHeader); + const rows = screen.getAllByRole("row"); + expect(rows.length).toBe(4); + }); + + it("allows row selection", () => { + const handleSelection = vi.fn(); + render(() => ( + r.id} + selectable + onSelectionChange={handleSelection} /> + )); + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes.length).toBe(4); + fireEvent.click(checkboxes[1]); + expect(handleSelection).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/ui/DataTable.tsx b/web/src/components/ui/DataTable.tsx new file mode 100644 index 0000000..813dd18 --- /dev/null +++ b/web/src/components/ui/DataTable.tsx @@ -0,0 +1,209 @@ +import { createMemo, createSignal, For, Show, splitProps } from "solid-js"; +import type { Accessor, Component, JSX } from "solid-js"; + +export type Column = { + key: keyof T | string; + header: string; + sortable?: boolean; + render?: (row: T, index: number) => JSX.Element; + width?: string; +}; + +type DataTableProps = { + columns: Column[]; + data: T[]; + getRowId: (row: T) => string; + selectable?: boolean; + expandable?: (row: T) => JSX.Element | null; + onSelectionChange?: (selectedIds: string[]) => void; + class?: string; +}; + +type SortDirection = "asc" | "desc" | null; + +const SortIcon: Component<{ direction: SortDirection }> = (props) => ( + + + + + + + + + + + +); + +export function DataTable(props: DataTableProps): JSX.Element { + const [local, _others] = splitProps(props, [ + "columns", + "data", + "getRowId", + "selectable", + "expandable", + "onSelectionChange", + "class", + ]); + + const [sortKey, setSortKey] = createSignal(null); + const [sortDir, setSortDir] = createSignal(null); + const [selected, setSelected] = createSignal>(new Set()); + const [expanded, setExpanded] = createSignal>(new Set()); + + const sortedData: Accessor = createMemo(() => { + const key = sortKey(); + const dir = sortDir(); + if (!key || !dir) return local.data; + + return [...local.data].sort((a, b) => { + const aVal = (a as Record)[key]; + const bVal = (b as Record)[key]; + if (aVal === bVal) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + const cmp = aVal < bVal ? -1 : 1; + return dir === "asc" ? cmp : -cmp; + }); + }); + + const handleSort = (key: string) => { + if (sortKey() === key) { + setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc")); + if (sortDir() === null) setSortKey(null); + } else { + setSortKey(key); + setSortDir("asc"); + } + }; + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + local.onSelectionChange?.([...next]); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selected().size === local.data.length) { + setSelected(new Set()); + local.onSelectionChange?.([]); + } else { + const all = new Set(local.data.map(local.getRowId)); + setSelected(all); + local.onSelectionChange?.([...all]); + } + }; + + const toggleExpand = (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const getCellValue = (row: T, col: Column, index: number): JSX.Element => { + if (col.render) return col.render(row, index); + const value = (row as Record)[col.key as string]; + return <>{value != null ? String(value) : ""}; + }; + + return ( +
+ + + + + + + + {(col) => ( + + )} + + + + + + {(row, index) => { + const id = local.getRowId(row); + const isExpanded = () => expanded().has(id); + const expandedContent = () => local.expandable?.(row); + + return ( + <> + + + + + + + + + {(col) => } + + + + + + + + + ); + }} + + +
+ + + + 0} + onChange={toggleSelectAll} + class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" /> + col.sortable && handleSort(col.key as string)}> + + {col.header} + + + + +
+ + + + + toggleSelect(id)} + class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" /> + {getCellValue(row, col, index())}
+ {expandedContent()} +
+
+ ); +} diff --git a/web/src/components/ui/Dialog.test.tsx b/web/src/components/ui/Dialog.test.tsx new file mode 100644 index 0000000..1aec437 --- /dev/null +++ b/web/src/components/ui/Dialog.test.tsx @@ -0,0 +1,35 @@ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Button } from "./Button"; +import { Dialog } from "./Dialog"; + +describe("Dialog", () => { + afterEach(cleanup); + + it("renders when open", () => { + render(() => {}} title="Test Dialog">Dialog content); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Test Dialog")).toBeInTheDocument(); + expect(screen.getByText("Dialog content")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + render(() => {}} title="Test Dialog">Dialog content); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("calls onClose when backdrop clicked", () => { + const handleClose = vi.fn(); + render(() => Content); + const backdrop = document.querySelector("[aria-hidden=\"true\"]"); + fireEvent.click(backdrop!); + expect(handleClose).toHaveBeenCalled(); + }); + + it("renders actions", () => { + render(() => ( + {}} title="Confirm" actions={}>Are you sure? + )); + expect(screen.getByRole("button", { name: /confirm/i })).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/ui/Dialog.tsx b/web/src/components/ui/Dialog.tsx new file mode 100644 index 0000000..0d7611e --- /dev/null +++ b/web/src/components/ui/Dialog.tsx @@ -0,0 +1,67 @@ +import { onCleanup, onMount, Show, splitProps } from "solid-js"; +import type { Component, JSX } from "solid-js"; + +export type DialogVariant = "transactional" | "danger" | "passive"; + +type DialogProps = { + open: boolean; + onClose: () => void; + title: string; + variant?: DialogVariant; + children: JSX.Element; + actions?: JSX.Element; +}; + +const variantStyles: Record = { + transactional: { header: "border-b border-gray-700", primary: "bg-blue-600 hover:bg-blue-500" }, + danger: { header: "border-b border-red-900/50", primary: "bg-red-600 hover:bg-red-500" }, + passive: { header: "border-b border-gray-700", primary: "bg-gray-600 hover:bg-gray-500" }, +}; + +export const Dialog: Component = (props) => { + const [local, _others] = splitProps(props, ["open", "onClose", "title", "variant", "children", "actions"]); + const variant = () => local.variant ?? "transactional"; + let dialogRef: HTMLDivElement | undefined; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && local.open) { + local.onClose(); + } + }; + + onMount(() => { + document.addEventListener("keydown", handleKeyDown); + }); + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown); + }); + + return ( + +
local.onClose()} + aria-hidden="true" /> + + + + ); +}; diff --git a/web/src/components/ui/Dropdown.test.tsx b/web/src/components/ui/Dropdown.test.tsx new file mode 100644 index 0000000..9bcd63c --- /dev/null +++ b/web/src/components/ui/Dropdown.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Dropdown, type DropdownOption } from "./Dropdown"; + +const options: DropdownOption[] = [{ value: "a", label: "Option A" }, { value: "b", label: "Option B" }, { + value: "c", + label: "Option C", + disabled: true, +}]; + +describe("Dropdown", () => { + afterEach(cleanup); + + it("renders with placeholder", () => { + render(() => ); + expect(screen.getByText("Select...")).toBeInTheDocument(); + }); + + it("opens dropdown on click", () => { + render(() => ); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getByText("Option A")).toBeInTheDocument(); + }); + + it("calls onChange when option selected", () => { + const handleChange = vi.fn(); + render(() => ); + fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByText("Option A")); + expect(handleChange).toHaveBeenCalledWith("a"); + }); + + it("supports multi-select", () => { + const handleChange = vi.fn(); + render(() => ); + fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByText("Option A")); + expect(handleChange).toHaveBeenCalledWith(["a"]); + }); +}); diff --git a/web/src/components/ui/Dropdown.tsx b/web/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..7bc54c7 --- /dev/null +++ b/web/src/components/ui/Dropdown.tsx @@ -0,0 +1,206 @@ +import { createSignal, For, onCleanup, onMount, Show, splitProps } from "solid-js"; +import type { Component } from "solid-js"; +import { Motion, Presence } from "solid-motionone"; + +export type DropdownOption = { value: string; label: string; disabled?: boolean }; + +type DropdownProps = { + options: DropdownOption[]; + value?: string | string[]; + onChange?: (value: string | string[]) => void; + placeholder?: string; + multiple?: boolean; + searchable?: boolean; + disabled?: boolean; + class?: string; +}; + +const ChevronIcon: Component<{ open: boolean }> = (props) => ( + + + +); + +export const Dropdown: Component = (props) => { + const [local, _others] = splitProps(props, [ + "options", + "value", + "onChange", + "placeholder", + "multiple", + "searchable", + "disabled", + "class", + ]); + + const [open, setOpen] = createSignal(false); + const [search, setSearch] = createSignal(""); + const [focusIndex, setFocusIndex] = createSignal(-1); + let containerRef: HTMLDivElement | undefined; + let inputRef: HTMLInputElement | undefined; + + const selectedValues = (): string[] => { + if (!local.value) return []; + return Array.isArray(local.value) ? local.value : [local.value]; + }; + + const filteredOptions = () => { + const q = search().toLowerCase(); + if (!q) return local.options; + return local.options.filter((o) => o.label.toLowerCase().includes(q)); + }; + + const displayLabel = () => { + const vals = selectedValues(); + if (vals.length === 0) return local.placeholder || "Select..."; + if (vals.length === 1) { + return local.options.find((o) => o.value === vals[0])?.label || vals[0]; + } + return `${vals.length} selected`; + }; + + const toggleOption = (value: string) => { + if (local.multiple) { + const current = selectedValues(); + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + local.onChange?.(next); + } else { + local.onChange?.(value); + setOpen(false); + } + setSearch(""); + }; + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef && !containerRef.contains(e.target as Node)) { + setOpen(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const opts = filteredOptions(); + if (e.key === "Escape") { + setOpen(false); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusIndex((prev) => Math.min(prev + 1, opts.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && focusIndex() >= 0) { + e.preventDefault(); + const opt = opts[focusIndex()]; + if (opt && !opt.disabled) toggleOption(opt.value); + } + }; + + onMount(() => { + document.addEventListener("click", handleClickOutside); + }); + + onCleanup(() => { + document.removeEventListener("click", handleClickOutside); + }); + + return ( +
+ {/* Trigger */} + + + {/* Dropdown */} + + + + {/* Search */} + +
+ { + setSearch(e.currentTarget.value); + setFocusIndex(0); + }} + placeholder="Search..." + class="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" /> +
+
+ + {/* Options */} +
    + + {(option, index) => { + const isSelected = () => selectedValues().includes(option.value); + const isFocused = () => focusIndex() === index(); + + return ( +
  • !option.disabled && toggleOption(option.value)} + onMouseEnter={() => setFocusIndex(index())} + class={`flex items-center gap-2 px-4 py-2 text-sm cursor-pointer transition-colors + ${option.disabled ? "opacity-50 cursor-not-allowed" : ""} + ${isFocused() ? "bg-gray-700" : ""} + ${isSelected() ? "text-blue-400" : "text-gray-300"} + `}> + + + + {option.label} + + + + + +
  • + ); + }} +
    + +
  • No options found
  • +
    +
+
+
+
+
+ ); +}; diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..f70fdab --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,35 @@ +import { Show, splitProps } from "solid-js"; +import type { Component, JSX } from "solid-js"; + +type EmptyStateProps = { + title: string; + description?: string; + icon?: JSX.Element; + action?: JSX.Element; + class?: string; +}; + +const DefaultIcon: Component = () => ( + + + +); + +export const EmptyState: Component = (props) => { + const [local, _others] = splitProps(props, ["title", "description", "icon", "action", "class"]); + + return ( +
+
{local.icon ?? }
+

{local.title}

+ +

{local.description}

+
+ {local.action} +
+ ); +}; diff --git a/web/src/components/ui/Menu.test.tsx b/web/src/components/ui/Menu.test.tsx new file mode 100644 index 0000000..39b3e07 --- /dev/null +++ b/web/src/components/ui/Menu.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Menu, type MenuItem } from "./Menu"; + +const items: MenuItem[] = [{ id: "edit", label: "Edit", shortcut: "⌘E" }, { + id: "delete", + label: "Delete", + danger: true, +}]; + +describe("Menu", () => { + afterEach(cleanup); + + it("renders trigger", () => { + render(() => Open} />); + expect(screen.getByText("Open")).toBeInTheDocument(); + }); + + it("opens menu on trigger click", () => { + render(() => Open} />); + fireEvent.click(screen.getByText("Open")); + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(screen.getByText("Edit")).toBeInTheDocument(); + }); + + it("calls onClick when item clicked", () => { + const handleClick = vi.fn(); + const itemsWithHandler: MenuItem[] = [{ id: "action", label: "Action", onClick: handleClick }]; + render(() => Open} />); + fireEvent.click(screen.getByText("Open")); + fireEvent.click(screen.getByText("Action")); + expect(handleClick).toHaveBeenCalled(); + }); + + it("displays keyboard shortcuts", () => { + render(() => Open} />); + fireEvent.click(screen.getByText("Open")); + expect(screen.getByText("⌘E")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/ui/Menu.tsx b/web/src/components/ui/Menu.tsx new file mode 100644 index 0000000..9b04b6e --- /dev/null +++ b/web/src/components/ui/Menu.tsx @@ -0,0 +1,148 @@ +import { createSignal, For, onCleanup, onMount, Show, splitProps } from "solid-js"; +import type { Component, JSX } from "solid-js"; +import { Motion, Presence } from "solid-motionone"; + +export type MenuItem = { + id: string; + label: string; + icon?: JSX.Element; + shortcut?: string; + disabled?: boolean; + danger?: boolean; + onClick?: () => void; +}; + +export type MenuDivider = { type: "divider" }; + +export type MenuItemType = MenuItem | MenuDivider; + +type MenuProps = { items: MenuItemType[]; trigger: JSX.Element; align?: "left" | "right"; class?: string }; + +const isDivider = (item: MenuItemType): item is MenuDivider => "type" in item && item.type === "divider"; + +export const Menu: Component = (props) => { + const [local, _others] = splitProps(props, ["items", "trigger", "align", "class"]); + const align = () => local.align ?? "left"; + + const [open, setOpen] = createSignal(false); + const [focusIndex, setFocusIndex] = createSignal(-1); + let containerRef: HTMLDivElement | undefined; + + const menuItems = () => local.items.filter((item): item is MenuItem => !isDivider(item)); + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef && !containerRef.contains(e.target as Node)) { + setOpen(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!open()) return; + + const items = menuItems(); + if (e.key === "Escape") { + setOpen(false); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusIndex((prev) => { + let next = prev + 1; + while (next < items.length && items[next].disabled) next++; + return next < items.length ? next : prev; + }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusIndex((prev) => { + let next = prev - 1; + while (next >= 0 && items[next].disabled) next--; + return next >= 0 ? next : prev; + }); + } else if (e.key === "Enter" && focusIndex() >= 0) { + e.preventDefault(); + const item = items[focusIndex()]; + if (item && !item.disabled) { + item.onClick?.(); + setOpen(false); + } + } + }; + + onMount(() => { + document.addEventListener("click", handleClickOutside); + }); + + onCleanup(() => { + document.removeEventListener("click", handleClickOutside); + }); + + let itemIndex = 0; + + return ( +
+ {/* Trigger */} +
{ + setOpen(!open()); + setFocusIndex(-1); + }} + class="cursor-pointer" + aria-haspopup="menu" + aria-expanded={open()}> + {local.trigger} +
+ + {/* Menu */} + + + + + {(item) => { + if (isDivider(item)) { + return + ); +}; diff --git a/web/src/components/ui/ProgressBar.tsx b/web/src/components/ui/ProgressBar.tsx new file mode 100644 index 0000000..8d2b49a --- /dev/null +++ b/web/src/components/ui/ProgressBar.tsx @@ -0,0 +1,59 @@ +import { Show, splitProps } from "solid-js"; +import type { Component } from "solid-js"; + +type ProgressBarSize = "sm" | "md" | "lg"; +type ProgressBarColor = "blue" | "green" | "red" | "yellow"; + +type ProgressBarProps = { + value?: number; + size?: ProgressBarSize; + color?: ProgressBarColor; + label?: string; + showValue?: boolean; + class?: string; +}; + +const sizeStyles: { [key in ProgressBarSize]: string } = { sm: "h-1", md: "h-2", lg: "h-3" }; + +const colorStyles: { [key in ProgressBarColor]: string } = { + blue: "bg-blue-500", + green: "bg-green-500", + red: "bg-red-500", + yellow: "bg-yellow-500", +}; + +export const ProgressBar: Component = (props) => { + const [local, _others] = splitProps(props, ["value", "size", "color", "label", "showValue", "class"]); + const size = () => local.size ?? "md"; + const color = () => local.color ?? "blue"; + const isIndeterminate = () => local.value === undefined; + const clampedValue = () => Math.min(100, Math.max(0, local.value ?? 0)); + + return ( +
+ +
+ + {local.label} + + + {Math.round(clampedValue())}% + +
+
+
+
+
+
+ ); +}; diff --git a/web/src/components/ui/RightPanel.tsx b/web/src/components/ui/RightPanel.tsx new file mode 100644 index 0000000..e4cf767 --- /dev/null +++ b/web/src/components/ui/RightPanel.tsx @@ -0,0 +1,77 @@ +import { onCleanup, onMount, Show, splitProps } from "solid-js"; +import type { Component, JSX } from "solid-js"; +import { Motion, Presence } from "solid-motionone"; + +type RightPanelProps = { + open: boolean; + onClose: () => void; + title?: string; + width?: string; + children: JSX.Element; + class?: string; +}; + +export const RightPanel: Component = (props) => { + const [local, _others] = splitProps(props, ["open", "onClose", "title", "width", "children", "class"]); + const width = () => local.width ?? "400px"; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && local.open) { + local.onClose(); + } + }; + + onMount(() => { + document.addEventListener("keydown", handleKeyDown); + }); + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown); + }); + + return ( + + + {/* Backdrop */} + + + ); +}; diff --git a/web/src/components/ui/SidePanel.tsx b/web/src/components/ui/SidePanel.tsx new file mode 100644 index 0000000..b19c143 --- /dev/null +++ b/web/src/components/ui/SidePanel.tsx @@ -0,0 +1,98 @@ +import { createSignal, For, Show, splitProps } from "solid-js"; +import type { Component, JSX } from "solid-js"; + +export type NavItem = { id: string; label: string; icon?: JSX.Element; href?: string; onClick?: () => void }; + +type SidePanelProps = { + items: NavItem[]; + activeId?: string; + collapsed?: boolean; + onToggle?: () => void; + header?: JSX.Element; + footer?: JSX.Element; + class?: string; +}; + +const MenuIcon: Component = () => ( + + + +); + +export const SidePanel: Component = (props) => { + const [local, _others] = splitProps(props, [ + "items", + "activeId", + "collapsed", + "onToggle", + "header", + "footer", + "class", + ]); + const [internalCollapsed, setInternalCollapsed] = createSignal(false); + const isCollapsed = () => local.collapsed ?? internalCollapsed(); + + const toggle = () => { + if (local.onToggle) { + local.onToggle(); + } else { + setInternalCollapsed(!internalCollapsed()); + } + }; + + return ( + + ); +}; diff --git a/web/src/components/ui/Skeleton.test.tsx b/web/src/components/ui/Skeleton.test.tsx new file mode 100644 index 0000000..88dc4d5 --- /dev/null +++ b/web/src/components/ui/Skeleton.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, render } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it } from "vitest"; +import { Skeleton, SkeletonAvatar, SkeletonText } from "./Skeleton"; + +describe("Skeleton", () => { + afterEach(cleanup); + + it("renders basic skeleton", () => { + render(() => ); + const skeleton = document.querySelector("[aria-hidden=\"true\"]"); + expect(skeleton).toBeInTheDocument(); + expect(skeleton).toHaveClass("animate-pulse"); + }); + + it("applies rounded classes", () => { + render(() => ); + const skeleton = document.querySelector("[aria-hidden=\"true\"]"); + expect(skeleton).toHaveClass("rounded-full"); + }); +}); + +describe("SkeletonText", () => { + afterEach(cleanup); + + it("renders multiple lines", () => { + render(() => ); + const skeletons = document.querySelectorAll("[aria-hidden=\"true\"]"); + expect(skeletons.length).toBe(4); + }); +}); + +describe("SkeletonAvatar", () => { + afterEach(cleanup); + + it("renders circular skeleton", () => { + render(() => ); + const skeleton = document.querySelector("[aria-hidden=\"true\"]"); + expect(skeleton).toHaveClass("rounded-full"); + }); +}); diff --git a/web/src/components/ui/Skeleton.tsx b/web/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..d655258 --- /dev/null +++ b/web/src/components/ui/Skeleton.tsx @@ -0,0 +1,47 @@ +import { For, splitProps } from "solid-js"; +import type { Component, JSX } from "solid-js"; + +type SkeletonSize = "sm" | "md" | "lg"; +type SkeletonRounded = "none" | SkeletonSize | "full"; + +type SkeletonProps = { width?: string; height?: string; rounded?: SkeletonRounded }; + +const roundedClass: Record = { + none: "", + sm: "rounded-sm", + md: "rounded-md", + lg: "rounded-lg", + full: "rounded-full", +}; + +export const Skeleton: Component & SkeletonProps> = (props) => { + const [local, others] = splitProps(props, ["width", "height", "rounded", "class"]); + const rounded = () => local.rounded ?? "md"; + + return ( +