learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: added UI component suite

* animation presets

+1999 -9
+55
docs/solid-carbon.md
··· 1 + # SolidJS UI Component Library 2 + 3 + Carbon-inspired component library for Malfestio. 4 + 5 + ## Animations 6 + 7 + Presets defined in `src/lib/animations.ts`: 8 + 9 + - `fadeIn`, `fadeOut` - opacity transitions 10 + - `slideInRight`, `slideInUp` - directional entry 11 + - `scaleIn`, `scaleOut` - scale with opacity 12 + - `springConfig` - natural bounce settings 13 + - `staggerDelay(index)` - list animation helper 14 + 15 + ## Components 16 + 17 + ### Core 18 + 19 + | Component | File | Purpose | 20 + | ---------- | ------------------- | ------------------------------------------------ | 21 + | Dialog | `ui/Dialog.tsx` | Modal with transactional/danger/passive variants | 22 + | TreeView | `ui/TreeView.tsx` | Expandable hierarchy with keyboard nav | 23 + | DataTable | `ui/DataTable.tsx` | Sortable, selectable, expandable rows | 24 + | SidePanel | `ui/SidePanel.tsx` | Collapsible left navigation | 25 + | RightPanel | `ui/RightPanel.tsx` | Slide-in detail panel | 26 + | Tabs | `ui/Tabs.tsx` | Line and contained variants | 27 + | Dropdown | `ui/Dropdown.tsx` | Single/multi select with search | 28 + | Tag | `ui/Tag.tsx` | Read-only, dismissible, selectable | 29 + 30 + ### Supporting 31 + 32 + | Component | File | Purpose | 33 + | ----------- | -------------------- | ---------------------------- | 34 + | Skeleton | `ui/Skeleton.tsx` | Loading placeholders | 35 + | EmptyState | `ui/EmptyState.tsx` | Zero-state UI | 36 + | Tooltip | `ui/Tooltip.tsx` | Hover info with positioning | 37 + | ProgressBar | `ui/ProgressBar.tsx` | Determinate/indeterminate | 38 + | Avatar | `ui/Avatar.tsx` | Image with initials fallback | 39 + | Menu | `ui/Menu.tsx` | Context/dropdown actions | 40 + 41 + ### Existing 42 + 43 + | Component | File | Purpose | 44 + | --------- | --------------- | ------------------------------ | 45 + | Button | `ui/Button.tsx` | Primary/secondary/danger/ghost | 46 + | Card | `ui/Card.tsx` | Container with optional title | 47 + | Toast | `ui/Toast.tsx` | Notifications | 48 + 49 + ## Keyboard Navigation 50 + 51 + - **Dialog**: `Escape` to close 52 + - **TreeView**: Arrow keys, `Enter`/`Space` to expand 53 + - **Tabs**: Left/Right arrows 54 + - **Dropdown**: Arrow keys, `Enter` to select, `Escape` to close 55 + - **Menu**: Arrow keys, `Enter` to select
+2 -1
docs/todo.md
··· 50 50 - PDS client for `putRecord`, `deleteRecord`, `uploadBlob`. 51 51 - TID generation and AT-URI builder in core crate. 52 52 - Database migration for token storage and AT-URI columns. 53 + - **(Done) Milestone E**: Internal component library/UI Foundation + Animations. 53 54 54 - ### Milestone E - Content Authoring (Notes + Cards + Deck Builder) 55 + ### Milestone F - Content Authoring (Notes + Cards + Deck Builder) 55 56 56 57 #### Deliverables 57 58
+2
web/package.json
··· 16 16 "@solidjs/router": "^0.15.4", 17 17 "@tailwindcss/vite": "^4.1.18", 18 18 "clsx": "^2.1.1", 19 + "motion": "^12.23.26", 19 20 "rehype-external-links": "^3.0.0", 20 21 "rehype-sanitize": "^6.0.0", 21 22 "rehype-stringify": "^10.0.1", 22 23 "remark-parse": "^11.0.0", 23 24 "remark-rehype": "^11.1.2", 24 25 "solid-js": "^1.9.10", 26 + "solid-motionone": "^1.0.4", 25 27 "tailwind-merge": "^3.4.0", 26 28 "tailwindcss": "^4.1.18", 27 29 "unified": "^11.0.5"
+169 -2
web/pnpm-lock.yaml
··· 23 23 clsx: 24 24 specifier: ^2.1.1 25 25 version: 2.1.1 26 + motion: 27 + specifier: ^12.23.26 28 + version: 12.23.26 26 29 rehype-external-links: 27 30 specifier: ^3.0.0 28 31 version: 3.0.0 ··· 41 44 solid-js: 42 45 specifier: ^1.9.10 43 46 version: 1.9.10 47 + solid-motionone: 48 + specifier: ^1.0.4 49 + version: 1.0.4(solid-js@1.9.10) 44 50 tailwind-merge: 45 51 specifier: ^3.4.0 46 52 version: 3.4.0 ··· 319 325 '@jridgewell/trace-mapping@0.3.31': 320 326 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 321 327 328 + '@motionone/animation@10.18.0': 329 + resolution: {integrity: sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==} 330 + 331 + '@motionone/dom@10.18.0': 332 + resolution: {integrity: sha512-bKLP7E0eyO4B2UaHBBN55tnppwRnaE3KFfh3Ps9HhnAkar3Cb69kUCJY9as8LrccVYKgHA+JY5dOQqJLOPhF5A==} 333 + 334 + '@motionone/easing@10.18.0': 335 + resolution: {integrity: sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==} 336 + 337 + '@motionone/generators@10.18.0': 338 + resolution: {integrity: sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==} 339 + 340 + '@motionone/types@10.17.1': 341 + resolution: {integrity: sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==} 342 + 343 + '@motionone/utils@10.18.0': 344 + resolution: {integrity: sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==} 345 + 322 346 '@napi-rs/wasm-runtime@1.1.0': 323 347 resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} 324 348 ··· 415 439 '@rolldown/pluginutils@1.0.0-beta.50': 416 440 resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} 417 441 442 + '@solid-primitives/props@3.2.2': 443 + resolution: {integrity: sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw==} 444 + peerDependencies: 445 + solid-js: ^1.6.12 446 + 447 + '@solid-primitives/refs@1.1.2': 448 + resolution: {integrity: sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==} 449 + peerDependencies: 450 + solid-js: ^1.6.12 451 + 452 + '@solid-primitives/transition-group@1.1.2': 453 + resolution: {integrity: sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==} 454 + peerDependencies: 455 + solid-js: ^1.6.12 456 + 457 + '@solid-primitives/utils@6.3.2': 458 + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} 459 + peerDependencies: 460 + solid-js: ^1.6.12 461 + 418 462 '@solidjs/meta@0.29.4': 419 463 resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==} 420 464 peerDependencies: ··· 980 1024 flatted@3.3.3: 981 1025 resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} 982 1026 1027 + framer-motion@12.23.26: 1028 + resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} 1029 + peerDependencies: 1030 + '@emotion/is-prop-valid': '*' 1031 + react: ^18.0.0 || ^19.0.0 1032 + react-dom: ^18.0.0 || ^19.0.0 1033 + peerDependenciesMeta: 1034 + '@emotion/is-prop-valid': 1035 + optional: true 1036 + react: 1037 + optional: true 1038 + react-dom: 1039 + optional: true 1040 + 983 1041 fsevents@2.3.3: 984 1042 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 985 1043 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} ··· 1019 1077 1020 1078 hast-util-whitespace@3.0.0: 1021 1079 resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} 1080 + 1081 + hey-listen@1.0.8: 1082 + resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} 1022 1083 1023 1084 html-encoding-sniffer@6.0.0: 1024 1085 resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} ··· 1328 1389 resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 1329 1390 engines: {node: '>=16 || 14 >=14.17'} 1330 1391 1392 + motion-dom@12.23.23: 1393 + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} 1394 + 1395 + motion-utils@12.23.6: 1396 + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} 1397 + 1398 + motion@12.23.26: 1399 + resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==} 1400 + peerDependencies: 1401 + '@emotion/is-prop-valid': '*' 1402 + react: ^18.0.0 || ^19.0.0 1403 + react-dom: ^18.0.0 || ^19.0.0 1404 + peerDependenciesMeta: 1405 + '@emotion/is-prop-valid': 1406 + optional: true 1407 + react: 1408 + optional: true 1409 + react-dom: 1410 + optional: true 1411 + 1331 1412 ms@2.1.3: 1332 1413 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1333 1414 ··· 1515 1596 1516 1597 solid-js@1.9.10: 1517 1598 resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==} 1599 + 1600 + solid-motionone@1.0.4: 1601 + resolution: {integrity: sha512-aqEjgecoO9raDFznu/dEci7ORSmA26Kjj9J4Cn1Gyr0GZuOVdvsNxdxClTL9J40Aq/uYFx4GLwC8n70fMLHiuA==} 1602 + peerDependencies: 1603 + solid-js: ^1.8.0 1518 1604 1519 1605 solid-refresh@0.6.3: 1520 1606 resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} ··· 2026 2112 '@jridgewell/resolve-uri': 3.1.2 2027 2113 '@jridgewell/sourcemap-codec': 1.5.5 2028 2114 2115 + '@motionone/animation@10.18.0': 2116 + dependencies: 2117 + '@motionone/easing': 10.18.0 2118 + '@motionone/types': 10.17.1 2119 + '@motionone/utils': 10.18.0 2120 + tslib: 2.8.1 2121 + 2122 + '@motionone/dom@10.18.0': 2123 + dependencies: 2124 + '@motionone/animation': 10.18.0 2125 + '@motionone/generators': 10.18.0 2126 + '@motionone/types': 10.17.1 2127 + '@motionone/utils': 10.18.0 2128 + hey-listen: 1.0.8 2129 + tslib: 2.8.1 2130 + 2131 + '@motionone/easing@10.18.0': 2132 + dependencies: 2133 + '@motionone/utils': 10.18.0 2134 + tslib: 2.8.1 2135 + 2136 + '@motionone/generators@10.18.0': 2137 + dependencies: 2138 + '@motionone/types': 10.17.1 2139 + '@motionone/utils': 10.18.0 2140 + tslib: 2.8.1 2141 + 2142 + '@motionone/types@10.17.1': {} 2143 + 2144 + '@motionone/utils@10.18.0': 2145 + dependencies: 2146 + '@motionone/types': 10.17.1 2147 + hey-listen: 1.0.8 2148 + tslib: 2.8.1 2149 + 2029 2150 '@napi-rs/wasm-runtime@1.1.0': 2030 2151 dependencies: 2031 2152 '@emnapi/core': 1.7.1 ··· 2083 2204 2084 2205 '@rolldown/pluginutils@1.0.0-beta.50': {} 2085 2206 2207 + '@solid-primitives/props@3.2.2(solid-js@1.9.10)': 2208 + dependencies: 2209 + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) 2210 + solid-js: 1.9.10 2211 + 2212 + '@solid-primitives/refs@1.1.2(solid-js@1.9.10)': 2213 + dependencies: 2214 + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) 2215 + solid-js: 1.9.10 2216 + 2217 + '@solid-primitives/transition-group@1.1.2(solid-js@1.9.10)': 2218 + dependencies: 2219 + solid-js: 1.9.10 2220 + 2221 + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': 2222 + dependencies: 2223 + solid-js: 1.9.10 2224 + 2086 2225 '@solidjs/meta@0.29.4(solid-js@1.9.10)': 2087 2226 dependencies: 2088 2227 solid-js: 1.9.10 ··· 2672 2811 2673 2812 flatted@3.3.3: {} 2674 2813 2814 + framer-motion@12.23.26: 2815 + dependencies: 2816 + motion-dom: 12.23.23 2817 + motion-utils: 12.23.6 2818 + tslib: 2.8.1 2819 + 2675 2820 fsevents@2.3.3: 2676 2821 optional: true 2677 2822 ··· 2716 2861 hast-util-whitespace@3.0.0: 2717 2862 dependencies: 2718 2863 '@types/hast': 3.0.4 2864 + 2865 + hey-listen@1.0.8: {} 2719 2866 2720 2867 html-encoding-sniffer@6.0.0: 2721 2868 dependencies: ··· 3086 3233 dependencies: 3087 3234 brace-expansion: 2.0.2 3088 3235 3236 + motion-dom@12.23.23: 3237 + dependencies: 3238 + motion-utils: 12.23.6 3239 + 3240 + motion-utils@12.23.6: {} 3241 + 3242 + motion@12.23.26: 3243 + dependencies: 3244 + framer-motion: 12.23.26 3245 + tslib: 2.8.1 3246 + 3089 3247 ms@2.1.3: {} 3090 3248 3091 3249 nanoid@3.3.11: {} ··· 3263 3421 seroval: 1.3.2 3264 3422 seroval-plugins: 1.3.3(seroval@1.3.2) 3265 3423 3424 + solid-motionone@1.0.4(solid-js@1.9.10): 3425 + dependencies: 3426 + '@motionone/dom': 10.18.0 3427 + '@motionone/utils': 10.18.0 3428 + '@solid-primitives/props': 3.2.2(solid-js@1.9.10) 3429 + '@solid-primitives/refs': 1.1.2(solid-js@1.9.10) 3430 + '@solid-primitives/transition-group': 1.1.2(solid-js@1.9.10) 3431 + csstype: 3.2.3 3432 + solid-js: 1.9.10 3433 + 3266 3434 solid-refresh@0.6.3(solid-js@1.9.10): 3267 3435 dependencies: 3268 3436 '@babel/generator': 7.28.5 ··· 3340 3508 dependencies: 3341 3509 typescript: 5.9.3 3342 3510 3343 - tslib@2.8.1: 3344 - optional: true 3511 + tslib@2.8.1: {} 3345 3512 3346 3513 type-check@0.4.0: 3347 3514 dependencies:
+2 -3
web/src/components/layout/Header.tsx
··· 1 1 import { A } from "@solidjs/router"; 2 2 import { type Component, Show } from "solid-js"; 3 3 import { authStore } from "../../lib/store"; 4 + import { Avatar } from "../ui/Avatar"; 4 5 5 6 const Login: Component = () => ( 6 7 <A href="/login" class="px-4 py-2 bg-white text-gray-900 text-sm font-medium hover:bg-gray-100 transition-colors"> ··· 27 28 class="text-xs text-red-400 hover:text-red-300 transition-colors"> 28 29 Logout 29 30 </button> 30 - <div class="w-8 h-8 rounded-full bg-blue-900/50 border border-blue-500/30 flex items-center justify-center text-blue-400 text-xs font-bold"> 31 - {authStore.user()?.handle.slice(0, 2).toUpperCase()} 32 - </div> 31 + <Avatar name={authStore.user()?.handle} size="sm" /> 33 32 </div> 34 33 </Show> 35 34 </div>
+24
web/src/components/ui/Avatar.test.tsx
··· 1 + import { cleanup, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it } from "vitest"; 3 + import { Avatar } from "./Avatar"; 4 + 5 + describe("Avatar", () => { 6 + afterEach(cleanup); 7 + 8 + it("renders initials fallback when no src", () => { 9 + render(() => <Avatar name="John Doe" />); 10 + expect(screen.getByText("JD")).toBeInTheDocument(); 11 + }); 12 + 13 + it("renders image when src provided", () => { 14 + render(() => <Avatar src="/avatar.jpg" alt="User" />); 15 + const img = screen.getByRole("img"); 16 + expect(img).toHaveAttribute("src", "/avatar.jpg"); 17 + }); 18 + 19 + it("applies size classes", () => { 20 + render(() => <Avatar name="Test" size="lg" />); 21 + const avatar = screen.getByText("T").closest("div"); 22 + expect(avatar).toHaveClass("w-12"); 23 + }); 24 + });
+65
web/src/components/ui/Avatar.tsx
··· 1 + import { Show, splitProps } from "solid-js"; 2 + import type { Component } from "solid-js"; 3 + 4 + type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; 5 + 6 + type AvatarProps = { src?: string; alt?: string; name?: string; size?: AvatarSize; class?: string }; 7 + 8 + const sizeStyles = { 9 + xs: "w-6 h-6 text-xs", 10 + sm: "w-8 h-8 text-xs", 11 + md: "w-10 h-10 text-sm", 12 + lg: "w-12 h-12 text-base", 13 + xl: "w-16 h-16 text-lg", 14 + }; 15 + 16 + const getInitials = (name: string): string => { 17 + return name.split(" ").map((part) => part[0]).join("").slice(0, 2).toUpperCase(); 18 + }; 19 + 20 + const stringToColor = (str: string): string => { 21 + const colors = [ 22 + "bg-blue-600", 23 + "bg-green-600", 24 + "bg-purple-600", 25 + "bg-pink-600", 26 + "bg-indigo-600", 27 + "bg-teal-600", 28 + "bg-orange-600", 29 + "bg-cyan-600", 30 + ]; 31 + let hash = 0; 32 + for (let i = 0; i < str.length; i++) { 33 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 34 + } 35 + return colors[Math.abs(hash) % colors.length]; 36 + }; 37 + 38 + export const Avatar: Component<AvatarProps> = (props) => { 39 + const [local, others] = splitProps(props, ["src", "alt", "name", "size", "class"]); 40 + const size = () => local.size ?? "md"; 41 + const initials = () => (local.name ? getInitials(local.name) : "?"); 42 + const bgColor = () => (local.name ? stringToColor(local.name) : "bg-gray-700"); 43 + 44 + return ( 45 + <div 46 + class={`relative inline-flex items-center justify-center rounded-full overflow-hidden flex-shrink-0 ${ 47 + sizeStyles[size()] 48 + } ${local.class || ""}`} 49 + {...others}> 50 + <Show 51 + when={local.src} 52 + fallback={ 53 + <span class={`w-full h-full flex items-center justify-center font-semibold text-white ${bgColor()}`}> 54 + {initials()} 55 + </span> 56 + }> 57 + <img 58 + src={local.src} 59 + alt={local.alt || local.name || "Avatar"} 60 + class="w-full h-full object-cover" 61 + onError={(e) => (e.currentTarget as HTMLImageElement).style.display = "none"} /> 62 + </Show> 63 + </div> 64 + ); 65 + };
+51
web/src/components/ui/DataTable.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { type Column, DataTable } from "./DataTable"; 4 + 5 + type TestRow = { id: string; name: string; status: string }; 6 + 7 + const columns: Column<TestRow>[] = [{ key: "name", header: "Name", sortable: true }, { 8 + key: "status", 9 + header: "Status", 10 + }]; 11 + 12 + const data: TestRow[] = [{ id: "1", name: "Alice", status: "Active" }, { id: "2", name: "Bob", status: "Inactive" }, { 13 + id: "3", 14 + name: "Charlie", 15 + status: "Active", 16 + }]; 17 + 18 + describe("DataTable", () => { 19 + afterEach(cleanup); 20 + 21 + it("renders table with data", () => { 22 + render(() => <DataTable columns={columns} data={data} getRowId={(r) => r.id} />); 23 + expect(screen.getByText("Name")).toBeInTheDocument(); 24 + expect(screen.getByText("Alice")).toBeInTheDocument(); 25 + expect(screen.getByText("Bob")).toBeInTheDocument(); 26 + }); 27 + 28 + it("sorts by column when clicked", () => { 29 + render(() => <DataTable columns={columns} data={data} getRowId={(r) => r.id} />); 30 + const nameHeader = screen.getByText("Name"); 31 + fireEvent.click(nameHeader); 32 + const rows = screen.getAllByRole("row"); 33 + expect(rows.length).toBe(4); 34 + }); 35 + 36 + it("allows row selection", () => { 37 + const handleSelection = vi.fn(); 38 + render(() => ( 39 + <DataTable 40 + columns={columns} 41 + data={data} 42 + getRowId={(r) => r.id} 43 + selectable 44 + onSelectionChange={handleSelection} /> 45 + )); 46 + const checkboxes = screen.getAllByRole("checkbox"); 47 + expect(checkboxes.length).toBe(4); 48 + fireEvent.click(checkboxes[1]); 49 + expect(handleSelection).toHaveBeenCalled(); 50 + }); 51 + });
+209
web/src/components/ui/DataTable.tsx
··· 1 + import { createMemo, createSignal, For, Show, splitProps } from "solid-js"; 2 + import type { Accessor, Component, JSX } from "solid-js"; 3 + 4 + export type Column<T> = { 5 + key: keyof T | string; 6 + header: string; 7 + sortable?: boolean; 8 + render?: (row: T, index: number) => JSX.Element; 9 + width?: string; 10 + }; 11 + 12 + type DataTableProps<T> = { 13 + columns: Column<T>[]; 14 + data: T[]; 15 + getRowId: (row: T) => string; 16 + selectable?: boolean; 17 + expandable?: (row: T) => JSX.Element | null; 18 + onSelectionChange?: (selectedIds: string[]) => void; 19 + class?: string; 20 + }; 21 + 22 + type SortDirection = "asc" | "desc" | null; 23 + 24 + const SortIcon: Component<{ direction: SortDirection }> = (props) => ( 25 + <svg class="w-4 h-4 ml-1 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 26 + <Show when={props.direction === "asc"}> 27 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /> 28 + </Show> 29 + <Show when={props.direction === "desc"}> 30 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> 31 + </Show> 32 + <Show when={!props.direction}> 33 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4M8 15l4 4 4-4" /> 34 + </Show> 35 + </svg> 36 + ); 37 + 38 + export function DataTable<T>(props: DataTableProps<T>): JSX.Element { 39 + const [local, _others] = splitProps(props, [ 40 + "columns", 41 + "data", 42 + "getRowId", 43 + "selectable", 44 + "expandable", 45 + "onSelectionChange", 46 + "class", 47 + ]); 48 + 49 + const [sortKey, setSortKey] = createSignal<string | null>(null); 50 + const [sortDir, setSortDir] = createSignal<SortDirection>(null); 51 + const [selected, setSelected] = createSignal<Set<string>>(new Set()); 52 + const [expanded, setExpanded] = createSignal<Set<string>>(new Set()); 53 + 54 + const sortedData: Accessor<T[]> = createMemo(() => { 55 + const key = sortKey(); 56 + const dir = sortDir(); 57 + if (!key || !dir) return local.data; 58 + 59 + return [...local.data].sort((a, b) => { 60 + const aVal = (a as Record<string, unknown>)[key]; 61 + const bVal = (b as Record<string, unknown>)[key]; 62 + if (aVal === bVal) return 0; 63 + if (aVal == null) return 1; 64 + if (bVal == null) return -1; 65 + const cmp = aVal < bVal ? -1 : 1; 66 + return dir === "asc" ? cmp : -cmp; 67 + }); 68 + }); 69 + 70 + const handleSort = (key: string) => { 71 + if (sortKey() === key) { 72 + setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc")); 73 + if (sortDir() === null) setSortKey(null); 74 + } else { 75 + setSortKey(key); 76 + setSortDir("asc"); 77 + } 78 + }; 79 + 80 + const toggleSelect = (id: string) => { 81 + setSelected((prev) => { 82 + const next = new Set(prev); 83 + if (next.has(id)) next.delete(id); 84 + else next.add(id); 85 + local.onSelectionChange?.([...next]); 86 + return next; 87 + }); 88 + }; 89 + 90 + const toggleSelectAll = () => { 91 + if (selected().size === local.data.length) { 92 + setSelected(new Set<string>()); 93 + local.onSelectionChange?.([]); 94 + } else { 95 + const all = new Set(local.data.map(local.getRowId)); 96 + setSelected(all); 97 + local.onSelectionChange?.([...all]); 98 + } 99 + }; 100 + 101 + const toggleExpand = (id: string) => { 102 + setExpanded((prev) => { 103 + const next = new Set(prev); 104 + if (next.has(id)) next.delete(id); 105 + else next.add(id); 106 + return next; 107 + }); 108 + }; 109 + 110 + const getCellValue = (row: T, col: Column<T>, index: number): JSX.Element => { 111 + if (col.render) return col.render(row, index); 112 + const value = (row as Record<string, unknown>)[col.key as string]; 113 + return <>{value != null ? String(value) : ""}</>; 114 + }; 115 + 116 + return ( 117 + <div class={`overflow-x-auto ${local.class || ""}`}> 118 + <table class="w-full text-sm text-left"> 119 + <thead class="text-xs text-gray-400 uppercase bg-gray-900 border-b border-gray-700"> 120 + <tr> 121 + <Show when={local.expandable}> 122 + <th class="w-8 px-2 py-3" /> 123 + </Show> 124 + <Show when={local.selectable}> 125 + <th class="w-8 px-2 py-3"> 126 + <input 127 + type="checkbox" 128 + checked={selected().size === local.data.length && local.data.length > 0} 129 + onChange={toggleSelectAll} 130 + class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" /> 131 + </th> 132 + </Show> 133 + <For each={local.columns}> 134 + {(col) => ( 135 + <th 136 + class={`px-4 py-3 ${col.sortable ? "cursor-pointer hover:bg-gray-800 select-none" : ""}`} 137 + style={{ width: col.width }} 138 + onClick={() => col.sortable && handleSort(col.key as string)}> 139 + <span class="flex items-center"> 140 + {col.header} 141 + <Show when={col.sortable}> 142 + <SortIcon direction={sortKey() === col.key ? sortDir() : null} /> 143 + </Show> 144 + </span> 145 + </th> 146 + )} 147 + </For> 148 + </tr> 149 + </thead> 150 + <tbody> 151 + <For each={sortedData()}> 152 + {(row, index) => { 153 + const id = local.getRowId(row); 154 + const isExpanded = () => expanded().has(id); 155 + const expandedContent = () => local.expandable?.(row); 156 + 157 + return ( 158 + <> 159 + <tr class="border-b border-gray-800 hover:bg-gray-800/50 text-gray-300"> 160 + <Show when={local.expandable}> 161 + <td class="px-2 py-3"> 162 + <Show when={expandedContent()}> 163 + <button 164 + onClick={() => toggleExpand(id)} 165 + class="p-1 hover:bg-gray-700 rounded" 166 + aria-expanded={isExpanded()} 167 + aria-label={isExpanded() ? "Collapse row" : "Expand row"}> 168 + <svg 169 + class={`w-4 h-4 transition-transform ${isExpanded() ? "rotate-90" : ""}`} 170 + fill="none" 171 + viewBox="0 0 24 24" 172 + stroke="currentColor"> 173 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 174 + </svg> 175 + </button> 176 + </Show> 177 + </td> 178 + </Show> 179 + <Show when={local.selectable}> 180 + <td class="px-2 py-3"> 181 + <input 182 + type="checkbox" 183 + checked={selected().has(id)} 184 + onChange={() => toggleSelect(id)} 185 + class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" /> 186 + </td> 187 + </Show> 188 + <For each={local.columns}> 189 + {(col) => <td class="px-4 py-3">{getCellValue(row, col, index())}</td>} 190 + </For> 191 + </tr> 192 + <Show when={isExpanded() && expandedContent()}> 193 + <tr class="bg-gray-900/50"> 194 + <td 195 + colSpan={local.columns.length + (local.selectable ? 1 : 0) + (local.expandable ? 1 : 0)} 196 + class="px-4 py-3"> 197 + {expandedContent()} 198 + </td> 199 + </tr> 200 + </Show> 201 + </> 202 + ); 203 + }} 204 + </For> 205 + </tbody> 206 + </table> 207 + </div> 208 + ); 209 + }
+35
web/src/components/ui/Dialog.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { Button } from "./Button"; 4 + import { Dialog } from "./Dialog"; 5 + 6 + describe("Dialog", () => { 7 + afterEach(cleanup); 8 + 9 + it("renders when open", () => { 10 + render(() => <Dialog open={true} onClose={() => {}} title="Test Dialog">Dialog content</Dialog>); 11 + expect(screen.getByRole("dialog")).toBeInTheDocument(); 12 + expect(screen.getByText("Test Dialog")).toBeInTheDocument(); 13 + expect(screen.getByText("Dialog content")).toBeInTheDocument(); 14 + }); 15 + 16 + it("does not render when closed", () => { 17 + render(() => <Dialog open={false} onClose={() => {}} title="Test Dialog">Dialog content</Dialog>); 18 + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 19 + }); 20 + 21 + it("calls onClose when backdrop clicked", () => { 22 + const handleClose = vi.fn(); 23 + render(() => <Dialog open={true} onClose={handleClose} title="Test Dialog">Content</Dialog>); 24 + const backdrop = document.querySelector("[aria-hidden=\"true\"]"); 25 + fireEvent.click(backdrop!); 26 + expect(handleClose).toHaveBeenCalled(); 27 + }); 28 + 29 + it("renders actions", () => { 30 + render(() => ( 31 + <Dialog open={true} onClose={() => {}} title="Confirm" actions={<Button>Confirm</Button>}>Are you sure?</Dialog> 32 + )); 33 + expect(screen.getByRole("button", { name: /confirm/i })).toBeInTheDocument(); 34 + }); 35 + });
+67
web/src/components/ui/Dialog.tsx
··· 1 + import { onCleanup, onMount, Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + export type DialogVariant = "transactional" | "danger" | "passive"; 5 + 6 + type DialogProps = { 7 + open: boolean; 8 + onClose: () => void; 9 + title: string; 10 + variant?: DialogVariant; 11 + children: JSX.Element; 12 + actions?: JSX.Element; 13 + }; 14 + 15 + const variantStyles: Record<DialogVariant, { header: string; primary: string }> = { 16 + transactional: { header: "border-b border-gray-700", primary: "bg-blue-600 hover:bg-blue-500" }, 17 + danger: { header: "border-b border-red-900/50", primary: "bg-red-600 hover:bg-red-500" }, 18 + passive: { header: "border-b border-gray-700", primary: "bg-gray-600 hover:bg-gray-500" }, 19 + }; 20 + 21 + export const Dialog: Component<DialogProps> = (props) => { 22 + const [local, _others] = splitProps(props, ["open", "onClose", "title", "variant", "children", "actions"]); 23 + const variant = () => local.variant ?? "transactional"; 24 + let dialogRef: HTMLDivElement | undefined; 25 + 26 + const handleKeyDown = (e: KeyboardEvent) => { 27 + if (e.key === "Escape" && local.open) { 28 + local.onClose(); 29 + } 30 + }; 31 + 32 + onMount(() => { 33 + document.addEventListener("keydown", handleKeyDown); 34 + }); 35 + 36 + onCleanup(() => { 37 + document.removeEventListener("keydown", handleKeyDown); 38 + }); 39 + 40 + return ( 41 + <Show when={local.open}> 42 + <div 43 + class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm animate-fade-in" 44 + onClick={() => local.onClose()} 45 + aria-hidden="true" /> 46 + 47 + <div 48 + class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-scale-in" 49 + role="dialog" 50 + aria-modal="true" 51 + aria-labelledby="dialog-title"> 52 + <div 53 + ref={dialogRef} 54 + class="w-full max-w-md bg-gray-900 border border-gray-700 shadow-2xl" 55 + onClick={(e) => e.stopPropagation()}> 56 + <div class={`px-6 py-4 ${variantStyles[variant()].header}`}> 57 + <h2 id="dialog-title" class="text-lg font-semibold text-white">{local.title}</h2> 58 + </div> 59 + <div class="px-6 py-4 text-gray-300">{local.children}</div> 60 + <Show when={local.actions}> 61 + <div class="px-6 py-4 bg-gray-950/50 flex justify-end gap-3">{local.actions}</div> 62 + </Show> 63 + </div> 64 + </div> 65 + </Show> 66 + ); 67 + };
+41
web/src/components/ui/Dropdown.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { Dropdown, type DropdownOption } from "./Dropdown"; 4 + 5 + const options: DropdownOption[] = [{ value: "a", label: "Option A" }, { value: "b", label: "Option B" }, { 6 + value: "c", 7 + label: "Option C", 8 + disabled: true, 9 + }]; 10 + 11 + describe("Dropdown", () => { 12 + afterEach(cleanup); 13 + 14 + it("renders with placeholder", () => { 15 + render(() => <Dropdown options={options} placeholder="Select..." />); 16 + expect(screen.getByText("Select...")).toBeInTheDocument(); 17 + }); 18 + 19 + it("opens dropdown on click", () => { 20 + render(() => <Dropdown options={options} />); 21 + fireEvent.click(screen.getByRole("button")); 22 + expect(screen.getByRole("listbox")).toBeInTheDocument(); 23 + expect(screen.getByText("Option A")).toBeInTheDocument(); 24 + }); 25 + 26 + it("calls onChange when option selected", () => { 27 + const handleChange = vi.fn(); 28 + render(() => <Dropdown options={options} onChange={handleChange} />); 29 + fireEvent.click(screen.getByRole("button")); 30 + fireEvent.click(screen.getByText("Option A")); 31 + expect(handleChange).toHaveBeenCalledWith("a"); 32 + }); 33 + 34 + it("supports multi-select", () => { 35 + const handleChange = vi.fn(); 36 + render(() => <Dropdown options={options} multiple onChange={handleChange} />); 37 + fireEvent.click(screen.getByRole("button")); 38 + fireEvent.click(screen.getByText("Option A")); 39 + expect(handleChange).toHaveBeenCalledWith(["a"]); 40 + }); 41 + });
+206
web/src/components/ui/Dropdown.tsx
··· 1 + import { createSignal, For, onCleanup, onMount, Show, splitProps } from "solid-js"; 2 + import type { Component } from "solid-js"; 3 + import { Motion, Presence } from "solid-motionone"; 4 + 5 + export type DropdownOption = { value: string; label: string; disabled?: boolean }; 6 + 7 + type DropdownProps = { 8 + options: DropdownOption[]; 9 + value?: string | string[]; 10 + onChange?: (value: string | string[]) => void; 11 + placeholder?: string; 12 + multiple?: boolean; 13 + searchable?: boolean; 14 + disabled?: boolean; 15 + class?: string; 16 + }; 17 + 18 + const ChevronIcon: Component<{ open: boolean }> = (props) => ( 19 + <svg 20 + class={`w-4 h-4 transition-transform ${props.open ? "rotate-180" : ""}`} 21 + fill="none" 22 + viewBox="0 0 24 24" 23 + stroke="currentColor"> 24 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> 25 + </svg> 26 + ); 27 + 28 + export const Dropdown: Component<DropdownProps> = (props) => { 29 + const [local, _others] = splitProps(props, [ 30 + "options", 31 + "value", 32 + "onChange", 33 + "placeholder", 34 + "multiple", 35 + "searchable", 36 + "disabled", 37 + "class", 38 + ]); 39 + 40 + const [open, setOpen] = createSignal(false); 41 + const [search, setSearch] = createSignal(""); 42 + const [focusIndex, setFocusIndex] = createSignal(-1); 43 + let containerRef: HTMLDivElement | undefined; 44 + let inputRef: HTMLInputElement | undefined; 45 + 46 + const selectedValues = (): string[] => { 47 + if (!local.value) return []; 48 + return Array.isArray(local.value) ? local.value : [local.value]; 49 + }; 50 + 51 + const filteredOptions = () => { 52 + const q = search().toLowerCase(); 53 + if (!q) return local.options; 54 + return local.options.filter((o) => o.label.toLowerCase().includes(q)); 55 + }; 56 + 57 + const displayLabel = () => { 58 + const vals = selectedValues(); 59 + if (vals.length === 0) return local.placeholder || "Select..."; 60 + if (vals.length === 1) { 61 + return local.options.find((o) => o.value === vals[0])?.label || vals[0]; 62 + } 63 + return `${vals.length} selected`; 64 + }; 65 + 66 + const toggleOption = (value: string) => { 67 + if (local.multiple) { 68 + const current = selectedValues(); 69 + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; 70 + local.onChange?.(next); 71 + } else { 72 + local.onChange?.(value); 73 + setOpen(false); 74 + } 75 + setSearch(""); 76 + }; 77 + 78 + const handleClickOutside = (e: MouseEvent) => { 79 + if (containerRef && !containerRef.contains(e.target as Node)) { 80 + setOpen(false); 81 + } 82 + }; 83 + 84 + const handleKeyDown = (e: KeyboardEvent) => { 85 + const opts = filteredOptions(); 86 + if (e.key === "Escape") { 87 + setOpen(false); 88 + } else if (e.key === "ArrowDown") { 89 + e.preventDefault(); 90 + setFocusIndex((prev) => Math.min(prev + 1, opts.length - 1)); 91 + } else if (e.key === "ArrowUp") { 92 + e.preventDefault(); 93 + setFocusIndex((prev) => Math.max(prev - 1, 0)); 94 + } else if (e.key === "Enter" && focusIndex() >= 0) { 95 + e.preventDefault(); 96 + const opt = opts[focusIndex()]; 97 + if (opt && !opt.disabled) toggleOption(opt.value); 98 + } 99 + }; 100 + 101 + onMount(() => { 102 + document.addEventListener("click", handleClickOutside); 103 + }); 104 + 105 + onCleanup(() => { 106 + document.removeEventListener("click", handleClickOutside); 107 + }); 108 + 109 + return ( 110 + <div ref={containerRef} class={`relative ${local.class || ""}`} onKeyDown={handleKeyDown}> 111 + {/* Trigger */} 112 + <button 113 + type="button" 114 + disabled={local.disabled} 115 + onClick={() => { 116 + setOpen(!open()); 117 + if (!open() && inputRef) inputRef.focus(); 118 + }} 119 + class={`w-full flex items-center justify-between gap-2 px-4 py-2 bg-gray-800 border border-gray-700 text-left text-sm transition-colors rounded 120 + ${ 121 + local.disabled 122 + ? "opacity-50 cursor-not-allowed" 123 + : "hover:border-gray-600 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" 124 + } 125 + ${open() ? "border-blue-500 ring-1 ring-blue-500" : ""} 126 + `} 127 + aria-haspopup="listbox" 128 + aria-expanded={open()}> 129 + <span class={`truncate ${selectedValues().length === 0 ? "text-gray-500" : "text-white"}`}> 130 + {displayLabel()} 131 + </span> 132 + <ChevronIcon open={open()} /> 133 + </button> 134 + 135 + {/* Dropdown */} 136 + <Presence> 137 + <Show when={open()}> 138 + <Motion.div 139 + initial={{ opacity: 0, y: -8 }} 140 + animate={{ opacity: 1, y: 0 }} 141 + exit={{ opacity: 0, y: -8 }} 142 + transition={{ duration: 0.15 }} 143 + class="absolute z-50 mt-1 w-full bg-gray-800 border border-gray-700 rounded shadow-xl max-h-60 overflow-hidden"> 144 + {/* Search */} 145 + <Show when={local.searchable}> 146 + <div class="p-2 border-b border-gray-700"> 147 + <input 148 + ref={inputRef} 149 + type="text" 150 + value={search()} 151 + onInput={(e) => { 152 + setSearch(e.currentTarget.value); 153 + setFocusIndex(0); 154 + }} 155 + placeholder="Search..." 156 + 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" /> 157 + </div> 158 + </Show> 159 + 160 + {/* Options */} 161 + <ul role="listbox" class="overflow-y-auto max-h-48 py-1"> 162 + <For each={filteredOptions()}> 163 + {(option, index) => { 164 + const isSelected = () => selectedValues().includes(option.value); 165 + const isFocused = () => focusIndex() === index(); 166 + 167 + return ( 168 + <li 169 + role="option" 170 + aria-selected={isSelected()} 171 + aria-disabled={option.disabled} 172 + onClick={() => !option.disabled && toggleOption(option.value)} 173 + onMouseEnter={() => setFocusIndex(index())} 174 + class={`flex items-center gap-2 px-4 py-2 text-sm cursor-pointer transition-colors 175 + ${option.disabled ? "opacity-50 cursor-not-allowed" : ""} 176 + ${isFocused() ? "bg-gray-700" : ""} 177 + ${isSelected() ? "text-blue-400" : "text-gray-300"} 178 + `}> 179 + <Show when={local.multiple}> 180 + <input 181 + type="checkbox" 182 + checked={isSelected()} 183 + disabled={option.disabled} 184 + class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600" 185 + tabIndex={-1} /> 186 + </Show> 187 + <span class="truncate">{option.label}</span> 188 + <Show when={isSelected() && !local.multiple}> 189 + <svg class="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 190 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> 191 + </svg> 192 + </Show> 193 + </li> 194 + ); 195 + }} 196 + </For> 197 + <Show when={filteredOptions().length === 0}> 198 + <li class="px-4 py-2 text-sm text-gray-500">No options found</li> 199 + </Show> 200 + </ul> 201 + </Motion.div> 202 + </Show> 203 + </Presence> 204 + </div> 205 + ); 206 + };
+35
web/src/components/ui/EmptyState.tsx
··· 1 + import { Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + type EmptyStateProps = { 5 + title: string; 6 + description?: string; 7 + icon?: JSX.Element; 8 + action?: JSX.Element; 9 + class?: string; 10 + }; 11 + 12 + const DefaultIcon: Component = () => ( 13 + <svg class="w-12 h-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 14 + <path 15 + stroke-linecap="round" 16 + stroke-linejoin="round" 17 + stroke-width="1.5" 18 + d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /> 19 + </svg> 20 + ); 21 + 22 + export const EmptyState: Component<EmptyStateProps> = (props) => { 23 + const [local, _others] = splitProps(props, ["title", "description", "icon", "action", "class"]); 24 + 25 + return ( 26 + <div class={`flex flex-col items-center justify-center py-12 px-4 text-center ${local.class || ""}`}> 27 + <div class="mb-4">{local.icon ?? <DefaultIcon />}</div> 28 + <h3 class="text-lg font-semibold text-white mb-2">{local.title}</h3> 29 + <Show when={local.description}> 30 + <p class="text-sm text-gray-400 max-w-sm mb-6">{local.description}</p> 31 + </Show> 32 + <Show when={local.action}>{local.action}</Show> 33 + </div> 34 + ); 35 + };
+40
web/src/components/ui/Menu.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { Menu, type MenuItem } from "./Menu"; 4 + 5 + const items: MenuItem[] = [{ id: "edit", label: "Edit", shortcut: "⌘E" }, { 6 + id: "delete", 7 + label: "Delete", 8 + danger: true, 9 + }]; 10 + 11 + describe("Menu", () => { 12 + afterEach(cleanup); 13 + 14 + it("renders trigger", () => { 15 + render(() => <Menu items={items} trigger={<button>Open</button>} />); 16 + expect(screen.getByText("Open")).toBeInTheDocument(); 17 + }); 18 + 19 + it("opens menu on trigger click", () => { 20 + render(() => <Menu items={items} trigger={<button>Open</button>} />); 21 + fireEvent.click(screen.getByText("Open")); 22 + expect(screen.getByRole("menu")).toBeInTheDocument(); 23 + expect(screen.getByText("Edit")).toBeInTheDocument(); 24 + }); 25 + 26 + it("calls onClick when item clicked", () => { 27 + const handleClick = vi.fn(); 28 + const itemsWithHandler: MenuItem[] = [{ id: "action", label: "Action", onClick: handleClick }]; 29 + render(() => <Menu items={itemsWithHandler} trigger={<button>Open</button>} />); 30 + fireEvent.click(screen.getByText("Open")); 31 + fireEvent.click(screen.getByText("Action")); 32 + expect(handleClick).toHaveBeenCalled(); 33 + }); 34 + 35 + it("displays keyboard shortcuts", () => { 36 + render(() => <Menu items={items} trigger={<button>Open</button>} />); 37 + fireEvent.click(screen.getByText("Open")); 38 + expect(screen.getByText("⌘E")).toBeInTheDocument(); 39 + }); 40 + });
+148
web/src/components/ui/Menu.tsx
··· 1 + import { createSignal, For, onCleanup, onMount, Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + import { Motion, Presence } from "solid-motionone"; 4 + 5 + export type MenuItem = { 6 + id: string; 7 + label: string; 8 + icon?: JSX.Element; 9 + shortcut?: string; 10 + disabled?: boolean; 11 + danger?: boolean; 12 + onClick?: () => void; 13 + }; 14 + 15 + export type MenuDivider = { type: "divider" }; 16 + 17 + export type MenuItemType = MenuItem | MenuDivider; 18 + 19 + type MenuProps = { items: MenuItemType[]; trigger: JSX.Element; align?: "left" | "right"; class?: string }; 20 + 21 + const isDivider = (item: MenuItemType): item is MenuDivider => "type" in item && item.type === "divider"; 22 + 23 + export const Menu: Component<MenuProps> = (props) => { 24 + const [local, _others] = splitProps(props, ["items", "trigger", "align", "class"]); 25 + const align = () => local.align ?? "left"; 26 + 27 + const [open, setOpen] = createSignal(false); 28 + const [focusIndex, setFocusIndex] = createSignal(-1); 29 + let containerRef: HTMLDivElement | undefined; 30 + 31 + const menuItems = () => local.items.filter((item): item is MenuItem => !isDivider(item)); 32 + 33 + const handleClickOutside = (e: MouseEvent) => { 34 + if (containerRef && !containerRef.contains(e.target as Node)) { 35 + setOpen(false); 36 + } 37 + }; 38 + 39 + const handleKeyDown = (e: KeyboardEvent) => { 40 + if (!open()) return; 41 + 42 + const items = menuItems(); 43 + if (e.key === "Escape") { 44 + setOpen(false); 45 + } else if (e.key === "ArrowDown") { 46 + e.preventDefault(); 47 + setFocusIndex((prev) => { 48 + let next = prev + 1; 49 + while (next < items.length && items[next].disabled) next++; 50 + return next < items.length ? next : prev; 51 + }); 52 + } else if (e.key === "ArrowUp") { 53 + e.preventDefault(); 54 + setFocusIndex((prev) => { 55 + let next = prev - 1; 56 + while (next >= 0 && items[next].disabled) next--; 57 + return next >= 0 ? next : prev; 58 + }); 59 + } else if (e.key === "Enter" && focusIndex() >= 0) { 60 + e.preventDefault(); 61 + const item = items[focusIndex()]; 62 + if (item && !item.disabled) { 63 + item.onClick?.(); 64 + setOpen(false); 65 + } 66 + } 67 + }; 68 + 69 + onMount(() => { 70 + document.addEventListener("click", handleClickOutside); 71 + }); 72 + 73 + onCleanup(() => { 74 + document.removeEventListener("click", handleClickOutside); 75 + }); 76 + 77 + let itemIndex = 0; 78 + 79 + return ( 80 + <div ref={containerRef} class={`relative inline-block ${local.class || ""}`} onKeyDown={handleKeyDown}> 81 + {/* Trigger */} 82 + <div 83 + onClick={() => { 84 + setOpen(!open()); 85 + setFocusIndex(-1); 86 + }} 87 + class="cursor-pointer" 88 + aria-haspopup="menu" 89 + aria-expanded={open()}> 90 + {local.trigger} 91 + </div> 92 + 93 + {/* Menu */} 94 + <Presence> 95 + <Show when={open()}> 96 + <Motion.div 97 + initial={{ opacity: 0, y: -8 }} 98 + animate={{ opacity: 1, y: 0 }} 99 + exit={{ opacity: 0, y: -8 }} 100 + transition={{ duration: 0.15 }} 101 + class={`absolute z-50 mt-1 min-w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 ${ 102 + align() === "right" ? "right-0" : "left-0" 103 + }`} 104 + role="menu"> 105 + <For each={local.items}> 106 + {(item) => { 107 + if (isDivider(item)) { 108 + return <div class="my-1 h-px bg-gray-700" role="separator" />; 109 + } 110 + 111 + const currentIndex = itemIndex; 112 + itemIndex++; 113 + const isFocused = () => focusIndex() === currentIndex; 114 + 115 + return ( 116 + <button 117 + type="button" 118 + role="menuitem" 119 + disabled={item.disabled} 120 + onClick={() => { 121 + if (!item.disabled) { 122 + item.onClick?.(); 123 + setOpen(false); 124 + } 125 + }} 126 + onMouseEnter={() => setFocusIndex(currentIndex)} 127 + class={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors 128 + ${item.disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} 129 + ${isFocused() ? "bg-gray-700" : ""} 130 + ${item.danger ? "text-red-400 hover:bg-red-900/30" : "text-gray-300"} 131 + `}> 132 + <Show when={item.icon}> 133 + <span class="w-4 h-4 flex items-center justify-center flex-shrink-0">{item.icon}</span> 134 + </Show> 135 + <span class="flex-1">{item.label}</span> 136 + <Show when={item.shortcut}> 137 + <kbd class="text-xs text-gray-500 font-mono">{item.shortcut}</kbd> 138 + </Show> 139 + </button> 140 + ); 141 + }} 142 + </For> 143 + </Motion.div> 144 + </Show> 145 + </Presence> 146 + </div> 147 + ); 148 + };
+59
web/src/components/ui/ProgressBar.tsx
··· 1 + import { Show, splitProps } from "solid-js"; 2 + import type { Component } from "solid-js"; 3 + 4 + type ProgressBarSize = "sm" | "md" | "lg"; 5 + type ProgressBarColor = "blue" | "green" | "red" | "yellow"; 6 + 7 + type ProgressBarProps = { 8 + value?: number; 9 + size?: ProgressBarSize; 10 + color?: ProgressBarColor; 11 + label?: string; 12 + showValue?: boolean; 13 + class?: string; 14 + }; 15 + 16 + const sizeStyles: { [key in ProgressBarSize]: string } = { sm: "h-1", md: "h-2", lg: "h-3" }; 17 + 18 + const colorStyles: { [key in ProgressBarColor]: string } = { 19 + blue: "bg-blue-500", 20 + green: "bg-green-500", 21 + red: "bg-red-500", 22 + yellow: "bg-yellow-500", 23 + }; 24 + 25 + export const ProgressBar: Component<ProgressBarProps> = (props) => { 26 + const [local, _others] = splitProps(props, ["value", "size", "color", "label", "showValue", "class"]); 27 + const size = () => local.size ?? "md"; 28 + const color = () => local.color ?? "blue"; 29 + const isIndeterminate = () => local.value === undefined; 30 + const clampedValue = () => Math.min(100, Math.max(0, local.value ?? 0)); 31 + 32 + return ( 33 + <div class={local.class || ""}> 34 + <Show when={local.label || local.showValue}> 35 + <div class="flex justify-between mb-1"> 36 + <Show when={local.label}> 37 + <span class="text-sm text-gray-300">{local.label}</span> 38 + </Show> 39 + <Show when={local.showValue && !isIndeterminate()}> 40 + <span class="text-sm text-gray-400">{Math.round(clampedValue())}%</span> 41 + </Show> 42 + </div> 43 + </Show> 44 + <div 45 + class={`w-full bg-gray-800 rounded-full overflow-hidden ${sizeStyles[size()]}`} 46 + role="progressbar" 47 + aria-valuenow={isIndeterminate() ? undefined : clampedValue()} 48 + aria-valuemin={0} 49 + aria-valuemax={100} 50 + aria-label={local.label}> 51 + <div 52 + class={`${sizeStyles[size()]} ${colorStyles[color()]} rounded-full transition-all duration-300 ${ 53 + isIndeterminate() ? "w-1/3 animate-[shimmer_1.5s_ease-in-out_infinite]" : "" 54 + }`} 55 + style={isIndeterminate() ? undefined : { width: `${clampedValue()}%` }} /> 56 + </div> 57 + </div> 58 + ); 59 + };
+77
web/src/components/ui/RightPanel.tsx
··· 1 + import { onCleanup, onMount, Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + import { Motion, Presence } from "solid-motionone"; 4 + 5 + type RightPanelProps = { 6 + open: boolean; 7 + onClose: () => void; 8 + title?: string; 9 + width?: string; 10 + children: JSX.Element; 11 + class?: string; 12 + }; 13 + 14 + export const RightPanel: Component<RightPanelProps> = (props) => { 15 + const [local, _others] = splitProps(props, ["open", "onClose", "title", "width", "children", "class"]); 16 + const width = () => local.width ?? "400px"; 17 + 18 + const handleKeyDown = (e: KeyboardEvent) => { 19 + if (e.key === "Escape" && local.open) { 20 + local.onClose(); 21 + } 22 + }; 23 + 24 + onMount(() => { 25 + document.addEventListener("keydown", handleKeyDown); 26 + }); 27 + 28 + onCleanup(() => { 29 + document.removeEventListener("keydown", handleKeyDown); 30 + }); 31 + 32 + return ( 33 + <Presence> 34 + <Show when={local.open}> 35 + {/* Backdrop */} 36 + <Motion.div 37 + initial={{ opacity: 0 }} 38 + animate={{ opacity: 1 }} 39 + exit={{ opacity: 0 }} 40 + transition={{ duration: 0.15 }} 41 + class="fixed inset-0 z-40 bg-black/40" 42 + onClick={local.onClose} 43 + aria-hidden="true" /> 44 + {/* Panel */} 45 + <Motion.div 46 + initial={{ x: "100%" }} 47 + animate={{ x: 0 }} 48 + exit={{ x: "100%" }} 49 + transition={{ duration: 0.25, easing: [0.22, 1, 0.36, 1] }} 50 + class={`fixed top-0 right-0 bottom-0 z-50 bg-gray-900 border-l border-gray-800 shadow-2xl flex flex-col ${ 51 + local.class || "" 52 + }`} 53 + style={{ width: width() }} 54 + role="dialog" 55 + aria-modal="true" 56 + aria-label={local.title || "Panel"}> 57 + {/* Header */} 58 + <div class="h-16 flex items-center justify-between px-6 border-b border-gray-800"> 59 + <Show when={local.title}> 60 + <h2 class="text-lg font-semibold text-white">{local.title}</h2> 61 + </Show> 62 + <button 63 + onClick={() => local.onClose()} 64 + class="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors ml-auto" 65 + aria-label="Close panel"> 66 + <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 67 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 68 + </svg> 69 + </button> 70 + </div> 71 + {/* Content */} 72 + <div class="flex-1 overflow-y-auto p-6 text-gray-300">{local.children}</div> 73 + </Motion.div> 74 + </Show> 75 + </Presence> 76 + ); 77 + };
+98
web/src/components/ui/SidePanel.tsx
··· 1 + import { createSignal, For, Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + export type NavItem = { id: string; label: string; icon?: JSX.Element; href?: string; onClick?: () => void }; 5 + 6 + type SidePanelProps = { 7 + items: NavItem[]; 8 + activeId?: string; 9 + collapsed?: boolean; 10 + onToggle?: () => void; 11 + header?: JSX.Element; 12 + footer?: JSX.Element; 13 + class?: string; 14 + }; 15 + 16 + const MenuIcon: Component = () => ( 17 + <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 18 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> 19 + </svg> 20 + ); 21 + 22 + export const SidePanel: Component<SidePanelProps> = (props) => { 23 + const [local, _others] = splitProps(props, [ 24 + "items", 25 + "activeId", 26 + "collapsed", 27 + "onToggle", 28 + "header", 29 + "footer", 30 + "class", 31 + ]); 32 + const [internalCollapsed, setInternalCollapsed] = createSignal(false); 33 + const isCollapsed = () => local.collapsed ?? internalCollapsed(); 34 + 35 + const toggle = () => { 36 + if (local.onToggle) { 37 + local.onToggle(); 38 + } else { 39 + setInternalCollapsed(!internalCollapsed()); 40 + } 41 + }; 42 + 43 + return ( 44 + <aside 45 + class={`flex flex-col bg-gray-900 border-r border-gray-800 transition-all duration-200 ${ 46 + isCollapsed() ? "w-16" : "w-64" 47 + } ${local.class || ""}`}> 48 + {/* Header */} 49 + <div class="h-16 flex items-center justify-between px-4 border-b border-gray-800"> 50 + <Show when={!isCollapsed() && local.header}>{local.header}</Show> 51 + <button 52 + onClick={toggle} 53 + class="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors" 54 + aria-label={isCollapsed() ? "Expand sidebar" : "Collapse sidebar"}> 55 + <MenuIcon /> 56 + </button> 57 + </div> 58 + 59 + {/* Navigation */} 60 + <nav class="flex-1 py-4 overflow-y-auto" role="navigation"> 61 + <ul class="space-y-1 px-2"> 62 + <For each={local.items}> 63 + {(item) => { 64 + const isActive = () => local.activeId === item.id; 65 + const Component = item.href ? "a" : "button"; 66 + 67 + return ( 68 + <li> 69 + <Component 70 + href={item.href} 71 + onClick={item.onClick} 72 + class={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors text-sm ${ 73 + isActive() ? "bg-blue-600/20 text-blue-400" : "text-gray-400 hover:text-white hover:bg-gray-800" 74 + }`} 75 + aria-current={isActive() ? "page" : undefined}> 76 + <Show when={item.icon}> 77 + <span class="w-5 h-5 flex items-center justify-center flex-shrink-0">{item.icon}</span> 78 + </Show> 79 + <Show when={!isCollapsed()}> 80 + <span class="truncate">{item.label}</span> 81 + </Show> 82 + </Component> 83 + </li> 84 + ); 85 + }} 86 + </For> 87 + </ul> 88 + </nav> 89 + 90 + {/* Footer */} 91 + <Show when={local.footer}> 92 + <div class="border-t border-gray-800 px-4 py-4"> 93 + <Show when={!isCollapsed()}>{local.footer}</Show> 94 + </div> 95 + </Show> 96 + </aside> 97 + ); 98 + };
+40
web/src/components/ui/Skeleton.test.tsx
··· 1 + import { cleanup, render } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it } from "vitest"; 3 + import { Skeleton, SkeletonAvatar, SkeletonText } from "./Skeleton"; 4 + 5 + describe("Skeleton", () => { 6 + afterEach(cleanup); 7 + 8 + it("renders basic skeleton", () => { 9 + render(() => <Skeleton width="100px" height="20px" />); 10 + const skeleton = document.querySelector("[aria-hidden=\"true\"]"); 11 + expect(skeleton).toBeInTheDocument(); 12 + expect(skeleton).toHaveClass("animate-pulse"); 13 + }); 14 + 15 + it("applies rounded classes", () => { 16 + render(() => <Skeleton rounded="full" />); 17 + const skeleton = document.querySelector("[aria-hidden=\"true\"]"); 18 + expect(skeleton).toHaveClass("rounded-full"); 19 + }); 20 + }); 21 + 22 + describe("SkeletonText", () => { 23 + afterEach(cleanup); 24 + 25 + it("renders multiple lines", () => { 26 + render(() => <SkeletonText lines={4} />); 27 + const skeletons = document.querySelectorAll("[aria-hidden=\"true\"]"); 28 + expect(skeletons.length).toBe(4); 29 + }); 30 + }); 31 + 32 + describe("SkeletonAvatar", () => { 33 + afterEach(cleanup); 34 + 35 + it("renders circular skeleton", () => { 36 + render(() => <SkeletonAvatar size="lg" />); 37 + const skeleton = document.querySelector("[aria-hidden=\"true\"]"); 38 + expect(skeleton).toHaveClass("rounded-full"); 39 + }); 40 + });
+47
web/src/components/ui/Skeleton.tsx
··· 1 + import { For, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + type SkeletonSize = "sm" | "md" | "lg"; 5 + type SkeletonRounded = "none" | SkeletonSize | "full"; 6 + 7 + type SkeletonProps = { width?: string; height?: string; rounded?: SkeletonRounded }; 8 + 9 + const roundedClass: Record<string, string> = { 10 + none: "", 11 + sm: "rounded-sm", 12 + md: "rounded-md", 13 + lg: "rounded-lg", 14 + full: "rounded-full", 15 + }; 16 + 17 + export const Skeleton: Component<JSX.HTMLAttributes<HTMLDivElement> & SkeletonProps> = (props) => { 18 + const [local, others] = splitProps(props, ["width", "height", "rounded", "class"]); 19 + const rounded = () => local.rounded ?? "md"; 20 + 21 + return ( 22 + <div 23 + class={`animate-pulse bg-gray-800 ${roundedClass[rounded()]} ${local.class || ""}`} 24 + style={{ width: local.width, height: local.height || "1rem" }} 25 + aria-hidden="true" 26 + {...others} /> 27 + ); 28 + }; 29 + 30 + /** Skeleton text line */ 31 + export const SkeletonText: Component<{ lines?: number; class?: string }> = (props) => { 32 + const lines = () => props.lines ?? 3; 33 + 34 + return ( 35 + <div class={`space-y-2 ${props.class || ""}`}> 36 + <For each={Array.from({ length: lines() })}> 37 + {(line) => <Skeleton width={line === lines() - 1 ? "75%" : "100%"} height="0.875rem" />} 38 + </For> 39 + </div> 40 + ); 41 + }; 42 + 43 + export const SkeletonAvatar: Component<{ size?: "sm" | "md" | "lg" }> = (props) => { 44 + const sizes = { sm: "32px", md: "48px", lg: "64px" }; 45 + const size = () => sizes[props.size ?? "md"]; 46 + return <Skeleton width={size()} height={size()} rounded="full" />; 47 + };
+41
web/src/components/ui/Tabs.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { type Tab, Tabs } from "./Tabs"; 4 + 5 + const tabs: Tab[] = [{ id: "tab1", label: "First" }, { id: "tab2", label: "Second" }, { 6 + id: "tab3", 7 + label: "Third", 8 + disabled: true, 9 + }]; 10 + 11 + describe("Tabs", () => { 12 + afterEach(cleanup); 13 + 14 + it("renders tab list", () => { 15 + render(() => <Tabs tabs={tabs} />); 16 + expect(screen.getByRole("tablist")).toBeInTheDocument(); 17 + expect(screen.getByText("First")).toBeInTheDocument(); 18 + expect(screen.getByText("Second")).toBeInTheDocument(); 19 + }); 20 + 21 + it("selects first tab by default", () => { 22 + render(() => <Tabs tabs={tabs} />); 23 + const first = screen.getByText("First"); 24 + expect(first).toHaveAttribute("aria-selected", "true"); 25 + }); 26 + 27 + it("switches tabs on click", () => { 28 + const handleChange = vi.fn(); 29 + render(() => <Tabs tabs={tabs} onTabChange={handleChange} />); 30 + fireEvent.click(screen.getByText("Second")); 31 + expect(handleChange).toHaveBeenCalledWith("tab2"); 32 + }); 33 + 34 + it("respects disabled state", () => { 35 + const handleChange = vi.fn(); 36 + render(() => <Tabs tabs={tabs} onTabChange={handleChange} />); 37 + const disabled = screen.getByText("Third"); 38 + fireEvent.click(disabled); 39 + expect(handleChange).not.toHaveBeenCalled(); 40 + }); 41 + });
+99
web/src/components/ui/Tabs.tsx
··· 1 + import { createEffect, createSignal, For, Show, splitProps } from "solid-js"; 2 + import type { Accessor, Component, JSX } from "solid-js"; 3 + 4 + export type Tab = { id: string; label: string; icon?: JSX.Element; disabled?: boolean }; 5 + 6 + type TabsProps = { 7 + tabs: Tab[]; 8 + activeTab?: string; 9 + onTabChange?: (tabId: string) => void; 10 + variant?: "line" | "contained"; 11 + children?: (activeTab: Accessor<string>) => JSX.Element; 12 + class?: string; 13 + }; 14 + 15 + // type TabsContextValue = { activeTab: Accessor<string>; setActiveTab: Setter<string> }; 16 + 17 + export const Tabs: Component<TabsProps> = (props) => { 18 + const [local, _others] = splitProps(props, ["tabs", "activeTab", "onTabChange", "variant", "children", "class"]); 19 + const variant = () => local.variant ?? "line"; 20 + const [internalTab, setInternalTab] = createSignal(""); 21 + const activeTab = () => local.activeTab ?? internalTab(); 22 + 23 + createEffect(() => { 24 + if (local.tabs.length > 0) { 25 + setInternalTab(local.tabs[0].id); 26 + } 27 + }); 28 + 29 + const selectTab = (tabId: string) => { 30 + if (local.onTabChange) { 31 + local.onTabChange(tabId); 32 + } else { 33 + setInternalTab(tabId); 34 + } 35 + }; 36 + 37 + const handleKeyDown = (e: KeyboardEvent, currentIndex: number) => { 38 + const enabledTabs = local.tabs.filter((t) => !t.disabled); 39 + const currentEnabledIndex = enabledTabs.findIndex((t) => t.id === local.tabs[currentIndex].id); 40 + 41 + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { 42 + e.preventDefault(); 43 + const delta = e.key === "ArrowRight" ? 1 : -1; 44 + const nextIndex = (currentEnabledIndex + delta + enabledTabs.length) % enabledTabs.length; 45 + selectTab(enabledTabs[nextIndex].id); 46 + } 47 + }; 48 + 49 + return ( 50 + <div class={local.class || ""}> 51 + {/* Tab List */} 52 + <div 53 + role="tablist" 54 + class={`flex ${variant() === "line" ? "border-b border-gray-800" : "bg-gray-900 p-1 rounded-lg"}`}> 55 + <For each={local.tabs}> 56 + {(tab, index) => { 57 + const isActive = () => activeTab() === tab.id; 58 + const isDisabled = () => tab.disabled ?? false; 59 + 60 + return ( 61 + <button 62 + role="tab" 63 + aria-selected={isActive()} 64 + aria-disabled={isDisabled()} 65 + tabIndex={isActive() ? 0 : -1} 66 + disabled={isDisabled()} 67 + onClick={() => !isDisabled() && selectTab(tab.id)} 68 + onKeyDown={(e) => handleKeyDown(e, index())} 69 + class={` 70 + flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset 71 + ${isDisabled() ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} 72 + ${ 73 + variant() === "line" 74 + ? `border-b-2 -mb-px ${ 75 + isActive() 76 + ? "border-blue-500 text-blue-400" 77 + : "border-transparent text-gray-400 hover:text-white hover:border-gray-600" 78 + }` 79 + : `rounded-md ${ 80 + isActive() ? "bg-gray-700 text-white shadow" : "text-gray-400 hover:text-white hover:bg-gray-800" 81 + }` 82 + } 83 + `}> 84 + <Show when={tab.icon}> 85 + <span class="w-4 h-4">{tab.icon}</span> 86 + </Show> 87 + {tab.label} 88 + </button> 89 + ); 90 + }} 91 + </For> 92 + </div> 93 + {/* Tab Content */} 94 + <Show when={local.children}> 95 + <div role="tabpanel" class="pt-4">{local.children!(activeTab)}</div> 96 + </Show> 97 + </div> 98 + ); 99 + };
+35
web/src/components/ui/Tag.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { Tag } from "./Tag"; 4 + 5 + describe("Tag", () => { 6 + afterEach(cleanup); 7 + 8 + it("renders label", () => { 9 + render(() => <Tag label="Test Tag" />); 10 + expect(screen.getByText("Test Tag")).toBeInTheDocument(); 11 + }); 12 + 13 + it("shows dismiss button for dismissible type", () => { 14 + const handleDismiss = vi.fn(); 15 + render(() => <Tag label="Dismissible" type="dismissible" onDismiss={handleDismiss} />); 16 + const dismissBtn = screen.getByRole("button", { name: /remove/i }); 17 + expect(dismissBtn).toBeInTheDocument(); 18 + fireEvent.click(dismissBtn); 19 + expect(handleDismiss).toHaveBeenCalled(); 20 + }); 21 + 22 + it("supports selectable type", () => { 23 + const handleSelect = vi.fn(); 24 + render(() => <Tag label="Selectable" type="selectable" onSelect={handleSelect} />); 25 + const tag = screen.getByRole("button"); 26 + fireEvent.click(tag); 27 + expect(handleSelect).toHaveBeenCalled(); 28 + }); 29 + 30 + it("shows selected state", () => { 31 + render(() => <Tag label="Selected" type="selectable" selected color="blue" />); 32 + const tag = screen.getByText("Selected").parentElement; 33 + expect(tag).toHaveClass("bg-blue-600"); 34 + }); 35 + });
+96
web/src/components/ui/Tag.tsx
··· 1 + import { Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + export type TagType = "read-only" | "dismissible" | "selectable"; 5 + export type TagColor = "gray" | "blue" | "green" | "red" | "yellow" | "purple"; 6 + 7 + type TagProps = { 8 + label: string; 9 + type?: TagType; 10 + color?: TagColor; 11 + selected?: boolean; 12 + onDismiss?: () => void; 13 + onSelect?: () => void; 14 + icon?: JSX.Element; 15 + class?: string; 16 + }; 17 + 18 + const colorStyles: Record<TagColor, { base: string; selected: string }> = { 19 + gray: { base: "bg-gray-800 text-gray-300 border-gray-700", selected: "bg-gray-600 text-white border-gray-500" }, 20 + blue: { base: "bg-blue-900/40 text-blue-300 border-blue-800", selected: "bg-blue-600 text-white border-blue-500" }, 21 + green: { 22 + base: "bg-green-900/40 text-green-300 border-green-800", 23 + selected: "bg-green-600 text-white border-green-500", 24 + }, 25 + red: { base: "bg-red-900/40 text-red-300 border-red-800", selected: "bg-red-600 text-white border-red-500" }, 26 + yellow: { 27 + base: "bg-yellow-900/40 text-yellow-300 border-yellow-800", 28 + selected: "bg-yellow-600 text-white border-yellow-500", 29 + }, 30 + purple: { 31 + base: "bg-purple-900/40 text-purple-300 border-purple-800", 32 + selected: "bg-purple-600 text-white border-purple-500", 33 + }, 34 + }; 35 + 36 + export const Tag: Component<TagProps> = (props) => { 37 + const [local, _others] = splitProps(props, [ 38 + "label", 39 + "type", 40 + "color", 41 + "selected", 42 + "onDismiss", 43 + "onSelect", 44 + "icon", 45 + "class", 46 + ]); 47 + const type = () => local.type ?? "read-only"; 48 + const color = () => local.color ?? "gray"; 49 + 50 + const baseClass = () => { 51 + const c = colorStyles[color()]; 52 + const isSelected = local.selected && type() === "selectable"; 53 + return isSelected ? c.selected : c.base; 54 + }; 55 + 56 + const handleClick = () => { 57 + if (type() === "selectable") { 58 + local.onSelect?.(); 59 + } 60 + }; 61 + 62 + const handleDismiss = (e: MouseEvent) => { 63 + e.stopPropagation(); 64 + local.onDismiss?.(); 65 + }; 66 + 67 + return ( 68 + <span 69 + onClick={handleClick} 70 + role={type() === "selectable" ? "button" : undefined} 71 + tabIndex={type() === "selectable" ? 0 : undefined} 72 + aria-pressed={type() === "selectable" ? local.selected : undefined} 73 + class={` 74 + inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium border rounded-full transition-colors 75 + ${baseClass()} 76 + ${type() === "selectable" ? "cursor-pointer hover:opacity-80" : ""} 77 + ${local.class || ""} 78 + `}> 79 + <Show when={local.icon}> 80 + <span class="w-3 h-3 flex items-center justify-center">{local.icon}</span> 81 + </Show> 82 + <span>{local.label}</span> 83 + <Show when={type() === "dismissible"}> 84 + <button 85 + type="button" 86 + onClick={handleDismiss} 87 + class="w-4 h-4 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors" 88 + aria-label={`Remove ${local.label}`}> 89 + <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 90 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 91 + </svg> 92 + </button> 93 + </Show> 94 + </span> 95 + ); 96 + };
+83
web/src/components/ui/Tooltip.tsx
··· 1 + import { createSignal, onCleanup, Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + type TooltipPosition = "top" | "right" | "bottom" | "left"; 5 + 6 + type TooltipProps = { 7 + content: string | JSX.Element; 8 + position?: TooltipPosition; 9 + delay?: number; 10 + children: JSX.Element; 11 + class?: string; 12 + }; 13 + 14 + const positionStyles: Record<TooltipPosition, { tooltip: string; arrow: string }> = { 15 + top: { 16 + tooltip: "bottom-full left-1/2 -translate-x-1/2 mb-2", 17 + arrow: "top-full left-1/2 -translate-x-1/2 border-t-gray-800 border-x-transparent border-b-transparent", 18 + }, 19 + bottom: { 20 + tooltip: "top-full left-1/2 -translate-x-1/2 mt-2", 21 + arrow: "bottom-full left-1/2 -translate-x-1/2 border-b-gray-800 border-x-transparent border-t-transparent", 22 + }, 23 + left: { 24 + tooltip: "right-full top-1/2 -translate-y-1/2 mr-2", 25 + arrow: "left-full top-1/2 -translate-y-1/2 border-l-gray-800 border-y-transparent border-r-transparent", 26 + }, 27 + right: { 28 + tooltip: "left-full top-1/2 -translate-y-1/2 ml-2", 29 + arrow: "right-full top-1/2 -translate-y-1/2 border-r-gray-800 border-y-transparent border-l-transparent", 30 + }, 31 + }; 32 + 33 + export const Tooltip: Component<TooltipProps> = (props) => { 34 + const [local, _others] = splitProps(props, ["content", "position", "delay", "children", "class"]); 35 + const position = () => local.position ?? "top"; 36 + const delay = () => local.delay ?? 200; 37 + 38 + const [visible, setVisible] = createSignal(false); 39 + let timeoutId: number | undefined; 40 + 41 + const show = () => { 42 + timeoutId = window.setTimeout(() => setVisible(true), delay()); 43 + }; 44 + 45 + const hide = () => { 46 + if (timeoutId) clearTimeout(timeoutId); 47 + setVisible(false); 48 + }; 49 + 50 + onCleanup(() => { 51 + if (timeoutId) clearTimeout(timeoutId); 52 + }); 53 + 54 + return ( 55 + <span 56 + class={`relative inline-flex ${local.class || ""}`} 57 + onMouseEnter={show} 58 + onMouseLeave={hide} 59 + onFocus={show} 60 + onBlur={hide}> 61 + {local.children} 62 + <Show when={visible()}> 63 + <span 64 + role="tooltip" 65 + class={`absolute z-50 px-2.5 py-1.5 text-xs text-white bg-gray-800 border border-gray-700 rounded shadow-lg whitespace-nowrap ${ 66 + positionStyles[position()].tooltip 67 + }`}> 68 + {local.content} 69 + <span class={`absolute w-0 h-0 border-4 ${positionStyles[position()].arrow}`} aria-hidden="true" /> 70 + </span> 71 + </Show> 72 + </span> 73 + ); 74 + }; 75 + 76 + export const KeyboardHint: Component<{ keys: string[]; children: JSX.Element }> = (props) => { 77 + const keysDisplay = () => 78 + props.keys.map((key) => ( 79 + <kbd class="px-1.5 py-0.5 bg-gray-700 border border-gray-600 rounded text-xs font-mono">{key}</kbd> 80 + )); 81 + 82 + return <Tooltip content={<span class="flex items-center gap-1">{keysDisplay()}</span>}>{props.children}</Tooltip>; 83 + };
+35
web/src/components/ui/TreeView.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { type TreeNode, TreeView } from "./TreeView"; 4 + 5 + const sampleNodes: TreeNode[] = [{ 6 + id: "1", 7 + label: "Root", 8 + children: [{ id: "1-1", label: "Child 1" }, { id: "1-2", label: "Child 2" }], 9 + }, { id: "2", label: "Sibling" }]; 10 + 11 + describe("TreeView", () => { 12 + afterEach(cleanup); 13 + 14 + it("renders nodes", () => { 15 + render(() => <TreeView nodes={sampleNodes} />); 16 + expect(screen.getByRole("tree")).toBeInTheDocument(); 17 + expect(screen.getByText("Root")).toBeInTheDocument(); 18 + expect(screen.getByText("Sibling")).toBeInTheDocument(); 19 + }); 20 + 21 + it("expands children on click", () => { 22 + render(() => <TreeView nodes={sampleNodes} />); 23 + expect(screen.queryByText("Child 1")).not.toBeInTheDocument(); 24 + fireEvent.click(screen.getByText("Root")); 25 + expect(screen.getByText("Child 1")).toBeInTheDocument(); 26 + expect(screen.getByText("Child 2")).toBeInTheDocument(); 27 + }); 28 + 29 + it("calls onSelect when node clicked", () => { 30 + const handleSelect = vi.fn(); 31 + render(() => <TreeView nodes={sampleNodes} onSelect={handleSelect} />); 32 + fireEvent.click(screen.getByText("Sibling")); 33 + expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "2" })); 34 + }); 35 + });
+79
web/src/components/ui/TreeView.tsx
··· 1 + import { createSignal, For, Show, splitProps } from "solid-js"; 2 + import type { Component, JSX } from "solid-js"; 3 + 4 + export type TreeNode = { id: string; label: string; icon?: JSX.Element; children?: TreeNode[] }; 5 + 6 + type TreeViewProps = { nodes: TreeNode[]; onSelect?: (node: TreeNode) => void; class?: string }; 7 + 8 + type TreeNodeItemProps = { node: TreeNode; level: number; onSelect?: (node: TreeNode) => void }; 9 + 10 + const ChevronIcon: Component<{ expanded: boolean }> = (props) => ( 11 + <svg 12 + class={`w-4 h-4 transition-transform duration-200 ${props.expanded ? "rotate-90" : ""}`} 13 + fill="none" 14 + viewBox="0 0 24 24" 15 + stroke="currentColor"> 16 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 17 + </svg> 18 + ); 19 + 20 + const TreeNodeItem: Component<TreeNodeItemProps> = (props) => { 21 + const [expanded, setExpanded] = createSignal(false); 22 + const hasChildren = () => props.node.children && props.node.children.length > 0; 23 + 24 + const handleKeyDown = (e: KeyboardEvent) => { 25 + if (e.key === "Enter" || e.key === " ") { 26 + e.preventDefault(); 27 + if (hasChildren()) { 28 + setExpanded(!expanded()); 29 + } 30 + props.onSelect?.(props.node); 31 + } else if (e.key === "ArrowRight" && hasChildren() && !expanded()) { 32 + setExpanded(true); 33 + } else if (e.key === "ArrowLeft" && expanded()) { 34 + setExpanded(false); 35 + } 36 + }; 37 + 38 + return ( 39 + <li role="treeitem" aria-expanded={hasChildren() ? expanded() : undefined}> 40 + <div 41 + class="flex items-center gap-1 px-2 py-1.5 hover:bg-gray-800 cursor-pointer text-gray-300 hover:text-white transition-colors rounded" 42 + style={{ "padding-left": `${props.level * 16 + 8}px` }} 43 + onClick={() => { 44 + if (hasChildren()) setExpanded(!expanded()); 45 + props.onSelect?.(props.node); 46 + }} 47 + onKeyDown={handleKeyDown} 48 + tabIndex={0} 49 + role="button"> 50 + <span class="w-4 h-4 flex items-center justify-center text-gray-500"> 51 + <Show when={hasChildren()} fallback={<span class="w-4" />}> 52 + <ChevronIcon expanded={expanded()} /> 53 + </Show> 54 + </span> 55 + <Show when={props.node.icon}> 56 + <span class="w-4 h-4 flex items-center justify-center">{props.node.icon}</span> 57 + </Show> 58 + <span class="text-sm truncate">{props.node.label}</span> 59 + </div> 60 + <Show when={expanded() && hasChildren()}> 61 + <ul role="group" class="border-l border-gray-800 ml-4"> 62 + <For each={props.node.children}> 63 + {(child) => <TreeNodeItem node={child} level={props.level + 1} onSelect={props.onSelect} />} 64 + </For> 65 + </ul> 66 + </Show> 67 + </li> 68 + ); 69 + }; 70 + 71 + export const TreeView: Component<TreeViewProps> = (props) => { 72 + const [local, others] = splitProps(props, ["nodes", "onSelect", "class"]); 73 + 74 + return ( 75 + <ul role="tree" class={`text-sm ${local.class || ""}`} {...others}> 76 + <For each={local.nodes}>{(node) => <TreeNodeItem node={node} level={0} onSelect={local.onSelect} />}</For> 77 + </ul> 78 + ); 79 + };
+56
web/src/lib/animations.ts
··· 1 + /** 2 + * Animation presets for solid-motionone 3 + * @module animations 4 + */ 5 + import type { Options as MotionOptions } from "solid-motionone"; 6 + 7 + /** Spring animation config for natural bounce */ 8 + export const springConfig = { stiffness: 300, damping: 24 }; 9 + 10 + /** Standard easing for UI animations */ 11 + export const easeOut = [0.22, 1, 0.36, 1] as const; 12 + 13 + /** Fade in animation */ 14 + export const fadeIn: MotionOptions = { 15 + initial: { opacity: 0 }, 16 + animate: { opacity: 1 }, 17 + transition: { duration: 0.2, easing: easeOut }, 18 + }; 19 + 20 + /** Fade out animation */ 21 + export const fadeOut: MotionOptions = { 22 + initial: { opacity: 1 }, 23 + animate: { opacity: 0 }, 24 + transition: { duration: 0.15 }, 25 + }; 26 + 27 + /** Slide in from right */ 28 + export const slideInRight: MotionOptions = { 29 + initial: { opacity: 0, x: 20 }, 30 + animate: { opacity: 1, x: 0 }, 31 + transition: { duration: 0.25, easing: easeOut }, 32 + }; 33 + 34 + /** Slide in from bottom */ 35 + export const slideInUp: MotionOptions = { 36 + initial: { opacity: 0, y: 10 }, 37 + animate: { opacity: 1, y: 0 }, 38 + transition: { duration: 0.2, easing: easeOut }, 39 + }; 40 + 41 + /** Scale in (pop) */ 42 + export const scaleIn: MotionOptions = { 43 + initial: { opacity: 0, scale: 0.95 }, 44 + animate: { opacity: 1, scale: 1 }, 45 + transition: { duration: 0.2, easing: easeOut }, 46 + }; 47 + 48 + /** Scale out */ 49 + export const scaleOut: MotionOptions = { 50 + initial: { opacity: 1, scale: 1 }, 51 + animate: { opacity: 0, scale: 0.95 }, 52 + transition: { duration: 0.15 }, 53 + }; 54 + 55 + /** Stagger delay for list items */ 56 + export const staggerDelay = (index: number, baseDelay = 0.05) => index * baseDelay;
+2 -2
web/src/pages/Landing.test.tsx
··· 16 16 it("renders hero text correctly", () => { 17 17 renderLanding(); 18 18 19 - expect(screen.getByText(/A Learning OS/i)).toBeInTheDocument(); 20 - expect(screen.getByText(/for daily study/i)).toBeInTheDocument(); 19 + expect(screen.getByText(/Learning on/i)).toBeInTheDocument(); 20 + expect(screen.getByText(/the AT Protocol/i)).toBeInTheDocument(); 21 21 expect(screen.getByText(/Master complex topics/i)).toBeInTheDocument(); 22 22 }); 23 23
+1 -1
web/vite.config.ts
··· 9 9 environment: "jsdom", 10 10 ui: false, 11 11 watch: false, 12 - server: { deps: { inline: ["@solidjs/router", "solid-js"] } }, 12 + server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } }, 13 13 }, 14 14 });