diff --git a/package-lock.json b/package-lock.json index 687e01a..39cb04a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.16", "@stellar/freighter-api": "^5.0.0", "@stellar/stellar-sdk": "^14.1.1", "buffer": "^6.0.3", @@ -3606,6 +3607,357 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -3742,6 +4094,52 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/package.json b/package.json index c404555..715aeb6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.16", "@stellar/freighter-api": "^5.0.0", "@stellar/stellar-sdk": "^14.1.1", "buffer": "^6.0.3", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a2d6e54..32b662b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { Providers } from "@/components/providers"; const geistSans = Geist({ @@ -28,7 +30,10 @@ export default function RootLayout({ - {children} + + {children} + + ); diff --git a/src/app/register/register-client.tsx b/src/app/register/register-client.tsx index c32dcab..045327f 100644 --- a/src/app/register/register-client.tsx +++ b/src/app/register/register-client.tsx @@ -21,6 +21,7 @@ import { UserRound, } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -456,9 +457,9 @@ export function RegisterClient() { ) : null} {error ? ( -
- {error} -
+ + {error} + ) : null}
diff --git a/src/app/sign-in/sign-in-client.tsx b/src/app/sign-in/sign-in-client.tsx index 211e303..c177854 100644 --- a/src/app/sign-in/sign-in-client.tsx +++ b/src/app/sign-in/sign-in-client.tsx @@ -3,6 +3,53 @@ import { BadgeCheck, WalletCards } from "lucide-react"; import { useRouter } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + getMerchantProfile, + MERCHANT_SESSION_KEY, +} from "@/lib/merchant-storage"; + +type AuthStatus = "idle" | "connecting" | "signing" | "verified" | "error"; + +type WalletSession = { + address: string; + challenge: string; + signature: string; + signedAt: string; +}; + +function createChallenge(address: string) { + const issuedAt = new Date().toISOString(); + const nonce = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + return [ + "Shade Merchant Sign In", + "", + "Sign this message to prove you control this Stellar wallet.", + "This request will not move funds or create a transaction.", + "", + `Wallet: ${address}`, + `Nonce: ${nonce}`, + `Issued At: ${issuedAt}`, + ].join("\n"); +} + +// async function verifySignedMessage( +// challenge: string, +// signedMessage: string, +// signerAddress: string, +// ) { +// const { Keypair } = await import("@stellar/stellar-sdk"); +// const keypair = Keypair.fromPublicKey(signerAddress); +// const messageBytes = Buffer.from(challenge, "utf8"); +// const signatureBytes = Buffer.from(signedMessage, "base64"); +// +// return keypair.verify(messageBytes, signatureBytes); +// } import { WalletConnectButton } from "@/components/wallet-connect-button"; import { useWalletConnect } from "@/hooks/use-wallet-connect"; import { getMerchantProfile } from "@/lib/merchant-storage"; @@ -37,9 +84,9 @@ export function SignInClient() {
{error ? ( -
- {error} -
+ + {error} + ) : null} {session ? ( diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..ba8f2b1 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { ThemeProvider, useTheme } from "@/components/ThemeProvider"; diff --git a/src/components/ui/alert.test.tsx b/src/components/ui/alert.test.tsx new file mode 100644 index 0000000..19b92f5 --- /dev/null +++ b/src/components/ui/alert.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +describe("Alert", () => { + it("exposes an alert role and renders title and description", () => { + render( + + Heads up + Something happened + , + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Heads up")).toBeInTheDocument(); + expect(screen.getByText("Something happened")).toBeInTheDocument(); + }); + + it("applies the destructive variant classes used by the auth error boxes", () => { + render(Boom); + + const alert = screen.getByRole("alert"); + expect(alert.className).toContain("border-destructive/30"); + expect(alert.className).toContain("bg-destructive/10"); + expect(alert.className).toContain("text-destructive"); + expect(alert.className).toContain("rounded-lg"); + expect(alert.className).toContain("p-3"); + expect(alert.className).toContain("text-sm"); + }); + + it("keeps caller classNames so layout spacing is preserved", () => { + render( + + Boom + , + ); + + expect(screen.getByRole("alert").className).toContain("mt-5"); + }); + + it("defaults to the neutral variant", () => { + render(Neutral); + + const alert = screen.getByRole("alert"); + expect(alert.className).toContain("bg-card"); + expect(alert.className).not.toContain("bg-destructive/10"); + }); +}); diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..c4c3960 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva("rounded-lg border p-3 text-sm", { + variants: { + variant: { + default: "border-border bg-card text-card-foreground", + destructive: "border-destructive/30 bg-destructive/10 text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +const Alert = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); + +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( +
+)); + +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( +
+)); + +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription, alertVariants }; diff --git a/src/components/ui/dropdown-menu.test.tsx b/src/components/ui/dropdown-menu.test.tsx new file mode 100644 index 0000000..719d753 --- /dev/null +++ b/src/components/ui/dropdown-menu.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +function Harness({ onSelect }: { onSelect?: () => void }) { + return ( +
+ + + Actions + + Edit + + Delete + + +
+ ); +} + +describe("DropdownMenu", () => { + it("opens on trigger click and renders items", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + await user.click(screen.getByText("Actions")); + + expect(await screen.findByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Delete" }), + ).toBeInTheDocument(); + }); + + it("marks the trigger as expanded while open so the menu is anchored to it", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByText("Actions"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "true"); + const menu = await screen.findByRole("menu"); + expect(trigger).toHaveAttribute("aria-controls", menu.id); + }); + + it("closes on Escape", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Actions")); + expect(await screen.findByRole("menu")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + await waitFor(() => + expect(screen.queryByRole("menu")).not.toBeInTheDocument(), + ); + }); + + it("closes on outside click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Actions")); + expect(await screen.findByRole("menu")).toBeInTheDocument(); + + // Radix sets `pointer-events: none` on the body while an open menu traps + // interaction, which user-event refuses to click through. Dispatch the + // dismiss sequence Radix actually listens for on the outside element. + const outside = screen.getByText("outside"); + fireEvent.pointerDown(outside, { pointerType: "mouse", button: 0 }); + fireEvent.mouseDown(outside, { button: 0 }); + + await waitFor(() => + expect(screen.queryByRole("menu")).not.toBeInTheDocument(), + ); + }); + + it("fires onSelect and closes when an item is chosen", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByText("Actions")); + await user.click(await screen.findByRole("menuitem", { name: "Edit" })); + + expect(onSelect).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(screen.queryByRole("menu")).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..3545c26 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,89 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); + +DropdownMenuContent.displayName = "DropdownMenuContent"; + +const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); + +DropdownMenuItem.displayName = "DropdownMenuItem"; + +const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuSeparator.displayName = "DropdownMenuSeparator"; + +const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuLabel.displayName = "DropdownMenuLabel"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, +}; diff --git a/src/components/ui/theme-toggle.test.tsx b/src/components/ui/theme-toggle.test.tsx new file mode 100644 index 0000000..816918d --- /dev/null +++ b/src/components/ui/theme-toggle.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ThemeProvider } from "@/components/theme-provider"; +import { ThemeToggle } from "@/components/ui/theme-toggle"; + +function Harness() { + return ( + + + + ); +} + +beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark"); + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); + +describe("ThemeToggle", () => { + it("toggles the dark class on the html element", async () => { + const user = userEvent.setup(); + render(); + + expect(document.documentElement.classList.contains("dark")).toBe(false); + + await user.click(screen.getByRole("button")); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + await user.click(screen.getByRole("button")); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + it("persists the choice to localStorage", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button")); + + expect(localStorage.getItem("shade:theme")).toBe("dark"); + }); + + it("restores the persisted choice on reload", () => { + localStorage.setItem("shade:theme", "dark"); + + render(); + + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("labels the action for screen readers", async () => { + const user = userEvent.setup(); + render(); + + expect( + screen.getByRole("button", { name: "Switch to dark theme" }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button")); + + expect( + screen.getByRole("button", { name: "Switch to light theme" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/theme-toggle.tsx b/src/components/ui/theme-toggle.tsx new file mode 100644 index 0000000..c1b6549 --- /dev/null +++ b/src/components/ui/theme-toggle.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; + +import { useTheme } from "@/components/theme-provider"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function ThemeToggle({ className }: { className?: string }) { + const { isDark, toggleTheme } = useTheme(); + + return ( + + ); +} + +export { ThemeToggle }; diff --git a/src/components/ui/tooltip.test.tsx b/src/components/ui/tooltip.test.tsx new file mode 100644 index 0000000..4b62fa9 --- /dev/null +++ b/src/components/ui/tooltip.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +function Harness() { + return ( + + + GABC...1234 + + GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQR1234 + + + + ); +} + +describe("Tooltip", () => { + it("shows the full value on hover", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + await user.hover(screen.getByText("GABC...1234")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQR1234", + ); + }); + + it("shows on keyboard focus for icon-only triggers", async () => { + const user = userEvent.setup(); + render(); + + await user.tab(); + + expect(screen.getByText("GABC...1234")).toHaveFocus(); + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..914ce56 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,41 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ + delayDuration = 200, + ...props +}: React.ComponentPropsWithoutRef) { + return ; +} + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); + +TooltipContent.displayName = "TooltipContent"; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };