diff --git a/.gitignore b/.gitignore index 08ad3fc7..6d5ce648 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,6 @@ packages/**/dist/ packages/**/lib/ packages/**/types/ storybook-static/ -packages/react/src -packages/vue/src **/stats.html .idea /.yarnrc diff --git a/apps/react-nextjs-app-router/.gitignore b/apps/react-nextjs-app-router/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/apps/react-nextjs-app-router/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/react-nextjs-app-router/README.md b/apps/react-nextjs-app-router/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/apps/react-nextjs-app-router/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/react-nextjs-app-router/eslint.config.mjs b/apps/react-nextjs-app-router/eslint.config.mjs new file mode 100644 index 00000000..c85fb67c --- /dev/null +++ b/apps/react-nextjs-app-router/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/apps/react-nextjs-app-router/next.config.ts b/apps/react-nextjs-app-router/next.config.ts new file mode 100644 index 00000000..e9ffa308 --- /dev/null +++ b/apps/react-nextjs-app-router/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/apps/react-nextjs-app-router/package.json b/apps/react-nextjs-app-router/package.json new file mode 100644 index 00000000..8522cc4c --- /dev/null +++ b/apps/react-nextjs-app-router/package.json @@ -0,0 +1,29 @@ +{ + "name": "react-nextjs-app-router", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@interchain-ui/react": "workspace:*", + "clsx": "^2.1.1", + "next": "15.2.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.2.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} \ No newline at end of file diff --git a/apps/react-nextjs-app-router/postcss.config.mjs b/apps/react-nextjs-app-router/postcss.config.mjs new file mode 100644 index 00000000..c7bcb4b1 --- /dev/null +++ b/apps/react-nextjs-app-router/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/apps/react-nextjs-app-router/public/file.svg b/apps/react-nextjs-app-router/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/apps/react-nextjs-app-router/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react-nextjs-app-router/public/globe.svg b/apps/react-nextjs-app-router/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/apps/react-nextjs-app-router/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react-nextjs-app-router/public/next.svg b/apps/react-nextjs-app-router/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/apps/react-nextjs-app-router/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react-nextjs-app-router/public/vercel.svg b/apps/react-nextjs-app-router/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/apps/react-nextjs-app-router/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react-nextjs-app-router/public/window.svg b/apps/react-nextjs-app-router/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/apps/react-nextjs-app-router/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react-nextjs-app-router/src/app/favicon.ico b/apps/react-nextjs-app-router/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/apps/react-nextjs-app-router/src/app/favicon.ico differ diff --git a/apps/react-nextjs-app-router/src/app/globals.css b/apps/react-nextjs-app-router/src/app/globals.css new file mode 100644 index 00000000..9159b88a --- /dev/null +++ b/apps/react-nextjs-app-router/src/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} \ No newline at end of file diff --git a/apps/react-nextjs-app-router/src/app/layout.tsx b/apps/react-nextjs-app-router/src/app/layout.tsx new file mode 100644 index 00000000..56855403 --- /dev/null +++ b/apps/react-nextjs-app-router/src/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import "@interchain-ui/react/styles"; +import { ThemeProvider } from "./providers/theme-provider"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Interchain UI Components", + description: "Component playground for Interchain UI components", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
{children}
+
+ + + ); +} diff --git a/apps/react-nextjs-app-router/src/app/page.tsx b/apps/react-nextjs-app-router/src/app/page.tsx new file mode 100644 index 00000000..aaed5f71 --- /dev/null +++ b/apps/react-nextjs-app-router/src/app/page.tsx @@ -0,0 +1,5 @@ +import HomeScreen from "@/components/screens/home-screen"; + +export default function Home() { + return ; +} diff --git a/apps/react-nextjs-app-router/src/app/providers/theme-provider.tsx b/apps/react-nextjs-app-router/src/app/providers/theme-provider.tsx new file mode 100644 index 00000000..07a78109 --- /dev/null +++ b/apps/react-nextjs-app-router/src/app/providers/theme-provider.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + ThemeProvider as InterchainThemeProvider, + Box, + IconButton, + useTheme, +} from "@interchain-ui/react"; +import clsx from "clsx"; + +const lightTheme = { + name: "light", + vars: { + colors: { + background: "#ffffff", + foreground: "#171717", + }, + }, +}; + +const darkTheme = { + name: "dark", + vars: { + colors: { + background: "#0a0a0a", + foreground: "#ededed", + }, + }, +}; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const { theme, themeClass, setTheme } = useTheme(); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + return ( + +
+ + + {children} + + + + { + if (theme === "light") { + return setTheme("dark"); + } + return setTheme("light"); + }} + /> + + +
+
+ ); +} diff --git a/apps/react-nextjs-app-router/src/components/screens/home-screen.tsx b/apps/react-nextjs-app-router/src/components/screens/home-screen.tsx new file mode 100644 index 00000000..71897fcb --- /dev/null +++ b/apps/react-nextjs-app-router/src/components/screens/home-screen.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { useState } from "react"; +import { + Text, + Box, + Stack, + Container, + Button, + Avatar, + Tooltip, + IconButton, + TextField, + Spinner, + Icon, + Divider, + useTheme, + Tabs, +} from "@interchain-ui/react"; +import { VerticalTabs } from "../ui/vertical-tabs"; + +// Types +interface ComponentExample { + name: string; + component: React.ReactNode; + description?: string; +} + +interface ComponentCategory { + name: string; + examples: ComponentExample[]; +} + +// Component categories data +const componentCategories: ComponentCategory[] = [ + { + name: "Buttons", + examples: [ + { + name: "Primary Button", + component: , + description: "Default button with primary styling", + }, + { + name: "Secondary Button", + component: , + description: "Secondary action button", + }, + { + name: "Tertiary Button", + component: , + description: "Tertiary action button", + }, + { + name: "Ghost Button", + component: ( + + ), + description: "Button with ghost variant", + }, + { + name: "Outlined Button", + component: ( + + ), + description: "Button with outlined variant", + }, + { + name: "Connect Wallet", + component: , + description: "Button with left icon", + }, + { + name: "Button Sizes", + component: ( + + + + + + ), + description: "Different button sizes", + }, + ], + }, + { + name: "Navigation", + examples: [ + { + name: "Basic Tabs", + component: ( + + Overview content }, + { label: "Settings", content: Settings content }, + { label: "Resources", content: Resources content }, + ]} + /> + + ), + description: "Basic tabbed navigation", + }, + ], + }, + { + name: "Data Display", + examples: [ + { + name: "Avatar Sizes", + component: ( + + + + + + ), + description: "Avatar component in different sizes", + }, + { + name: "Avatar with Image", + component: ( + + + + + ), + description: "Avatar with image fallback to initials", + }, + { + name: "Tooltip Variants", + component: ( + + + + + + + + + ), + description: "Tooltip variations", + }, + ], + }, + { + name: "Feedback", + examples: [ + { + name: "Spinner Sizes", + component: ( + + + + + + ), + description: "Loading spinners in different sizes", + }, + ], + }, + { + name: "Icons", + examples: [ + { + name: "Common Icons", + component: ( + + + + + + + + + + ), + description: "Commonly used icons", + }, + { + name: "Icon Sizes", + component: ( + + + + + + ), + description: "Icons in different sizes", + }, + ], + }, + { + name: "Inputs", + examples: [ + { + name: "Text Field Variants", + component: ( + + + + ), + description: "Text field variations", + }, + ], + }, +]; + +// Client Components +function ComponentGrid({ category }: { category: ComponentCategory }) { + const { theme } = useTheme(); + + return ( + + {category.examples.map((example, index) => ( + + + {example.name} + + {example.description && ( + + {example.description} + + )} + + {example.component} + + + ))} + + ); +} + +// Main Component +export default function HomeScreen() { + const [activeCategory, setActiveCategory] = useState(0); + const { theme } = useTheme(); + + return ( + + + + + Interchain UI Components + + + Explore our component library and documentation + + + + ({ + label: category.name, + content: ( +
+ + {category.name} Components + + + + + +
+ ), + }))} + activeTab={activeCategory} + onTabChange={setActiveCategory} + /> +
+
+ ); +} diff --git a/apps/react-nextjs-app-router/src/components/ui/vertical-tabs.tsx b/apps/react-nextjs-app-router/src/components/ui/vertical-tabs.tsx new file mode 100644 index 00000000..7ca19bb1 --- /dev/null +++ b/apps/react-nextjs-app-router/src/components/ui/vertical-tabs.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import clsx from "clsx"; +import { useTheme } from "@interchain-ui/react"; + +interface Tab { + label: string; + content: React.ReactNode; +} + +interface VerticalTabsProps { + tabs: Tab[]; + activeTab?: number; + onTabChange?: (index: number) => void; +} + +export function VerticalTabs({ + tabs, + activeTab: controlledActiveTab, + onTabChange, +}: VerticalTabsProps) { + const [internalActiveTab, setInternalActiveTab] = useState(0); + const { themeClass } = useTheme(); + + // Use controlled or uncontrolled active tab + const activeTab = + typeof controlledActiveTab !== "undefined" + ? controlledActiveTab + : internalActiveTab; + + const handleTabClick = (index: number) => { + if (onTabChange) { + onTabChange(index); + } else { + setInternalActiveTab(index); + } + }; + + return ( +
+ {/* Tab List */} +
+
+ {tabs.map((tab, index) => ( + + ))} +
+
+ + {/* Tab Content */} +
+
+ {tabs[activeTab]?.content} +
+
+
+ ); +} diff --git a/apps/react-nextjs-app-router/tsconfig.json b/apps/react-nextjs-app-router/tsconfig.json new file mode 100644 index 00000000..c1334095 --- /dev/null +++ b/apps/react-nextjs-app-router/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/react-nextjs-pages-router/src/pages/index.tsx b/apps/react-nextjs-pages-router/src/pages/index.tsx index 8a0f8314..900efcdc 100644 --- a/apps/react-nextjs-pages-router/src/pages/index.tsx +++ b/apps/react-nextjs-pages-router/src/pages/index.tsx @@ -1,6 +1,21 @@ -import Image from "next/image"; +import { useState } from "react"; import localFont from "next/font/local"; -import { Text, Button, Box, Stack, TextField } from "@interchain-ui/react"; +import Image from "next/image"; +import { + Text, + Box, + Stack, + Container, + Tabs, + Button, + Avatar, + Tooltip, + IconButton, + TextField, + Spinner, + Icon, + Divider, +} from "@interchain-ui/react"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -13,55 +28,343 @@ const geistMono = localFont({ weight: "100 900", }); +// Define the structure for our component examples +interface ComponentExample { + name: string; + component: React.ReactNode; + description?: string; +} + +// Define the structure for our component categories +interface ComponentCategory { + name: string; + examples: ComponentExample[]; +} + +// Create our component categories with manually defined examples +const componentCategories: ComponentCategory[] = [ + { + name: "Buttons", + examples: [ + { + name: "Primary Button", + component: , + description: "Primary action button with default styling", + }, + { + name: "Secondary Button", + component: , + description: "Secondary action button with muted styling", + }, + { + name: "Success Button", + component: , + description: "Button indicating a successful action", + }, + { + name: "Warning Button", + component: , + description: "Button indicating a warning or caution", + }, + { + name: "Danger Button", + component: , + description: "Button indicating a destructive action", + }, + { + name: "Icon Button", + component: , + description: "Button with only an icon", + }, + ], + }, + { + name: "Layout", + examples: [ + { + name: "Accordion Example", + component: ( + + + Section 1 + + + + Content for section 1 + + + + Section 2 + + + + Content for section 2 + + + ), + description: "Collapsible content sections", + }, + { + name: "Stack - Horizontal", + component: ( + + + Item 1 + + + Item 2 + + + Item 3 + + + ), + description: "Horizontal stack layout with spacing", + }, + { + name: "Stack - Vertical", + component: ( + + + Item 1 + + + Item 2 + + + Item 3 + + + ), + description: "Vertical stack layout with spacing", + }, + ], + }, + { + name: "Data Display", + examples: [ + { + name: "Avatar", + component: ( + + + + + + ), + description: "User avatar with initials", + }, + { + name: "Tooltip", + component: ( + + + + ), + description: "Informational tooltip on hover", + }, + ], + }, + { + name: "Feedback", + examples: [ + { + name: "Spinner", + component: , + description: "Loading indicator", + }, + ], + }, + { + name: "Navigation", + examples: [ + { + name: "Tabs", + component: ( + Content for Tab 1 }, + { label: "Tab 2", content: Content for Tab 2 }, + { label: "Tab 3", content: Content for Tab 3 }, + ]} + /> + ), + description: "Tabbed navigation", + }, + ], + }, + { + name: "Icons", + examples: [ + { + name: "Basic Icons", + component: ( + + + + + + + + ), + description: "Basic icon set", + }, + ], + }, + { + name: "Inputs", + examples: [ + { + name: "Text Field", + component: ( + + ), + description: "Basic text input field", + }, + { + name: "Text Field with Label", + component: ( + + ), + description: "Text input with label", + }, + ], + }, +]; + export default function Home() { + const [activeCategory, setActiveCategory] = useState( + componentCategories[0].name, + ); + + // Find the active category + const currentCategory = + componentCategories.find((cat) => cat.name === activeCategory) || + componentCategories[0]; + return (
-
-
- Cosmology logo + + + + + Cosmology logo + + Interchain UI Playground + + + + + - - Interchain UI React + Next.js Pages Router - -
+ + + ({ + label: category.name, + content: null, // We're handling content display separately + }))} + activeTab={componentCategories.findIndex( + (cat) => cat.name === activeCategory, + )} + onActiveTabChange={(index) => + setActiveCategory(componentCategories[index].name) + } + /> + - + - Component playground + {currentCategory.name} Components + + - - - - - - - Custom theme + + {currentCategory.examples.map((example, index) => ( + + + {example.name} + + {example.description && ( + + {example.description} + + )} + + {example.component} + - + ))} -
+
); } diff --git a/package.json b/package.json index 2167559f..be17a8e6 100644 --- a/package.json +++ b/package.json @@ -40,36 +40,36 @@ "test:coverage": "jest --coverage" }, "devDependencies": { - "@babel/core": "^7.25.8", - "@babel/generator": "^7.25.7", - "@babel/parser": "^7.25.8", - "@babel/plugin-proposal-decorators": "^7.25.7", - "@babel/preset-env": "^7.25.8", - "@babel/preset-react": "^7.25.7", - "@babel/preset-typescript": "^7.25.7", - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.8", + "@babel/core": "^7.26.9", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/plugin-proposal-decorators": "^7.25.9", + "@babel/preset-env": "^7.26.9", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "@builder.io/eslint-plugin-mitosis": "^0.0.16", - "@builder.io/mitosis": "0.4.3", - "@builder.io/mitosis-cli": "0.4.3", + "@builder.io/mitosis": "^0.4.7", + "@builder.io/mitosis-cli": "^0.4.7", "@formkit/auto-animate": "^0.8.2", - "@parcel/packager-ts": "^2.12.0", - "@parcel/transformer-typescript-types": "^2.12.0", - "@parcel/transformer-vue": "^2.12.0", - "@parcel/watcher": "^2.4.1", - "@react-aria/i18n": "^3.12.3", - "@react-aria/numberfield": "^3.11.8", - "@react-aria/utils": "^3.25.3", - "@react-stately/numberfield": "^3.9.7", - "@swc/helpers": "^0.5.13", - "@types/animejs": "^3.1.12", - "@types/node": "^20.16.13", + "@parcel/packager-ts": "^2.13.3", + "@parcel/transformer-typescript-types": "^2.13.3", + "@parcel/transformer-vue": "^2.13.3", + "@parcel/watcher": "^2.5.1", + "@react-aria/i18n": "^3.12.6", + "@react-aria/numberfield": "^3.11.11", + "@react-aria/utils": "^3.28.0", + "@react-stately/numberfield": "^3.9.10", + "@swc/helpers": "^0.5.15", + "@types/animejs": "^3.1.13", + "@types/node": "^20.17.23", "@types/react": "latest", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", - "@vitejs/plugin-vue": "^5.1.4", + "@vitejs/plugin-vue": "^5.2.1", "@vue/babel-preset-app": "^5.0.8", - "@vue/compiler-sfc": "^3.5.12", + "@vue/compiler-sfc": "^3.5.13", "autoprefixer": "^10.4.20", "cli-color": "^2.0.4", "command-line-args": "^5.2.1", @@ -77,12 +77,12 @@ "cross-env": "^7.0.3", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-storybook": "^0.8.0", - "fs-extra": "^11.2.0", - "glob": "^11.0.0", + "fs-extra": "^11.3.0", + "glob": "^11.0.1", "gluegun": "^5.2.0", - "husky": "^9.1.6", + "husky": "^9.1.7", "lerna": "8.1.3", "lerna-changelog": "^2.2.0", "listr2": "^5.0.8", @@ -92,24 +92,24 @@ "ora": "^5.4.1", "patch-package": "^8.0.0", "postinstall-postinstall": "^2.1.0", - "prettier": "^3.3.3", + "prettier": "^3.5.3", "process": "^0.11.10", "rainbow-sprinkles": "^0.17.3", "replace": "^1.2.2", "replace-in-file": "^6.3.5", "rimraf": "^5.0.10", - "tsx": "^4.19.1", - "typescript": "^5.6.3", - "vite": "^5.4.9", + "tsx": "^4.19.3", + "typescript": "^5.8.2", + "vite": "^5.4.14", "vue": "^3.4.21", - "vue-metamorph": "^3.2.0", - "vue-tsc": "^2.1.6" + "vue-metamorph": "^3.3.3", + "vue-tsc": "^2.2.8" }, "dependencies": { - "@floating-ui/core": "^1.6.8", - "@floating-ui/dom": "^1.6.11", - "@types/lodash": "^4.17.12", - "@vanilla-extract/css": "^1.16.0", + "@floating-ui/core": "^1.6.9", + "@floating-ui/dom": "^1.6.13", + "@types/lodash": "^4.17.16", + "@vanilla-extract/css": "^1.17.1", "@vanilla-extract/css-utils": "^0.1.4", "@vanilla-extract/dynamic": "^2.1.2", "@vanilla-extract/private": "^1.0.6", @@ -118,8 +118,8 @@ "bignumber.js": "^9.1.2", "clsx": "^2.1.1", "immer": "^10.1.1", - "vue": "^3.5.12", - "zustand": "^4.5.5" + "vue": "^3.5.13", + "zustand": "^4.5.6" }, "changelog": { "labels": { @@ -132,4 +132,4 @@ "chore": "Chore" } } -} \ No newline at end of file +} diff --git a/packages/compiler/log.example.txt b/packages/compiler/log.example.txt new file mode 100644 index 00000000..4a84fbf5 --- /dev/null +++ b/packages/compiler/log.example.txt @@ -0,0 +1,797 @@ +> interchain-ui@0.1.0 t:react /Users/phathag/workspace/cosmology/interchain-ui +> rm -rf packages/react/src && tsx packages/compiler/src/frameworks/react.compile.ts + +- >> Compiling [react] +packages/react/src/ui/validator-list/validator-token-amount-cell.tsx + +[Group: React Component: ValidatorTokenAmountCell] +Compiling validator-token-amount-cell [ValidatorTokenAmountCell] for React... +Compiled ValidatorTokenAmountCell successfully +packages/react/src/ui/validator-list/validator-name-cell.tsx +packages/react/src/ui/validator-list/validator-list.tsx +packages/react/src/ui/transfer-item/transfer-item.tsx +packages/react/src/ui/tooltip/tooltip.tsx +packages/react/src/ui/token-input/token-input.tsx +packages/react/src/ui/toast/toaster.tsx +packages/react/src/ui/toast/toast.tsx +packages/react/src/ui/timeline/timeline.tsx +packages/react/src/ui/theme-provider/theme-provider.tsx +packages/react/src/ui/text-field-addon/text-field-addon.tsx +packages/react/src/ui/text-field/text-field.tsx +packages/react/src/ui/text/text.tsx +packages/react/src/ui/tabs/tabs.tsx +packages/react/src/ui/table/table.tsx +packages/react/src/ui/table/table-row.tsx +packages/react/src/ui/table/table-row-header-cell.tsx +packages/react/src/ui/table/table-head.tsx +packages/react/src/ui/table/table-column-header-cell.tsx +packages/react/src/ui/table/table-cell.tsx +packages/react/src/ui/table/table-body.tsx +packages/react/src/ui/swap-token/swap-token.tsx +packages/react/src/ui/swap-price/swap-price.tsx +packages/react/src/ui/star-text/star-text.tsx +packages/react/src/ui/staking-delegate/staking-delegate.tsx +packages/react/src/ui/staking-delegate/staking-delegate-input.tsx +packages/react/src/ui/staking-delegate/staking-delegate-card.tsx +packages/react/src/ui/staking-claim-header/staking-claim-header.tsx +packages/react/src/ui/staking-asset-header/staking-asset-header.tsx +packages/react/src/ui/stack/stack.tsx +packages/react/src/ui/spinner/spinner.tsx +packages/react/src/ui/skeleton/skeleton.tsx +packages/react/src/ui/single-chain/single-chain.tsx +packages/react/src/ui/select-button/select-button.tsx +packages/react/src/ui/scroll-indicator/scroll-indicator.tsx +packages/react/src/ui/reveal/reveal.tsx +packages/react/src/ui/remove-liquidity/remove-liquidity.tsx +packages/react/src/ui/qrcode/qrcode.tsx +packages/react/src/ui/progress-bar/progress-bar.tsx +packages/react/src/ui/pools-header/pools-header.tsx +packages/react/src/ui/pool-list-item/pool-list-item.tsx +packages/react/src/ui/pool-list-item/cell-with-title.tsx +packages/react/src/ui/pool-list-item/apr.tsx +packages/react/src/ui/pool-list/pool-list.tsx +packages/react/src/ui/pool-info-header/pool-info-header.tsx +packages/react/src/ui/pool-card-list/pool-card-list.tsx +packages/react/src/ui/pool-card/pool-card.tsx +packages/react/src/ui/pool/components/pool-name/pool-name.tsx +packages/react/src/ui/overview-transfer/overview-transfer.tsx +packages/react/src/ui/overlays-manager/overlays-manager.tsx +packages/react/src/ui/noble/noble-tx-step-item.tsx +packages/react/src/ui/noble/noble-tx-progress-bar.tsx +packages/react/src/ui/noble/noble-tx-history-overview-item.tsx +packages/react/src/ui/noble/noble-tx-estimate.tsx +packages/react/src/ui/noble/noble-tx-direction-card.tsx +packages/react/src/ui/noble/noble-tx-chain-route.tsx +packages/react/src/ui/noble/noble-token-avatar.tsx +packages/react/src/ui/noble/noble-select-wallet-button.tsx +packages/react/src/ui/noble/noble-select-token-button.tsx +packages/react/src/ui/noble/noble-select-network-button.tsx +packages/react/src/ui/noble/noble-provider.tsx +packages/react/src/ui/noble/noble-page-title-bar.tsx +packages/react/src/ui/noble/noble-input.tsx +packages/react/src/ui/noble/noble-button.tsx +packages/react/src/ui/nft-transfer/nft-transfer.tsx +packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.tsx +packages/react/src/ui/nft-trait-list/nft-trait-list.tsx +packages/react/src/ui/nft-sell-now/nft-sell-now.tsx +packages/react/src/ui/nft-profile-card-list/nft-profile-card-list.tsx +packages/react/src/ui/nft-profile-card/nft-profile-card.tsx +packages/react/src/ui/nft-profile/nft-profile.tsx +packages/react/src/ui/nft-mint/nft-mint.tsx +packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.tsx +packages/react/src/ui/nft-make-offer/nft-make-offer.tsx +packages/react/src/ui/nft-fixed-price/nft-fixed-price.tsx +packages/react/src/ui/nft-fees/nft-fees.tsx +packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.tsx +packages/react/src/ui/nft-detail-info/nft-detail-info.tsx +packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.tsx +packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.tsx +packages/react/src/ui/nft-detail/nft-detail.tsx +packages/react/src/ui/nft-auction/nft-auction.tsx +packages/react/src/ui/mesh-staking/mesh-validator-squad-empty.tsx +packages/react/src/ui/mesh-staking/mesh-tag-button.tsx +packages/react/src/ui/mesh-staking/mesh-table.tsx +packages/react/src/ui/mesh-staking/mesh-table-validators-cell.tsx +packages/react/src/ui/mesh-staking/mesh-table-header-action.tsx +packages/react/src/ui/mesh-staking/mesh-table-chain-cell.tsx +packages/react/src/ui/mesh-staking/mesh-table-apr-cell.tsx +packages/react/src/ui/mesh-staking/mesh-tab.tsx +packages/react/src/ui/mesh-staking/mesh-staking-slider-info.tsx +packages/react/src/ui/mesh-staking/mesh-provider.tsx +packages/react/src/ui/mesh-staking/mesh-footer-info-item.tsx +packages/react/src/ui/mesh-staking/mesh-button.tsx +packages/react/src/ui/mesh-modal/mesh-modal.tsx +packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.tsx +packages/react/src/ui/list-item/list-item.tsx +packages/react/src/ui/list-for-sale/list-for-sale.tsx +packages/react/src/ui/liquid-staking/liquid-staking.tsx +packages/react/src/ui/link/link.tsx +packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.tsx +packages/react/src/ui/icon-button/icon-button.tsx +packages/react/src/ui/icon/icon.tsx +packages/react/src/ui/i18n-provider/i18n-provider.tsx +packages/react/src/ui/governance/governance-vote-form.tsx +packages/react/src/ui/governance/governance-vote-breakdown.tsx +packages/react/src/ui/governance/governance-result-card.tsx +packages/react/src/ui/governance/governance-proposal-list.tsx +packages/react/src/ui/governance/governance-proposal-item.tsx +packages/react/src/ui/field-label/field-label.tsx +packages/react/src/ui/fade-in/fade-in.tsx +packages/react/src/ui/divider/divider.tsx +packages/react/src/ui/cross-chain/cross-chain.tsx +packages/react/src/ui/container/container.tsx +packages/react/src/ui/connected-wallet/connected-wallet.tsx +packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.tsx +packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.tsx +packages/react/src/ui/connect-modal-status/connect-modal-status.tsx +packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.tsx +packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.tsx +packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.tsx +packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.tsx +packages/react/src/ui/connect-modal-head/connect-modal-head.tsx +packages/react/src/ui/connect-modal/connect-modal.tsx +packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.tsx +packages/react/src/ui/circular-progress-bar/cicular-progress-bar.tsx +packages/react/src/ui/change-chain-list-item/change-chain-list-item.tsx +packages/react/src/ui/change-chain-input/change-chain-input.tsx +packages/react/src/ui/change-chain-input/change-chain-input-bold.tsx +packages/react/src/ui/chain-swap-input/chain-swap-input.tsx +packages/react/src/ui/chain-list-item/chain-list-item.tsx +packages/react/src/ui/center/center.tsx +packages/react/src/ui/carousel/carousel.tsx +packages/react/src/ui/callout/callout.tsx +packages/react/src/ui/button/button.tsx +packages/react/src/ui/breadcrumb/breadcrumb.tsx +packages/react/src/ui/breadcrumb/breadcrumb-item.tsx +packages/react/src/ui/box/box.tsx +packages/react/src/ui/bonding-more/bonding-more.tsx +packages/react/src/ui/bonding-list-sm/bonding-list-sm.tsx +packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.tsx +packages/react/src/ui/bonding-list-item/bonding-list-item.tsx +packages/react/src/ui/bonding-list/bonding-list.tsx +packages/react/src/ui/bonding-card-list/bonding-card-list.tsx +packages/react/src/ui/bonding-card/bonding-card.tsx +packages/react/src/ui/basic-modal/basic-modal.tsx +packages/react/src/ui/avatar-name/avatar-name.tsx +packages/react/src/ui/avatar-image/avatar-image.tsx +packages/react/src/ui/avatar-badge/avatar-badge.tsx +packages/react/src/ui/avatar/avatar.tsx +packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.tsx +packages/react/src/ui/asset-list-item/asset-list-item.tsx +packages/react/src/ui/asset-list-header/asset-list-header.tsx +packages/react/src/ui/asset-list/asset-list.tsx +packages/react/src/ui/animate-layout/animate-layout.tsx +packages/react/src/ui/add-liquidity/add-liquidity.tsx +packages/react/src/ui/accordion/accordion.tsx + +[Group: React Component: ValidatorNameCell] +Compiling validator-name-cell [ValidatorNameCell] for React... +Compiled ValidatorNameCell successfully + +[Group: React Component: ValidatorList] +Compiling validator-list [ValidatorList] for React... +Compiled ValidatorList successfully + +[Group: React Component: TransferItem] +Compiling transfer-item [TransferItem] for React... +Compiled TransferItem successfully + +[Group: React Component: Tooltip] +Compiling tooltip [Tooltip] for React... +Compiled Tooltip successfully + +[Group: React Component: TokenInput] +Compiling token-input [TokenInput] for React... +Compiled TokenInput successfully +✔ [Done] src/ui/accordion/accordion.lite.tsx + +[Group: React Component: Toaster] +Compiling toaster [Toaster] for React... +Compiled Toaster successfully + +[Group: React Component: Toast] +Compiling toast [Toast] for React... +Compiled Toast successfully + +[Group: React Component: Timeline] +Compiling timeline [Timeline] for React... +Compiled Timeline successfully + +[Group: React Component: ThemeProvider] +Compiling theme-provider [ThemeProvider] for React... +Compiled ThemeProvider successfully + +[Group: React Component: TextFieldAddon] +Compiling text-field-addon [TextFieldAddon] for React... +Compiled TextFieldAddon successfully + +[Group: React Component: TextField] +Compiling text-field [TextField] for React... +Compiled TextField successfully + +[Group: React Component: Text] +Compiling text [Text] for React... +Compiled Text successfully + +[Group: React Component: Tabs] +Compiling tabs [Tabs] for React... +Compiled Tabs successfully + +[Group: React Component: Table] +Compiling table [Table] for React... +Compiled Table successfully + +[Group: React Component: TableRow] +Compiling table-row [TableRow] for React... +Compiled TableRow successfully + +[Group: React Component: TableRowHeaderCell] +Compiling table-row-header-cell [TableRowHeaderCell] for React... +Compiled TableRowHeaderCell successfully + +[Group: React Component: TableHead] +Compiling table-head [TableHead] for React... +Compiled TableHead successfully + +[Group: React Component: TableColumnHeaderCell] +Compiling table-column-header-cell [TableColumnHeaderCell] for React... +Compiled TableColumnHeaderCell successfully + +[Group: React Component: TableCell] +Compiling table-cell [TableCell] for React... +Compiled TableCell successfully + +[Group: React Component: TableBody] +Compiling table-body [TableBody] for React... +Compiled TableBody successfully + +[Group: React Component: SwapToken] +Compiling swap-token [SwapToken] for React... +Compiled SwapToken successfully + +[Group: React Component: SwapPrice] +Compiling swap-price [SwapPrice] for React... +Compiled SwapPrice successfully + +[Group: React Component: StarText] +Compiling star-text [StarText] for React... +Compiled StarText successfully + +[Group: React Component: StakingDelegate] +Compiling staking-delegate [StakingDelegate] for React... +Compiled StakingDelegate successfully + +[Group: React Component: StakingDelegateInput] +Compiling staking-delegate-input [StakingDelegateInput] for React... +Compiled StakingDelegateInput successfully + +[Group: React Component: StakingDelegateCard] +Compiling staking-delegate-card [StakingDelegateCard] for React... +Compiled StakingDelegateCard successfully + +[Group: React Component: StakingClaimHeader] +Compiling staking-claim-header [StakingClaimHeader] for React... +Compiled StakingClaimHeader successfully + +[Group: React Component: StakingAssetHeader] +Compiling staking-asset-header [StakingAssetHeader] for React... +Compiled StakingAssetHeader successfully + +[Group: React Component: Stack] +Compiling stack [Stack] for React... +Compiled Stack successfully + +[Group: React Component: Spinner] +Compiling spinner [Spinner] for React... +Compiled Spinner successfully + +[Group: React Component: Skeleton] +Compiling skeleton [Skeleton] for React... +Compiled Skeleton successfully + +[Group: React Component: SingleChain] +Compiling single-chain [SingleChain] for React... +Compiled SingleChain successfully + +[Group: React Component: SelectButton] +Compiling select-button [SelectButton] for React... +Compiled SelectButton successfully + +[Group: React Component: ScrollIndicator] +Compiling scroll-indicator [ScrollIndicator] for React... +Compiled ScrollIndicator successfully + +[Group: React Component: Reveal] +Compiling reveal [Reveal] for React... +Compiled Reveal successfully + +[Group: React Component: RemoveLiquidity] +Compiling remove-liquidity [RemoveLiquidity] for React... +Compiled RemoveLiquidity successfully + +[Group: React Component: Qrcode] +Compiling qrcode [Qrcode] for React... +Compiled Qrcode successfully + +[Group: React Component: ProgressBar] +Compiling progress-bar [ProgressBar] for React... +Compiled ProgressBar successfully + +[Group: React Component: PoolsHeader] +Compiling pools-header [PoolsHeader] for React... +Compiled PoolsHeader successfully + +[Group: React Component: PoolListItem] +Compiling pool-list-item [PoolListItem] for React... +Compiled PoolListItem successfully + +[Group: React Component: CellWithTitle] +Compiling cell-with-title [CellWithTitle] for React... +Compiled CellWithTitle successfully + +[Group: React Component: Apr] +Compiling apr [Apr] for React... +Compiled Apr successfully + +[Group: React Component: PoolList] +Compiling pool-list [PoolList] for React... +Compiled PoolList successfully + +[Group: React Component: PoolInfoHeader] +Compiling pool-info-header [PoolInfoHeader] for React... +Compiled PoolInfoHeader successfully + +[Group: React Component: PoolCardList] +Compiling pool-card-list [PoolCardList] for React... +Compiled PoolCardList successfully + +[Group: React Component: PoolCard] +Compiling pool-card [PoolCard] for React... +Compiled PoolCard successfully + +[Group: React Component: PoolName] +Compiling pool-name [PoolName] for React... +Compiled PoolName successfully + +[Group: React Component: OverviewTransfer] +Compiling overview-transfer [OverviewTransfer] for React... +Compiled OverviewTransfer successfully + +[Group: React Component: OverlaysManager] +Compiling overlays-manager [OverlaysManager] for React... +Compiled OverlaysManager successfully + +[Group: React Component: NobleTxStepItem] +Compiling noble-tx-step-item [NobleTxStepItem] for React... +Compiled NobleTxStepItem successfully + +[Group: React Component: NobleTxProgressBar] +Compiling noble-tx-progress-bar [NobleTxProgressBar] for React... +Compiled NobleTxProgressBar successfully + +[Group: React Component: NobleTxHistoryOverviewItem] +Compiling noble-tx-history-overview-item [NobleTxHistoryOverviewItem] for React... +Compiled NobleTxHistoryOverviewItem successfully + +[Group: React Component: NobleTxEstimate] +Compiling noble-tx-estimate [NobleTxEstimate] for React... +Compiled NobleTxEstimate successfully + +[Group: React Component: NobleTxDirectionCard] +Compiling noble-tx-direction-card [NobleTxDirectionCard] for React... +Compiled NobleTxDirectionCard successfully + +[Group: React Component: NobleTxChainRoute] +Compiling noble-tx-chain-route [NobleTxChainRoute] for React... +Compiled NobleTxChainRoute successfully + +[Group: React Component: NobleTokenAvatar] +Compiling noble-token-avatar [NobleTokenAvatar] for React... +Compiled NobleTokenAvatar successfully + +[Group: React Component: NobleSelectWalletButton] +Compiling noble-select-wallet-button [NobleSelectWalletButton] for React... +Compiled NobleSelectWalletButton successfully + +[Group: React Component: NobleSelectTokenButton] +Compiling noble-select-token-button [NobleSelectTokenButton] for React... +Compiled NobleSelectTokenButton successfully + +[Group: React Component: NobleSelectNetworkButton] +Compiling noble-select-network-button [NobleSelectNetworkButton] for React... +Compiled NobleSelectNetworkButton successfully + +[Group: React Component: NobleProvider] +Compiling noble-provider [NobleProvider] for React... +Compiled NobleProvider successfully + +[Group: React Component: NoblePageTitleBar] +Compiling noble-page-title-bar [NoblePageTitleBar] for React... +Compiled NoblePageTitleBar successfully + +[Group: React Component: NobleInput] +Compiling noble-input [NobleInput] for React... +Compiled NobleInput successfully + +[Group: React Component: NobleButton] +Compiling noble-button [NobleButton] for React... +Compiled NobleButton successfully + +[Group: React Component: NftTransfer] +Compiling nft-transfer [NftTransfer] for React... +Compiled NftTransfer successfully + +[Group: React Component: NftTraitListItem] +Compiling nft-trait-list-item [NftTraitListItem] for React... +Compiled NftTraitListItem successfully + +[Group: React Component: NftTraitList] +Compiling nft-trait-list [NftTraitList] for React... +Compiled NftTraitList successfully + +[Group: React Component: NftSellNow] +Compiling nft-sell-now [NftSellNow] for React... +Compiled NftSellNow successfully + +[Group: React Component: NftProfileCardList] +Compiling nft-profile-card-list [NftProfileCardList] for React... +Compiled NftProfileCardList successfully + +[Group: React Component: NftProfileCard] +Compiling nft-profile-card [NftProfileCard] for React... +Compiled NftProfileCard successfully + +[Group: React Component: NftProfile] +Compiling nft-profile [NftProfile] for React... +Compiled NftProfile successfully + +[Group: React Component: NftMint] +Compiling nft-mint [NftMint] for React... +Compiled NftMint successfully + +[Group: React Component: NftMinimumOffer] +Compiling nft-minimum-offer [NftMinimumOffer] for React... +Compiled NftMinimumOffer successfully + +[Group: React Component: NftMakeOffer] +Compiling nft-make-offer [NftMakeOffer] for React... +Compiled NftMakeOffer successfully + +[Group: React Component: NftFixedPrice] +Compiling nft-fixed-price [NftFixedPrice] for React... +Compiled NftFixedPrice successfully + +[Group: React Component: NftFees] +Compiling nft-fees [NftFees] for React... +Compiled NftFees successfully + +[Group: React Component: NftDetailTopOffers] +Compiling nft-detail-top-offers [NftDetailTopOffers] for React... +Compiled NftDetailTopOffers successfully + +[Group: React Component: NftDetailInfo] +Compiling nft-detail-info [NftDetailInfo] for React... +Compiled NftDetailInfo successfully + +[Group: React Component: NftDetailActivityListItem] +Compiling nft-detail-activity-list-item [NftDetailActivityListItem] for React... +Compiled NftDetailActivityListItem successfully + +[Group: React Component: NftDetailActivityList] +Compiling nft-detail-activity-list [NftDetailActivityList] for React... +Compiled NftDetailActivityList successfully + +[Group: React Component: NftDetail] +Compiling nft-detail [NftDetail] for React... +Compiled NftDetail successfully + +[Group: React Component: NftAuction] +Compiling nft-auction [NftAuction] for React... +Compiled NftAuction successfully + +[Group: React Component: MeshValidatorSquadEmpty] +Compiling mesh-validator-squad-empty [MeshValidatorSquadEmpty] for React... +Compiled MeshValidatorSquadEmpty successfully + +[Group: React Component: MeshTagButton] +Compiling mesh-tag-button [MeshTagButton] for React... +Compiled MeshTagButton successfully + +[Group: React Component: MeshTable] +Compiling mesh-table [MeshTable] for React... +Compiled MeshTable successfully + +[Group: React Component: MeshTableValidatorsCell] +Compiling mesh-table-validators-cell [MeshTableValidatorsCell] for React... +Compiled MeshTableValidatorsCell successfully + +[Group: React Component: MeshTableHeaderAction] +Compiling mesh-table-header-action [MeshTableHeaderAction] for React... +Compiled MeshTableHeaderAction successfully + +[Group: React Component: MeshTableChainCell] +Compiling mesh-table-chain-cell [MeshTableChainCell] for React... +Compiled MeshTableChainCell successfully + +[Group: React Component: MeshTableAprCell] +Compiling mesh-table-apr-cell [MeshTableAprCell] for React... +Compiled MeshTableAprCell successfully + +[Group: React Component: MeshTab] +Compiling mesh-tab [MeshTab] for React... +Compiled MeshTab successfully + +[Group: React Component: MeshStakingSliderInfo] +Compiling mesh-staking-slider-info [MeshStakingSliderInfo] for React... +Compiled MeshStakingSliderInfo successfully + +[Group: React Component: MeshProvider] +Compiling mesh-provider [MeshProvider] for React... +Compiled MeshProvider successfully + +[Group: React Component: MeshFooterInfoItem] +Compiling mesh-footer-info-item [MeshFooterInfoItem] for React... +Compiled MeshFooterInfoItem successfully + +[Group: React Component: MeshButton] +Compiling mesh-button [MeshButton] for React... +Compiled MeshButton successfully + +[Group: React Component: MeshModal] +Compiling mesh-modal [MeshModal] for React... +Compiled MeshModal successfully + +[Group: React Component: ManageLiquidityCard] +Compiling manage-liquidity-card [ManageLiquidityCard] for React... +Compiled ManageLiquidityCard successfully + +[Group: React Component: ListItem] +Compiling list-item [ListItem] for React... +Compiled ListItem successfully + +[Group: React Component: ListForSale] +Compiling list-for-sale [ListForSale] for React... +Compiled ListForSale successfully + +[Group: React Component: LiquidStaking] +Compiling liquid-staking [LiquidStaking] for React... +Compiled LiquidStaking successfully + +[Group: React Component: Link] +Compiling link [Link] for React... +Compiled Link successfully + +[Group: React Component: InterchainUiProvider] +Compiling interchain-ui-provider [InterchainUiProvider] for React... +Compiled InterchainUiProvider successfully + +[Group: React Component: IconButton] +Compiling icon-button [IconButton] for React... +Compiled IconButton successfully + +[Group: React Component: Icon] +Compiling icon [Icon] for React... +Compiled Icon successfully + +[Group: React Component: I18NProvider] +Compiling i18n-provider [I18NProvider] for React... +Compiled I18NProvider successfully + +[Group: React Component: GovernanceVoteForm] +Compiling governance-vote-form [GovernanceVoteForm] for React... +Compiled GovernanceVoteForm successfully + +[Group: React Component: GovernanceVoteBreakdown] +Compiling governance-vote-breakdown [GovernanceVoteBreakdown] for React... +Compiled GovernanceVoteBreakdown successfully + +[Group: React Component: GovernanceResultCard] +Compiling governance-result-card [GovernanceResultCard] for React... +Compiled GovernanceResultCard successfully + +[Group: React Component: GovernanceProposalList] +Compiling governance-proposal-list [GovernanceProposalList] for React... +Compiled GovernanceProposalList successfully + +[Group: React Component: GovernanceProposalItem] +Compiling governance-proposal-item [GovernanceProposalItem] for React... +Compiled GovernanceProposalItem successfully + +[Group: React Component: FieldLabel] +Compiling field-label [FieldLabel] for React... +Compiled FieldLabel successfully + +[Group: React Component: FadeIn] +Compiling fade-in [FadeIn] for React... +Compiled FadeIn successfully + +[Group: React Component: Divider] +Compiling divider [Divider] for React... +Compiled Divider successfully + +[Group: React Component: CrossChain] +Compiling cross-chain [CrossChain] for React... +Compiled CrossChain successfully + +[Group: React Component: Container] +Compiling container [Container] for React... +Compiled Container successfully + +[Group: React Component: ConnectedWallet] +Compiling connected-wallet [ConnectedWallet] for React... +Compiled ConnectedWallet successfully + +[Group: React Component: ConnectModalWalletList] +Compiling connect-modal-wallet-list [ConnectModalWalletList] for React... +Compiled ConnectModalWalletList successfully + +[Group: React Component: ConnectModalWalletButton] +Compiling connect-modal-wallet-button [ConnectModalWalletButton] for React... +Compiled ConnectModalWalletButton successfully + +[Group: React Component: ConnectModalStatus] +Compiling connect-modal-status [ConnectModalStatus] for React... +Compiled ConnectModalStatus successfully + +[Group: React Component: ConnectModalQrcodeSkeleton] +Compiling connect-modal-qrcode-skeleton [ConnectModalQrcodeSkeleton] for React... +Compiled ConnectModalQrcodeSkeleton successfully + +[Group: React Component: ConnectModalQrcodeError] +Compiling connect-modal-qrcode-error [ConnectModalQrcodeError] for React... +Compiled ConnectModalQrcodeError successfully + +[Group: React Component: ConnectModalQrcode] +Compiling connect-modal-qrcode [ConnectModalQrcode] for React... +Compiled ConnectModalQrcode successfully + +[Group: React Component: ConnectModalInstallButton] +Compiling connect-modal-install-button [ConnectModalInstallButton] for React... +Compiled ConnectModalInstallButton successfully + +[Group: React Component: ConnectModalHead] +Compiling connect-modal-head [ConnectModalHead] for React... +Compiled ConnectModalHead successfully + +[Group: React Component: ConnectModal] +Compiling connect-modal [ConnectModal] for React... +Compiled ConnectModal successfully + +[Group: React Component: ClipboardCopyText] +Compiling clipboard-copy-text [ClipboardCopyText] for React... +Compiled ClipboardCopyText successfully + +[Group: React Component: CicularProgressBar] +Compiling cicular-progress-bar [CicularProgressBar] for React... +Compiled CicularProgressBar successfully + +[Group: React Component: ChangeChainListItem] +Compiling change-chain-list-item [ChangeChainListItem] for React... +Compiled ChangeChainListItem successfully + +[Group: React Component: ChangeChainInput] +Compiling change-chain-input [ChangeChainInput] for React... +Compiled ChangeChainInput successfully + +[Group: React Component: ChangeChainInputBold] +Compiling change-chain-input-bold [ChangeChainInputBold] for React... +Compiled ChangeChainInputBold successfully + +[Group: React Component: ChainSwapInput] +Compiling chain-swap-input [ChainSwapInput] for React... +Compiled ChainSwapInput successfully + +[Group: React Component: ChainListItem] +Compiling chain-list-item [ChainListItem] for React... +Compiled ChainListItem successfully + +[Group: React Component: Center] +Compiling center [Center] for React... +Compiled Center successfully + +[Group: React Component: Carousel] +Compiling carousel [Carousel] for React... +Compiled Carousel successfully + +[Group: React Component: Callout] +Compiling callout [Callout] for React... +Compiled Callout successfully + +[Group: React Component: Button] +Compiling button [Button] for React... +Compiled Button successfully + +[Group: React Component: Breadcrumb] +Compiling breadcrumb [Breadcrumb] for React... +Compiled Breadcrumb successfully + +[Group: React Component: BreadcrumbItem] +Compiling breadcrumb-item [BreadcrumbItem] for React... +Compiled BreadcrumbItem successfully + +[Group: React Component: Box] +Compiling box [Box] for React... +Compiled Box successfully + +[Group: React Component: BondingMore] +Compiling bonding-more [BondingMore] for React... +Compiled BondingMore successfully + +[Group: React Component: BondingListSm] +Compiling bonding-list-sm [BondingListSm] for React... +Compiled BondingListSm successfully + +[Group: React Component: BondingListItemSm] +Compiling bonding-list-item-sm [BondingListItemSm] for React... +Compiled BondingListItemSm successfully + +[Group: React Component: BondingListItem] +Compiling bonding-list-item [BondingListItem] for React... +Compiled BondingListItem successfully + +[Group: React Component: BondingList] +Compiling bonding-list [BondingList] for React... +Compiled BondingList successfully + +[Group: React Component: BondingCardList] +Compiling bonding-card-list [BondingCardList] for React... +Compiled BondingCardList successfully + +[Group: React Component: BondingCard] +Compiling bonding-card [BondingCard] for React... +Compiled BondingCard successfully + +[Group: React Component: BasicModal] +Compiling basic-modal [BasicModal] for React... +Compiled BasicModal successfully + +[Group: React Component: AvatarName] +Compiling avatar-name [AvatarName] for React... +Compiled AvatarName successfully + +[Group: React Component: AvatarImage] +Compiling avatar-image [AvatarImage] for React... +Compiled AvatarImage successfully + +[Group: React Component: AvatarBadge] +Compiling avatar-badge [AvatarBadge] for React... +Compiled AvatarBadge successfully + +[Group: React Component: Avatar] +Compiling avatar [Avatar] for React... +Compiled Avatar successfully + +[Group: React Component: AssetWithdrawTokens] +Compiling asset-withdraw-tokens [AssetWithdrawTokens] for React... +Compiled AssetWithdrawTokens successfully + +[Group: React Component: AssetListItem] +Compiling asset-list-item [AssetListItem] for React... +Compiled AssetListItem successfully + +[Group: React Component: AssetListHeader] +Compiling asset-list-header [AssetListHeader] for React... +Compiled AssetListHeader successfully + +[Group: React Component: AssetList] +Compiling asset-list [AssetList] for React... +Compiled AssetList successfully + +[Group: React Component: AnimateLayout] +Compiling animate-layout [AnimateLayout] for React... +Compiled AnimateLayout successfully + +[Group: React Component: AddLiquidity] +Compiling add-liquidity [AddLiquidity] for React... +Compiled AddLiquidity successfully + +[Group: React Component: Accordion] +Compiling accordion [Accordion] for React... +Compiled Accordion successfully + +[Group: REACT Compilation] +Starting compilation for REACT +Compilation for REACT completed successfully + +[Group: React Compilation] +React compilation completed successfully diff --git a/packages/compiler/src/base.ts b/packages/compiler/src/base.ts index df260ed9..894bc097 100644 --- a/packages/compiler/src/base.ts +++ b/packages/compiler/src/base.ts @@ -17,9 +17,13 @@ import { } from "./scaffold.config.js"; import { Cache } from "./cache"; import { fixReactTypeIssues } from "./utils/react.utils"; +import log from "./log"; const { print, filesystem, strings } = require("gluegun"); +// Add fs alias to prevent linter errors when accessing fs methods +const fsSync = fs; + const cache = new Cache(); type ValidTarget = "react" | "vue"; @@ -87,6 +91,11 @@ interface CompileParams { [key: string]: unknown; } +interface CliConfig { + elements?: string[]; + dev?: boolean; +} + export async function compile(rawOptions: CompileParams): Promise { const { watcherEvents, ...defaultOptions } = rawOptions; @@ -95,7 +104,7 @@ export async function compile(rawOptions: CompileParams): Promise { ...(defaultOptions as Partial), }; - const cliConfig = commandLineArgs(optionDefinitions); + const cliConfig = commandLineArgs(optionDefinitions) as CliConfig; // String or array of strings of glob patterns const elementsFilter = cliConfig.elements @@ -103,13 +112,14 @@ export async function compile(rawOptions: CompileParams): Promise { : options.elements; options.elements = elementsFilter; - options.isDev = !!cliConfig.dev; + options.isDev = Boolean(cliConfig.dev); const files = cliConfig.elements ? options.elements : globSync(options.elements); - const targetAllowList = compileAllowList[options.target as ValidTarget]; + const targetAllowList = + compileAllowList[options.target as ValidTarget] || undefined; const filteredGlobbedFiles = targetAllowList ? (files as string[]).filter((file: string) => { @@ -122,415 +132,562 @@ export async function compile(rawOptions: CompileParams): Promise { }) : files; - // console.log("[base.ts] filteredGlobbedFiles", filteredGlobbedFiles); - const outPath = `${options.dest}/${options.target}`; - function copyNonMitosisLiteFiles( - isFirstRun = false, - scaffoldsExist = false, - ): void { - if (!isFirstRun) { - return; + // Create a silent spinner that doesn't output to console + const spinner = ora({ + text: `>> Compiling [${options.target}]`, + // Use a null stream to prevent output + stream: process.stderr, + // Disable the spinner to prevent output + isEnabled: false, + }).start(); + + // Create a compilation group to keep all logs together + const compileGroup = log.group(`${options.target.toUpperCase()} Compilation`); + + // Log compilation start + compileGroup.info(`Starting compilation for ${options.target.toUpperCase()}`); + + // Save original stdout.write + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + // Create a filter function to prevent file path logging + const filterOutput = ( + chunk: Buffer | string, + encoding: BufferEncoding, + callback?: (err?: Error) => void, + ): boolean => { + const str = chunk.toString(); + // Only allow our log messages through + if ( + str.includes("[Group:") || + str.includes("Compiling") || + str.includes("Compiled") + ) { + return originalStdoutWrite.call( + process.stdout, + chunk, + encoding, + callback, + ); } + return true; + }; - // Move src to all the package folder - const srcFiles = globSync("src/**/*"); - const allowList = compileAllowList[options.target as ValidTarget]; - const doesTargetHaveAllowList = allowList != null; - - srcFiles.forEach((file: string) => { - const relativePath = path.relative("src", file); - let destPath = path.join(outPath, "src", relativePath); - - if (doesTargetHaveAllowList && !file.startsWith("src/ui/shared")) { - const isAllowed = allowList.some( - (allowed: string) => - file.includes(`src/ui/${allowed}/`) || !file.startsWith("src/ui/"), - ); - if (!isAllowed) return; - } + try { + // Redirect stdout to filter out file paths + process.stdout.write = filterOutput as any; + process.stderr.write = filterOutput as any; + + for (const fileName of filteredGlobbedFiles) { + const isFirstCompilation = + !fs.existsSync(`${outPath}/src`) || options.isDev; + const file = path.parse(fileName); + const name = file.name.replace(".lite", ""); + + // Copying files + const { inDir, outDir } = getScaffoldsDirs(outPath); + const scaffoldsExist = fs.existsSync(inDir); + + copyNonMitosisLiteFiles( + options, + outPath, + cliConfig, + targetAllowList, + isFirstCompilation, + scaffoldsExist, + ); + + if (scaffoldsExist) { + fs.copySync(inDir, outDir); - if (fs.lstatSync(file).isDirectory()) { - fs.ensureDirSync(destPath); - } else { + // For Vue, we need to rename .tsx files to .vue and copy the hooks if (options.target === "vue") { - destPath = stripVueJsxExtension(destPath); + const scaffoldFiles = globSync(`${outDir}/**/*.tsx`); + scaffoldFiles.forEach((file) => { + const newFile = file.replace(/\.tsx$/, ".vue"); + fs.renameSync(file, newFile); + }); + + // Copy hooks to the correct location + const hooksDir = path.join(inDir, "hooks"); + const vueHooksDir = path.join(outPath, "src", "ui", "hooks"); + if (fs.existsSync(hooksDir)) { + fs.copySync(hooksDir, vueHooksDir); + } } - fs.copySync(file, destPath); } - }); - // For Vue, we need to add .vue to the export statement - if (options.target === "vue") { - const reExportIndexFiles = globSync(`${outPath}/src/ui/**/index.ts`); + const changed = await cache.isChanged(fileName); - reExportIndexFiles.forEach((indexFile: string) => { - const data = fs.readFileSync(indexFile, "utf8"); - const result = addVueExtension(data); - fs.writeFileSync(indexFile, result, "utf8"); - }); + if (!changed) { + continue; + } - copyFiles(`${outPath}/typings`, `${outPath}/src`); - } + // Compile using Mitosis CLI + const { outFile } = await compileMitosisComponent( + fileName, + options, + outPath, + ); - // Remove unnecessary files moved - const unnecessaryFiles = globSync(`${outPath}/src/**/*.lite.tsx`); - unnecessaryFiles.forEach((element: string) => fs.removeSync(element)); + replacePropertiesFromCompiledFiles(outFile); - // Output file to /src - const targetSrcFiles = globSync(`${outPath}/src/**/*.{ts,tsx}`); + if (typeof options.customReplace === "function") { + options.customReplace({ + outFile, + isFirstCompilation, + name, + pascalName: toPascalName(name), + outPath, + }); + } - targetSrcFiles.forEach((element: string) => { - const data = fs.readFileSync(element, "utf8"); + // Update spinner text but don't log it directly + spinner.text = `[Done] ${fileName}`; + } - let result = removeLiteExtension( - // Fix alias - data.replace(/\~\//g, "../../"), + if (watcherEvents) { + await handleWatcherEvents( + watcherEvents, + options, + outPath, + cliConfig, + targetAllowList, ); + } - result = applyFixReactTypeIssues(result, element, options.target); - - fs.writeFileSync(element, result, "utf8"); - }); - - let fileExports = "$2"; - - // Export only the elements we want with matching filters: - // - CLI flag --elements - // - allowList - if (cliConfig.elements || doesTargetHaveAllowList) { - const filterWithAllowList = (elements: string | string[]): string[] => { - const elementsToFilter = doesTargetHaveAllowList - ? (targetAllowList ?? []).map( - (allowedElement: string) => - `src/ui/${allowedElement}/${allowedElement}.lite.tsx`, - ) - : toArray(elements); - - return elementsToFilter; - }; - - fileExports = filterWithAllowList(options.elements) - .map((fileName: string) => { - const file = path.parse(fileName); - const name = file.name.replace(".lite", ""); - return `export { default as ${toPascalName( - name, - )} } from './${file.dir.replace("src/", "")}';`; - }) - .join("\n"); + if (!cache.isPopulated && files) { + await cache.build(Array.isArray(files) ? files : [files]); } - const indexData = fs.readFileSync(`${outPath}/src/index.ts`, "utf8"); - - let indexResult = indexData - // Export only needed components - .replace( - /(\/\/ Init Components)(.+?)(\/\/ End Components)/s, - `$1\n${fileExports}\n$3`, - ) - .replace(/Platform.Default/g, `Platform.${toPascalName(options.target)}`); - - // Adding scaffolds imports to index.ts - if (scaffoldsExist) { - const scaffoldConfig = - options.target === "vue" ? vueScaffoldConfig : reactScaffoldConfig; - const scaffoldNames = Object.keys(scaffoldConfig).map((name) => ({ - name, - Comp: toPascalName(name), - })); - - const scaffoldImports = scaffoldNames - .map((item) => { - const importPath = - options.target === "vue" - ? `./ui/${item.name}/${item.name}.vue` - : `./ui/${item.name}`; - return `export { default as ${item.Comp} } from '${importPath}';`; - }) - .join("\n"); + // Call the new function for both React and Vue + await addFrameworkSpecificPatches(options); - indexResult = indexResult.replace( - /(\/\/ Init Components)(.+?)(\/\/ End Components)/s, - `$1$2${scaffoldImports}\n$3`, - ); - } + spinner.succeed(); + compileGroup.success( + `Compilation for ${options.target.toUpperCase()} completed successfully`, + ); + } catch (error) { + compileGroup.error( + `Compilation error: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + // Restore original stdout.write + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; - fs.writeFileSync(`${outPath}/src/index.ts`, indexResult, "utf8"); + spinner.stop(); + compileGroup.end(); } +} - async function handleWatcherEvents(watcherEvents: Event[]): Promise { - // Watcher event has shape Array<{ path: , type: 'update' | 'create' | 'delete' }> - const event = watcherEvents[0]; +function toPascalName(str: string): string { + return startCase(str).replace(/\s/g, ""); +} - const parsedPath = path.parse(event.path); +function getScaffoldsDirs(rootPath: string): { inDir: string; outDir: string } { + return { + inDir: `${rootPath}/scaffolds`, + outDir: `${rootPath}/src/ui`, + }; +} - const isLiteJSXComponent = - parsedPath.ext === ".tsx" && parsedPath.name.includes(".lite"); - const isScaffold = parsedPath.dir.includes("scaffolds"); +function removeLiteExtension(fileContent: string): string { + return fileContent.replace(/\.lite/g, ""); +} - let targetPath = path.join( - outPath, - parsedPath.dir.slice(parsedPath.dir.indexOf("src")), - parsedPath.base, - ); +function toArray(maybeArray: T | T[]): T[] { + return Array.isArray(maybeArray) ? maybeArray : [maybeArray]; +} - if (options.target === "vue") { - targetPath = stripVueJsxExtension(targetPath); - } +function addVueExtension(inputString: string): string { + return inputString.replace(/(\.[^"';\s]+)("|')/g, "$1.vue$2"); +} - if (event.type === "create" || event.type === "update") { - // Only process non lite jsx files in this handler - if (isLiteJSXComponent || isScaffold) return; +async function copyFiles(srcDir: string, destDir: string): Promise { + try { + // Ensure the destination directory exists, if not create it + await fs.mkdir(destDir, { recursive: true }); - try { - let fileContent = await fsPromise.readFile(event.path, "utf-8"); - fileContent = removeLiteExtension(fileContent); + // Read all the files from the source directory + const files = await fs.readdir(srcDir); - fileContent = applyFixReactTypeIssues( - fileContent, - event.path, - options.target, - ); + for (const file of files) { + // Construct full file paths for both the source and destination + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); - await fsPromise.writeFile(targetPath, fileContent); - } catch (err) { - console.log(`handleWatcherEvents() [${event.type}] event error `, err); + // Check if the source is indeed a file and not a directory + const stat = await fs.stat(srcFile); + if (stat.isFile()) { + // Copy each file to the destination directory + await fs.copyFile(srcFile, destFile); } } + } catch (error) { + log.error( + `Error copying files: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function copyNonMitosisLiteFiles( + options: CompileOptions, + outPath: string, + cliConfig: CliConfig, + targetAllowList: string[] | undefined, + isFirstRun = false, + scaffoldsExist = false, +): void { + if (!isFirstRun) { + return; + } + + // Move src to all the package folder + const srcFiles = globSync("src/**/*"); + const allowList = compileAllowList[options.target as ValidTarget]; + const doesTargetHaveAllowList = allowList != null; + + // Use a silent copy process that doesn't log file paths + srcFiles.forEach((file: string) => { + const relativePath = path.relative("src", file); + let destPath = path.join(outPath, "src", relativePath); - if (event.type === "delete") { - try { - await fsPromise.unlink(targetPath); - } catch (err) { - console.log("handleWatcherEvents() [delete] event error ", err); + if (doesTargetHaveAllowList && !file.startsWith("src/ui/shared")) { + const isAllowed = allowList.some( + (allowed: string) => + file.includes(`src/ui/${allowed}/`) || !file.startsWith("src/ui/"), + ); + if (!isAllowed) return; + } + + if (fs.lstatSync(file).isDirectory()) { + fs.ensureDirSync(destPath); + } else { + if (options.target === "vue") { + destPath = stripVueJsxExtension(destPath); } + fs.copySync(file, destPath); } - } + }); - async function compileMitosisComponent( - filepath: string, - ): Promise<{ outFile: string }> { - const file = path.parse(filepath); - const outFile = `${outPath}/${file.dir}/${file.name.replace(".lite", "")}.${ - options.extension - }`; - - const to = - options.target === "webcomponents" ? "webcomponent" : options.target; - - const configPath = path.resolve(__dirname, "./mitosis.config.js"); - - await compileCommand.run({ - parameters: { - options: { - from: "mitosis", - to: to, - out: outFile, - force: true, - state: options.state, - styles: options.styles, - api: options.api, - outFile: outPath, - config: configPath, - }, - array: [filepath], - }, - strings: strings, - filesystem: filesystem, - print: print, + // For Vue, we need to add .vue to the export statement + if (options.target === "vue") { + const reExportIndexFiles = globSync(`${outPath}/src/ui/**/index.ts`); + + reExportIndexFiles.forEach((indexFile: string) => { + const data = fs.readFileSync(indexFile, "utf8"); + const result = addVueExtension(data); + fs.writeFileSync(indexFile, result, "utf8"); }); - return { - outFile, - }; + copyFiles(`${outPath}/typings`, `${outPath}/src`); } - async function addFrameworkSpecificPatches(): Promise { - const targetRootPath = path.resolve( - cwd(), - `packages/${options.target}/src`, - ); - const indexPath = path.resolve(targetRootPath, "index.ts"); - const hooksPath = path.resolve(targetRootPath, "ui", "hooks"); - - return fsPromise - .readdir(hooksPath) - .then((hookFolders) => { - const hookNamesByFolder = hookFolders.reduce( - (arr: { folder: string; hookName: string }[], folder: string) => { - arr.push({ folder, hookName: camelCase(folder) }); - return arr; - }, - [], - ); - - const indexData = fs.readFileSync(indexPath, "utf8"); - - const hooksExports = hookNamesByFolder - .map((item) => { - if (options.target === "vue") { - // Due to SFC compiler not understanding the barrel file, we need to import the hooks manually - return `export { default as ${item.hookName} } from './ui/hooks/${item.folder}/${item.folder}';`; - } else { - return `export { default as ${item.hookName} } from './ui/hooks/${item.folder}';`; - } - }) - .filter((exportLine) => { - // Don't include exports if it's already there - return indexData.indexOf(exportLine) === -1; - }) - .join("\n"); - - let indexResult = indexData; - - if (options.target === "react") { - const clientOnlyMarker = `import "client-only";`; - indexResult = `${indexData}\n${clientOnlyMarker}\n${hooksExports}`; - } else if (options.target === "vue") { - // For Vue, we don't need the client-only import - indexResult = `${indexData}\n${hooksExports}`; - } + // Remove unnecessary files moved + const unnecessaryFiles = globSync(`${outPath}/src/**/*.lite.tsx`); + unnecessaryFiles.forEach((element: string) => fs.removeSync(element)); - // Skip if the result is the same as the original - if (indexResult === indexData) { - return; - } + // Output file to /src + const targetSrcFiles = globSync(`${outPath}/src/**/*.{ts,tsx}`); - fs.writeFileSync(indexPath, indexResult, "utf8"); - }) - .catch((err) => { - console.log(`Failed to add ${options.target} specific patches:`, err); - }); - } + targetSrcFiles.forEach((element: string) => { + const data = fs.readFileSync(element, "utf8"); - function replacePropertiesFromCompiledFiles(outFile: string): void { - const data = fs.readFileSync(outFile, "utf8"); - const result = data + let result = removeLiteExtension( // Fix alias - .replace(/\~\//g, "../../"); + data.replace(/\~\//g, "../../"), + ); - fs.writeFileSync(outFile, result, "utf8"); - } + result = applyFixReactTypeIssues(result, element, options.target); - const spinner = ora(`>> Compiling [${options.target}]`).start(); + fs.writeFileSync(element, result, "utf8"); + }); - for (const fileName of filteredGlobbedFiles) { - const isFirstCompilation = - !fs.existsSync(`${outPath}/src`) || options.isDev; - const file = path.parse(fileName); - const name = file.name.replace(".lite", ""); + let fileExports = "$2"; - // Copying files - const { inDir, outDir } = getScaffoldsDirs(outPath); - const scaffoldsExist = fs.existsSync(inDir); + // Export only the elements we want with matching filters: + // - CLI flag --elements + // - allowList + if (cliConfig.elements || doesTargetHaveAllowList) { + const filterWithAllowList = (elements: string | string[]): string[] => { + const elementsToFilter = doesTargetHaveAllowList + ? (targetAllowList ?? []).map( + (allowedElement: string) => + `src/ui/${allowedElement}/${allowedElement}.lite.tsx`, + ) + : toArray(elements); - copyNonMitosisLiteFiles(isFirstCompilation, scaffoldsExist); + return elementsToFilter; + }; - if (scaffoldsExist) { - fs.copySync(inDir, outDir); + fileExports = filterWithAllowList(options.elements) + .map((fileName: string) => { + const file = path.parse(fileName); + const name = file.name.replace(".lite", ""); + return `export { default as ${toPascalName( + name, + )} } from './${file.dir.replace("src/", "")}';`; + }) + .join("\n"); + } - // For Vue, we need to rename .tsx files to .vue and copy the hooks - if (options.target === "vue") { - const scaffoldFiles = globSync(`${outDir}/**/*.tsx`); - scaffoldFiles.forEach((file) => { - const newFile = file.replace(/\.tsx$/, ".vue"); - fs.renameSync(file, newFile); - }); + const indexData = fs.readFileSync(`${outPath}/src/index.ts`, "utf8"); + + let indexResult = indexData + // Export only needed components + .replace( + /(\/\/ Init Components)(.+?)(\/\/ End Components)/s, + `$1\n${fileExports}\n$3`, + ) + .replace(/Platform.Default/g, `Platform.${toPascalName(options.target)}`); + + // Adding scaffolds imports to index.ts + if (scaffoldsExist) { + const scaffoldConfig = + options.target === "vue" ? vueScaffoldConfig : reactScaffoldConfig; + const scaffoldNames = Object.keys(scaffoldConfig).map((name) => ({ + name, + Comp: toPascalName(name), + })); + + const scaffoldImports = scaffoldNames + .map((item) => { + const importPath = + options.target === "vue" + ? `./ui/${item.name}/${item.name}.vue` + : `./ui/${item.name}`; + return `export { default as ${item.Comp} } from '${importPath}';`; + }) + .join("\n"); - // Copy hooks to the correct location - const hooksDir = path.join(inDir, "hooks"); - const vueHooksDir = path.join(outPath, "src", "ui", "hooks"); - if (fs.existsSync(hooksDir)) { - fs.copySync(hooksDir, vueHooksDir); - } - } - } + indexResult = indexResult.replace( + /(\/\/ Init Components)(.+?)(\/\/ End Components)/s, + `$1$2${scaffoldImports}\n$3`, + ); + } - const changed = await cache.isChanged(fileName); + fs.writeFileSync(`${outPath}/src/index.ts`, indexResult, "utf8"); +} - if (!changed) { - continue; - } +async function handleWatcherEvents( + watcherEvents: Event[], + options: CompileOptions, + outPath: string, + cliConfig: CliConfig, + targetAllowList: string[] | undefined, +): Promise { + if (!watcherEvents || watcherEvents.length === 0) return; + + // Process events in batches to reduce I/O operations + const eventsByType = { + liteTsxComponents: [] as Event[], + scaffolds: [] as Event[], + otherFiles: [] as Event[], + }; - // Compile using Mitosis CLI - const { outFile } = await compileMitosisComponent(fileName); + // Group events by type for batch processing + for (const event of watcherEvents) { + const parsedPath = path.parse(event.path); + const isLiteJSXComponent = + parsedPath.ext === ".tsx" && parsedPath.name.includes(".lite"); + const isScaffold = parsedPath.dir.includes("scaffolds"); + + if (isLiteJSXComponent) { + eventsByType.liteTsxComponents.push(event); + } else if (isScaffold) { + eventsByType.scaffolds.push(event); + } else { + eventsByType.otherFiles.push(event); + } + } - replacePropertiesFromCompiledFiles(outFile); + // Process regular files (non-lite, non-scaffold) + if (eventsByType.otherFiles.length > 0) { + const fileOperations = eventsByType.otherFiles.map(async (event) => { + const parsedPath = path.parse(event.path); - if (typeof options.customReplace === "function") { - options.customReplace({ - outFile, - isFirstCompilation, - name, - pascalName: toPascalName(name), + let targetPath = path.join( outPath, - }); - } + parsedPath.dir.slice(parsedPath.dir.indexOf("src")), + parsedPath.base, + ); - spinner.text = `[Done] ${fileName}`; - } + if (options.target === "vue") { + targetPath = stripVueJsxExtension(targetPath); + } - if (watcherEvents) { - await handleWatcherEvents(watcherEvents); - } + if (event.type === "create" || event.type === "update") { + try { + const fileContent = await fsPromise.readFile(event.path, "utf-8"); + + // Apply transformations once + const transformedContent = applyFixReactTypeIssues( + removeLiteExtension(fileContent), + event.path, + options.target, + ); + + await fsPromise.mkdir(path.dirname(targetPath), { recursive: true }); + await fsPromise.writeFile(targetPath, transformedContent); + } catch (err) { + log.error( + `Error processing ${path.basename(event.path)}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else if (event.type === "delete") { + try { + // Check if file exists before attempting to delete + if (fsSync.existsSync(targetPath)) { + await fsPromise.unlink(targetPath); + } + } catch (err) { + log.error( + `Error deleting ${path.basename(targetPath)}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + }); - if (!cache.isPopulated && files) { - await cache.build(Array.isArray(files) ? files : [files]); + // Process all file operations in parallel + await Promise.all(fileOperations); } - // Call the new function for both React and Vue - await addFrameworkSpecificPatches(); + // Handle lite components with Mitosis compilation + if (eventsByType.liteTsxComponents.length > 0) { + for (const event of eventsByType.liteTsxComponents) { + if (event.type === "create" || event.type === "update") { + try { + await compileMitosisComponent(event.path, options, outPath); + } catch (err) { + log.error( + `Error compiling ${path.basename(event.path)}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + } - spinner.succeed(); - spinner.stop(); + // Handle scaffolds if needed + if (eventsByType.scaffolds.length > 0) { + copyNonMitosisLiteFiles( + options, + outPath, + cliConfig, + targetAllowList, + false, + true, + ); + } } -function toPascalName(str: string): string { - return startCase(str).replace(/\s/g, ""); -} +async function compileMitosisComponent( + filepath: string, + options: CompileOptions, + outPath: string, +): Promise<{ outFile: string }> { + const file = path.parse(filepath); + const outFile = `${outPath}/${file.dir}/${file.name.replace(".lite", "")}.${ + options.extension + }`; + + const to = + options.target === "webcomponents" ? "webcomponent" : options.target; + + const configPath = path.resolve(__dirname, "./mitosis.config.js"); + + await compileCommand.run({ + parameters: { + options: { + from: "mitosis", + to: to, + out: outFile, + force: true, + state: options.state, + styles: options.styles, + api: options.api, + outFile: outPath, + config: configPath, + }, + array: [filepath], + }, + strings: strings, + filesystem: filesystem, + print: print, + }); -function getScaffoldsDirs(rootPath: string): { inDir: string; outDir: string } { return { - inDir: `${rootPath}/scaffolds`, - outDir: `${rootPath}/src/ui`, + outFile, }; } -function removeLiteExtension(fileContent: string): string { - return fileContent.replace(/\.lite/g, ""); -} - -function toArray(maybeArray: T | T[]): T[] { - return Array.isArray(maybeArray) ? maybeArray : [maybeArray]; -} +async function addFrameworkSpecificPatches( + options: CompileOptions, +): Promise { + const targetRootPath = path.resolve(cwd(), `packages/${options.target}/src`); + const indexPath = path.resolve(targetRootPath, "index.ts"); + const hooksPath = path.resolve(targetRootPath, "ui", "hooks"); + + return fsPromise + .readdir(hooksPath) + .then((hookFolders) => { + const hookNamesByFolder = hookFolders.reduce( + (arr: { folder: string; hookName: string }[], folder: string) => { + arr.push({ folder, hookName: camelCase(folder) }); + return arr; + }, + [], + ); -function addVueExtension(inputString: string): string { - return inputString.replace(/(\.[^"';\s]+)("|')/g, "$1.vue$2"); -} + const indexData = fs.readFileSync(indexPath, "utf8"); -async function copyFiles(srcDir: string, destDir: string): Promise { - try { - // Ensure the destination directory exists, if not create it - await fs.mkdir(destDir, { recursive: true }); + const hooksExports = hookNamesByFolder + .map((item) => { + if (options.target === "vue") { + // Due to SFC compiler not understanding the barrel file, we need to import the hooks manually + return `export { default as ${item.hookName} } from './ui/hooks/${item.folder}/${item.folder}';`; + } else { + return `export { default as ${item.hookName} } from './ui/hooks/${item.folder}';`; + } + }) + .filter((exportLine) => { + // Don't include exports if it's already there + return indexData.indexOf(exportLine) === -1; + }) + .join("\n"); - // Read all the files from the source directory - const files = await fs.readdir(srcDir); + let indexResult = indexData; - for (const file of files) { - // Construct full file paths for both the source and destination - const srcFile = path.join(srcDir, file); - const destFile = path.join(destDir, file); + if (options.target === "react") { + const clientOnlyMarker = `import "client-only";`; + indexResult = `${indexData}\n${clientOnlyMarker}\n${hooksExports}`; + } else if (options.target === "vue") { + // For Vue, we don't need the client-only import + indexResult = `${indexData}\n${hooksExports}`; + } - // Check if the source is indeed a file and not a directory - const stat = await fs.stat(srcFile); - if (stat.isFile()) { - // Copy each file to the destination directory - await fs.copyFile(srcFile, destFile); + // Skip if the result is the same as the original + if (indexResult === indexData) { + return; } - } - } catch (error) { - console.error("Error copying files:", error); - } + + fs.writeFileSync(indexPath, indexResult, "utf8"); + }) + .catch((err) => { + log.error( + `Failed to add ${options.target} specific patches: ${err instanceof Error ? err.message : String(err)}`, + ); + }); +} + +function replacePropertiesFromCompiledFiles(outFile: string): void { + const data = fs.readFileSync(outFile, "utf8"); + const result = data + // Fix alias + .replace(/\~\//g, "../../"); + + fs.writeFileSync(outFile, result, "utf8"); } diff --git a/packages/compiler/src/cache.ts b/packages/compiler/src/cache.ts index 2515f703..ec34b88e 100644 --- a/packages/compiler/src/cache.ts +++ b/packages/compiler/src/cache.ts @@ -1,6 +1,7 @@ import fs from "fs/promises"; import fsSync from "fs"; import crypto from "crypto"; +import log from "./log"; function replacer(_key: string, value: unknown): unknown { if (value instanceof Map) { @@ -15,16 +16,18 @@ function replacer(_key: string, value: unknown): unknown { class Cache { private cache: Map; + private fileContentCache: Map; public isPopulated: boolean; constructor() { // { : } this.cache = new Map(); + this.fileContentCache = new Map(); this.isPopulated = false; } async build(filePaths: string[] | undefined = []): Promise { - if (!filePaths) { + if (!filePaths || filePaths.length === 0) { return; } @@ -32,16 +35,11 @@ class Cache { .filter((filePath) => fsSync.existsSync(filePath)) .map(async (filePath) => { try { - const fileContent = await fs.readFile(filePath, "utf8"); + const fileContent = await this.readFile(filePath); const hash = this.hash(fileContent); - // Check if the file still exists and its content hasn't changed - // before setting the cache to avoid race conditions - const currentContent = await fs.readFile(filePath, "utf8"); - if (currentContent === fileContent) { - this.cache.set(filePath, hash); - } + this.cache.set(filePath, hash); } catch (err) { - console.error("Cannot build cache, error reading file ", filePath); + log.error(`Cannot build cache, error reading file ${filePath}`); // Don't throw here, allow other files to be processed } }); @@ -50,7 +48,9 @@ class Cache { await Promise.all(promises); this.isPopulated = true; } catch (err) { - console.error("Cannot build cache ", err); + log.error( + `Cannot build cache: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -59,7 +59,8 @@ class Cache { } private hash(source: string): string { - return crypto.createHash("sha1").update(source).digest("base64"); + // Using base64url for smaller hash strings (no padding needed) + return crypto.createHash("sha1").update(source).digest("base64url"); } has(filePath: string): boolean { @@ -73,6 +74,18 @@ class Cache { set(filePath: string, source: string): void { const hash = this.hash(source); this.cache.set(filePath, hash); + this.fileContentCache.set(filePath, source); + } + + // Read file with caching + private async readFile(filePath: string): Promise { + if (this.fileContentCache.has(filePath)) { + return this.fileContentCache.get(filePath)!; + } + + const content = await fs.readFile(filePath, "utf8"); + this.fileContentCache.set(filePath, content); + return content; } // Check if file content changed @@ -82,15 +95,27 @@ class Cache { } try { - const fileContent = await fs.readFile(filePath, "utf8"); + const fileContent = await this.readFile(filePath); const currentHash = this.hash(fileContent); const prevHash = this.cache.get(filePath); return prevHash !== currentHash; } catch (err) { - console.error("isDiff error ", err); + log.error( + `isDiff error: ${err instanceof Error ? err.message : String(err)}`, + ); return true; } } + + // Clear file content cache for a specific path + clearFileCache(filePath: string): void { + this.fileContentCache.delete(filePath); + } + + // Clear all file content caches + clearAllFileCaches(): void { + this.fileContentCache.clear(); + } } export { Cache }; diff --git a/packages/compiler/src/dev.ts b/packages/compiler/src/dev.ts index fb32afa8..66d8d731 100644 --- a/packages/compiler/src/dev.ts +++ b/packages/compiler/src/dev.ts @@ -6,6 +6,12 @@ import lodash from "lodash"; import { compileReact } from "./frameworks/react.compile"; import { command as execa } from "execa"; +// Track pending compilation operations +const pendingOperations = { + isCompiling: false, + events: [] as Event[], +}; + (async () => { let unsub: (() => void) | undefined; @@ -61,59 +67,87 @@ import { command as execa } from "execa"; "packages/react/scaffolds", ); - const onChange = lodash.debounce( - (err: Error | null, _events: Array) => { - const spinner = ora(`Watching src/ for changes...`).start(); - spinner.text = `src/ changed, compiling...`; - - const t = +Date.now(); - const timingLabel = `[t:${t}] Recompile took`; - console.time(timingLabel); - - const compilationPromise = compile(_events, { - cancel: () => {}, - }) - .then(() => { - spinner.succeed("Compiled successfully."); - console.timeEnd(timingLabel); - }) - .catch((e: Error) => { - spinner.fail(`Error compiling mitosis ${e.message}.`); - }); + // Enhanced event handler with batching + const handleEvents = async (events: Event[]): Promise => { + // Add new events to pending batch + pendingOperations.events.push(...events); + + // If already compiling, just return - the events have been queued + if (pendingOperations.isCompiling) { + return; + } + + pendingOperations.isCompiling = true; + const spinner = ora(`Watching src/ for changes...`).start(); + spinner.text = `src/ changed, compiling...`; + + const t = +Date.now(); + const timingLabel = `[t:${t}] Recompile took`; + console.time(timingLabel); + + try { + // Process all accumulated events + const eventsToProcess = [...pendingOperations.events]; + pendingOperations.events = []; // Clear the queue + + await compile(eventsToProcess, { cancel: () => {} }); + spinner.succeed("Compiled successfully."); + console.timeEnd(timingLabel); + } catch (e) { + const error = e as Error; + spinner.fail(`Error compiling mitosis ${error.message}.`); + } finally { + pendingOperations.isCompiling = false; + + // If more events accumulated while we were compiling, process them now + if (pendingOperations.events.length > 0) { + // Small delay to prevent potential rapid consecutive compilations + setTimeout(() => handleEvents([]), 100); + } + spinner.stop(); + } + }; + + // Smart debounce with shorter delay for initial changes and longer for subsequent changes + const onChange = lodash.debounce( + (err: Error | null, events: Array) => { if (err) { - spinner.fail( - `Error watching src/ for changes ${err?.message}.`, + console.error( + `Error watching for changes: ${err?.message}`, ); + return; } - return () => { - if (compilationPromise) { - compilationPromise.then(() => { - spinner.stop(); - }); - } - }; + // Filter out irrelevant events (like .DS_Store files) + const relevantEvents = events.filter( + (event) => + !event.path.includes(".DS_Store") && + !event.path.includes(".git/"), + ); + + if (relevantEvents.length === 0) return; + + handleEvents(relevantEvents); + }, + 300, // Shorter debounce time for better responsiveness + { + leading: false, + trailing: true, + maxWait: 1000, // Maximum wait time for batching }, - 500, ); - const watchSrc = watcher.subscribe(srcDir, (err, _events) => { - const cleanup = onChange(err, _events); - - return () => { - cleanup?.(); - }; + const watchSrc = watcher.subscribe(srcDir, (err, events) => { + onChange(err, events); + return () => {}; }); const watchScaffold = watcher.subscribe( scaffoldDir, - (err, _events) => { - const cleanup = onChange(err, _events); - - return () => { - cleanup?.(); - }; + (err, events) => { + onChange(err, events); + return () => {}; }, ); diff --git a/packages/compiler/src/frameworks/react.compile.ts b/packages/compiler/src/frameworks/react.compile.ts index ae35f425..cb2b7527 100644 --- a/packages/compiler/src/frameworks/react.compile.ts +++ b/packages/compiler/src/frameworks/react.compile.ts @@ -12,23 +12,60 @@ const DEFAULT_OPTIONS = { styles: "style-tag", }; +// Keep track of processed components to avoid duplicate logs +const processedComponents = new Set(); + function customReplaceReact(props: CustomReplaceProps): void { const { name, pascalName, outFile } = props; - log.info(`\nCompiling ${name} [${pascalName}] for React...`); - const data = fs.readFileSync(outFile, "utf8"); + // Skip if we've already processed this component + if (processedComponents.has(outFile)) { + return; + } + + // Mark as processed + processedComponents.add(outFile); + + // Use the group logging feature to ensure all logs for this component stay together + const componentLogger = log.group(`React Component: ${pascalName}`); - const result = fixReactTypeIssues(data); + try { + componentLogger.info(`Compiling ${name} [${pascalName}] for React...`); - fs.writeFileSync(outFile, result, "utf8"); + const data = fs.readFileSync(outFile, "utf8"); + const result = fixReactTypeIssues(data); + fs.writeFileSync(outFile, result, "utf8"); + + componentLogger.success(`Compiled ${pascalName} successfully`); + } catch (error) { + componentLogger.error( + `Error processing ${pascalName}: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + componentLogger.end(); + } } async function compileReact(watcherEvents?: Event[]): Promise { - await compiler.compile({ - ...DEFAULT_OPTIONS, - watcherEvents: watcherEvents as Event[], - customReplace: customReplaceReact, - }); + // Clear the processed components set for a fresh compilation + processedComponents.clear(); + + const compileLogger = log.group("React Compilation"); + + try { + await compiler.compile({ + ...DEFAULT_OPTIONS, + watcherEvents: watcherEvents as Event[], + customReplace: customReplaceReact, + }); + compileLogger.success("React compilation completed successfully"); + } catch (error) { + compileLogger.error( + `React compilation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + compileLogger.end(); + } } export { compileReact }; diff --git a/packages/compiler/src/frameworks/vue.compile.ts b/packages/compiler/src/frameworks/vue.compile.ts index 34b3c1e9..4ca3f964 100644 --- a/packages/compiler/src/frameworks/vue.compile.ts +++ b/packages/compiler/src/frameworks/vue.compile.ts @@ -13,84 +13,102 @@ const DEFAULT_OPTIONS = { styles: "", }; +// Keep track of processed components to avoid duplicate logs +const processedComponents = new Set(); + function customReplaceVue(props: CustomReplaceProps): void { const { name, pascalName, outFile, outPath, isFirstCompilation } = props; - log.info(`\nCompiling ${name} [${pascalName}] for Vue...`); - - if (isFirstCompilation) { - const data = fs.readFileSync(`${outPath}/src/index.ts`, "utf8"); - - log.info("\n ============== before edit index.ts =========== \n" + data); + // Skip if we've already processed this component + if (processedComponents.has(outFile)) { + return; + } - const result = data - // Add .vue to index and filter by compileAllowList - .replace( - /(export)(.*)\/ui\/(?!.*(\.css|\.css\.ts)")(.+)";/g, - (match, p1, p2, p3, p4) => { - const componentName = p4.split("/").pop(); - return compileAllowList["vue"]?.includes(componentName) - ? `${p1}${p2}/ui/${componentName}/${componentName}.vue";` - : ""; - }, - ) - .replace(/(extensions)\/(.*)\.vue/g, "$1/$2") - .replace(/\/helpers\.vue/g, "") - // Add .vue and a subpath to each export, and filter by compileAllowList - .replace( - /(export { default as (\w+) } from '\.\/ui\/)([^';]+)';/g, - (match, p1, p2, p3) => { - return compileAllowList["vue"]?.includes(p3) - ? `${p1}${p3}/${p3}.vue';` - : ""; - }, - ) - // Remove empty lines created by filtered out exports - .replace(/^\s*[\r\n]/gm, ""); + // Mark as processed + processedComponents.add(outFile); + + // Use the group logging feature to ensure all logs for this component stay together + const componentLogger = log.group(`Vue Component: ${pascalName}`); + + try { + componentLogger.info(`Compiling ${name} [${pascalName}] for Vue...`); + + if (isFirstCompilation) { + const data = fs.readFileSync(`${outPath}/src/index.ts`, "utf8"); + + if (data) { + componentLogger.info("Processing index.ts"); + + const result = data + // Add .vue to index and filter by compileAllowList + .replace( + /(export)(.*)\/ui\/(?!.*(\.css|\.css\.ts)")(.+)";/g, + (match, p1, p2, p3, p4) => { + const componentName = p4.split("/").pop(); + return compileAllowList["vue"]?.includes(componentName) + ? `${p1}${p2}/ui/${componentName}/${componentName}.vue";` + : ""; + }, + ) + .replace(/(extensions)\/(.*)\.vue/g, "$1/$2") + .replace(/\/helpers\.vue/g, "") + // Add .vue and a subpath to each export, and filter by compileAllowList + .replace( + /(export { default as (\w+) } from '\.\/ui\/)([^';]+)';/g, + (match, p1, p2, p3) => { + return compileAllowList["vue"]?.includes(p3) + ? `${p1}${p3}/${p3}.vue';` + : ""; + }, + ) + // Remove empty lines created by filtered out exports + .replace(/^\s*[\r\n]/gm, ""); + + fs.writeFileSync(`${outPath}/src/index.ts`, result, "utf8"); + + // Add .vue extension to all the indexes in src folder + globSync(`${outPath}/src/ui/**/index.ts`).forEach((src: string) => { + const data = fs + .readFileSync(src, "utf8") + // add vue to index + .replace(/(export { default } from)(.*)(';)/g, "$1$2.vue$3") + // but remove from hooks + .replace(/\.hook\.vue/g, ".hook"); + + fs.writeFileSync(src, data, "utf8"); + }); + } + } - log.warn("\n ============== after edit index.ts =========== \n" + result); + componentLogger.success(`Compiled ${pascalName} successfully`); + } catch (error) { + componentLogger.error( + `Error processing ${pascalName}: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + componentLogger.end(); + } +} - fs.writeFileSync(`${outPath}/src/index.ts`, result, "utf8"); +const compileVue = async (): Promise => { + // Clear the processed components set for a fresh compilation + processedComponents.clear(); - // Add .vue extension to all the indexes in src folder - globSync(`${outPath}/src/ui/**/index.ts`).forEach((src: string) => { - const data = fs - .readFileSync(src, "utf8") - // add vue to index - .replace(/(export { default } from)(.*)(';)/g, "$1$2.vue$3") - // but remove from hooks - .replace(/\.hook\.vue/g, ".hook"); + const compileLogger = log.group("Vue Compilation"); - fs.writeFileSync(src, data, "utf8"); + try { + await compiler.compile({ + ...DEFAULT_OPTIONS, + customReplace: customReplaceVue, }); + compileLogger.success("Vue compilation completed successfully"); + } catch (error) { + compileLogger.error( + `Vue compilation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + compileLogger.end(); } - - const data = fs.readFileSync(outFile, "utf8"); - - let result = data; - - const transforms = [ - patchPropsDestructuring, - // TODO: work on TS output later - // Need to parse + follow imports and resolve + inline types in the .vue files - // addPathAliasToHelperTypeImports, - // addPathAliasToRelativeTypeImports, - // (fileData) => inlineTypes(fileData, name, pascalName), - ]; - - result = transforms.reduce((acc, transform) => { - acc = transform(acc); - return acc; - }, result); - - fs.writeFileSync(outFile, result, "utf8"); -} - -const compileVue = async (): Promise => { - await compiler.compile({ - ...DEFAULT_OPTIONS, - customReplace: customReplaceVue, - }); }; if (require.main === module) { diff --git a/packages/compiler/src/log.ts b/packages/compiler/src/log.ts index 4adb95f0..2303ccdb 100644 --- a/packages/compiler/src/log.ts +++ b/packages/compiler/src/log.ts @@ -3,19 +3,132 @@ import clc from "cli-color"; const error = clc.red.bold; const warn = clc.yellow; const info = clc.blue; +const success = clc.green; interface LogFunction { (...args: unknown[]): void; } +// Queue for log operations to prevent interleaving in concurrent processes +class LogQueue { + private queue: Array<() => void> = []; + private processing = false; + private groupStack: string[] = []; + + enqueue(operation: () => void): void { + this.queue.push(operation); + if (!this.processing) { + this.processQueue(); + } + } + + // Add batch operations to ensure related logs stay together + enqueueBatch(operations: Array<() => void>): void { + if (operations.length === 0) return; + + // Combine all operations into a single atomic operation + this.enqueue(() => { + operations.forEach((op) => op()); + }); + } + + // Get current group depth for indentation + private getCurrentIndent(): string { + return " ".repeat(this.groupStack.length); + } + + // Push a group onto the stack + pushGroup(groupName: string): void { + this.groupStack.push(groupName); + } + + // Pop a group from the stack + popGroup(): void { + this.groupStack.pop(); + } + + private processQueue(): void { + if (this.queue.length === 0) { + this.processing = false; + return; + } + + this.processing = true; + const operation = this.queue.shift(); + + if (operation) { + operation(); + } + + // Use setImmediate for better performance than setTimeout + setImmediate(() => this.processQueue()); + } +} + +const logQueue = new LogQueue(); + +interface LogGroup { + info: LogFunction; + error: LogFunction; + warn: LogFunction; + success: LogFunction; + end: () => void; +} + const log: { error: LogFunction; warn: LogFunction; info: LogFunction; + success: LogFunction; + group: (groupName?: string) => LogGroup; } = { - error: (...args: unknown[]) => console.log(error(...args)), - warn: (...args: unknown[]) => console.log(warn(...args)), - info: (...args: unknown[]) => console.log(info(...args)), + error: (...args: unknown[]) => + logQueue.enqueue(() => console.log(error(...args))), + warn: (...args: unknown[]) => + logQueue.enqueue(() => console.log(warn(...args))), + info: (...args: unknown[]) => + logQueue.enqueue(() => console.log(info(...args))), + success: (...args: unknown[]) => + logQueue.enqueue(() => console.log(success(...args))), + + // Add a group function to batch related logs + group: (groupName?: string) => { + const pendingLogs: Array<() => void> = []; + + if (groupName) { + pendingLogs.push(() => { + logQueue.pushGroup(groupName); + console.log(info(`\n[Group: ${groupName}]`)); + }); + } + + const groupLog = { + info: (...args: unknown[]) => { + pendingLogs.push(() => console.log(info(...args))); + return groupLog; + }, + error: (...args: unknown[]) => { + pendingLogs.push(() => console.log(error(...args))); + return groupLog; + }, + warn: (...args: unknown[]) => { + pendingLogs.push(() => console.log(warn(...args))); + return groupLog; + }, + success: (...args: unknown[]) => { + pendingLogs.push(() => console.log(success(...args))); + return groupLog; + }, + end: () => { + if (pendingLogs.length > 0) { + logQueue.enqueueBatch(pendingLogs); + } + logQueue.enqueue(() => logQueue.popGroup()); + }, + }; + + return groupLog; + }, }; export default log; diff --git a/packages/compiler/src/tasks.ts b/packages/compiler/src/tasks.ts index 9644678c..ffc80359 100644 --- a/packages/compiler/src/tasks.ts +++ b/packages/compiler/src/tasks.ts @@ -3,6 +3,7 @@ import commandLineArgs from "command-line-args"; import util from "util"; import { exec as execCallback } from "child_process"; import { command as execa } from "execa"; +import ora from "ora"; const exec = util.promisify(execCallback); @@ -11,6 +12,7 @@ interface CliConfig { platforms: string[]; lint: boolean; "no-lint"?: boolean; + concurrency?: number; } const optionDefinitions: commandLineArgs.OptionDefinition[] = [ @@ -25,15 +27,36 @@ const optionDefinitions: commandLineArgs.OptionDefinition[] = [ }, { name: "lint", type: Boolean, defaultValue: true }, { name: "no-lint", type: Boolean }, + { + name: "concurrency", + alias: "c", + type: Number, + defaultValue: 4, // Default to 4 concurrent processes + }, ]; +// Add a comment for clarity even though we removed the description property +// concurrency: Number of concurrent compilation processes (1-8) + const shouldMinify = process.env.MINIFY === "true"; const shouldSkipBundling = process.env.NO_BUILD === "true"; (async () => { + const startTime = Date.now(); const cliConfig = commandLineArgs(optionDefinitions) as CliConfig; cliConfig.lint = cliConfig.lint && !cliConfig["no-lint"]; // TODO: add linting + // Limit concurrency to reasonable values + if ( + cliConfig.concurrency && + (cliConfig.concurrency < 1 || cliConfig.concurrency > 8) + ) { + console.warn( + `Concurrency value ${cliConfig.concurrency} is out of range (1-8), defaulting to 4`, + ); + cliConfig.concurrency = 4; + } + const tasks = new Listr([ { title: "Pretasks", @@ -54,7 +77,11 @@ const shouldSkipBundling = process.env.NO_BUILD === "true"; const cleanCmd = `rimraf packages/${platformPkgRoot}/{src,dist,lib,types,stats.html}`; task.output = `Cleaning dir: ${cleanCmd}`; - return exec(cleanCmd); + return exec(cleanCmd).catch((error) => { + console.error(`Error cleaning directories: ${error.message}`); + // Continue even if cleaning fails + return Promise.resolve(); + }); }, }, ], @@ -67,28 +94,45 @@ const shouldSkipBundling = process.env.NO_BUILD === "true"; cliConfig.elements?.join(", ") || "all" }`, task: () => { + const spinner = ora("Preparing compilation...").start(); + return new Listr( cliConfig.platforms.map((platform) => ({ title: `Compile ${platform}`, - task: () => - execa( + task: () => { + spinner.text = `Compiling ${platform}...`; + return execa( `tsx packages/compiler/src/frameworks/${platform}.compile.ts ${ cliConfig.elements ? `--elements ${cliConfig.elements.join(" ")}` : "" }`, - ).catch((error: Error) => { - throw new Error(`Error compiling ${platform} ${error.message}`); - }), + ) + .catch((error: Error) => { + spinner.fail(`Error compiling ${platform}`); + throw new Error( + `Error compiling ${platform} ${error.message}`, + ); + }) + .finally(() => { + spinner.succeed(`Compiled ${platform}`); + }); + }, })), - { concurrent: true }, + { + concurrent: cliConfig.concurrency || true, + exitOnError: false, // Continue with other platforms if one fails + }, ); }, }, { title: `Bundle Packages: ${cliConfig.platforms?.join(", ") || ""}`, - task: async () => { - if (shouldSkipBundling) return true; + task: async (_, task) => { + if (shouldSkipBundling) { + task.skip("Skipping bundling (NO_BUILD=true)"); + return; + } const platforms = Array.isArray(cliConfig.platforms) ? cliConfig.platforms @@ -102,12 +146,14 @@ const shouldSkipBundling = process.env.NO_BUILD === "true"; const filters = `--filter "@interchain-ui/${platformGlob}"`; const buildCmd = `pnpm run --stream ${filters} build`; + task.output = `Running: ${buildCmd}`; try { await exec(buildCmd); if (shouldMinify) { const minifyCssCmd = `pnpm run --stream ${filters} minifyCss`; + task.output = `Running: ${minifyCssCmd}`; await exec(minifyCssCmd); } } catch (error) { @@ -117,7 +163,15 @@ const shouldSkipBundling = process.env.NO_BUILD === "true"; }, ]); - tasks.run().catch((err: Error) => { - console.error(err); - }); + tasks + .run() + .then(() => { + const endTime = Date.now(); + const duration = (endTime - startTime) / 1000; + console.log(`🚀 Compilation completed in ${duration.toFixed(2)}s`); + }) + .catch((err: Error) => { + console.error(err); + process.exit(1); + }); })(); diff --git a/packages/compiler/src/utils/performance.ts b/packages/compiler/src/utils/performance.ts new file mode 100644 index 00000000..33d7717f --- /dev/null +++ b/packages/compiler/src/utils/performance.ts @@ -0,0 +1,238 @@ +import clc from "cli-color"; + +/** + * Performance timer utility for detailed profiling + */ +class Timer { + private timers: Map = new Map(); + private results: Map = new Map(); + private isEnabled: boolean = true; + + /** + * Start timing an operation + */ + start(label: string): void { + if (!this.isEnabled) return; + this.timers.set(label, performance.now()); + } + + /** + * End timing an operation and record the duration + */ + end(label: string): number { + if (!this.isEnabled) return 0; + + const startTime = this.timers.get(label); + if (startTime === undefined) { + console.warn(`Timer '${label}' was never started`); + return 0; + } + + const duration = performance.now() - startTime; + this.results.set(label, duration); + return duration; + } + + /** + * Get the duration of a completed operation + */ + get(label: string): number { + return this.results.get(label) || 0; + } + + /** + * Format a duration in milliseconds to a human-readable string + */ + formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms.toFixed(2)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; + } + + /** + * Enable or disable timing + */ + setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + } + + /** + * Reset all timers and results + */ + reset(): void { + this.timers.clear(); + this.results.clear(); + } + + /** + * Log the recorded timings to the console + */ + logTimings(): void { + if (!this.isEnabled || this.results.size === 0) return; + + console.log(clc.cyan("\n📊 Build Performance Metrics:")); + + // Sort timings by duration (descending) + const sortedEntries = [...this.results.entries()].sort( + (a, b) => b[1] - a[1], + ); + + const totalTime = + this.get("Total Compilation") || + this.get("compileReact") || + this.get("compileVue") || + this.get("React Compilation (CLI)") || + this.get("Vue Compilation (CLI)"); + + // Group by category + const categories = new Map>(); + + for (const [label, duration] of sortedEntries) { + const category = label.includes("(") ? label.split("(")[0] : "Other"; + if (!categories.has(category)) { + categories.set(category, []); + } + categories.get(category)!.push([label, duration]); + } + + // Print by category + for (const [category, items] of categories) { + if (category === "Total" || category === "Other") continue; // Handle these separately + + console.log(clc.bold(`\n🔷 ${category}:`)); + for (const [label, duration] of items) { + const percentage = totalTime + ? ((duration / totalTime) * 100).toFixed(1) + : "0"; + const durationStr = this.formatDuration(duration); + + let colorFn = clc.green; + if (duration > 5000) { + colorFn = clc.red; + } else if (duration > 2000) { + colorFn = clc.yellow; + } + + console.log( + ` ${label}: ${colorFn(durationStr)} ${clc.blackBright(`(${percentage}% of total)`)}`, + ); + } + } + + // Print 'Other' category + if (categories.has("Other")) { + console.log(clc.bold(`\n🔹 Other Operations:`)); + for (const [label, duration] of categories.get("Other")!) { + if (label.startsWith("Total")) continue; + + const percentage = totalTime + ? ((duration / totalTime) * 100).toFixed(1) + : "0"; + const durationStr = this.formatDuration(duration); + + let colorFn = clc.green; + if (duration > 5000) { + colorFn = clc.red; + } else if (duration > 2000) { + colorFn = clc.yellow; + } + + console.log( + ` ${label}: ${colorFn(durationStr)} ${clc.blackBright(`(${percentage}% of total)`)}`, + ); + } + } + + if (totalTime) { + console.log( + clc.bold( + `\n⌛ Total Compilation Time: ${clc.cyan(this.formatDuration(totalTime))}`, + ), + ); + } + + console.log( + clc.blackBright("\nℹ️ Add the '--no-timing' flag to disable this output"), + ); + } + + // Type-safe wrapper with more explicit types + wrap( + fn: (...args: Args) => Return, + label: string, + ): (...args: Args) => Return { + return (...args: Args): Return => { + this.start(label); + try { + const result = fn(...args); + + // Handle promises + if (result instanceof Promise) { + // TypeScript doesn't know that Return === Promise + // We use a type assertion here + return result + .then((value) => { + this.end(label); + return value; + }) + .catch((error) => { + this.end(label); + throw error; + }) as Return; + } + + this.end(label); + return result; + } catch (error) { + this.end(label); + throw error; + } + }; + } +} + +// Create and export singleton instance immediately +// This ensures it's available as soon as the module is imported +const timerInstance = new Timer(); + +// Helper function to time async operations +async function timeAsync(label: string, fn: () => Promise): Promise { + timerInstance.start(label); + try { + const result = await fn(); + timerInstance.end(label); + return result; + } catch (error) { + timerInstance.end(label); + throw error; + } +} + +// Helper function to time synchronous operations +function timeSync(label: string, fn: () => T): T { + timerInstance.start(label); + try { + const result = fn(); + timerInstance.end(label); + return result; + } catch (error) { + timerInstance.end(label); + throw error; + } +} + +// Export timer interface for TypeScript +export interface TimerInterface { + start: (label: string) => void; + end: (label: string) => number; + get: (label: string) => number; + reset: () => void; + formatDuration: (ms: number) => string; + setEnabled: (enabled: boolean) => void; + logTimings: () => void; +} + +// Export the timer instance and helper functions +export const timer: TimerInterface = timerInstance; +export { timeAsync, timeSync }; diff --git a/packages/react-codemod/package.json b/packages/react-codemod/package.json index 16538b4f..41b942cd 100644 --- a/packages/react-codemod/package.json +++ b/packages/react-codemod/package.json @@ -44,14 +44,14 @@ "@babel/plugin-syntax-typescript": "^7.x" }, "devDependencies": { - "@parcel/config-default": "^2.12.0", - "@parcel/transformer-typescript-tsc": "^2.12.0", + "@parcel/config-default": "^2.13.3", + "@parcel/transformer-typescript-tsc": "^2.13.3", "@types/node": "^20.16.5", "@typescript-eslint/eslint-plugin": "^7.15.0", "@typescript-eslint/parser": "^7.15.0", "acorn": "^8.10.0", "eslint": "^9.10.0", - "parcel": "^2.12.0", + "parcel": "^2.13.3", "typescript": "^5.5.4", "vite": "^5.4.3", "vitest": "^1.6.0" @@ -73,4 +73,4 @@ "bugs": { "url": "https://github.com/hyperweb-io/interchain-ui/issues" } -} \ No newline at end of file +} diff --git a/packages/react-no-ssr/package.json b/packages/react-no-ssr/package.json index 35190237..0d4fe5ff 100644 --- a/packages/react-no-ssr/package.json +++ b/packages/react-no-ssr/package.json @@ -39,11 +39,11 @@ "devDependencies": { "@eslint/compat": "^1.1.0", "@eslint/js": "^9.5.0", - "@parcel/config-default": "^2.12.0", - "@parcel/core": "^2.12.0", - "@parcel/transformer-js": "^2.12.0", - "@parcel/transformer-react-refresh-wrap": "^2.12.0", - "@parcel/transformer-typescript-tsc": "^2.12.0", + "@parcel/config-default": "^2.13.3", + "@parcel/core": "^2.13.3", + "@parcel/transformer-js": "^2.13.3", + "@parcel/transformer-react-refresh-wrap": "^2.13.3", + "@parcel/transformer-typescript-tsc": "^2.13.3", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", @@ -53,7 +53,7 @@ "eslint-plugin-react": "^7.34.3", "globals": "^15.6.0", "jsdom": "^24.1.0", - "parcel": "^2.12.0", + "parcel": "^2.13.3", "tshy": "^1.15.1", "typescript": "^5.4.5", "typescript-eslint": "^7.13.1", diff --git a/packages/react/package.json b/packages/react/package.json index 2233a026..1c648c98 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -89,8 +89,8 @@ "zustand": "^4.5.5" }, "peerDependencies": { - "react": "^16.14.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "directories": { "lib": "lib", @@ -128,28 +128,28 @@ "@chain-registry/osmosis": "^1.62.59", "@chain-registry/types": "^0.18.19", "@chain-registry/utils": "^1.46.59", - "@chromatic-com/storybook": "^3.1.0", - "@parcel/config-default": "^2.12.0", - "@parcel/core": "^2.12.0", - "@parcel/optimizer-swc": "^2.12.0", - "@parcel/optimizer-terser": "^2.12.0", - "@parcel/reporter-bundle-analyzer": "^2.12.0", - "@parcel/resolver-default": "^2.12.0", - "@parcel/transformer-css": "^2.12.0", - "@parcel/transformer-js": "^2.12.0", - "@parcel/transformer-react-refresh-wrap": "^2.12.0", - "@parcel/transformer-typescript-tsc": "^2.12.0", - "@parcel/transformer-typescript-types": "^2.12.0", + "@chromatic-com/storybook": "^3.2.6", + "@parcel/config-default": "^2.13.3", + "@parcel/core": "^2.13.3", + "@parcel/optimizer-swc": "^2.13.3", + "@parcel/optimizer-terser": "^2.13.3", + "@parcel/reporter-bundle-analyzer": "^2.13.3", + "@parcel/resolver-default": "^2.13.3", + "@parcel/transformer-css": "^2.13.3", + "@parcel/transformer-js": "^2.13.3", + "@parcel/transformer-react-refresh-wrap": "^2.13.3", + "@parcel/transformer-typescript-tsc": "^2.13.3", + "@parcel/transformer-typescript-types": "^2.13.3", "@react-types/combobox": "^3.12.1", "@react-types/shared": "^3.25.0", - "@storybook/addon-essentials": "^8.3.6", - "@storybook/addon-interactions": "^8.3.6", - "@storybook/addon-links": "^8.3.6", - "@storybook/addon-viewport": "^8.3.6", - "@storybook/blocks": "^8.3.6", - "@storybook/react": "^8.3.6", - "@storybook/react-vite": "^8.3.6", - "@storybook/test": "^8.3.6", + "@storybook/addon-essentials": "^8.6.7", + "@storybook/addon-interactions": "^8.6.7", + "@storybook/addon-links": "^8.6.7", + "@storybook/addon-viewport": "^8.6.7", + "@storybook/blocks": "^8.6.7", + "@storybook/react": "^8.6.7", + "@storybook/react-vite": "^8.6.7", + "@storybook/test": "^8.6.7", "@types/react": "latest", "@vanilla-extract/parcel-transformer": "^1.0.9", "@vanilla-extract/vite-plugin": "^4.0.15", @@ -157,12 +157,12 @@ "@vitejs/plugin-react-swc": "^3.7.0", "match-sorter": "^6.3.4", "mkdirp": "^3.0.1", - "parcel": "^2.12.0", + "parcel": "^2.13.3", "parcel-optimizer-unlink-css": "workspace:*", "parcel-resolver-ts-base-url": "^1.3.1", "prop-types": "^15.8.1", - "storybook": "^8.3.6", - "storybook-react-rsbuild": "^0.1.2", + "storybook": "^8.6.7", + "storybook-react-rsbuild": "^1.0.0", "vite": "^5.4.2", "vite-plugin-replace": "^0.1.1" }, diff --git a/packages/react/scaffolds/number-field/number-field.tsx b/packages/react/scaffolds/number-field/number-field.tsx index 99b0782f..b227f038 100644 --- a/packages/react/scaffolds/number-field/number-field.tsx +++ b/packages/react/scaffolds/number-field/number-field.tsx @@ -20,7 +20,7 @@ import * as styles from "./number-field.css"; import type { NumberInputProps } from "./number-field.types"; function usePrevious(value: T): T { - const ref = React.useRef(); + const ref = React.useRef(undefined); useEffect(() => { ref.current = value; }); diff --git a/packages/react/src/app-env.d.ts b/packages/react/src/app-env.d.ts new file mode 100644 index 00000000..dad4fed1 --- /dev/null +++ b/packages/react/src/app-env.d.ts @@ -0,0 +1,17 @@ +// Workaround for importing images +declare module "*.svg" { + const svgContent: string; + export default svgContent; +} + +declare module "*.png" { + const pngContent: string; + export default pngContent; +} + +// Workaround for Vue SFC compiler: Failed to resolve index type into finite keys +import { createRainbowSprinkles } from "rainbow-sprinkles"; + +export const rainbowSprinkles: ReturnType; + +export type Sprinkles = Parameters[0]; diff --git a/packages/react/src/helpers/debug.ts b/packages/react/src/helpers/debug.ts new file mode 100644 index 00000000..076e2bae --- /dev/null +++ b/packages/react/src/helpers/debug.ts @@ -0,0 +1,24 @@ +export enum DebugLevel { + None = 0, + Log = 1, + Breakpoint = 2, +} + +let debugLevel = DebugLevel.None; + +const debugStrategies = { + [DebugLevel.None]: () => null, + [DebugLevel.Log]: (message: string) => console.log(message), + [DebugLevel.Breakpoint]: (message: string) => { + console.log(message); + debugger; + }, +}; + +export function setDebugLevel(level: DebugLevel) { + debugLevel = level; +} + +export function debug(message: string) { + debugStrategies[debugLevel](message); +} diff --git a/packages/react/src/helpers/index.ts b/packages/react/src/helpers/index.ts new file mode 100644 index 00000000..0d70c6ca --- /dev/null +++ b/packages/react/src/helpers/index.ts @@ -0,0 +1,29 @@ +export * from "./debug"; +export * from "./platform"; +export * from "./style"; +export * from "./number"; + +export type Args = T extends (...args: infer R) => any + ? R + : never; + +export type AnyFunction = (...args: T[]) => any; + +export function callAllHandlers void>( + ...fns: (T | undefined)[] +) { + return function func(event: Args[0]) { + fns.some((fn) => { + fn?.(event); + return event?.defaultPrevented; + }); + }; +} + +export function callAll(...fns: (T | undefined)[]) { + return function mergedFn(arg: Args[0]) { + fns.forEach((fn) => { + fn?.(arg); + }); + }; +} diff --git a/packages/react/src/helpers/number.ts b/packages/react/src/helpers/number.ts new file mode 100644 index 00000000..4617f209 --- /dev/null +++ b/packages/react/src/helpers/number.ts @@ -0,0 +1,85 @@ +import BigNumber from "bignumber.js"; + +export function getCurrencyFormatter( + locale?: string, + options?: Intl.NumberFormatOptions +): Intl.NumberFormat { + return new Intl.NumberFormat(locale ?? "en-US", options); +} + +export function formatIntlNumber( + value: number, + locale?: string, + options?: Intl.NumberFormatOptions +) { + const formatter = new Intl.NumberFormat(locale ?? "en-US", { + ...options, + style: options?.style ?? "decimal", + currency: options?.currency ?? "USD", + minimumFractionDigits: options?.minimumFractionDigits ?? 0, + maximumFractionDigits: options?.maximumFractionDigits ?? 4, + }); + return formatter.format(value); +} + +export function formatCurrency( + value: number, + locale?: string, + options?: Intl.NumberFormatOptions +) { + return formatIntlNumber(value, locale, { + ...options, + style: "currency", + }); +} + +export function safelyFormatNumberWithFallback( + formatter: Intl.NumberFormat, + value: BigNumber +): string { + // First, attempt to format the BigNumber as a number primitive + try { + return formatter.format(value.toNumber()); + } catch {} + + // As a fallback, simply return the ugly string value + return value.toString(); +} + +/** + * Function for lamp value + * @param min + * @param max + * @param value + * @returns + */ +export function clampBigNumber( + min: string | number, + max: string | number, + value: string +): string { + if (value === "") { + return ""; + } + if (new BigNumber(value).gt(max)) { + return new BigNumber(max).toString(); + } + if (new BigNumber(value).lt(min)) { + return new BigNumber(min).toString(); + } + return value?.toString(); +} + +export function toNumber(value?: string | number, fallbackValue?: number) { + if (value == null) return fallbackValue ?? 0; + if (typeof value === "number") return value; + if (isNaN(Number(value))) return fallbackValue ?? 0; + return Number(value); +} + +export function formatNumeric( + value: string | number, + maximumFractionDigits = 6 +) { + return new BigNumber(value).decimalPlaces(maximumFractionDigits).toString(); +} diff --git a/packages/react/src/helpers/platform.ts b/packages/react/src/helpers/platform.ts new file mode 100644 index 00000000..80b7283a --- /dev/null +++ b/packages/react/src/helpers/platform.ts @@ -0,0 +1,48 @@ +import { debug } from "./debug"; + +export enum Platform { + Default = "default", + Angular = "angular", + Preact = "preact", + Qwik = "qwik", + React = "react", + Solid = "solid", + Svelte = "svelte", + Vue = "vue", + Webcomponents = "webcomponent", +} + +let platform = Platform.Default; + +export function setPlatform(newPlatform: Platform) { + debug(`Setting new platform ${newPlatform}`); + + platform = newPlatform; +} + +export function getPlatform(): Platform { + return platform; +} + +export function isSSR(): boolean { + try { + return typeof window === undefined; + } catch (error) {} + + return true; +} + +export function closestBodyElement(node: HTMLElement) { + let parent = node.parentElement; + while (parent) { + if (parent.tagName === "BODY") { + return parent as HTMLIFrameElement; + } + parent = parent.parentElement; + } + return null; +} + +export function getOwnerDocument(node: HTMLElement) { + return node.ownerDocument; +} diff --git a/packages/react/src/helpers/string.ts b/packages/react/src/helpers/string.ts new file mode 100644 index 00000000..fad54b6f --- /dev/null +++ b/packages/react/src/helpers/string.ts @@ -0,0 +1,14 @@ +export function truncateTextMiddle(addr: string, maxLength: number) { + const midChar = "…"; + if (!addr) addr = ""; + + if (addr.length <= maxLength) return addr; + + // length of beginning part + const left = Math.ceil(maxLength / 2); + + // start index of ending part + const right = addr.length - Math.floor(maxLength / 2) + 1; + + return addr.substring(0, left) + midChar + addr.substring(right); +} diff --git a/packages/react/src/helpers/style.ts b/packages/react/src/helpers/style.ts new file mode 100644 index 00000000..025d590f --- /dev/null +++ b/packages/react/src/helpers/style.ts @@ -0,0 +1,111 @@ +import { globalStyle, GlobalStyleRule } from "@vanilla-extract/css"; +import type { Writable } from "../helpers/types"; +import { store } from "../models/store"; +import { + Accent, + DEFAULT_ACCENTS, + accents, + accentsForeground, +} from "../styles/tokens"; +import { + ModePreference, + ModePreferences, + ThemeVariant, +} from "../models/system.model"; +import { isSSR } from "./platform"; + +const hexToRgb = (hex: string) => { + const channels = hex + .replace( + /^#?([a-f\d])([a-f\d])([a-f\d])$/i, + (m, r, g, b) => "#" + r + r + g + g + b + b, + ) + .substring(1) + .match(/.{2}/g) + .map((x) => parseInt(x, 16)); + return `rgb(${channels[0]}, ${channels[1]}, ${channels[2]})`; +}; + +export const rgb = (color: string, alpha?: string) => { + const rgbColor = color.includes("rgb") ? color : hexToRgb(color); + // 'rgb(x, y, z)' -> 'x, y, z' + const partial = rgbColor.replace("rgb(", "").replace(")", ""); + return alpha ? `rgba(${partial}, ${alpha})` : `rgb(${partial})`; +}; + +export const isDefaultAccent = (accent: Accent) => { + return (DEFAULT_ACCENTS as unknown as Writable).includes(accent); +}; + +// Get accent color, if not default provided then it's a color string +export const getAccent = (accent: Accent, colorMode: ThemeVariant | null) => { + return isDefaultAccent(accent) + ? accents[colorMode ?? "light"][accent] + : accent; +}; + +export const getAccentText = (colorMode: ThemeVariant | null) => { + return accentsForeground[colorMode ?? "light"]; +}; + +export const getAccentHover = (color: string) => { + return rgb(color, "0.075"); +}; + +export const mediaQueryColorScheme = (mode: string) => + `(prefers-color-scheme: ${mode})`; + +const isValidThemeMode = (mode: ModePreference) => { + return ModePreferences.includes(mode); +}; + +// Resolve theme mode by priority: +// props.defaultProps > saved theme > system theme +export const resolveThemeMode = ( + defaultThemeMode?: ModePreference, +): ModePreference => { + const hasHydrated = store.getState()._hasHydrated; + + // While in SSR, return a default theme + if (isSSR() || !hasHydrated) { + return "light"; + } + + const savedTheme = store.getState().themeMode; + + if (isValidThemeMode(defaultThemeMode)) { + // Only set the theme if it's different from the saved theme + if (defaultThemeMode !== savedTheme) { + store.getState().setThemeMode(defaultThemeMode); + } + return defaultThemeMode; + } + + // props.defaultThemeMode is not provided or invalid, rely on persisted theme + if (isValidThemeMode(savedTheme)) { + return savedTheme; + } + + // persisted value not a valid theme mode, fallback to 'system' + store.getState().setThemeMode("system"); + console.log("[resolveThemeMode] using system theme mode"); + return "system"; +}; + +export const isPreferLightMode = () => + window.matchMedia && + window.matchMedia("(prefers-color-scheme: light)").matches; + +export const isPreferDarkMode = () => + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches; + +export function childSelectors( + styleVariantsSelector: string, + selector: string, + rule: GlobalStyleRule, +) { + const allSelectors = Array.from(new Set(styleVariantsSelector.split(" "))); + const targetSelector = allSelectors[allSelectors.length - 1]; + globalStyle(`${targetSelector} ${selector}`, rule); +} diff --git a/packages/react/src/helpers/types.ts b/packages/react/src/helpers/types.ts new file mode 100644 index 00000000..aa94970d --- /dev/null +++ b/packages/react/src/helpers/types.ts @@ -0,0 +1,40 @@ +// Help "resolve" to the final type value of a type +export type Resolve = { + [Key in keyof T]: T[Key]; +} & {}; + +// Utility type to convert string literal types to String constructor +export type StringifyLeaf = T extends string ? String : T; + +// Recursive type to walk through the object tree +export type DeepStringConstructor = { + [P in keyof T]: T[P] extends object + ? DeepStringConstructor + : StringifyLeaf; +}; + +export type Primitive = + | string + | number + | boolean + | bigint + | symbol + | null + | undefined; + +export type LiteralUnion< + LiteralType extends Primitive, + BaseType extends Primitive, +> = LiteralType | (BaseType & { _?: never }); + +export type PartialDeep = T extends object + ? { + [P in keyof T]?: PartialDeep; + } + : T; + +export type UnknownRecord = Record; + +export type Writable = { + -readonly [P in keyof T]: T[P]; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..fa71f359 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,307 @@ +/// +export type { UIStore, UIState, UIAction } from "./models/store"; +export type { + Intent, + ThemeVariant, + ModePreference, + NumberFormatProps, + NumberFormatter, +} from "./models/system.model"; +export type { GridColumn } from "./models/components.model"; +export type { Sprinkles } from "./styles/rainbow-sprinkles.css"; +export { store } from "./models/store"; +export { skeleton as skeletonClass } from "./ui/shared/shared.css"; + +// Init Components + +export { default as Box } from "./ui/box"; +export { default as Container } from "./ui/container"; +export { default as Link } from "./ui/link"; +export { default as Stack } from "./ui/stack"; +export { default as Center } from "./ui/center"; +export { default as Icon } from "./ui/icon"; +export { default as Callout } from "./ui/callout"; +export type { IconProps, IconName } from "./ui/icon/icon.types"; +export { default as Text } from "./ui/text"; +export type { TextProps } from "./ui/text/text.types"; +export { default as Button } from "./ui/button"; +export type { ButtonProps } from "./ui/button/button.types"; +export { default as Skeleton } from "./ui/skeleton"; +export { default as ClipboardCopyText } from "./ui/clipboard-copy-text"; +export type { ClipboardCopyTextProps } from "./ui/clipboard-copy-text/clipboard-copy-text.types"; +export { default as ThemeProvider } from "./ui/theme-provider"; +export type { ThemeProviderProps } from "./ui/theme-provider/theme-provider.types"; +export { default as OverlaysManager } from "./ui/overlays-manager"; +export type { OverlaysManagerProps } from "./ui/overlays-manager/overlays-manager.types"; +export { default as InterchainUIProvider } from "./ui/interchain-ui-provider"; +export { default as FadeIn } from "./ui/fade-in"; +export { default as PoolsHeader } from "./ui/pools-header"; +export type { PoolsHeaderProps } from "./ui/pools-header/pools-header.types"; +export { default as PoolList } from "./ui/pool-list"; +export type { PoolListProps } from "./ui/pool-list/pool-list.types"; +export { default as PoolListItem } from "./ui/pool-list-item"; +export type { PoolListItemProps } from "./ui/pool-list-item/pool-list-item.types"; +export { default as PoolCard } from "./ui/pool-card"; +export type { PoolCardProps } from "./ui/pool-card/pool-card.types"; +export { default as PoolCardList } from "./ui/pool-card-list"; +export type { PoolCardListProps } from "./ui/pool-card-list/pool-card-list.types"; +export { default as ConnectModal } from "./ui/connect-modal"; +export type { ConnectModalProps } from "./ui/connect-modal/connect-modal.types"; +export { default as ConnectModalHead } from "./ui/connect-modal-head"; +export type { ConnectModalHeadProps } from "./ui/connect-modal-head/connect-modal-head.types"; +export { default as ConnectModalQRCode } from "./ui/connect-modal-qrcode"; +export type { QRCodeStatus } from "./ui/connect-modal-qrcode/connect-modal-qrcode.types"; +export { default as ConnectModalQRCodeError } from "./ui/connect-modal-qrcode-error"; +export type { ConnectModalQRCodeErrorProps } from "./ui/connect-modal-qrcode-error/connect-modal-qrcode-error.types"; +export { default as ConnectModalQRCodeSkeleton } from "./ui/connect-modal-qrcode-skeleton"; +export { default as ConnectModalStatus } from "./ui/connect-modal-status"; +export type { ConnectModalStatusProps } from "./ui/connect-modal-status/connect-modal-status.types"; +export { default as ConnectModalWalletButton } from "./ui/connect-modal-wallet-button"; +export type { ConnectModalWalletButtonProps } from "./ui/connect-modal-wallet-button/connect-modal-wallet-button.types"; +export { default as ConnectModalWalletList } from "./ui/connect-modal-wallet-list"; +export type { ConnectModalWalletListProps } from "./ui/connect-modal-wallet-list/connect-modal-wallet-list.types"; +export { default as Reveal } from "./ui/reveal"; +export { default as I18nProvider } from "./ui/i18n-provider"; +export { default as PoolInfoHeader } from "./ui/pool-info-header"; +export type { PoolInfoHeaderProps } from "./ui/pool-info-header/pool-info-header.types"; +export { default as ManageLiquidityCard } from "./ui/manage-liquidity-card"; +export type { ManageLiquidityCardProps } from "./ui/manage-liquidity-card/manage-liquidity-card.types"; +export { default as BondingCard } from "./ui/bonding-card"; +export type { BondingCardProps } from "./ui/bonding-card/bonding-card.types"; +export { default as BondingCardList } from "./ui/bonding-card-list"; +export type { BondingCardListProps } from "./ui/bonding-card-list/bonding-card-list.types"; +export { default as BondingListItem } from "./ui/bonding-list-item"; +export type { BondingListItemProps } from "./ui/bonding-list-item/bonding-list-item.types"; +export { default as BondingList } from "./ui/bonding-list"; +export type { BondingListProps } from "./ui/bonding-list/bonding-list.types"; +export { default as BondingListItemSm } from "./ui/bonding-list-item-sm"; +export type { BondingListItemSmProps } from "./ui/bonding-list-item-sm/bonding-list-item-sm.types"; +export { default as BondingListSm } from "./ui/bonding-list-sm"; +export type { BondingListSmProps } from "./ui/bonding-list-sm/bonding-list-sm.types"; +export { default as QRCode } from "./ui/qrcode"; +export type { QRProps } from "./ui/qrcode/qrcode.types"; +export { default as IconButton } from "./ui/icon-button"; +export type { IconButtonProps } from "./ui/icon-button/icon-button.types"; +export { default as ProgressBar } from "./ui/progress-bar"; +export type { ProgressBarProps } from "./ui/progress-bar/progress-bar.types"; +export { default as CircularProgressBar } from "./ui/circular-progress-bar"; +export type { CircularProgressBarProps } from "./ui/circular-progress-bar/circular-progress-bar.types"; +export { default as TokenInput } from "./ui/token-input"; +export type { TokenInputProps } from "./ui/token-input/token-input.types"; +export { default as AddLiquidity } from "./ui/add-liquidity"; +export type { AddLiquidityProps } from "./ui/add-liquidity/add-liquidity.types"; +export { default as RemoveLiquidity } from "./ui/remove-liquidity"; +export type { RemoveLiquidityProps } from "./ui/remove-liquidity/remove-liquidity.types"; +export { default as BondingMore } from "./ui/bonding-more"; +export type { BondingMoreProps } from "./ui/bonding-more/bonding-more.types"; +export { default as AssetListHeader } from "./ui/asset-list-header"; +export type { AssetListHeaderProps } from "./ui/asset-list-header/asset-list-header.types"; +export { default as AssetListItem } from "./ui/asset-list-item"; +export type { AssetListItemProps } from "./ui/asset-list-item/asset-list-item.types"; +export { default as AssetList } from "./ui/asset-list"; +export type { AssetListProps } from "./ui/asset-list/asset-list.types"; +export { default as CrossChain } from "./ui/cross-chain"; +export type { + CrossChainProps, + CrossChainListItemProps, +} from "./ui/cross-chain/cross-chain.types"; +export { default as SingleChain } from "./ui/single-chain"; +export type { + SingleChainProps, + SingleChainListItemProps, +} from "./ui/single-chain/single-chain.types"; +export { default as OverviewTransfer } from "./ui/overview-transfer"; +export type { OverviewTransferProps } from "./ui/overview-transfer/overview-transfer.types"; +export { default as AssetWithdrawTokens } from "./ui/asset-withdraw-tokens"; +export type { AssetWithdrawTokensProps } from "./ui/asset-withdraw-tokens/asset-withdraw-tokens.types"; +export { default as NftMint } from "./ui/nft-mint"; +export type { NftMintProps } from "./ui/nft-mint/nft-mint.types"; +export { default as NftProfileCard } from "./ui/nft-profile-card"; +export type { NftProfileCardProps } from "./ui/nft-profile-card/nft-profile-card.types"; +export { default as NftProfileCardList } from "./ui/nft-profile-card-list"; +export { default as NftProfile } from "./ui/nft-profile"; +export type { NftProfileProps } from "./ui/nft-profile/nft-profile.types"; +export { default as NftDetail } from "./ui/nft-detail"; +export type { NftDetailProps } from "./ui/nft-detail/nft-detail.types"; +export { default as NftTraitListItem } from "./ui/nft-trait-list-item"; +export type { NftTraitListItemProps } from "./ui/nft-trait-list-item/nft-trait-list-item.types"; +export { default as NftTraitList } from "./ui/nft-trait-list"; +export type { NftTraitListProps } from "./ui/nft-trait-list/nft-trait-list.types"; +export { default as NftDetailInfo } from "./ui/nft-detail-info"; +export { default as NftDetailTopOffers } from "./ui/nft-detail-top-offers"; +export type { NftDetailTopOfferProps } from "./ui/nft-detail-top-offers/nft-detail-top-offers.types"; +export { default as NftDetailActivityListItem } from "./ui/nft-detail-activity-list-item"; +export type { NftDetailActivityListItemProps } from "./ui/nft-detail-activity-list-item/nft-detail-activity-list-item.types"; +export { default as NftDetailActivityList } from "./ui/nft-detail-activity-list"; +export type { NftDetailActivityListProps } from "./ui/nft-detail-activity-list/nft-detail-activity-list.types"; +export { default as Tooltip } from "./ui/tooltip"; +export type { TooltipProps } from "./ui/tooltip/tooltip.types"; +export { default as Tabs } from "./ui/tabs"; +export type { TabProps, TabsProps } from "./ui/tabs/tabs.types"; +export { default as StarText } from "./ui/star-text"; +export type { StarTextProps } from "./ui/star-text/star-text.types"; +export { default as ListForSale } from "./ui/list-for-sale"; +export { default as NftFees } from "./ui/nft-fees"; +export type { + NftFeeItemProps, + NftFeesProps, +} from "./ui/nft-fees/nft-fees.types"; +export { default as TransferItem } from "./ui/transfer-item"; +export type { TransferItemProps } from "./ui/transfer-item/transfer-item.types"; +export { default as SwapToken } from "./ui/swap-token"; +export type { + SwapInfo, + SwapItemProps, + SwapTokenProps, +} from "./ui/swap-token/swap-token.types"; +export { default as SwapPrice } from "./ui/swap-price"; +export type { SwapPriceProps } from "./ui/swap-price/swap-price.types"; +export { default as BasicModal } from "./ui/basic-modal"; +export type { BasicModalProps } from "./ui/basic-modal/basic-modal.types"; +export { default as NftMakeOffer } from "./ui/nft-make-offer"; +export type { NftMakeOfferProps } from "./ui/nft-make-offer/nft-make-offer.types"; +export { default as FieldLabel } from "./ui/field-label"; +export type { FieldLabelProps } from "./ui/field-label/field-label.types"; +export { default as TextField } from "./ui/text-field"; +export type { TextFieldProps } from "./ui/text-field/text-field.types"; +export { default as TextFieldAddon } from "./ui/text-field-addon"; +export type { TextFieldAddonProps } from "./ui/text-field-addon/text-field-addon.types"; +export { default as AnimateLayout } from "./ui/animate-layout"; +export type { AnimateLayoutProps } from "./ui/animate-layout/animate-layout.types"; +export { default as ListItem } from "./ui/list-item"; +export type { ListItemProps } from "./ui/list-item/list-item.types"; +export { default as ChainListItem } from "./ui/chain-list-item"; +export type { ChainListItemProps } from "./ui/chain-list-item/chain-list-item.types"; +export { default as ChainSwapInput } from "./ui/chain-swap-input"; +export type { ChainSwapInputProps } from "./ui/chain-swap-input/chain-swap-input.types"; +export { default as SelectButton } from "./ui/select-button"; +export type { SelectButtonProps } from "./ui/select-button/select-button.types"; +export { default as ConnectedWallet } from "./ui/connected-wallet"; +export type { ConnectedWalletProps } from "./ui/connected-wallet/connected-wallet.types"; +export { default as Spinner } from "./ui/spinner"; +export { default as StakingAssetHeader } from "./ui/staking-asset-header"; +export { default as StakingClaimHeader } from "./ui/staking-claim-header"; + +// ==== Validators +export { default as ValidatorList } from "./ui/validator-list"; +export { default as ValidatorNameCell } from "./ui/validator-list/validator-name-cell"; +export { default as ValidatorTokenAmountCell } from "./ui/validator-list/validator-token-amount-cell"; + +export { default as LiquidStaking } from "./ui/liquid-staking"; +export type { LiquidStakingProps } from "./ui/liquid-staking/liquid-staking.types"; +export type { SpinnerProps } from "./ui/spinner/spinner.types"; +export { default as Divider } from "./ui/divider"; +export type { DividerProps } from "./ui/divider/divider.types"; +export { default as Carousel } from "./ui/carousel"; +export type { CarouselProps } from "./ui/carousel/carousel.types"; +export { default as Accordion } from "./ui/accordion"; +export type { AccordionProps } from "./ui/accordion/accordion.types"; +export { default as Breadcrumb } from "./ui/breadcrumb"; +export { default as BreadcrumbItem } from "./ui/breadcrumb/breadcrumb-item"; +export type { + BreadcrumbProps, + BreadcrumbLink, +} from "./ui/breadcrumb/breadcrumb.types"; +export { default as ScrollIndicator } from "./ui/scroll-indicator"; +export type { ScrollIndicatorProps } from "./ui/scroll-indicator/scroll-indicator.types"; +export { default as NftMinimumOffer } from "./ui/nft-minimum-offer"; +export type { NftMinimumOfferProps } from "./ui/nft-minimum-offer/nft-minimum-offer.types"; +export { default as NftSellNow } from "./ui/nft-sell-now"; +export type { NftSellNowProps } from "./ui/nft-sell-now/nft-sell-now.types"; +export { default as NftTransfer } from "./ui/nft-transfer"; +export type { NftTransferProps } from "./ui/nft-transfer/nft-transfer.types"; +export { default as Toast } from "./ui/toast"; +export { default as Toaster } from "./ui/toast/toaster"; +export { toast } from "./ui/toast/toast.state"; +export type { + ToastProps, + ToasterProps, + ToastType, + Toast as ToastShape, + ToastPosition, +} from "./ui/toast/toast.types"; +export { default as Avatar } from "./ui/avatar"; +export { default as AvatarBadge } from "./ui/avatar-badge"; +export { default as AvatarImage } from "./ui/avatar-image"; +export { default as AvatarName } from "./ui/avatar-name"; +export type { AvatarProps, AvatarBadgeProps } from "./ui/avatar/avatar.types"; +export { default as ChangeChainListItem } from "./ui/change-chain-list-item"; +export type { ChangeChainListItemProps } from "./ui/change-chain-list-item/change-chain-list-item.types"; +export { default as ChangeChainInput } from "./ui/change-chain-input"; +export type { ChangeChainInputProps } from "./ui/change-chain-input/change-chain-input.types"; + +// Staking delegate +export { default as StakingDelegate } from "./ui/staking-delegate"; +export { default as StakingDelegateCard } from "./ui/staking-delegate/staking-delegate-card"; +export { default as StakingDelegateInput } from "./ui/staking-delegate/staking-delegate-input"; + +// Table +export { default as Table } from "./ui/table"; +export { default as TableHead } from "./ui/table/table-head"; +export { default as TableBody } from "./ui/table/table-body"; +export { default as TableRow } from "./ui/table/table-row"; +export { default as TableCell } from "./ui/table/table-cell"; +export { default as TableRowHeaderCell } from "./ui/table/table-row-header-cell"; +export { default as TableColumnHeaderCell } from "./ui/table/table-column-header-cell"; + +// Marketing components +export { default as Timeline } from "./ui/timeline"; + +// Governance +export { default as GovernanceProposalItem } from "./ui/governance/governance-proposal-item"; +export { default as GovernanceVoteBreakdown } from "./ui/governance/governance-vote-breakdown"; +export { default as GovernanceVoteForm } from "./ui/governance/governance-vote-form"; +export { default as GovernanceResultCard } from "./ui/governance/governance-result-card"; + +// Mesh security +export { default as MeshProvider } from "./ui/mesh-staking/mesh-provider"; +export { default as MeshStakingSliderInfo } from "./ui/mesh-staking/mesh-staking-slider-info"; +export { default as MeshButton } from "./ui/mesh-staking/mesh-button"; +export { default as MeshTagButton } from "./ui/mesh-staking/mesh-tag-button"; +export { default as MeshTab } from "./ui/mesh-staking/mesh-tab"; +export { default as MeshFooterInfoItem } from "./ui/mesh-staking/mesh-footer-info-item"; +export { default as MeshValidatorSquadEmpty } from "./ui/mesh-staking/mesh-validator-squad-empty"; +export { default as MeshModal } from "./ui/mesh-modal"; +export { default as MeshTable } from "./ui/mesh-staking/mesh-table"; +export { default as MeshTableChainCell } from "./ui/mesh-staking/mesh-table-chain-cell"; +export { default as MeshTableAPRCell } from "./ui/mesh-staking/mesh-table-apr-cell"; +export { default as MeshTableHeaderAction } from "./ui/mesh-staking/mesh-table-header-action"; +export { default as MeshTableValidatorsCell } from "./ui/mesh-staking/mesh-table-validators-cell"; + +// Noble +export { default as NobleProvider } from "./ui/noble/noble-provider"; +export { default as NobleTxDirectionCard } from "./ui/noble/noble-tx-direction-card"; +export { default as NobleTxProgressBar } from "./ui/noble/noble-tx-progress-bar"; +export { default as NobleTxStepItem } from "./ui/noble/noble-tx-step-item"; +export { default as NobleButton } from "./ui/noble/noble-button"; +export { default as NobleInput } from "./ui/noble/noble-input"; +export { default as NobleSelectNetworkButton } from "./ui/noble/noble-select-network-button"; +export { default as NobleSelectTokenButton } from "./ui/noble/noble-select-token-button"; +export { default as NobleSelectWalletButton } from "./ui/noble/noble-select-wallet-button"; +export { default as NobleTokenAvatar } from "./ui/noble/noble-token-avatar"; +export { default as NobleTxChainRoute } from "./ui/noble/noble-tx-chain-route"; +export { default as NobleTxEstimate } from "./ui/noble/noble-tx-estimate"; +export { default as NoblePageTitleBar } from "./ui/noble/noble-page-title-bar"; +export { default as NobleTxHistoryOverviewItem } from "./ui/noble/noble-tx-history-overview-item"; + + +export { default as Modal } from './ui/modal'; +export { default as Select } from './ui/select'; +export { default as Combobox } from './ui/combobox'; +export { default as Slider } from './ui/slider'; +export { default as SelectOption } from './ui/select-option'; +export { default as Popover } from './ui/popover'; +export { default as PopoverTrigger } from './ui/popover-trigger'; +export { default as PopoverContent } from './ui/popover-content'; +export { default as ChainSwapCombobox } from './ui/chain-swap-combobox'; +export { default as ChangeChainCombobox } from './ui/change-chain-combobox'; +export { default as NumberField } from './ui/number-field'; +export { default as GovernanceCheckbox } from './ui/governance-checkbox'; +export { default as GovernanceRadio } from './ui/governance-radio'; +export { default as GovernanceRadioGroup } from './ui/governance-radio-group'; +export { default as NobleChainCombobox } from './ui/noble-chain-combobox'; +// End Components + +import "client-only"; +export { default as useColorModeValue } from './ui/hooks/use-color-mode-value'; +export { default as useTheme } from './ui/hooks/use-theme'; \ No newline at end of file diff --git a/packages/react/src/models/components.model.ts b/packages/react/src/models/components.model.ts new file mode 100644 index 00000000..c0618b9f --- /dev/null +++ b/packages/react/src/models/components.model.ts @@ -0,0 +1,35 @@ +import type { Sprinkles } from "../styles/rainbow-sprinkles.css"; + +export type ComponentRef = T & ((el: T) => void); +export type Children = React.ReactNode; // TODO + +export interface BaseComponentProps { + className?: string; + class?: string; // Fallback className + classList?: string; // Fallback class + children?: Children; + forwardedRef?: any; +} + +export type BaseState = { + loaded: boolean; +}; + +export type CSS = Partial & { + [key: string]: Partial | string; +}; + +export type NumberFormatOptions = { + minimumFractionDigits: number; + maximumFractionDigits: number; +}; + +export type GridColumn = { + id: string; + width?: Sprinkles["width"]; + label?: string; + align?: "left" | "center" | "right"; + color?: Sprinkles["color"]; + textTransform?: Sprinkles["textTransform"]; + render?: (value: any, column: GridColumn, isPinned?: boolean) => Children; +}; diff --git a/packages/react/src/models/store.ts b/packages/react/src/models/store.ts new file mode 100644 index 00000000..c8abd974 --- /dev/null +++ b/packages/react/src/models/store.ts @@ -0,0 +1,170 @@ +import { createStore } from "zustand/vanilla"; +import { persist } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; +import { current } from "immer"; + +import BigNumber from "bignumber.js"; +import { + ModePreference, + ThemeVariant, + NumberFormatter, + NumberFormatProps, +} from "./system.model"; +import { isPreferDarkMode, isPreferLightMode } from "../helpers/style"; +import { + getCurrencyFormatter, + safelyFormatNumberWithFallback, +} from "../helpers/number"; +import { darkThemeClass, lightThemeClass } from "../styles/themes.css"; +import { Accent } from "../styles/tokens"; +import { + OverrideStyleManager, + assignThemeVars, +} from "../styles/override/override"; +import type { ThemeDef } from "../styles/override/override.types"; + +export const STORAGE_NAME = "interchain-ui-store"; + +export interface UIState { + // This is the value persisted in localstorage + themeMode: ModePreference; + // This is the value that our theme system uses for styling .ie styleVariants + // which is derived from themeMode + theme: ThemeVariant; + themeClass: string; + themeClasses: [string, string]; + themeAccent: Accent; + customTheme: string | null; + themeDefs: Array; + overrideStyleManager: OverrideStyleManager; + // Useful for use in SSR frameworks to check if the store has hydrated + // and merged state with localstorage yet + _hasHydrated: boolean; +} + +export interface UIAction { + setThemeMode: (mode: ModePreference) => void; + setTheme: (theme: ThemeVariant, themeClass: string) => void; + setThemeDefs: (defs: Array, defaultTheme?: string) => void; + setCustomTheme: (customTheme: string) => void; + setThemeAccent: (accent: Accent) => void; + setHasHydrated: (hasHydrated: boolean) => void; +} + +export interface I18nState { + formatNumber: NumberFormatter; +} + +export interface I18nAction { + setFormatNumberFn: (fn: NumberFormatter) => void; +} + +export interface UIStore extends UIState, UIAction, I18nState, I18nAction {} + +export const store = createStore( + persist( + immer((set) => ({ + themeMode: null, + theme: null, + themeClass: "", + themeClasses: [lightThemeClass, darkThemeClass], + themeAccent: "blue", + // Custom theme contract + themeDefs: [], + customTheme: null, + overrideStyleManager: new OverrideStyleManager("light"), + _hasHydrated: false, + setTheme: (newTheme: ThemeVariant, themeClass: string) => + set((state) => { + state.theme = newTheme; + state.themeClass = themeClass; + }), + setThemeMode: (newThemeMode: ModePreference) => + set((state) => { + const resolveSystemMode = ( + themeMode: ModePreference, + ): [ThemeVariant, string] => { + if (themeMode === "system") { + if (isPreferDarkMode()) { + return ["dark", darkThemeClass]; + } + if (isPreferLightMode()) { + return ["light", lightThemeClass]; + } + } else { + const themeClass = { + dark: darkThemeClass, + light: lightThemeClass, + }[themeMode]; + + return [themeMode, themeClass]; + } + }; + + const [resolvedTheme, resolvedClass] = + resolveSystemMode(newThemeMode); + + state.overrideStyleManager?.update(null, resolvedTheme); + state.themeMode = newThemeMode; + state.theme = resolvedTheme; + state.themeClass = resolvedClass; + }), + setThemeDefs: (defs: Array) => { + set((state) => { + state.themeDefs = defs; + }); + }, + setCustomTheme: (customTheme: string) => + set((state) => { + state.customTheme = customTheme; + const currentState = current(state); + + if (customTheme !== null) { + const customThemeObj = currentState.themeDefs.find( + (item) => item.name === customTheme, + ); + + if (customThemeObj) { + assignThemeVars(customThemeObj.vars, state.theme); + } + } + }), + setThemeAccent: (accent: Accent) => + set((state) => { + state.themeAccent = accent; + }), + formatNumber: (props: NumberFormatProps): string => { + const formatter = getCurrencyFormatter("en-US", { + currency: "USD", + style: props.style, + }); + return safelyFormatNumberWithFallback( + formatter, + new BigNumber(props.value), + ); + }, + setFormatNumberFn: (fn: NumberFormatter) => + set((state) => { + state.formatNumber = fn; + }), + setHasHydrated: (hasHydrated) => { + set({ + _hasHydrated: hasHydrated, + }); + }, + })), + { + name: STORAGE_NAME, + onRehydrateStorage: (state) => { + return (persistedState) => { + state.setHasHydrated(true); + state.setThemeMode(persistedState.themeMode); + }; + }, + // Only choose to persist theme preference, ignore other state + partialize: (state) => ({ + themeMode: state.themeMode, + }), + }, + ), +); diff --git a/packages/react/src/models/system.model.ts b/packages/react/src/models/system.model.ts new file mode 100644 index 00000000..70060f5a --- /dev/null +++ b/packages/react/src/models/system.model.ts @@ -0,0 +1,31 @@ +export enum Intent { + None = "none", + Info = "info", + Success = "warning", + Warning = "success", + Error = "error", +} + +export const intents = Object.entries(Intent).map( + ([key, value]: [string, string]) => ({ key, value }), +); + +export type IntentValues = "none" | "info" | "warning" | "success" | "error"; + +export type ThemeVariant = "light" | "dark"; + +export type ModePreference = ThemeVariant | "system"; + +export const ModePreferences: ModePreference[] = ["light", "dark", "system"]; + +export interface NumberFormatProps { + value: number | string; + style?: keyof Intl.NumberFormatOptionsStyleRegistry; +} +export type NumberFormatter = (props: NumberFormatProps) => string; + +export type Token = { + iconSrc: string; + name: string; + symbol: string; +}; diff --git a/packages/react/src/styles/global.css.ts b/packages/react/src/styles/global.css.ts new file mode 100644 index 00000000..e3e8fe58 --- /dev/null +++ b/packages/react/src/styles/global.css.ts @@ -0,0 +1,39 @@ +import { globalStyle } from "@vanilla-extract/css"; +import { SYSTEM_FONT_STACK } from "../styles/tokens/typography"; + +globalStyle(`*, *::before, *::after`, { + boxSizing: `border-box`, +}); + +globalStyle(`*`, { + margin: 0, +}); + +globalStyle(`html, body`, { + height: `100%`, + fontFamily: `Inter, ${SYSTEM_FONT_STACK}`, +}); + +globalStyle(`body`, { + lineHeight: 1.5, + WebkitFontSmoothing: `antialiased`, +}); + +globalStyle(`img, picture, video, canvas, svg`, { + display: `block`, + maxWidth: `100%`, +}); + +globalStyle(`input, button, textarea, select, output`, { + font: `inherit`, +}); + +// Avoid text overflow +globalStyle(`p, h1, h2, h3, h4, h5, h6`, { + overflowWrap: `break-word`, +}); + +// Create a root stacking context +globalStyle(`#root`, { + isolation: `isolate`, +}); diff --git a/packages/react/src/styles/layers.css.ts b/packages/react/src/styles/layers.css.ts new file mode 100644 index 00000000..b8aa0419 --- /dev/null +++ b/packages/react/src/styles/layers.css.ts @@ -0,0 +1,7 @@ +import { globalLayer } from "@vanilla-extract/css"; + +// CSS Layers +// Last layer defined here will have the most power +// See: https://vanilla-extract.style/documentation/api/layer/#layer +export const baseLayer = globalLayer("base"); +export const themeLayer = globalLayer("theme"); diff --git a/packages/react/src/styles/override/override.ts b/packages/react/src/styles/override/override.ts new file mode 100644 index 00000000..820bce8f --- /dev/null +++ b/packages/react/src/styles/override/override.ts @@ -0,0 +1,150 @@ +import { assignInlineVars, setElementVars } from "@vanilla-extract/dynamic"; +import { merge } from "lodash"; +import type { + OverridableProp, + OverrideValue, + ComponentOverrideMap, + ComponentOverrideSchema, + OverridableComponents, + CustomThemeVars, +} from "./override.types"; +import type { ThemeVariant } from "../../models/system.model"; +import { + themeVars, + commonVars, + darkThemeClass, + lightThemeClass, +} from "../../styles/themes.css"; +import type { ThemeContractValues } from "../../styles/themes.css"; + +// ==== +import { buttonOverrides } from "../../ui/button/button.helper"; +import { clipboardCopyTextOverrides } from "../../ui/clipboard-copy-text/clipboard-copy-text.helper"; +import { connectModalOverrides } from "../../ui/connect-modal/connect-modal.helper"; +import { connectModalHeadTitleOverrides } from "../../ui/connect-modal-head/connect-modal-head.helper"; +import { installButtonOverrides } from "../../ui/connect-modal-install-button/connect-modal-install-button.helper"; +import { + connectQRCodeOverrides, + connectQRCodeShadowOverrides, +} from "../../ui/connect-modal-qrcode/connect-modal-qrcode.helper"; +import { + connectModalQRCodeErrorOverrides, + connectModalQRCodeErrorButtonOverrides, +} from "../../ui/connect-modal-qrcode-error/connect-modal-qrcode-error.helper"; +import { connectModalQRCodeSkeletonOverrides } from "../../ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.helper"; +import { + buttonOverrides as walletButtonOverrides, + buttonLabelOverrides as walletButtonLabelOverrides, + buttonSublogoOverrides as walletButtonSubLogoOverrides, +} from "../../ui/connect-modal-wallet-button/connect-modal-wallet-button.helper"; + +// Must manually add the overrides schema to this object for every component that you want users +// to be able to override styles +const overrideSchemas: Record = + { + button: buttonOverrides, + "clipboard-copy-text": clipboardCopyTextOverrides, + "connect-modal": connectModalOverrides, + "connect-modal-head-title": connectModalHeadTitleOverrides, + "connect-modal-install-button": installButtonOverrides, + "connect-modal-qr-code": connectQRCodeOverrides, + "connect-modal-qr-code-shadow": connectQRCodeShadowOverrides, + "connect-modal-qr-code-error": connectModalQRCodeErrorOverrides, + "connect-modal-qr-code-error-button": + connectModalQRCodeErrorButtonOverrides, + "connect-modal-qr-code-loading": connectModalQRCodeSkeletonOverrides, + "connect-modal-wallet-button": walletButtonOverrides, + "connect-modal-wallet-button-label": walletButtonLabelOverrides, + "connect-modal-wallet-button-sublogo": walletButtonSubLogoOverrides, + }; + +export class OverrideStyleManager { + _overrideMap: ComponentOverrideMap; + _theme: ThemeVariant; + + constructor(theme: ThemeVariant) { + this._overrideMap = {}; + this._theme = theme; + } + + update(overrideMap: ComponentOverrideMap | null, theme: ThemeVariant | null) { + if (overrideMap) { + this._overrideMap = overrideMap; + } + if (theme) { + this._theme = theme; + } + } + + applyOverrides(component: OverridableComponents) { + const schema = overrideSchemas[component]; + const componentOverrideValue = this._overrideMap[component] ?? {}; + const configByTheme = groupByTheme(componentOverrideValue); + + if (!schema || !configByTheme) { + return {}; + } + + const config = configByTheme[this._theme]; + const varsMap: Record = {}; + + // Get the actual css value for each css var + for (const [vanillaCssVar, propertyName] of schema.overrides) { + const propertyValue = config[propertyName]; + + if (!propertyValue) { + continue; + } + + varsMap[`${vanillaCssVar}`] = propertyValue; + } + + return assignInlineVars(varsMap); + } +} + +// Transform config object +// From: +// { bg: { light: 'red500', dark: 'blue400' }, +// color: { light: 'yellow200', dark: 'gray400'} +// } +// To: { +// light: { bg: 'red500', color: 'yellow200' }, +// dark: { bg: 'blue400', color: 'gray400' } +// } + +function groupByTheme(config: OverrideValue) { + if (!config) return null; + + const result: Record< + ThemeVariant, + Partial> + > = { + light: {}, + dark: {}, + }; + + for (const [overrideProperty, propertyConfig] of Object.entries(config)) { + for (const theme of ["light", "dark"] as ThemeVariant[]) { + result[theme] = { + ...result[theme], + [overrideProperty]: propertyConfig[theme], + }; + } + } + + return result; +} + +export function assignThemeVars( + customTheme: CustomThemeVars, + colorMode: ThemeVariant, +) { + const schemeClass = colorMode === "light" ? lightThemeClass : darkThemeClass; + const elements = document.getElementsByClassName(schemeClass); + const mergedVars = merge(commonVars, customTheme as ThemeContractValues); + + for (let el of elements) { + setElementVars(el as HTMLElement, themeVars, mergedVars); + } +} diff --git a/packages/react/src/styles/override/override.types.ts b/packages/react/src/styles/override/override.types.ts new file mode 100644 index 00000000..70e355b8 --- /dev/null +++ b/packages/react/src/styles/override/override.types.ts @@ -0,0 +1,75 @@ +import { createVar } from "@vanilla-extract/css"; +import { themeContractTemplate } from "../../styles/themes.css"; +import type { LiteralUnion, PartialDeep } from "../../helpers/types"; +import type { DeepStringConstructor } from "../../helpers/types"; + +export type CssVar = ReturnType; + +export type OverridableState = "hover" | "active" | "disabled" | "focused"; + +export type Bg = "bg" | `${OverridableState}Bg`; + +export type TextColor = "color" | `${OverridableState}Color`; + +export type Shadow = "shadow" | `${OverridableState}Shadow`; + +export type BorderColor = "borderColor" | `${OverridableState}BorderColor`; + +export type OverridableProp = Bg | TextColor | Shadow | BorderColor; + +// Add more slots here when you need a component to be overridable +// TODO: infer through a register() function so that we don't need to do this manually +export type OverridableComponents = + | "button" + | "clipboard-copy-text" + | "connect-modal" + | "connect-modal-install-button" + | "connect-modal-head-title" + // == Connect wallet button in wallet list + | "connect-modal-wallet-button" + | "connect-modal-wallet-button-label" + | "connect-modal-wallet-button-sublogo" + | "connect-modal-qr-code" + | "connect-modal-qr-code-shadow" + | "connect-modal-qr-code-error" + | "connect-modal-qr-code-error-button" + | "connect-modal-qr-code-loading"; + +export type OverrideValue = Partial< + Record +>; + +export type OverrideValueByVariant< + TVariant extends LiteralUnion<"default", string>, +> = Record; + +// This is provided for user to provide concrete values to override a component +export type ComponentOverrideMap = Partial< + Record +>; + +// This tells us how to override a component +export type ComponentOverrideSchema = { + name: OverridableComponents; + overrides: Array<[CssVar, OverridableProp]>; +}; + +export type CustomThemeVars = PartialDeep< + DeepStringConstructor +>; + +// Theme contract customization +export type SingleThemeDef = { + name: string; + vars: CustomThemeVars; +}; + +export type DualThemeDef = { + name: string; + vars: { + light: CustomThemeVars; + dark: CustomThemeVars; + }; +}; + +export type ThemeDef = SingleThemeDef; diff --git a/packages/react/src/styles/rainbow-sprinkles.css.ts b/packages/react/src/styles/rainbow-sprinkles.css.ts new file mode 100644 index 00000000..7112941b --- /dev/null +++ b/packages/react/src/styles/rainbow-sprinkles.css.ts @@ -0,0 +1,249 @@ +import { + createRainbowSprinkles, + defineProperties, + SprinklesFn, +} from "rainbow-sprinkles"; +import { themeVars } from "./themes.css"; +import { breakpoints } from "./tokens"; + +const extendedSpace = { + "1/5": "20%", + "1/4": "25%", + "1/3": "33.333333%", + "1/2": "50%", + "2/3": "66.666667%", + "3/4": "75%", +}; + +const allSpace = { ...themeVars.space, ...extendedSpace }; + +const margins = themeVars.space; + +const responsiveProperties = defineProperties({ + conditions: transformBreakpoints<{ + mobile: {}; + tablet: {}; + desktop: {}; + mdMobile: {}; + }>(breakpoints), + defaultCondition: "mobile", + dynamicProperties: { + display: true, + tableLayout: true, + backgroundImage: true, + backgroundSize: true, + backgroundPosition: true, + backgroundRepeat: true, + objectFit: true, + flex: true, + flexBasis: true, + flexShrink: true, + flexGrow: true, + flexDirection: true, + flexWrap: true, + alignItems: true, + alignSelf: true, + justifyContent: true, + padding: true, + paddingLeft: true, + paddingRight: true, + paddingTop: true, + paddingBottom: true, + width: true, + height: true, + minWidth: true, + minHeight: true, + maxWidth: true, + maxHeight: true, + borderRadius: themeVars.radii, + borderTopLeftRadius: true, + borderBottomLeftRadius: true, + borderTopRightRadius: true, + borderBottomRightRadius: true, + fontFamily: themeVars.font, + fontSize: themeVars.fontSize, + lineHeight: themeVars.lineHeight, + textAlign: true, + zIndex: themeVars.zIndex, + position: true, + top: margins, + left: margins, + right: margins, + bottom: margins, + verticalAlign: true, + margin: margins, + marginBottom: margins, + marginLeft: margins, + marginRight: margins, + marginTop: margins, + letterSpacing: themeVars.letterSpacing, + textTransform: true, + fontWeight: themeVars.fontWeight, + whiteSpace: true, + wordBreak: true, + overflowWrap: true, + fill: true, + overflow: true, + overflowX: true, + overflowY: true, + textOverflow: true, + aspectRatio: true, + opacity: true, + cursor: true, + gridTemplateColumns: true, + grid: true, + gridArea: true, + gridAutoColumns: true, + gridAutoFlow: true, + gridAutoRows: true, + gridColumn: true, + gridColumnEnd: true, + gridColumnStart: true, + gridTemplate: true, + gridTemplateAreas: true, + gridTemplateRows: true, + gridRow: true, + gridRowEnd: true, + gridRowStart: true, + gap: themeVars.space, + columnGap: themeVars.space, + rowGap: themeVars.space, + inset: true, + insetInlineStart: true, + insetInlineEnd: true, + transform: true, + appearance: true, + userSelect: true, + }, + staticProperties: { + display: [ + "block", + "inline-block", + "inline", + "flex", + "inline-flex", + "grid", + "inline-grid", + "table", + "table-row", + "table-cell", + "none", + ], + position: ["absolute", "relative", "fixed", "sticky"], + padding: allSpace, + paddingLeft: allSpace, + paddingRight: allSpace, + paddingTop: allSpace, + paddingBottom: allSpace, + width: allSpace, + height: allSpace, + minWidth: allSpace, + minHeight: allSpace, + maxWidth: allSpace, + maxHeight: allSpace, + }, + shorthands: { + p: ["padding"], + pl: ["paddingLeft"], + pr: ["paddingRight"], + pt: ["paddingTop"], + pb: ["paddingBottom"], + paddingX: ["paddingLeft", "paddingRight"], + paddingY: ["paddingTop", "paddingBottom"], + px: ["paddingLeft", "paddingRight"], + py: ["paddingTop", "paddingBottom"], + m: ["margin"], + mr: ["marginRight"], + ml: ["marginLeft"], + mt: ["marginTop"], + mb: ["marginBottom"], + marginX: ["marginLeft", "marginRight"], + marginY: ["marginTop", "marginBottom"], + mx: ["marginLeft", "marginRight"], + my: ["marginTop", "marginBottom"], + }, +}); + +const interactiveProperties = defineProperties({ + conditions: { + base: {}, + hover: { selector: "&:hover:not([disabled])" }, + focus: { selector: "&:focus:not([disabled])" }, + active: { selector: "&:active:not([disabled])" }, + }, + defaultCondition: "base", + dynamicProperties: { + color: themeVars.colors, + outline: true, + visibility: true, + filter: true, + fill: themeVars.colors, + stroke: themeVars.colors, + backgroundColor: themeVars.colors, + borderWidth: themeVars.borderWidth, + borderStyle: themeVars.borderStyle, + borderColor: themeVars.colors, + borderBottomColor: themeVars.colors, + borderBottomStyle: true, + borderBottomWidth: true, + borderTopColor: themeVars.colors, + borderTopStyle: true, + borderTopWidth: true, + borderLeftColor: themeVars.colors, + borderLeftStyle: true, + borderLeftWidth: true, + borderRightColor: themeVars.colors, + borderRightStyle: true, + borderRightWidth: true, + borderCollapse: true, + boxShadow: themeVars.boxShadow, + transform: true, + transition: true, + transitionProperty: true, + transitionDuration: true, + animation: true, + textDecoration: true, + zIndex: themeVars.zIndex, + fontVariantNumeric: true, + }, + staticProperties: { + color: themeVars.colors, + backgroundColor: themeVars.colors, + borderColor: themeVars.colors, + boxShadow: themeVars.boxShadow, + visibility: ["collapse", "hidden", "visible"], + }, + shorthands: { + bg: ["backgroundColor"], + border: ["borderWidth", "borderStyle", "borderColor"], + }, +}); + +export const rainbowSprinkles = createRainbowSprinkles( + responsiveProperties, + interactiveProperties, +); + +export type Sprinkles = Parameters[0]; + +function transformBreakpoints(input: Record) { + let responsiveConditions!: Output; + + Object.entries(input).forEach(([key, value]) => { + if (value === 0) { + responsiveConditions = { + ...responsiveConditions, + [key]: {}, + }; + } else { + responsiveConditions = { + ...responsiveConditions, + [key]: { + "@media": `screen and (min-width: ${value}px)`, + }, + }; + } + }); + + return responsiveConditions; +} diff --git a/packages/react/src/styles/themes.css.ts b/packages/react/src/styles/themes.css.ts new file mode 100644 index 00000000..896be94d --- /dev/null +++ b/packages/react/src/styles/themes.css.ts @@ -0,0 +1,760 @@ +import { + createThemeContract, + createTheme, + globalFontFace, +} from "@vanilla-extract/css"; +import { + colors, + SYSTEM_FONT_STACK, + letterSpacing, + lineHeight, + fontSize, + fontWeight, + radii, + borderStyle, + borderWidth, + space, + zIndex, +} from "./tokens"; + +const fontInterName = "Inter"; + +globalFontFace(fontInterName, { + src: `url(https://fonts.googleapis.com/css?family=Inter)`, + fontWeight: [100, 200, 300, 400, 500, 600, 700, 800, 900], + fontStyle: `normal`, + fontDisplay: `swap`, +}); + +export const boxShadow = { + xs: `0 0 0 1px rgba(0, 0, 0, 0.05)`, + sm: `0 1px 2px 0 rgba(0, 0, 0, 0.05)`, + base: `0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)`, + md: `0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)`, + lg: `0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)`, + xl: `0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)`, + "2xl": `0 25px 50px -12px rgba(0, 0, 0, 0.25)`, + inset: `inset 0 2px 4px 0 rgba(0,0,0,0.06)`, + primaryOutline: `0 0 0 2px ${colors.primary200}`, + none: `none`, + "dark-lg": `rgba(0, 0, 0, 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0.2) 0px 5px 10px, rgba(0, 0, 0, 0.4) 0px 15px 40px`, +}; + +export const themeContractTemplate = { + colors: { + accent: ``, + accentText: ``, + primary: ``, + body: ``, + background: ``, + link: ``, + linkHover: ``, + text: ``, + textInverse: ``, + textSecondary: ``, + textMuted: ``, + textDanger: ``, + textWarning: ``, + textPlaceholder: ``, + textSuccess: ``, + rewardBg: ``, + rewardContent: ``, + cardBg: ``, + inputBorder: ``, + inputBorderFocus: ``, + inputBg: ``, + inputDangerBorder: "", + inputDangerBg: "", + inputDisabledBg: "", + inputDisabledText: "", + progressBg: ``, + progressValue: ``, + progressCursor: ``, + trackBg: ``, + divider: ``, + menuItemBg: ``, + menuItemBgSelected: ``, + menuItemBgHovered: ``, + menuItemBgActive: ``, + // ==== TagButton + tagButtonText: ``, + tagButtonBg: ``, + tagButtonBgHovered: ``, + // ==== MeshButton + meshButtonSolidPrimaryBg: ``, + meshButtonSolidPrimaryBgHovered: ``, + meshButtonSolidPrimaryText: ``, + meshButtonSolidSecondaryBg: ``, + meshButtonSolidSecondaryBgHovered: ``, + meshButtonSolidSecondaryText: ``, + meshButtonGhostText: ``, + meshButtonGhostTextHovered: ``, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: ``, + meshStakingSliderInfoSecondaryText: ``, + meshStakingSliderInfoSecondaryTextActive: ``, + // ==== MeshTab + meshTabText: ``, + // ==== MeshTableCell + meshTableCellText: ``, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: ``, + meshTableHeaderActionSecondaryText: ``, + meshTableCellBorder: ``, + // ==== + skeletonBg: ``, + overflowShadowBg: ``, + ...colors, + }, + font: { + body: ``, + }, + space: { + "0": ``, + "1": ``, + "2": ``, + "3": ``, + "4": ``, + "5": ``, + "6": ``, + "7": ``, + "8": ``, + "9": ``, + "10": ``, + "11": ``, + "12": ``, + "13": ``, + "14": ``, + "15": ``, + "16": ``, + "17": ``, + "18": ``, + "19": ``, + "20": ``, + "21": ``, + "22": ``, + "23": ``, + "24": ``, + "25": ``, + "26": ``, + "27": ``, + "28": ``, + "29": ``, + "30": ``, + auto: ``, + full: ``, + fit: ``, + max: ``, + min: ``, + viewHeight: ``, + viewWidth: ``, + containerSm: ``, + containerMd: ``, + containerLg: ``, + containerXl: ``, + prose: ``, + none: ``, + }, + borderWidth: { + none: ``, + sm: ``, + base: ``, + md: ``, + lg: ``, + xl: ``, + }, + borderStyle: { + none: ``, + solid: ``, + dotted: ``, + dashed: ``, + groove: ``, + ridge: ``, + hidden: ``, + double: ``, + inset: ``, + outset: ``, + unset: ``, + }, + boxShadow: { + xs: ``, + sm: ``, + base: ``, + md: ``, + lg: ``, + xl: ``, + "2xl": ``, + inset: ``, + primaryOutline: ``, + none: ``, + "dark-lg": ``, + }, + radii: { + none: ``, + sm: ``, + base: ``, + md: ``, + lg: ``, + xl: ``, + "2xl": ``, + "3xl": ``, + "4xl": ``, + full: ``, + }, + letterSpacing: { + tighter: ``, + tight: ``, + normal: ``, + wide: ``, + wider: ``, + widest: ``, + }, + lineHeight: { + normal: ``, + none: ``, + shorter: ``, + short: ``, + base: ``, + tall: ``, + taller: ``, + }, + fontWeight: { + hairline: ``, + thin: ``, + light: ``, + normal: ``, + medium: ``, + semibold: ``, + bold: ``, + extrabold: ``, + black: ``, + }, + fontSize: { + "3xs": ``, + "2xs": ``, + xs: ``, + sm: ``, + md: ``, + lg: ``, + xl: ``, + "2xl": ``, + "3xl": ``, + "4xl": ``, + "5xl": ``, + "6xl": ``, + "7xl": ``, + "8xl": ``, + "9xl": ``, + "10xl": ``, + "11xl": ``, + "12xl": ``, + "13xl": ``, + "14xl": ``, + "15xl": ``, + }, + zIndex: { + "-1": ``, + "0": ``, + "10": ``, + "20": ``, + "30": ``, + "40": ``, + "50": ``, + "100": ``, + auto: ``, + }, +} as const; + +export type ThemeContractValues = typeof themeContractTemplate; + +// Enforce a theme contract so that light/dark/xxx themes will have the same properties +export const themeVars = createThemeContract(themeContractTemplate); + +export const commonVars = { + font: { + body: [fontInterName, SYSTEM_FONT_STACK].join(`, `), + }, + fontSize, + fontWeight, + letterSpacing, + lineHeight, + space, + boxShadow, + radii, + borderWidth, + borderStyle, + zIndex, +}; + +export const lightThemeClass = createTheme(themeVars, { + ...commonVars, + colors: { + accent: colors.primary500, + accentText: "#2C3137", + primary: colors.primary500, + body: colors.gray800, + background: colors.gray100, + link: colors.blue800, + linkHover: colors.blue600, + text: "#2C3137", + textMuted: `#8C9196`, + textInverse: `#EEF2F8`, + textSecondary: "#697584", + textDanger: colors.red500, + textWarning: colors.orange300, + textSuccess: colors.green400, + textPlaceholder: `#A2AEBB`, + rewardBg: "#E5FFE4", + rewardContent: "#36BB35", + cardBg: "#F5F7FB", + inputBorder: "#D1D6DD", + inputBorderFocus: "#D1D6DD", + inputBg: "#ffffff", + inputDangerBorder: "#FF8080", + inputDangerBg: "#FFDBDB", + inputDisabledBg: "#CBD3DD", + inputDisabledText: "#8895A3", + progressBg: `#EEF2F8`, + progressValue: `#697584`, + progressCursor: `#2C3137`, + divider: `#D1D6DD`, + menuItemBg: `#ffffff`, + menuItemBgSelected: `#dce4f0`, + menuItemBgHovered: `#EEF2F8`, + menuItemBgActive: `#DDE4ED`, + // ==== TagButton + tagButtonText: `#2C3137`, + tagButtonBg: `#dce4f0`, + tagButtonBgHovered: `#EEF2F8`, + // ==== MeshButton + meshButtonSolidPrimaryBg: `#2C3137`, + meshButtonSolidPrimaryBgHovered: `#A2AEBB`, + meshButtonSolidPrimaryText: colors.white, + meshButtonSolidSecondaryBg: colors.gray100, + meshButtonSolidSecondaryBgHovered: colors.gray100, + meshButtonSolidSecondaryText: `#2C3137`, + meshButtonGhostText: `#2C3137`, + meshButtonGhostTextHovered: `#697584`, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: `#2C3137`, + meshStakingSliderInfoSecondaryText: `#697584`, + meshStakingSliderInfoSecondaryTextActive: colors.green400, + // ==== MeshTab + meshTabText: `#2C3137`, + meshTableCellText: `#2C3137`, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: `#697584`, + meshTableHeaderActionSecondaryText: `#A2AEBB`, + meshTableCellBorder: `#E1DBEB`, + skeletonBg: `#DDE4ED`, + trackBg: `#EBEFF5`, + overflowShadowBg: + "linear-gradient(0deg, rgba(255,255,255,1) 6%, rgba(255,255,255,0.95) 16%, rgba(255,255,255,0.85) 24%, rgba(255,255,255,0.75) 32%, rgba(255,255,255,0.65) 48%, rgba(255,255,255,0.4) 65%, rgba(255,255,255,0.2) 80%, rgba(255,255,255,0.1) 95%)", + ...colors, + }, +}); + +export const darkThemeClass = createTheme(themeVars, { + ...commonVars, + colors: { + accent: colors.primary400, + accentText: "#EEF2F8", + primary: colors.primary400, + body: colors.gray300, + background: colors.gray800, + link: colors.blue300, + linkHover: colors.blue400, + text: "#EEF2F8", + textMuted: "#BCC4D1", + textInverse: `#2C3137`, + textSecondary: "#A7B4C2", + textDanger: colors.red400, + textWarning: colors.orange200, + textSuccess: "#AEFFAB", + textPlaceholder: `#8895A3`, + rewardBg: "#2F4139", + rewardContent: "#AEFFAB", + cardBg: "#1D2024", + inputBorder: "#434B55", + inputBorderFocus: "#697584", + inputBg: "#0f172a", + inputDangerBorder: "#FFD0D0", + inputDangerBg: "#E17171", + inputDisabledBg: "#A7B4C2", + inputDisabledText: "#697584", + progressBg: "#16191c", + progressValue: `#A7B4C2`, + progressCursor: `#EEF2F8`, + divider: colors.whiteAlpha200, + menuItemBg: `#1D2024`, + menuItemBgSelected: `#2e3339`, + menuItemBgHovered: `#25292E`, + menuItemBgActive: `#2C3137`, + // ==== TagButton + tagButtonText: `#DAD5E3`, + tagButtonBg: `#434B55`, + tagButtonBgHovered: `#27272B`, + // ==== MeshButton + meshButtonSolidPrimaryBg: `#DAD5E3`, + meshButtonSolidPrimaryBgHovered: `#E2E2E2`, + meshButtonSolidPrimaryText: `#0E0E0F`, + meshButtonSolidSecondaryBg: `#1E1E1F`, + meshButtonSolidSecondaryBgHovered: `#1E1E1F`, + meshButtonSolidSecondaryText: `#DAD5E3`, + meshButtonGhostText: `#85858E`, + meshButtonGhostTextHovered: `#E1DBEB`, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: `#8895A3`, + meshStakingSliderInfoSecondaryText: `#A7B4C2`, + meshStakingSliderInfoSecondaryTextActive: `#AEFFAB`, + meshTabText: `#E2E2E2`, + meshTableCellText: `#EEF2F8`, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: `#EEF2F8`, + meshTableHeaderActionSecondaryText: `#A7B4C2`, + meshTableCellBorder: `#131313`, + skeletonBg: `#3B434D`, + trackBg: `#49525E`, + overflowShadowBg: + "linear-gradient(to bottom, rgba(29, 32, 36, 0), rgba(29, 32, 36, 0.6))", + ...colors, + }, +}); + +export const meshLightThemeClass = createTheme(themeVars, { + ...commonVars, + colors: { + ...colors, + accent: colors.primary500, + accentText: "#2C3137", + primary: colors.primary400, + black: "#131313", + body: colors.gray800, + background: colors.gray100, + link: colors.blue800, + linkHover: colors.blue600, + text: "#2C3137", + textMuted: `#8C9196`, + textInverse: `#EEF2F8`, + textSecondary: "#697584", + textDanger: colors.red500, + textWarning: colors.orange300, + textSuccess: colors.green400, + textPlaceholder: `#A2AEBB`, + rewardBg: "#E5FFE4", + rewardContent: "#36BB35", + cardBg: "#ffffff", + inputBorder: "#D1D6DD", + inputBorderFocus: "#D1D6DD", + inputBg: "#ffffff", + inputDangerBorder: "#FF8080", + inputDangerBg: "#FFDBDB", + inputDisabledBg: "#CBD3DD", + inputDisabledText: "#8895A3", + progressBg: `#EEF2F8`, + progressValue: `#697584`, + progressCursor: `#2C3137`, + divider: `#D1D6DD`, + menuItemBg: `#ffffff`, + menuItemBgSelected: `#dce4f0`, + menuItemBgHovered: `#EEF2F8`, + menuItemBgActive: `#DDE4ED`, + tagButtonText: `#2C3137`, + tagButtonBg: `#dce4f0`, + tagButtonBgHovered: `#EEF2F8`, + // ==== MeshButton + meshButtonSolidPrimaryBg: `#2C3137`, + meshButtonSolidPrimaryBgHovered: `#A2AEBB`, + meshButtonSolidPrimaryText: colors.white, + meshButtonSolidSecondaryBg: colors.gray100, + meshButtonSolidSecondaryBgHovered: colors.gray100, + meshButtonSolidSecondaryText: `#2C3137`, + meshButtonGhostText: `#2C3137`, + meshButtonGhostTextHovered: `#697584`, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: `#2C3137`, + meshStakingSliderInfoSecondaryText: `#697584`, + meshStakingSliderInfoSecondaryTextActive: colors.green400, + // ==== MeshTab + meshTabText: `#2C3137`, + meshTableCellText: `#2C3137`, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: `#2C3137`, + meshTableHeaderActionSecondaryText: `#697584`, + meshTableCellBorder: `#E1DBEB`, + // ==== + skeletonBg: `#DDE4ED`, + trackBg: `#EBEFF5`, + overflowShadowBg: + "linear-gradient(0deg, rgba(255,255,255,1) 6%, rgba(255,255,255,0.95) 16%, rgba(255,255,255,0.85) 24%, rgba(255,255,255,0.75) 32%, rgba(255,255,255,0.65) 48%, rgba(255,255,255,0.4) 65%, rgba(255,255,255,0.2) 80%, rgba(255,255,255,0.1) 95%)", + // Override colors + gray50: "#F7FAFC", + gray100: "#E1DBEB", + gray200: "#858591", + gray300: "#3F3F48", + gray400: "#2E2E34", + gray500: "#27272B", + gray600: "#202023", + gray700: "#1A1A1D", + gray800: "#101012", + gray900: "#0C0C0D", + }, +}); + +export const meshDarkThemeClass = createTheme(themeVars, { + ...commonVars, + colors: { + ...colors, + accent: colors.primary400, + accentText: "#0E0E0F", + primary: colors.primary400, + black: "#131313", + body: "#1E1E1F", + background: colors.gray800, + link: colors.blue300, + linkHover: colors.blue400, + text: "#DAD5E3", + textMuted: "#AEA7BC", + textInverse: `#2C3137`, + textSecondary: "#85858E", + textDanger: "#E35B5B", + textWarning: colors.orange200, + textPlaceholder: `#E2E2E2`, + textSuccess: "#C0EEA4", + rewardBg: "#2F4139", + rewardContent: "#AEFFAB", + cardBg: "#111113", + inputBorder: "#3D3D42", + inputBorderFocus: "#D1D6DD", + inputBg: "#1D2024", + inputDangerBorder: "#FFD0D0", + inputDangerBg: "#E35B5B", + inputDisabledBg: "#A7B4C2", + inputDisabledText: "#697584", + progressBg: `#C0EEA4`, + progressValue: `#A7B4C2`, + progressCursor: `#EEF2F8`, + divider: "#201E25", + menuItemBg: `#1D2024`, + menuItemBgSelected: `#2e3339`, + menuItemBgHovered: `#25292E`, + menuItemBgActive: `#2C3137`, + tagButtonText: `#DAD5E3`, + tagButtonBg: `#202023`, + tagButtonBgHovered: `#27272B`, + // ==== MeshButton + meshButtonSolidPrimaryBg: `#DAD5E3`, + meshButtonSolidPrimaryBgHovered: `#E2E2E2`, + meshButtonSolidPrimaryText: `#0E0E0F`, + meshButtonSolidSecondaryBg: `#1E1E1F`, + meshButtonSolidSecondaryBgHovered: `#1E1E1F`, + meshButtonSolidSecondaryText: `#DAD5E3`, + meshButtonGhostText: `#85858E`, + meshButtonGhostTextHovered: `#E1DBEB`, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: `#E2E2E2`, + meshStakingSliderInfoSecondaryText: `#85858E`, + meshStakingSliderInfoSecondaryTextActive: `#C0EEA4`, + // ==== MeshTab + meshTabText: `#E2E2E2`, + meshTableCellText: `#E2E2E2`, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: colors.white, + meshTableHeaderActionSecondaryText: `#E2E2E2`, + meshTableCellBorder: `#131313`, + // ==== + skeletonBg: `#3B434D`, + trackBg: `#49525E`, + overflowShadowBg: + "linear-gradient(0deg, rgba(17,17,19,1) 5%, rgba(9,9,121,0) 35%)", + // Override colors + gray50: "#F7FAFC", + gray100: "#E1DBEB", + gray200: "#858591", + gray300: "#3F3F48", + gray400: "#2E2E34", + gray500: "#27272B", + gray600: "#202023", + gray700: "#1A1A1D", + gray800: "#101012", + gray900: "#0C0C0D", + }, +}); + +export const nobleLightThemeClass = createTheme(themeVars, { + ...commonVars, + colors: { + ...colors, + accent: colors.primary500, + accentText: `#2C3137`, + primary: `#6B7FFF`, + body: `#f6f6ff`, + background: colors.gray100, + link: colors.blue800, + linkHover: colors.blue600, + text: `#020418`, + textMuted: "#4A4C5F", + textInverse: `#F5F5F5`, + textSecondary: `#6D6D88`, + textDanger: colors.red500, + textWarning: colors.orange300, + textSuccess: `#54B375`, + textPlaceholder: `#A2AEBB`, + rewardBg: `#E5FFE4`, + rewardContent: `#36BB35`, + cardBg: `#ffffff`, + inputBorder: `#D5D5E8`, + inputBorderFocus: `#AAAABF`, + inputBg: `#ffffff`, + inputDangerBorder: `#FF8080`, + inputDangerBg: `#FFDBDB`, + inputDisabledBg: `#CBD3DD`, + inputDisabledText: `#8895A3`, + progressBg: `#D5D5E8`, + progressValue: `#6B7FFF`, + progressCursor: `#2C3137`, + divider: `#D5D5E8`, + menuItemBg: `#ffffff`, + menuItemBgSelected: `#dce4f0`, + menuItemBgHovered: `#EEF2F8`, + menuItemBgActive: `#DDE4ED`, + skeletonBg: `#DDE4ED`, + trackBg: `#EBEFF5`, + tagButtonText: `#2C3137`, + tagButtonBg: `#dce4f0`, + tagButtonBgHovered: `#EEF2F8`, + // ==== MeshButton + meshButtonSolidPrimaryBg: `#2C3137`, + meshButtonSolidPrimaryBgHovered: `#A2AEBB`, + meshButtonSolidPrimaryText: colors.white, + meshButtonSolidSecondaryBg: colors.gray100, + meshButtonSolidSecondaryBgHovered: colors.gray100, + meshButtonSolidSecondaryText: `#2C3137`, + meshButtonGhostText: `#2C3137`, + meshButtonGhostTextHovered: `#697584`, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: `#2C3137`, + meshStakingSliderInfoSecondaryText: `#697584`, + meshStakingSliderInfoSecondaryTextActive: colors.green400, + // ==== MeshTab + meshTabText: `#2C3137`, + meshTableCellText: `#2C3137`, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: `#2C3137`, + meshTableHeaderActionSecondaryText: `#697584`, + meshTableCellBorder: `#E1DBEB`, + // ==== + overflowShadowBg: + "linear-gradient(0deg, rgba(255,255,255,1) 6%, rgba(255,255,255,0.95) 16%, rgba(255,255,255,0.85) 24%, rgba(255,255,255,0.75) 32%, rgba(255,255,255,0.65) 48%, rgba(255,255,255,0.4) 65%, rgba(255,255,255,0.2) 80%, rgba(255,255,255,0.1) 95%)", + // Override colors + blue50: "#020418", + blue100: "#070B28", + blue200: "#0F1331", + blue300: "#1E2457", + blue400: "#3C4992", + blue500: "#4E5DBC", + blue600: "#6B7FFF", + blue700: "#909FFF", + blue800: "#BAC3FF", + blue900: "#E2E6FD", + gray50: "#101219", + gray100: "#181921", + gray200: "#212536", + gray300: "#34384E", + gray400: "#4D5270", + gray500: "#6D6D88", + gray600: "#AAAABF", + gray700: "#D5D5E8", + gray800: "#EAEAF6", + gray900: "#F6F6FE", + }, +}); + +export const nobleDarkThemeClass = createTheme(themeVars, { + ...commonVars, + colors: { + ...colors, + accent: colors.primary400, + accentText: `#EEF2F8`, + primary: `#6B7FFF`, + body: `#020418`, + background: colors.gray800, + link: colors.blue300, + linkHover: colors.blue400, + text: `#FFF`, + textMuted: "#CCCCCC", + textInverse: `#FFF`, + textSecondary: `#909FFF`, + textDanger: colors.red400, + textWarning: colors.orange200, + textSuccess: `#8BD9A6`, + textPlaceholder: `#8895A3`, + rewardBg: `#2F4139`, + rewardContent: `#AEFFAB`, + cardBg: `#0F1331`, + inputBorder: `#1E2457`, + inputBorderFocus: `#3C4992`, + inputBg: `#0F1331`, + inputDangerBorder: `#FFD0D0`, + inputDangerBg: `#E17171`, + inputDisabledBg: `#A7B4C2`, + inputDisabledText: `#697584`, + progressBg: `#1E2457`, + progressValue: `#6B7FFF`, + progressCursor: `#EEF2F8`, + divider: `#1E2457`, + menuItemBg: `#1D2024`, + menuItemBgSelected: `#2e3339`, + menuItemBgHovered: `#25292E`, + menuItemBgActive: `#2C3137`, + skeletonBg: `#3B434D`, + trackBg: `#49525E`, + tagButtonText: `#DAD5E3`, + tagButtonBg: `#202023`, + tagButtonBgHovered: `#27272B`, + // ==== MeshButton + meshButtonSolidPrimaryBg: `#DAD5E3`, + meshButtonSolidPrimaryBgHovered: `#E2E2E2`, + meshButtonSolidPrimaryText: `#0E0E0F`, + meshButtonSolidSecondaryBg: `#1E1E1F`, + meshButtonSolidSecondaryBgHovered: `#1E1E1F`, + meshButtonSolidSecondaryText: `#DAD5E3`, + meshButtonGhostText: `#85858E`, + meshButtonGhostTextHovered: `#E1DBEB`, + // ==== MeshStakingSliderInfo + meshStakingSliderInfoPrimaryText: `#E2E2E2`, + meshStakingSliderInfoSecondaryText: `#85858E`, + meshStakingSliderInfoSecondaryTextActive: `#C0EEA4`, + // ==== MeshTab + meshTabText: `#E2E2E2`, + meshTableCellText: `#E2E2E2`, + // ==== MeshTableHeaderAction + meshTableHeaderActionText: colors.white, + meshTableHeaderActionSecondaryText: `#E2E2E2`, + meshTableCellBorder: `#131313`, + // ==== + overflowShadowBg: + "linear-gradient(to bottom, rgba(29, 32, 36, 0), rgba(29, 32, 36, 0.6))", + // Override colors + blue50: "#020418", + blue100: "#070B28", + blue200: "#0F1331", + blue300: "#1E2457", + blue400: "#3C4992", + blue500: "#4E5DBC", + blue600: "#6B7FFF", + blue700: "#909FFF", + blue800: "#BAC3FF", + blue900: "#E2E6FD", + gray50: "#101219", + gray100: "#181921", + gray200: "#212536", + gray300: "#34384E", + gray400: "#4D5270", + gray500: "#6D6D88", + gray600: "#AAAABF", + gray700: "#D5D5E8", + gray800: "#EAEAF6", + gray900: "#F6F6FE", + }, +}); diff --git a/packages/react/src/styles/tokens/border.ts b/packages/react/src/styles/tokens/border.ts new file mode 100644 index 00000000..17253550 --- /dev/null +++ b/packages/react/src/styles/tokens/border.ts @@ -0,0 +1,35 @@ +export const radii = { + none: `0`, + sm: `0.125rem`, // 2px + base: `0.25rem`, // 4px + md: `0.375rem`, // 6px + lg: `0.5rem`, // 8px + xl: `0.625rem`, // 10px + "2xl": `0.75rem`, // 12px + "3xl": `0.875rem`, // 14px + "4xl": `1rem`, // 16px + full: `9999px`, +}; + +export const borderWidth = { + none: "0px", + sm: "1px", + base: "2px", + md: "4px", + lg: "8px", + xl: "16px", +}; + +export const borderStyle = { + none: "none", + solid: "solid", + dotted: "dotted", + dashed: "dashed", + groove: "groove", + ridge: "ridge", + hidden: "hidden", + double: "double", + inset: "inset", + outset: "outset", + unset: "unset", +}; diff --git a/packages/react/src/styles/tokens/breakpoints.ts b/packages/react/src/styles/tokens/breakpoints.ts new file mode 100644 index 00000000..4d99bca2 --- /dev/null +++ b/packages/react/src/styles/tokens/breakpoints.ts @@ -0,0 +1,11 @@ +export const breakpoints = { + mobile: 0, + mdMobile: 568, + smTablet: 666, + tablet: 768, + desktop: 1200, +}; + +export type Breakpoint = keyof typeof breakpoints; + +export const breakpointNames = Object.keys(breakpoints) as Breakpoint[]; diff --git a/packages/react/src/styles/tokens/colors.ts b/packages/react/src/styles/tokens/colors.ts new file mode 100644 index 00000000..e063f788 --- /dev/null +++ b/packages/react/src/styles/tokens/colors.ts @@ -0,0 +1,146 @@ +import type { LiteralUnion } from "../../helpers/types"; +import type { ThemeVariant } from "../../models/system.model"; + +export const colors = { + black: "#000", + blackPrimary: "#2C3137", + blackSecondary: "#3B434D", + white: "#fff", + transparent: "transparent", + current: "currentColor", + whiteAlpha50: "rgba(255, 255, 255, 0.04)", + whiteAlpha100: "rgba(255, 255, 255, 0.06)", + whiteAlpha200: "rgba(255, 255, 255, 0.08)", + whiteAlpha300: "rgba(255, 255, 255, 0.16)", + whiteAlpha400: "rgba(255, 255, 255, 0.24)", + whiteAlpha500: "rgba(255, 255, 255, 0.36)", + whiteAlpha600: "rgba(255, 255, 255, 0.48)", + whiteAlpha700: "rgba(255, 255, 255, 0.64)", + whiteAlpha800: "rgba(255, 255, 255, 0.80)", + whiteAlpha900: "rgba(255, 255, 255, 0.92)", + blackAlpha50: "rgba(0, 0, 0, 0.04)", + blackAlpha100: "rgba(0, 0, 0, 0.06)", + blackAlpha200: "rgba(0, 0, 0, 0.08)", + blackAlpha300: "rgba(0, 0, 0, 0.16)", + blackAlpha400: "rgba(0, 0, 0, 0.24)", + blackAlpha500: "rgba(0, 0, 0, 0.36)", + blackAlpha600: "rgba(0, 0, 0, 0.48)", + blackAlpha700: "rgba(0, 0, 0, 0.64)", + blackAlpha800: "rgba(0, 0, 0, 0.80)", + blackAlpha900: "rgba(0, 0, 0, 0.92)", + gray50: "#F7FAFC", + gray100: "#EDF2F7", + gray200: "#EBEFF5", + gray300: "#CBD5E0", + gray400: "#A0AEC0", + gray500: "#718096", + gray600: "#4A5568", + gray700: "#2D3748", + gray800: "#404752", + gray900: "#343A42", + red50: "#FFF5F5", + red100: "#FED7D7", + red200: "#FEB2B2", + red300: "#FC8181", + red400: "#F56565", + red500: "#E53E3E", + red600: "#C73636", + red700: "#9B2C2C", + red800: "#822727", + red900: "#63171B", + orange50: "#FFFAF0", + orange100: "#FEEBC8", + orange200: "#FBD38D", + orange300: "#F6AD55", + orange400: "#ED8936", + orange500: "#DD6B20", + orange600: "#C05621", + orange700: "#9C4221", + orange800: "#7B341E", + orange900: "#652B19", + yellow50: "#FFFFF0", + yellow100: "#FEFCBF", + yellow200: "#FAF089", + yellow300: "#F6E05E", + yellow400: "#ECC94B", + yellow500: "#D69E2E", + yellow600: "#B7791F", + yellow700: "#975A16", + yellow800: "#744210", + yellow900: "#5F370E", + green50: "#F0FFF4", + green100: "#C6F6D5", + green200: "#36BB35", + green300: "#68D391", + green400: "#48BB78", + green500: "#38A169", + green600: "#2F855A", + green700: "#276749", + green800: "#22543D", + green900: "#1C4532", + blue50: "#ebf8ff", + blue100: "#bee3f8", + blue200: "#90cdf4", + blue300: "#63b3ed", + blue400: "#4299e1", + blue500: "#3182ce", + blue600: "#2b6cb0", + blue700: "#2c5282", + blue800: "#2a4365", + blue900: "#1A365D", + primary50: "#e5e7f9", + primary100: "#bec4ef", + primary200: "#929ce4", + primary300: "#6674d9", + primary400: "#4657d1", + primary500: "#2539c9", + primary600: "#2133c3", + primary700: "#1b2cbc", + primary800: "#1624b5", + primary900: "#0d17a9", + purple50: "#C99EFF", + purple100: "#782FD2", + purple200: "#5B249E", + purple300: "#B794F4", + purple400: "#7310FF", + purple500: "#421876", + purple600: "#6B46C1", + purple700: "#553C9A", + purple800: "#44337A", + purple900: "#322659", +}; + +export const DEFAULT_ACCENTS = [ + "red", + "orange", + "yellow", + "green", + "purple", + "blue", +] as const; + +export type Accent = LiteralUnion<(typeof DEFAULT_ACCENTS)[number], string>; + +export const accents: { [key in ThemeVariant]: { [key in Accent]: string } } = { + light: { + red: colors.red400, + orange: colors.orange400, + yellow: colors.yellow400, + green: colors.green400, + purple: colors.purple400, + blue: colors.primary400, + }, + dark: { + red: colors.red300, + orange: colors.orange300, + yellow: colors.yellow300, + green: colors.green300, + purple: colors.purple200, + blue: colors.primary300, + }, +} as const; + +export const accentsForeground: { [key in ThemeVariant]: string } = { + light: colors.white, + dark: "#EEF2F8", +} as const; diff --git a/packages/react/src/styles/tokens/index.ts b/packages/react/src/styles/tokens/index.ts new file mode 100644 index 00000000..8a8d3f9e --- /dev/null +++ b/packages/react/src/styles/tokens/index.ts @@ -0,0 +1,6 @@ +export * from "./colors"; +export * from "./typography"; +export * from "./breakpoints"; +export * from "./border"; +export * from "./space"; +export * from "./overlay"; diff --git a/packages/react/src/styles/tokens/overlay.ts b/packages/react/src/styles/tokens/overlay.ts new file mode 100644 index 00000000..2e98995a --- /dev/null +++ b/packages/react/src/styles/tokens/overlay.ts @@ -0,0 +1,11 @@ +export const zIndex = { + "-1": "-1", + "0": "0", + "10": "10", + "20": "20", + "30": "30", + "40": "40", + "50": "50", + "100": "100", + auto: "auto", +}; diff --git a/packages/react/src/styles/tokens/space.ts b/packages/react/src/styles/tokens/space.ts new file mode 100644 index 00000000..5a9d1365 --- /dev/null +++ b/packages/react/src/styles/tokens/space.ts @@ -0,0 +1,46 @@ +export const space = { + "0": "0rem", // 0px + "1": "0.125rem", // 2px + "2": "0.25rem", // 4px + "3": "0.375rem", // 6px + "4": "0.5rem", // 8px + "5": "0.625rem", // 10px + "6": "0.75rem", // 12px + "7": "0.875rem", // 14px + "8": "1rem", // 16px + "9": "1.25rem", // 20px + "10": "1.5rem", // 24px + "11": "1.75rem", // 28px + "12": "2rem", // 32px + "13": "2.25rem", // 36px + "14": "2.5rem", // 40px + "15": "3rem", // 48px + "16": "3.5rem", // 56px + "17": "4rem", // 64px + "18": "4.5rem", // 72px + "19": "5rem", // 80px + "20": "5.5rem", // 88px + "21": "6rem", // 96px + "22": "7rem", // 112px + "23": "8rem", // 128px + "24": "9rem", // 144px + "25": "10rem", // 160px + "26": "11rem", // 176px + "27": "12rem", // 192px + "28": "13rem", // 208px + "29": "14rem", // 224px + "30": "15rem", // 240px + auto: "auto", + full: "100%", + fit: "fit-content", + max: "max-content", + min: "min-content", + viewHeight: "100vh", + viewWidth: "100vw", + containerSm: "640px", + containerMd: "768px", + containerLg: "1024px", + containerXl: "1280px", + prose: "60ch", + none: "0", +}; diff --git a/packages/react/src/styles/tokens/typography.ts b/packages/react/src/styles/tokens/typography.ts new file mode 100644 index 00000000..9c52e3b6 --- /dev/null +++ b/packages/react/src/styles/tokens/typography.ts @@ -0,0 +1,57 @@ +export const SYSTEM_FONT_STACK = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, +Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`; + +export const letterSpacing = { + tighter: "-0.05em", + tight: "-0.025em", + normal: "0", + wide: "0.025em", + wider: "0.05em", + widest: "0.1em", +}; + +export const lineHeight = { + normal: "normal", + none: "1", + shorter: "1.1", + short: "1.25", + base: "1.5", + tall: "1.75", + taller: "2", +}; + +export const fontWeight = { + hairline: "100", + thin: "200", + light: "300", + normal: "400", + medium: "500", + semibold: "600", + bold: "700", + extrabold: "800", + black: "900", +}; + +export const fontSize = { + "3xs": "0.5rem", // ≈8px + "2xs": "0.625rem", // ≈10px + xs: "0.75rem", // ≈12px + sm: "0.875rem", // ≈14px + md: "1rem", // ≈16px + lg: "1.125rem", // ≈18px + xl: "1.25rem", // ≈20px + "2xl": "1.375rem", // ≈22px + "3xl": "1.5rem", // ≈24px + "4xl": "1.625rem", // ≈26px + "5xl": "1.75rem", // ≈28px + "6xl": "1.875rem", // ≈30px + "7xl": "2rem", // ≈32px + "8xl": "2.25rem", // ≈36px + "9xl": "2.5rem", // ≈40px + "10xl": "3rem", // ≈48px + "11xl": "3.5rem", // ≈56px + "12xl": "4rem", // ≈64px + "13xl": "4.5rem", // ≈72px + "14xl": "5rem", // ≈80px + "15xl": "5.5rem", // ≈88px +}; diff --git a/packages/react/src/ui/accordion/accordion.tsx b/packages/react/src/ui/accordion/accordion.tsx new file mode 100644 index 00000000..b6e7f18b --- /dev/null +++ b/packages/react/src/ui/accordion/accordion.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import type { AccordionProps } from "./accordion.types"; + +function Accordion(props: AccordionProps) { + const { + width = "$full", + isExpanded: defaultIsExpanded = false, + transition = "all 0.2s ease-out", + } = props; + const contentRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(() => defaultIsExpanded); + function toggleExpanded() { + if (typeof props.onToggle === "function") { + props.onToggle(); + return; + } + setIsExpanded(!isExpanded); + } + useEffect(() => { + setIsExpanded(defaultIsExpanded); + }, [defaultIsExpanded]); + return ( + +
toggleExpanded()}> + {typeof props.renderTrigger === "function" ? ( + <>{props.renderTrigger({ isExpanded: isExpanded })} + ) : null} + {typeof props.renderTrigger !== "function" ? ( + <>{props.renderTrigger} + ) : null} +
+ + {props.renderContent} + +
+ ); +} + +export default Accordion; diff --git a/packages/react/src/ui/accordion/accordion.types.tsx b/packages/react/src/ui/accordion/accordion.types.tsx new file mode 100644 index 00000000..74ad6e23 --- /dev/null +++ b/packages/react/src/ui/accordion/accordion.types.tsx @@ -0,0 +1,12 @@ +import { BoxProps } from "../box/box.types"; + +export interface AccordionProps { + renderTrigger: + | React.ReactNode + | ((props: { isExpanded: boolean }) => React.ReactNode); + renderContent: React.ReactNode; + width?: BoxProps["width"]; + onToggle?: () => void; + isExpanded?: boolean; + transition?: string; +} diff --git a/packages/react/src/ui/accordion/index.ts b/packages/react/src/ui/accordion/index.ts new file mode 100644 index 00000000..9bde91bf --- /dev/null +++ b/packages/react/src/ui/accordion/index.ts @@ -0,0 +1 @@ +export { default } from "./accordion"; diff --git a/packages/react/src/ui/add-liquidity/add-liquidity.tsx b/packages/react/src/ui/add-liquidity/add-liquidity.tsx new file mode 100644 index 00000000..9a39403d --- /dev/null +++ b/packages/react/src/ui/add-liquidity/add-liquidity.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import BigNumber from "bignumber.js"; +import { isEqual, cloneDeep } from "lodash"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Box from "../box"; +import TokenInput from "../token-input"; +import { + AddItem, + AddLiquidityProps, + onAddLiquidityItem, +} from "./add-liquidity.types"; + +function AddLiquidity(props: AddLiquidityProps) { + const amountChangeTypeRef = useRef<"1" | "2">("1"); + const lastValuesRef = useRef([]); + const [progress1, setProgress1] = useState(() => 50); + + const [progress2, setProgress2] = useState(() => 50); + + const [amount1, setAmount1] = useState(() => 0); + + const [amount2, setAmount2] = useState(() => 0); + + const [btnText, setBtnText] = useState(() => "Add liquidity"); + + const [disabled, setDisabled] = useState(() => true); + + function onChangeHandler(values) { + if (isEqual(values, lastValuesRef.current)) return; + props?.onChange(values); + lastValuesRef.current = cloneDeep(values); + } + + function addLiquidityItem1() { + return Object.assign(props?.poolAssets[0], { + addAmount: progress2 === 100 ? "0" : amount1, + }); + } + + function addLiquidityItem2() { + return Object.assign(props?.poolAssets[1], { + addAmount: progress1 === 100 ? "0" : amount2, + }); + } + + function isInsufficient() { + const amount1Invalid = new BigNumber(amount1 || 0).gt( + props?.poolAssets[0]?.available + ); + const amount2Invalid = new BigNumber(amount2 || 0).gt( + props?.poolAssets[1]?.available + ); + if (progress1 === 100) { + return amount1Invalid; + } + if (progress2 === 100) { + return amount2Invalid; + } + return amount1Invalid || amount2Invalid; + } + + function isUnAvailable() { + const amount1Disabled = new BigNumber(amount1 || 0).eq(0); + const amount2isabled = new BigNumber(amount2 || 0).eq(0); + if (progress1 === 100) { + return amount1Disabled; + } + if (progress2 === 100) { + return amount2isabled; + } + return amount1Disabled || amount2isabled; + } + + function handleProgress1Change(progress) { + setProgress1(progress); + setProgress2(100 - progress); + onChangeHandler([ + { + progress: progress, + value: amount1, + }, + { + progress: 100 - progress, + value: amount2, + }, + ]); + } + + function handleProgress2Change(progress) { + setProgress2(progress); + setProgress1(100 - progress); + onChangeHandler([ + { + progress: 100 - progress, + value: amount1, + }, + { + progress: progress, + value: amount2, + }, + ]); + } + + function handleAmount1Change(value) { + if (amountChangeTypeRef.current !== "1") return; + let value1 = value; + let value2 = amount2; + if (progress1 === 50) { + value2 = new BigNumber(`${value || 0}`) + .multipliedBy(props?.poolAssets[0]?.priceDisplayAmount || 0) + .dividedBy(props?.poolAssets[1]?.priceDisplayAmount) + .toNumber(); + } + onChangeHandler([ + { + progress: progress1, + value: value1, + }, + { + progress: progress2, + value: value2, + }, + ]); + setAmount1(value1); + setAmount2(value2); + } + + function handleAmount2Change(value) { + if (amountChangeTypeRef.current !== "2") return; + let value2 = value; + let value1 = amount1; + if (progress2 === 50) { + value1 = new BigNumber(`${value || 0}`) + .multipliedBy(props?.poolAssets[1]?.priceDisplayAmount || 0) + .dividedBy(props?.poolAssets[0]?.priceDisplayAmount) + .toNumber(); + } + onChangeHandler([ + { + progress: progress1, + value: value1, + }, + { + progress: progress2, + value: value2, + }, + ]); + setAmount1(value1); + setAmount2(value2); + } + + useEffect(() => { + if (isInsufficient()) { + setBtnText("Insufficient Balance"); + setDisabled(true); + } else if (isUnAvailable()) { + setBtnText("Add liquidity"); + setDisabled(true); + } else { + setBtnText("Add liquidity"); + setDisabled(false); + } + }, [amount1, amount2, progress1, progress2]); + + return ( + + + + {props?.poolAssets[0]?.symbol} + + / + + {props?.poolAssets[1]?.symbol} + + + + handleProgress1Change(v)} + onAmountChange={(value) => handleAmount1Change(value)} + onFocus={(event) => { + amountChangeTypeRef.current = "1"; + }} + /> + + + handleProgress2Change(v)} + onAmountChange={(value) => handleAmount2Change(value)} + onFocus={(event) => { + amountChangeTypeRef.current = "2"; + }} + /> + + + + ); +} + +export default AddLiquidity; diff --git a/packages/react/src/ui/add-liquidity/add-liquidity.types.tsx b/packages/react/src/ui/add-liquidity/add-liquidity.types.tsx new file mode 100644 index 00000000..ef8f52ee --- /dev/null +++ b/packages/react/src/ui/add-liquidity/add-liquidity.types.tsx @@ -0,0 +1,18 @@ +import { PoolListItemProps } from "../pool-list-item/pool-list-item.types"; +import { AvailableItem } from "../transfer-item/transfer-item.types"; + +export interface onAddLiquidityItem extends AvailableItem { + addAmount: string; +} + +export type AddItem = { + progress: number; + value: string; +} + +export interface AddLiquidityProps { + poolAssets: PoolListItemProps["poolAssets"]; + onAddLiquidity: (event?:any) => void; + onChange: (values: AddItem[]) => void; + isLoading?: boolean; +} diff --git a/packages/react/src/ui/add-liquidity/index.ts b/packages/react/src/ui/add-liquidity/index.ts new file mode 100644 index 00000000..b7ca97f0 --- /dev/null +++ b/packages/react/src/ui/add-liquidity/index.ts @@ -0,0 +1 @@ +export { default } from "./add-liquidity"; diff --git a/packages/react/src/ui/animate-layout/animate-layout.helper.ts b/packages/react/src/ui/animate-layout/animate-layout.helper.ts new file mode 100644 index 00000000..82370f94 --- /dev/null +++ b/packages/react/src/ui/animate-layout/animate-layout.helper.ts @@ -0,0 +1,82 @@ +import autoAnimate, { getTransitionSizes } from "@formkit/auto-animate"; +import { isSSR } from "../../helpers/platform"; +type KeyframeStep = Record; + +const easing = "cubic-bezier(0.25,0.1,0.25,1)"; + +export function animateLayout(element: HTMLElement) { + if (isSSR()) return; + + autoAnimate(element, (el, action, oldCoords, newCoords) => { + let keyframes: Array; + + if (action === "add") { + keyframes = [ + { opacity: 0 }, + { opacity: 0.5, offset: 0.5 }, + { opacity: 1 }, + ]; + return new KeyframeEffect(el, keyframes, { + duration: 250, + easing: easing, + }); + } + + if (action === "remove") { + keyframes = [ + { + transform: "scale(1)", + opacity: 1, + }, + { + transform: "scale(0.97)", + opacity: 0, + offset: 0.5, + }, + { + transform: "scale(0)", + opacity: 0, + }, + ]; + + return new KeyframeEffect(el, keyframes, { + duration: 150, + easing: easing, + }); + } + + if (action === "remain") { + // use the getTransitionSizes() helper function to + // get the old and new widths of the elements + const [widthFrom, widthTo, heightFrom, heightTo] = getTransitionSizes( + el, + oldCoords, + newCoords + ); + // set up our steps with our positioning keyframes + let start: KeyframeStep = {}; + let mid: KeyframeStep = {}; + let end: KeyframeStep = {}; + + if (widthFrom !== widthTo) { + start.width = `${widthFrom}px`; + end.width = `${widthTo}px`; + } + + if (heightFrom !== heightTo) { + start.height = `${heightFrom}px`; + end.height = `${heightTo}px`; + } + + start.opacity = 0; + mid.opacity = 0.5; + mid.offset = 0.5; + end.opacity = 1; + + return new KeyframeEffect(el, [start, mid, end], { + duration: 350, + easing: easing, + }); + } + }); +} diff --git a/packages/react/src/ui/animate-layout/animate-layout.tsx b/packages/react/src/ui/animate-layout/animate-layout.tsx new file mode 100644 index 00000000..b8056005 --- /dev/null +++ b/packages/react/src/ui/animate-layout/animate-layout.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { useRef, useEffect } from "react"; +import { animateLayout } from "./animate-layout.helper"; +import type { AnimateLayoutProps } from "./animate-layout.types"; + +function AnimateLayout(props: AnimateLayoutProps) { + const parentRef = useRef(null); + + useEffect(() => { + if (parentRef.current) { + animateLayout(parentRef.current); + } + }, []); + + useEffect(() => { + if (parentRef.current) { + animateLayout(parentRef.current); + } + }, [parentRef.current, props.children]); + + return ( +
+ {props.children} +
+ ); +} + +export default AnimateLayout; diff --git a/packages/react/src/ui/animate-layout/animate-layout.types.tsx b/packages/react/src/ui/animate-layout/animate-layout.types.tsx new file mode 100644 index 00000000..e0895f5c --- /dev/null +++ b/packages/react/src/ui/animate-layout/animate-layout.types.tsx @@ -0,0 +1,3 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export interface AnimateLayoutProps extends BaseComponentProps {} diff --git a/packages/react/src/ui/animate-layout/index.ts b/packages/react/src/ui/animate-layout/index.ts new file mode 100644 index 00000000..cc8c798c --- /dev/null +++ b/packages/react/src/ui/animate-layout/index.ts @@ -0,0 +1 @@ +export { default } from "./animate-layout"; diff --git a/packages/react/src/ui/asset-list-header/asset-list-header.tsx b/packages/react/src/ui/asset-list-header/asset-list-header.tsx new file mode 100644 index 00000000..173bae1a --- /dev/null +++ b/packages/react/src/ui/asset-list-header/asset-list-header.tsx @@ -0,0 +1,172 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import type { AssetListHeaderProps } from "./asset-list-header.types"; + +function AssetListHeader(props: AssetListHeaderProps) { + const { + showDeposit = true, + showWithdraw = true, + withdrawButtonLabel = "Withdraw", + depositButtonLabel = "Deposit", + } = props; + return ( + + + {props.title} + + {props.multiChainHeader ? ( + + {props.multiChainHeader?.map((item, index) => ( + + + {item.label} + + + + $ + + + {item.value} + + + + ))} + {!!props.onWithdraw ? ( + + + + ) : null} + {!!props.onDeposit ? ( + + + + ) : null} + + ) : null} + {props.singleChainHeader ? ( + + + {props.singleChainHeader.label} + + $ + + {props.singleChainHeader.value} + + + + + {typeof props.onWithdraw === "function" && showWithdraw ? ( + + + + ) : null} + {typeof props.onDeposit === "function" && showDeposit ? ( + + + + ) : null} + + + ) : null} + + ); +} +export default AssetListHeader; diff --git a/packages/react/src/ui/asset-list-header/asset-list-header.types.tsx b/packages/react/src/ui/asset-list-header/asset-list-header.types.tsx new file mode 100644 index 00000000..407bcdd3 --- /dev/null +++ b/packages/react/src/ui/asset-list-header/asset-list-header.types.tsx @@ -0,0 +1,24 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export type HeaderInfo = { + label: string; + value: string; +}; + +export type MultiChainHeader = [HeaderInfo, HeaderInfo]; + +export type SingleChainHeader = HeaderInfo; + +export interface AssetListHeaderProps extends BaseComponentProps { + title: string; + multiChainHeader?: MultiChainHeader; + singleChainHeader?: SingleChainHeader; + depositButtonLabel?: string; + withdrawButtonLabel?: string; + // ==== Action props + showDeposit?: boolean; + showWithdraw?: boolean; + onDeposit?: (event?: any) => void; + onWithdraw?: (event?: any) => void; + attributes?: any; +} diff --git a/packages/react/src/ui/asset-list-header/index.ts b/packages/react/src/ui/asset-list-header/index.ts new file mode 100644 index 00000000..10b9d4fe --- /dev/null +++ b/packages/react/src/ui/asset-list-header/index.ts @@ -0,0 +1 @@ +export { default } from "./asset-list-header"; diff --git a/packages/react/src/ui/asset-list-item/asset-list-item.tsx b/packages/react/src/ui/asset-list-item/asset-list-item.tsx new file mode 100644 index 00000000..af617c2d --- /dev/null +++ b/packages/react/src/ui/asset-list-item/asset-list-item.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import type { AssetListItemProps } from "./asset-list-item.types"; +import type { BoxProps } from "../box/box.types"; + +function AssetListItem(props: AssetListItemProps) { + const { + isOtherChains = false, + needChainSpace = false, + showDeposit = true, + showWithdraw = true, + depositLabel = "Deposit", + withdrawLabel = "Withdraw", + } = props; + const [size, setSize] = useState(() => "$xs"); + useEffect(() => { + setSize(isOtherChains ? "$xs" : "$sm"); + }, [isOtherChains]); + return ( + + + + + + + + {props.symbol} + + + {props.name} + + + {needChainSpace ? ( + + {isOtherChains ? ( + + {props.chainName} + + ) : null} + + ) : null} + + + {props.tokenAmount} + + + {props.tokenAmountPrice} + + + {!needChainSpace ? : null} + + {!!props.onDeposit && showDeposit ? ( + + ) : null} + {!!props.onWithdraw && showWithdraw ? ( + + ) : null} + + + + ); +} + +export default AssetListItem; diff --git a/packages/react/src/ui/asset-list-item/asset-list-item.types.tsx b/packages/react/src/ui/asset-list-item/asset-list-item.types.tsx new file mode 100644 index 00000000..3fcda422 --- /dev/null +++ b/packages/react/src/ui/asset-list-item/asset-list-item.types.tsx @@ -0,0 +1,19 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export interface AssetListItemProps extends BaseComponentProps { + isOtherChains?: boolean; + imgSrc: string; + symbol: string; + name: string; + tokenAmount: string; + tokenAmountPrice: string; + chainName?: string; + needChainSpace?: boolean; + // Withdraw and deposit button props + depositLabel?: string; + withdrawLabel?: string; + showDeposit?: boolean; + showWithdraw?: boolean; + onDeposit?: (event?: any) => void; + onWithdraw?: (event?: any) => void; +} diff --git a/packages/react/src/ui/asset-list-item/index.ts b/packages/react/src/ui/asset-list-item/index.ts new file mode 100644 index 00000000..9f806cf9 --- /dev/null +++ b/packages/react/src/ui/asset-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./asset-list-item"; diff --git a/packages/react/src/ui/asset-list/asset-list.tsx b/packages/react/src/ui/asset-list/asset-list.tsx new file mode 100644 index 00000000..c22335a8 --- /dev/null +++ b/packages/react/src/ui/asset-list/asset-list.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import AssetListItem from "../asset-list-item"; +import type { AssetListProps } from "./asset-list.types"; +import type { AssetListItemProps } from "../asset-list-item/asset-list-item.types"; + +function AssetList(props: AssetListProps) { + const { isOtherChains = false, titles = ["Asset", "Balance"] } = props; + return ( + + + + + + + {titles[0]} + + {props.needChainSpace ? ( + + {isOtherChains ? ( + Chain + ) : null} + + ) : null} + + {titles[1]} + + + + + {props.list?.map((item, index) => ( + + item?.onDeposit()} + onWithdraw={(event) => item?.onWithdraw()} + withdrawLabel={item.withdrawLabel} + depositLabel={item.depositLabel} + /> + + ))} + + + + ); +} + +export default AssetList; diff --git a/packages/react/src/ui/asset-list/asset-list.types.tsx b/packages/react/src/ui/asset-list/asset-list.types.tsx new file mode 100644 index 00000000..9274a050 --- /dev/null +++ b/packages/react/src/ui/asset-list/asset-list.types.tsx @@ -0,0 +1,10 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { AssetListItemProps } from "../asset-list-item/asset-list-item.types"; + +export interface AssetListProps extends BaseComponentProps { + isOtherChains: boolean; + needChainSpace: boolean; + list: AssetListItemProps[]; + titles?: [string, string]; + attributes?: any; +} diff --git a/packages/react/src/ui/asset-list/index.ts b/packages/react/src/ui/asset-list/index.ts new file mode 100644 index 00000000..4ba429e5 --- /dev/null +++ b/packages/react/src/ui/asset-list/index.ts @@ -0,0 +1 @@ +export {default} from './asset-list' diff --git a/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.css.ts b/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.css.ts new file mode 100644 index 00000000..d68a7e5d --- /dev/null +++ b/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.css.ts @@ -0,0 +1,183 @@ +import { + style, + styleVariants, + createVar, + keyframes, +} from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +const buttonTextColorVar = createVar(); + +export const container = style({ + minWidth: "340px", + maxWidth: "460px", + position: "relative", +}); + +export const flex1 = style({ + flex: 1, + maxWidth: "215px", +}); + +export const onlyLg = style({ + display: "flex", + "@media": { + [`screen and (max-width: 700px)`]: { + display: "none", + }, + }, +}); + +export const onlySm = style({ + display: "none", + "@media": { + [`screen and (max-width: 700px)`]: { + display: "flex", + }, + }, +}); + +export const bgClass = styleVariants({ + light: [ + style({ + "@media": { + [`screen and (max-width: 700px)`]: { + backgroundColor: themeVars.colors.white, + }, + }, + }), + ], + dark: [ + style({ + "@media": { + [`screen and (max-width: 700px)`]: { + backgroundColor: "#1D2024", + }, + }, + }), + ], +}); + +const btnTextBase = style({ + color: buttonTextColorVar, +}); + +export const btnText = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + }, + }), + btnTextBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.cardBg, + }, + }), + btnTextBase, + ], +}); + +export const addressInput = style({ + paddingLeft: themeVars.space["15"], + paddingRight: themeVars.space["17"], + height: themeVars.space["15"], + overflow: "hidden", +}); + +export const checkIcon = style({ + borderTopLeftRadius: "0", + borderBottomLeftRadius: "0", + position: "absolute", + right: "0", + bottom: "0", + height: themeVars.space["15"], +}); + +const expandHorizontal = keyframes({ + "0%": { width: "100%", opacity: 0 }, + "100%": { width: "462px", opacity: 1 }, +}); +const expandHorizontalReverse = keyframes({ + "0%": { width: "462px", opacity: 1 }, + "100%": { width: "100%", opacity: 0 }, +}); + +export const addressContainer = style({ + animation: `${expandHorizontal} .5s`, + width: "460px", +}); + +export const addressContainerReverse = style({ + animation: `${expandHorizontalReverse} .5s`, +}); + +const expandVertical = keyframes({ + "0%": { opacity: "0", height: 0 }, + "100%": { opacity: "0.8", height: "400px" }, +}); + +const expandVerticalReverse = keyframes({ + "0%": { opacity: "0.8", height: "400px" }, + "100%": { opacity: "0", height: 0 }, +}); + +const fadeIn = keyframes({ + "0%": { opacity: 0, transform: "scale(0.95)" }, + "100%": { + opacity: 1, + transform: "scale(1)", + }, +}); + +const fadeOut = keyframes({ + "0%": { opacity: 0, transform: "scale(0.95)" }, + "100%": { + opacity: 1, + transform: "scale(1)", + }, +}); + +export const addressBackgroundReverse = style({ + animation: `${expandVerticalReverse} .5s`, +}); + +export const smPanelShow = style({ + animation: `${fadeIn} 350ms cubic-bezier(0.22, 1, 0.36, 1)`, +}); + +export const smPanelHide = style({ + animation: `${fadeOut} 250ms cubic-bezier(0.22, 1, 0.36, 1)`, +}); + +export const fromAddressInput = style({ + paddingRight: "20px", + backgroundColor: themeVars.colors.inputBg, +}); + +export const transferMask = styleVariants({ + light: [ + style({ + backgroundColor: themeVars.colors.white, + }), + ], + dark: [ + style({ + backgroundColor: themeVars.colors.blackPrimary, + }), + ], +}); + +export const addressBackground = style({ + animation: `${expandVertical} 0.6s cubic-bezier(0.22, 1, 0.36, 1)`, + position: "absolute", + width: "460px", + height: "400px", + top: "84px", + right: "0", + zIndex: "1", + opacity: 0.8, +}); diff --git a/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.tsx b/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.tsx new file mode 100644 index 00000000..73564e54 --- /dev/null +++ b/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.tsx @@ -0,0 +1,510 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Icon from "../icon"; +import Box from "../box"; +import TokenInput from "../token-input"; +import * as styles from "./asset-withdraw-tokens.css"; +import { store } from "../../models/store"; +import { truncateTextMiddle } from "../../helpers/string"; +import IconButton from "../icon-button"; +import TextField from "../text-field"; +import { rootInput, inputBorderAndShadow } from "../text-field/text-field.css"; +import { standardTransitionProperties } from "../shared/shared.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { AssetWithdrawTokensProps } from "./asset-withdraw-tokens.types"; + +function AssetWithdrawTokens(props: AssetWithdrawTokensProps) { + const { + transferLabel = "Transfer", + cancelLabel = "Cancel", + partials = [ + { + label: "Max", + percentage: 1, + }, + { + label: "1/2", + percentage: 0.5, + }, + { + label: "1/3", + percentage: 1 / 3, + }, + ], + } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + const [inputAmount, setInputAmount] = useState(() => 0); + const [toAddress, setToAddress] = useState(() => ""); + const [lgAddressVisible, setLgAddressVisible] = useState(() => false); + const [smAddressVisible, setSmAddressVisible] = useState(() => false); + const [reverseAnimation, setReverseAnimation] = useState(() => false); + function handleConfirmAddress() { + props.onAddressConfirm?.(); + setReverseAnimation(true); + setLgAddressVisible(false); + setSmAddressVisible(false); + setReverseAnimation(false); + } + function handleAmountChange(percent) { + const newAmount = new BigNumber(props.available) + .multipliedBy(percent) + .toNumber(); + setInputAmount(newAmount); + props.onChange?.(new BigNumber(newAmount).toString()); + } + function onAmountChange(value) { + setInputAmount(value); + props.onChange?.(new BigNumber(value).toString()); + } + useEffect(() => { + setToAddress(props.toAddress); + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + + + + + + { + setSmAddressVisible(true); + }} + /> + + + + + {`From ${props.fromName}`} + + + + {truncateTextMiddle(props.fromAddress, 12)} + + + + + + {`To ${props.toName}`} + + + + {truncateTextMiddle(props.toAddress, 12)} + + { + setLgAddressVisible(true); + }} + /> + + + + + {`To ${props.toName}`} + { + setToAddress(e.target.value); + props.onAddressChange(e.target.value); + }} + inputClassName={styles.addressInput} + /> + + + + { + handleConfirmAddress(); + }} + className={styles.checkIcon} + /> + + + + + onAmountChange(value)} + inputClass={styles.bgClass[theme]} + imgClass={styles.bgClass[theme]} + /> + + {partials?.map((partial, index) => ( + + ))} + + + + Estimated time: + {props.timeEstimateLabel} + + + + + + + { + setSmAddressVisible(false); + }} + /> + + + + {`From ${props.fromName}`} + + + + + + + {`To ${props.toName}`} + { + setToAddress(e.target.value); + props.onAddressChange(e.target.value); + }} + inputClassName={styles.addressInput} + /> + + + + { + handleConfirmAddress(); + }} + className={styles.checkIcon} + /> + + + + + ); +} + +export default AssetWithdrawTokens; diff --git a/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.types.tsx b/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.types.tsx new file mode 100644 index 00000000..9a7f5ab9 --- /dev/null +++ b/packages/react/src/ui/asset-withdraw-tokens/asset-withdraw-tokens.types.tsx @@ -0,0 +1,32 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; + +export type TransferPartial = { + percentage: number; + label: string; +}; + +export interface AssetWithdrawTokensProps extends BaseComponentProps { + isDropdown?: boolean; + fromSymbol: AvailableItem["symbol"]; + fromName: AvailableItem["name"]; + fromAddress: string; + fromImgSrc: AvailableItem["imgSrc"]; + toName: AvailableItem["name"]; + toAddress: string; + toImgSrc: AvailableItem["imgSrc"]; + available: AvailableItem["available"]; + priceDisplayAmount: AvailableItem["priceDisplayAmount"]; + amount?: string; + // ==== Labels + transferLabel?: string; + cancelLabel?: string; + timeEstimateLabel: string; + isSubmitDisabled?: boolean; + partials?: Array; + onChange?: (value: string) => void; + onTransfer?: (event?: any) => void; + onCancel?: (event?: any) => void; + onAddressChange?: (value: string) => void; + onAddressConfirm?: (event?: any) => void; +} diff --git a/packages/react/src/ui/asset-withdraw-tokens/index.ts b/packages/react/src/ui/asset-withdraw-tokens/index.ts new file mode 100644 index 00000000..a266bf62 --- /dev/null +++ b/packages/react/src/ui/asset-withdraw-tokens/index.ts @@ -0,0 +1 @@ +export { default } from "./asset-withdraw-tokens"; diff --git a/packages/react/src/ui/avatar-badge/avatar-badge.tsx b/packages/react/src/ui/avatar-badge/avatar-badge.tsx new file mode 100644 index 00000000..ca78cd35 --- /dev/null +++ b/packages/react/src/ui/avatar-badge/avatar-badge.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Box from "../box"; +import { store } from "../../models/store"; +import { avatarBadge, avatarBadgePlacement } from "../avatar/avatar.css"; +import type { AvatarBadgeProps } from "../avatar/avatar.types"; + +function AvatarBadge(props: AvatarBadgeProps) { + const { + placement = "bottom-right", + size = "1.25em", + borderWidth = "0.2em", + } = props; + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + useEffect(() => { + setInternalTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setInternalTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + {props.children} + + ); +} + +export default AvatarBadge; diff --git a/packages/react/src/ui/avatar-badge/index.ts b/packages/react/src/ui/avatar-badge/index.ts new file mode 100644 index 00000000..87fd8960 --- /dev/null +++ b/packages/react/src/ui/avatar-badge/index.ts @@ -0,0 +1 @@ +export { default } from "./avatar-badge"; diff --git a/packages/react/src/ui/avatar-image/avatar-image.tsx b/packages/react/src/ui/avatar-image/avatar-image.tsx new file mode 100644 index 00000000..a4937737 --- /dev/null +++ b/packages/react/src/ui/avatar-image/avatar-image.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import cls from "clsx"; +import AvatarName from "../avatar-name"; +import { avatarImg } from "../avatar/avatar.css"; +import type { AvatarImageProps } from "../avatar/avatar.types"; + +function AvatarImage(props: AvatarImageProps) { + const imgRef = useRef(null); + const cleanupRef = useRef(null); + const [status, setStatus] = useState(() => "pending"); + + const [transitionState, setTransitionState] = useState(() => "idle"); + + function load() { + if (!props.src) return; + flush(); + const img = new Image(); + img.src = props.src; + if (props.crossOrigin) img.crossOrigin = props.crossOrigin; + if (props.srcSet) img.srcset = props.srcSet; + if (props.sizes) img.sizes = props.sizes; + if (props.loading) img.loading = props.loading; + img.onload = (event) => { + flush(); + setStatus("loaded"); + setTransitionState("entered"); + props.onLoad?.(event); + }; + img.onerror = (error) => { + flush(); + setStatus("failed"); + props.onError?.(error); + }; + imgRef.current = img; + } + + function flush() { + if (imgRef.current) { + imgRef.current.onload = null; + imgRef.current.onerror = null; + imgRef.current = null; + setTransitionState("idle"); + } + } + + function getResolvedStatus() { + return props.ignoreFallback ? "loaded" : status; + } + + function getShowFallback() { + return !props.src || getResolvedStatus() !== "loaded"; + } + + function getInlineStyles() { + return { + opacity: `${transitionState === "idle" ? 0 : 1}`, + transition: + transitionState === "entered" + ? "opacity 150ms cubic-bezier(0.7, 0, 0.84, 0)" + : "none !important", + }; + } + + useEffect(() => { + setStatus(props.src ? "loading" : "pending"); + }, [props.src]); + useEffect(() => { + if (props.ignoreFallback) return; + if (status === "loading") { + load(); + } + cleanupRef.current = () => { + flush(); + }; + }, [ + status, + props.ignoreFallback, + props.crossOrigin, + props.srcSet, + props.sizes, + props.onLoad, + props.onError, + props.loading, + ]); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") { + cleanupRef.current(); + } + }; + }, []); + + return ( + <> + {getShowFallback() ? ( + <> + + + ) : ( + <> + {props.name} props.onLoad} + width={props.width} + height={props.height} + referrerPolicy={props.referrerPolicy as any} + crossOrigin={(props.crossOrigin as any) ?? undefined} + loading={props.loading} + data-status={getResolvedStatus()} + style={getInlineStyles()} + className={cls(avatarImg, props.className)} + /> + + )} + + ); +} + +export default AvatarImage; diff --git a/packages/react/src/ui/avatar-image/index.ts b/packages/react/src/ui/avatar-image/index.ts new file mode 100644 index 00000000..04279f93 --- /dev/null +++ b/packages/react/src/ui/avatar-image/index.ts @@ -0,0 +1 @@ +export { default } from "./avatar-image"; diff --git a/packages/react/src/ui/avatar-name/avatar-name.tsx b/packages/react/src/ui/avatar-name/avatar-name.tsx new file mode 100644 index 00000000..68250c96 --- /dev/null +++ b/packages/react/src/ui/avatar-name/avatar-name.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import Box from "../box"; +import { avatarName } from "../avatar/avatar.css"; +import type { AvatarNameProps } from "../avatar/avatar.types"; + +function AvatarName(props: AvatarNameProps) { + const { showInitials = true } = props; + function initials(name: string) { + if (typeof props.getInitials === "function") { + return props.getInitials(props.name); + } + const names = name.split(" "); + const firstName = names[0] ?? ""; + const lastName = names.length > 1 ? names[names.length - 1] : ""; + return firstName && lastName + ? `${firstName.charAt(0)}${lastName.charAt(0)}` + : firstName.charAt(0); + } + return ( + + {!!props.name && showInitials ? <>{initials(props.name)} : null} + + ); +} + +export default AvatarName; diff --git a/packages/react/src/ui/avatar-name/index.ts b/packages/react/src/ui/avatar-name/index.ts new file mode 100644 index 00000000..63395105 --- /dev/null +++ b/packages/react/src/ui/avatar-name/index.ts @@ -0,0 +1 @@ +export { default } from "./avatar-name"; diff --git a/packages/react/src/ui/avatar/avatar.css.ts b/packages/react/src/ui/avatar/avatar.css.ts new file mode 100644 index 00000000..9137907a --- /dev/null +++ b/packages/react/src/ui/avatar/avatar.css.ts @@ -0,0 +1,113 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const avatarSizeVar = createVar(); +export const avatarBgVar = createVar(); +export const avatarBorderColorVar = createVar(); + +export const avatarBase = style({ + display: "inline-flex", + borderRadius: "inherit", + width: avatarSizeVar, + height: avatarSizeVar, + position: "relative", + alignItems: "center", + justifyContent: "center", + flex: "auto 0 1px", + textAlign: "center", + textTransform: "uppercase", + fontWeight: themeVars.fontWeight.medium, + selectors: { + "&:not([data-loaded='true']):not([data-custom-bg='true'])": { + backgroundColor: avatarBgVar, + }, + "&:not([data-loaded='true'])": { + color: themeVars.colors.text, + }, + }, +}); + +export const avatar = styleVariants({ + light: [ + avatarBase, + style({ + vars: { + [avatarBgVar]: themeVars.colors.gray200, + }, + }), + ], + dark: [ + avatarBase, + style({ + vars: { + [avatarBgVar]: themeVars.colors.whiteAlpha300, + }, + }), + ], +}); + +export const avatarName = style({ + display: "inline-block", + backgroundColor: "transparent", + borderRadius: "100%", + fontSize: `calc(${avatarSizeVar} / 2.5)`, +}); + +export const avatarImg = style({ + display: "inline-block", + backgroundColor: "transparent", + maxWidth: "100%", + borderRadius: "inherit", +}); + +export const avatarBadge = styleVariants({ + light: [ + style({ + vars: { + [avatarBorderColorVar]: themeVars.colors.white, + }, + borderStyle: "solid", + borderColor: avatarBorderColorVar, + }), + ], + dark: [ + style({ + vars: { + [avatarBorderColorVar]: themeVars.colors.gray300, + }, + borderStyle: "solid", + borderColor: avatarBorderColorVar, + }), + ], +}); + +export const avatarBadgePlacement = styleVariants({ + "top-left": [ + { + top: "0", + left: "0", + transform: "translate(-25%, -25%)", + }, + ], + "top-right": [ + { + top: "0", + right: "0", + transform: "translate(25%, -25%)", + }, + ], + "bottom-left": [ + { + bottom: "0", + left: "0", + transform: "translate(-25%, 25%)", + }, + ], + "bottom-right": [ + { + bottom: "0", + right: "0", + transform: "translate(25%, 25%)", + }, + ], +}); diff --git a/packages/react/src/ui/avatar/avatar.helper.ts b/packages/react/src/ui/avatar/avatar.helper.ts new file mode 100644 index 00000000..f0010c70 --- /dev/null +++ b/packages/react/src/ui/avatar/avatar.helper.ts @@ -0,0 +1,18 @@ +import { themeVars } from "../../styles/themes.css"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { AvatarProps } from "./avatar.types"; + +export function avatarSize(size: AvatarProps["size"]) { + const sizeMap: Record = { + "3xs": themeVars.space[8], + "2xs": themeVars.space[9], + xs: themeVars.space[10], + sm: themeVars.space[12], + md: themeVars.space[16], + lg: themeVars.space[17], + xl: themeVars.space[21], + "2xl": themeVars.space[23], + }; + + return sizeMap[size ?? "sm"]; +} diff --git a/packages/react/src/ui/avatar/avatar.tsx b/packages/react/src/ui/avatar/avatar.tsx new file mode 100644 index 00000000..75ed3053 --- /dev/null +++ b/packages/react/src/ui/avatar/avatar.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import { assignInlineVars } from "@vanilla-extract/dynamic"; +import Box from "../box"; +import AvatarImage from "../avatar-image"; +import { store } from "../../models/store"; +import { avatarSize } from "./avatar.helper"; +import { avatarSizeVar, avatar } from "./avatar.css"; +import type { AvatarProps } from "./avatar.types"; + +function Avatar(props: AvatarProps) { + const { size = "md", rounded = true, fallbackMode = "initials" } = props; + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + const [isLoaded, setIsLoaded] = useState(() => false); + const [sizeValue, setSizeValue] = useState(() => avatarSize(size)); + function cssVars() { + return assignInlineVars({ [avatarSizeVar]: sizeValue }); + } + useEffect(() => { + setInternalTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + }); + }, []); + useEffect(() => { + setSizeValue(avatarSize(size)); + }, [size]); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + { + props.onLoad?.(event); + setIsLoaded(true); + }} + width={sizeValue} + height={sizeValue} + onError={(event) => props.onError} + getInitials={props.getInitials} + name={props.name} + borderRadius={props.borderRadius} + ignoreFallback={props.ignoreFallback} + crossOrigin={props.crossOrigin} + referrerPolicy={props.referrerPolicy} + /> + {props.children} + + + ); +} + +export default Avatar; diff --git a/packages/react/src/ui/avatar/avatar.types.tsx b/packages/react/src/ui/avatar/avatar.types.tsx new file mode 100644 index 00000000..c71e672f --- /dev/null +++ b/packages/react/src/ui/avatar/avatar.types.tsx @@ -0,0 +1,67 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +// Whether to display initials or plain background while avatar src is still loading +export type AvatarFallbackMode = "initials" | "bg"; + +export interface AvatarImageProps extends BaseComponentProps { + src?: string; + srcSet?: string; + width?: string; + height?: string; + name: string; + borderRadius?: Sprinkles["borderRadius"]; + loading?: "eager" | "lazy"; + ignoreFallback?: boolean; + referrerPolicy?: string; + crossOrigin?: string; + sizes?: string; + fallbackMode?: AvatarFallbackMode; + onError?: (error?: any) => void; + onLoad?: (event?: any) => void; + getInitials?: (name: string) => string; +} + +export interface AvatarNameProps extends BaseComponentProps { + name: string; + getInitials?: (name: string) => string; + children?: React.ReactNode; + boxRef?: any; + showInitials?: boolean; + attributes?: Sprinkles; +} + +export type AvatarSize = + | "3xs" + | "2xs" + | "xs" + | "sm" + | "md" + | "lg" + | "xl" + | "2xl"; + +export interface AvatarProps + extends AvatarImageProps, + Omit { + size?: AvatarSize; + rounded?: boolean; + showBorder?: boolean; + fallbackMode?: AvatarFallbackMode; + borderColor?: Sprinkles["borderColor"]; + backgroundColor?: Sprinkles["backgroundColor"]; +} + +export type AvatarBadgePlacement = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right"; + +export interface AvatarBadgeProps extends BaseComponentProps { + boxRef?: any; + attributes?: any; + size?: string; + placement?: AvatarBadgePlacement; + borderWidth?: Sprinkles["borderWidth"]; +} diff --git a/packages/react/src/ui/avatar/index.ts b/packages/react/src/ui/avatar/index.ts new file mode 100644 index 00000000..a02975c8 --- /dev/null +++ b/packages/react/src/ui/avatar/index.ts @@ -0,0 +1 @@ +export { default } from "./avatar"; diff --git a/packages/react/src/ui/basic-modal/basic-modal.css.ts b/packages/react/src/ui/basic-modal/basic-modal.css.ts new file mode 100644 index 00000000..12f3298b --- /dev/null +++ b/packages/react/src/ui/basic-modal/basic-modal.css.ts @@ -0,0 +1,62 @@ +import { style, createVar, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const connectModalShadowVar = createVar(); +export const connectModalBgVar = createVar(); + +const modalContentBase = style([ + style({ + boxShadow: connectModalShadowVar, + backgroundColor: connectModalBgVar, + maxHeight: "100%", + overflow: "auto !important", + display: "flex", + flexDirection: "column", + height: "auto", + borderRadius: themeVars.radii["xl"], + selectors: { + "&::-webkit-scrollbar": { + display: "none", + }, + }, + }), +]); + +export const modalContent = styleVariants({ + light: [ + style({ + vars: { + [connectModalBgVar]: themeVars.colors.white, + [connectModalShadowVar]: + "0 10px 15px -3px rgba(0, 0, 0, 0.1),0 4px 6px -2px rgba(0, 0, 0, 0.05)", + }, + }), + modalContentBase, + ], + dark: [ + style({ + vars: { + [connectModalBgVar]: themeVars.colors.blackPrimary, + [connectModalShadowVar]: + "rgba(0, 0, 0, 0.1) 0px 0px 0px 1px,rgba(0, 0, 0, 0.2) 0px 5px 10px,rgba(0, 0, 0, 0.4) 0px 15px 40px", + }, + }), + modalContentBase, + ], +}); + +export const modalChildren = style([ + { + minWidth: "320px", + paddingLeft: themeVars.space["9"], + paddingRight: themeVars.space["9"], + paddingBottom: themeVars.space["9"], + }, +]); + +export const modalHeader = style({ + paddingTop: themeVars.space["4"], + paddingLeft: themeVars.space["9"], + paddingRight: themeVars.space["4"], + paddingBottom: "0", +}); diff --git a/packages/react/src/ui/basic-modal/basic-modal.tsx b/packages/react/src/ui/basic-modal/basic-modal.tsx new file mode 100644 index 00000000..14fa013b --- /dev/null +++ b/packages/react/src/ui/basic-modal/basic-modal.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import autoAnimate from "@formkit/auto-animate"; +import { store } from "../../models/store"; +import Stack from "../stack"; +import Text from "../text"; +import IconButton from "../icon-button"; +import type { ThemeVariant } from "../../models/system.model"; +import type { BasicModalProps } from "./basic-modal.types"; +import { modalHeader, modalContent, modalChildren } from "./basic-modal.css"; +import Modal from "../modal"; + +function BasicModal(props: BasicModalProps) { + const { closeOnClickaway = true } = props; + const cleanupRef = useRef<(() => void) | null>(null); + const parentRef = useRef(null); + const [theme, setTheme] = useState(() => "light"); + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + if (parentRef.current) { + autoAnimate(parentRef.current); + } + }, [parentRef.current]); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + props.onOpen?.()} + onClose={(event) => props.onClose?.()} + preventScroll + renderTrigger={props.renderTrigger} + contentClassName={clsx(modalContent[theme], props?.modalContentClassName)} + childrenClassName={modalChildren} + className={props.modalContainerClassName} + header={ + + {props.title && typeof props.title === "string" ? ( + + {props?.title} + + ) : null} + {props.title && typeof props.title !== "string" ? ( + <>{props.title} + ) : null} + {typeof props.renderCloseButton === "function" ? ( + <>{props.renderCloseButton({ onClose: props.onClose })} + ) : ( + <> + { + props.onClose?.(e); + }} + /> + + )} + + } + > +
{props.children}
+
+ ); +} + +export default BasicModal; diff --git a/packages/react/src/ui/basic-modal/basic-modal.types.tsx b/packages/react/src/ui/basic-modal/basic-modal.types.tsx new file mode 100644 index 00000000..3b207c14 --- /dev/null +++ b/packages/react/src/ui/basic-modal/basic-modal.types.tsx @@ -0,0 +1,17 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface BasicModalProps extends BaseComponentProps { + isOpen: boolean; + closeOnClickaway?: boolean; + modalRoot?: HTMLElement | null; + onOpen?: (event?: any) => void; + onClose?: (event?: any) => void; + renderCloseButton?: (props: any) => BaseComponentProps["children"]; + renderTrigger?: (props: any) => BaseComponentProps["children"]; + title: BaseComponentProps["children"]; + children?: BaseComponentProps["children"]; + className?: string; + modalContainerClassName?: string; + modalContentClassName?: string; + modalChildrenClassName?: string; +} diff --git a/packages/react/src/ui/basic-modal/index.ts b/packages/react/src/ui/basic-modal/index.ts new file mode 100644 index 00000000..7f3fd1da --- /dev/null +++ b/packages/react/src/ui/basic-modal/index.ts @@ -0,0 +1 @@ +export { default } from "./basic-modal"; diff --git a/packages/react/src/ui/bonding-card-list/bonding-card-list.tsx b/packages/react/src/ui/bonding-card-list/bonding-card-list.tsx new file mode 100644 index 00000000..9915c907 --- /dev/null +++ b/packages/react/src/ui/bonding-card-list/bonding-card-list.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Box from "../box"; +import BondingCard from "../bonding-card"; +import { BondingCardListProps } from "./bonding-card-list.types"; +import { BondingCardProps } from "../bonding-card/bonding-card.types"; + +function PoolCardList(props: BondingCardListProps) { + return ( + + + {props.list?.map((item, index) => ( + + + + ))} + + + ); +} + +export default PoolCardList; diff --git a/packages/react/src/ui/bonding-card-list/bonding-card-list.types.tsx b/packages/react/src/ui/bonding-card-list/bonding-card-list.types.tsx new file mode 100644 index 00000000..2ebbe933 --- /dev/null +++ b/packages/react/src/ui/bonding-card-list/bonding-card-list.types.tsx @@ -0,0 +1,5 @@ +import { BondingCardProps } from "../bonding-card/bonding-card.types"; + +export interface BondingCardListProps { + list: BondingCardProps[]; +} diff --git a/packages/react/src/ui/bonding-card-list/index.ts b/packages/react/src/ui/bonding-card-list/index.ts new file mode 100644 index 00000000..3151543d --- /dev/null +++ b/packages/react/src/ui/bonding-card-list/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-card-list"; diff --git a/packages/react/src/ui/bonding-card/bonding-card.tsx b/packages/react/src/ui/bonding-card/bonding-card.tsx new file mode 100644 index 00000000..fa364680 --- /dev/null +++ b/packages/react/src/ui/bonding-card/bonding-card.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import { BondingCardProps } from "./bonding-card.types"; + +function BondingCard(props: BondingCardProps) { + return ( + + + + {props.title} + + {props.value} + + + ); +} + +export default BondingCard; diff --git a/packages/react/src/ui/bonding-card/bonding-card.types.tsx b/packages/react/src/ui/bonding-card/bonding-card.types.tsx new file mode 100644 index 00000000..823244e6 --- /dev/null +++ b/packages/react/src/ui/bonding-card/bonding-card.types.tsx @@ -0,0 +1,4 @@ +export interface BondingCardProps { + title: string; + value: string; +} diff --git a/packages/react/src/ui/bonding-card/index.ts b/packages/react/src/ui/bonding-card/index.ts new file mode 100644 index 00000000..91ef03eb --- /dev/null +++ b/packages/react/src/ui/bonding-card/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-card"; diff --git a/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.css.ts b/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.css.ts new file mode 100644 index 00000000..7fc1d7be --- /dev/null +++ b/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.css.ts @@ -0,0 +1,3 @@ +import { style } from "@vanilla-extract/css"; + +export const container = style({ minWidth: "350px" }); diff --git a/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.tsx b/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.tsx new file mode 100644 index 00000000..b8d3b825 --- /dev/null +++ b/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import Button from "../button"; +import { store } from "../../models/store"; +import { BondingListItemSmProps } from "./bonding-list-item-sm.types"; + +function BondingListItemSm(props: BondingListItemSmProps) { + function unbondDisabled() { + return new BigNumber(props.bondedValue || 0).lte(0); + } + + return ( + + + + + {props.title} + + + + APR + + + {new BigNumber(props.totalApr || 0).decimalPlaces(2).toString()} + + + % + + + + + + + + $ + + + {store.getState().formatNumber({ + value: props.bondedValue, + })} + + + + {props.bondedShares} + pool shares + + + + + + ); +} + +export default BondingListItemSm; diff --git a/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.types.tsx b/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.types.tsx new file mode 100644 index 00000000..3eb23a61 --- /dev/null +++ b/packages/react/src/ui/bonding-list-item-sm/bonding-list-item-sm.types.tsx @@ -0,0 +1,12 @@ +import { APR } from "../pool-list-item/pool-list-item.types"; + +export interface BondingListItemSmProps { + title: string; + bondedValue: APR["bondedShares"]; + bondedShares: APR["bondedShares"]; + totalApr: APR["totalApr"]; + isUnbondLoading?: boolean; + isBondLoading?: boolean; + onBond?: (event?: any) => void; + onUnbond?: (event?: any) => void; +} diff --git a/packages/react/src/ui/bonding-list-item-sm/index.ts b/packages/react/src/ui/bonding-list-item-sm/index.ts new file mode 100644 index 00000000..9f802be2 --- /dev/null +++ b/packages/react/src/ui/bonding-list-item-sm/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-list-item-sm"; diff --git a/packages/react/src/ui/bonding-list-item/bonding-list-item.css.ts b/packages/react/src/ui/bonding-list-item/bonding-list-item.css.ts new file mode 100644 index 00000000..56bcb14d --- /dev/null +++ b/packages/react/src/ui/bonding-list-item/bonding-list-item.css.ts @@ -0,0 +1,13 @@ +import { style } from "@vanilla-extract/css"; + +export const textItem = style({ + display: "inline-flex", + justifyContent: "left", + alignItems: "center", +}); + +export const numericItem = style({ + display: "inline-flex", + justifyContent: "right", + alignItems: "center", +}); diff --git a/packages/react/src/ui/bonding-list-item/bonding-list-item.tsx b/packages/react/src/ui/bonding-list-item/bonding-list-item.tsx new file mode 100644 index 00000000..45a59897 --- /dev/null +++ b/packages/react/src/ui/bonding-list-item/bonding-list-item.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import Stack from "../stack"; +import BigNumber from "bignumber.js"; +import Button from "../button"; +import Text from "../text"; +import Box from "../box"; +import { store } from "../../models/store"; +import * as styles from "./bonding-list-item.css"; +import { BondingListItemProps } from "./bonding-list-item.types"; + +function BondingListItem(props: BondingListItemProps) { + return ( + + + {props.title} + + + {new BigNumber(props.totalApr || 0).decimalPlaces(2).toString()}% + + + $ + {store.getState().formatNumber({ + value: props.amount || 0, + })} + + + {new BigNumber(props.superfluidApr || 0).decimalPlaces(2).toString()}% + + + + + + ); +} + +export default BondingListItem; diff --git a/packages/react/src/ui/bonding-list-item/bonding-list-item.types.tsx b/packages/react/src/ui/bonding-list-item/bonding-list-item.types.tsx new file mode 100644 index 00000000..87df57a5 --- /dev/null +++ b/packages/react/src/ui/bonding-list-item/bonding-list-item.types.tsx @@ -0,0 +1,8 @@ +export interface BondingListItemProps { + title: string; + superfluidApr: string; + amount: number | string; + totalApr: string; + onUnbond: (event?: any) => void; + isLoading?: boolean; +} diff --git a/packages/react/src/ui/bonding-list-item/index.ts b/packages/react/src/ui/bonding-list-item/index.ts new file mode 100644 index 00000000..c7b3b6b2 --- /dev/null +++ b/packages/react/src/ui/bonding-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-list-item"; diff --git a/packages/react/src/ui/bonding-list-sm/bonding-list-sm.tsx b/packages/react/src/ui/bonding-list-sm/bonding-list-sm.tsx new file mode 100644 index 00000000..876d1835 --- /dev/null +++ b/packages/react/src/ui/bonding-list-sm/bonding-list-sm.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import BondingListItemSm from "../bonding-list-item-sm"; +import { BondingListSmProps } from "./bonding-list-sm.types"; +import { BondingListItemSmProps } from "../bonding-list-item-sm/bonding-list-item-sm.types"; +import { store } from "../../models/store"; + +function BondingListSm(props: BondingListSmProps) { + const { + title = "Bond your liquidity", + description = "Bond your tokens to earn additional OSMO rewards to the swap fees.", + unbondedTitle = "Unbonded", + unbondedSharesTitle = "pool shares", + } = props; + return ( + + + {title} + + {description} + + {unbondedTitle} + + + {store + .getState() + .formatNumber({ value: props.unbondedBalance, style: "currency" })} + + + + {new BigNumber(props.unbondedShares).decimalPlaces(4).toString()} + + {unbondedSharesTitle} + + + {props.list?.map((item, index) => ( + item?.onBond?.()} + onUnbond={(event) => item?.onUnbond?.()} + key={item.title} + title={item.title} + bondedValue={item.bondedValue} + bondedShares={item.bondedShares} + totalApr={item.totalApr} + /> + ))} + + + ); +} + +export default BondingListSm; diff --git a/packages/react/src/ui/bonding-list-sm/bonding-list-sm.types.tsx b/packages/react/src/ui/bonding-list-sm/bonding-list-sm.types.tsx new file mode 100644 index 00000000..0600bc4f --- /dev/null +++ b/packages/react/src/ui/bonding-list-sm/bonding-list-sm.types.tsx @@ -0,0 +1,12 @@ +import type { BondingListItemSmProps } from "../bonding-list-item-sm/bonding-list-item-sm.types"; + +export interface BondingListSmProps { + list: BondingListItemSmProps[]; + title?: string; + description?: string; + unbondedTitle?: string; + unbondedSharesTitle?: string; + // ==== + unbondedBalance: number | string; + unbondedShares: number | string; +} diff --git a/packages/react/src/ui/bonding-list-sm/index.ts b/packages/react/src/ui/bonding-list-sm/index.ts new file mode 100644 index 00000000..14a8f131 --- /dev/null +++ b/packages/react/src/ui/bonding-list-sm/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-list-sm"; diff --git a/packages/react/src/ui/bonding-list/bonding-list.tsx b/packages/react/src/ui/bonding-list/bonding-list.tsx new file mode 100644 index 00000000..3da0d6cd --- /dev/null +++ b/packages/react/src/ui/bonding-list/bonding-list.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import Stack from "../stack"; +import Box from "../box"; +import BondingListItem from "../bonding-list-item"; +import { BondingListProps } from "./bonding-list.types"; +import { BondingListItemProps } from "../bonding-list-item/bonding-list-item.types"; + +function BondingList(props: BondingListProps) { + return ( + + + {props.list?.map((item, index) => ( + item?.onUnbond?.()} + /> + ))} + + + ); +} + +export default BondingList; diff --git a/packages/react/src/ui/bonding-list/bonding-list.types.tsx b/packages/react/src/ui/bonding-list/bonding-list.types.tsx new file mode 100644 index 00000000..145cde90 --- /dev/null +++ b/packages/react/src/ui/bonding-list/bonding-list.types.tsx @@ -0,0 +1,5 @@ +import { BondingListItemProps } from "../bonding-list-item/bonding-list-item.types"; + +export interface BondingListProps { + list: BondingListItemProps[]; +} diff --git a/packages/react/src/ui/bonding-list/index.ts b/packages/react/src/ui/bonding-list/index.ts new file mode 100644 index 00000000..67659e8f --- /dev/null +++ b/packages/react/src/ui/bonding-list/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-list"; diff --git a/packages/react/src/ui/bonding-more/bonding-more.css.ts b/packages/react/src/ui/bonding-more/bonding-more.css.ts new file mode 100644 index 00000000..1f55e30a --- /dev/null +++ b/packages/react/src/ui/bonding-more/bonding-more.css.ts @@ -0,0 +1,20 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + width: "464px", +}); +export const inputContainer = style({ + borderColor: themeVars.colors.inputBorder, + borderWidth: "1px", + borderStyle: "solid", + height: "68px", +}); + +export const token = style({ + width: "100%", + height: "100%", + border: 0, + outline: 0, + backgroundColor: themeVars.colors.cardBg, +}); diff --git a/packages/react/src/ui/bonding-more/bonding-more.tsx b/packages/react/src/ui/bonding-more/bonding-more.tsx new file mode 100644 index 00000000..20a3b43d --- /dev/null +++ b/packages/react/src/ui/bonding-more/bonding-more.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; +import { useState } from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Box from "../box"; +import * as styles from "./bonding-more.css"; +import { BondingMoreProps } from "./bonding-more.types"; +import NumberField from "../number-field"; + +function BondingMore(props: BondingMoreProps) { + const [btnText, setBtnText] = useState(() => "Amount is empty"); + + const [disabled, setDisabled] = useState(() => true); + + function handleInputChange(value: number) { + if (value === 0) { + setDisabled(true); + setBtnText("Amount is zero"); + } else if (new BigNumber(value).gt(props.available)) { + setDisabled(true); + setBtnText("Insufficient amount"); + } else { + setDisabled(false); + setBtnText("Bond"); + } + props?.onChange(value); + } + + return ( + + + + {props.bondingName} + + + + + + + + Available LP Token + + {props.available} + + + + + handleInputChange(value)} + /> + + + + ); +} + +export default BondingMore; diff --git a/packages/react/src/ui/bonding-more/bonding-more.types.tsx b/packages/react/src/ui/bonding-more/bonding-more.types.tsx new file mode 100644 index 00000000..56d8eee1 --- /dev/null +++ b/packages/react/src/ui/bonding-more/bonding-more.types.tsx @@ -0,0 +1,8 @@ +export interface BondingMoreProps { + bondingName: string; + available: number | string; + onBond: (event?: any) => void; + onChange: (value: number) => void; + isLoading?: boolean; + value: number; +} diff --git a/packages/react/src/ui/bonding-more/index.ts b/packages/react/src/ui/bonding-more/index.ts new file mode 100644 index 00000000..c6c83200 --- /dev/null +++ b/packages/react/src/ui/bonding-more/index.ts @@ -0,0 +1 @@ +export { default } from "./bonding-more"; diff --git a/packages/react/src/ui/box/box.tsx b/packages/react/src/ui/box/box.tsx new file mode 100644 index 00000000..6e8613cb --- /dev/null +++ b/packages/react/src/ui/box/box.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import { forwardRef } from "react"; +import clsx from "clsx"; +import { omit } from "lodash"; +import { rainbowSprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BoxProps } from "./box.types"; +import { DEFAULT_VALUES } from "./box.types"; + +const Box = forwardRef(function Box( + props: BoxProps, + boxRef: BoxProps["boxRef"] +) { + function comp() { + return props.as ?? DEFAULT_VALUES.as; + } + + function finalPassThroughProps() { + return boxStyles().passThroughProps; + } + + function boxStyles() { + const sprinklesObj = rainbowSprinkles({ + ...omit(props, ["attributes", "as", "boxRef"]), + ...props.attributes, + }); + return { + combinedClassName: clsx(sprinklesObj.className, props.className), + style: sprinklesObj.style, + passThroughProps: omit(sprinklesObj.otherProps, [ + "attributes", + "style", + "rawCSS", + "colorScheme", + ]), + }; + } + + function eventHandlers() { + const handlers: Record void> = {}; + const eventProps = [ + "onClick", + "onDoubleClick", + "onMouseDown", + "onMouseUp", + "onMouseEnter", + "onMouseLeave", + "onMouseMove", + "onMouseOver", + "onMouseOut", + "onKeyDown", + "onKeyUp", + "onKeyPress", + "onFocus", + "onBlur", + "onInput", + "onChange", + "onSubmit", + "onReset", + "onScroll", + "onWheel", + "onDragStart", + "onDrag", + "onDragEnd", + "onDragEnter", + "onDragLeave", + "onDragOver", + "onDrop", + "onTouchStart", + "onTouchMove", + "onTouchEnd", + "onTouchCancel", + ]; + eventProps.forEach((eventName) => { + if (props[eventName]) { + handlers[eventName] = (event: any) => props[eventName](event); + } + }); + return handlers; + } + + const CompRef = comp(); + + return ( + + {props.children} + + ); +}); + +export default Box; diff --git a/packages/react/src/ui/box/box.types.ts b/packages/react/src/ui/box/box.types.ts new file mode 100644 index 00000000..25c68c51 --- /dev/null +++ b/packages/react/src/ui/box/box.types.ts @@ -0,0 +1,48 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export type BoxProps = Sprinkles & { + as?: any; + className?: string; + class?: string; + children?: React.ReactNode; + attributes?: any; + rawCSS?: any; + boxRef?: any; + + // Common DOM events with 'any' type for event parameters + onClick?: (event: any) => void; + onDoubleClick?: (event: any) => void; + onMouseDown?: (event: any) => void; + onMouseUp?: (event: any) => void; + onMouseEnter?: (event: any) => void; + onMouseLeave?: (event: any) => void; + onMouseMove?: (event: any) => void; + onMouseOver?: (event: any) => void; + onMouseOut?: (event: any) => void; + onKeyDown?: (event: any) => void; + onKeyUp?: (event: any) => void; + onKeyPress?: (event: any) => void; + onFocus?: (event: any) => void; + onBlur?: (event: any) => void; + onInput?: (event: any) => void; + onChange?: (event: any) => void; + onSubmit?: (event: any) => void; + onReset?: (event: any) => void; + onScroll?: (event: any) => void; + onWheel?: (event: any) => void; + onDragStart?: (event: any) => void; + onDrag?: (event: any) => void; + onDragEnd?: (event: any) => void; + onDragEnter?: (event: any) => void; + onDragLeave?: (event: any) => void; + onDragOver?: (event: any) => void; + onDrop?: (event: any) => void; + onTouchStart?: (event: any) => void; + onTouchMove?: (event: any) => void; + onTouchEnd?: (event: any) => void; + onTouchCancel?: (event: any) => void; +}; + +export const DEFAULT_VALUES = { + as: "div", +}; diff --git a/packages/react/src/ui/box/index.ts b/packages/react/src/ui/box/index.ts new file mode 100644 index 00000000..a954a5a1 --- /dev/null +++ b/packages/react/src/ui/box/index.ts @@ -0,0 +1 @@ +export { default } from "./box"; diff --git a/packages/react/src/ui/breadcrumb/breadcrumb-item.tsx b/packages/react/src/ui/breadcrumb/breadcrumb-item.tsx new file mode 100644 index 00000000..2ae57815 --- /dev/null +++ b/packages/react/src/ui/breadcrumb/breadcrumb-item.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "../box"; +import Text from "../text"; +import type { BreadcrumbItemProps } from "./breadcrumb.types"; +import * as styles from "./breadcrumb.css"; + +function BreadcrumbItem(props: BreadcrumbItemProps) { + const { + isLast = false, + gapLeft = "16px", + primaryColor = "$text", + secondaryColor = "$textSecondary", + separator = "/", + } = props; + return ( + + + + {props.name} + + + {!isLast ? ( + + {separator} + + ) : null} + + ); +} + +export default BreadcrumbItem; diff --git a/packages/react/src/ui/breadcrumb/breadcrumb.css.ts b/packages/react/src/ui/breadcrumb/breadcrumb.css.ts new file mode 100644 index 00000000..c8b67a2a --- /dev/null +++ b/packages/react/src/ui/breadcrumb/breadcrumb.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css"; + +export const lineClamp = style({ + overflow: "hidden", + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 1, +}); + +export const pointer = style({ + cursor: "pointer", +}); diff --git a/packages/react/src/ui/breadcrumb/breadcrumb.tsx b/packages/react/src/ui/breadcrumb/breadcrumb.tsx new file mode 100644 index 00000000..9fcc158b --- /dev/null +++ b/packages/react/src/ui/breadcrumb/breadcrumb.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import Box from "../box"; +import { BreadcrumbProps } from "./breadcrumb.types"; + +function Breadcrumb(props: BreadcrumbProps) { + const { + width = { + tablet: "90%", + desktop: "80%", + }, + gapRight = "22px", + } = props; + return ( + + {props.children} + + ); +} + +export default Breadcrumb; diff --git a/packages/react/src/ui/breadcrumb/breadcrumb.types.ts b/packages/react/src/ui/breadcrumb/breadcrumb.types.ts new file mode 100644 index 00000000..f2503906 --- /dev/null +++ b/packages/react/src/ui/breadcrumb/breadcrumb.types.ts @@ -0,0 +1,33 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +export interface BreadcrumbProps { + children: BaseComponentProps["children"]; + mt?: Sprinkles["mt"]; + mb?: Sprinkles["mb"]; + width?: Sprinkles["width"]; + gapLeft?: Sprinkles["gap"]; + gapRight?: Sprinkles["gap"]; + primaryColor?: Sprinkles["color"]; + secondaryColor?: Sprinkles["color"]; +} + +export interface BreadcrumbLink { + name: string; + href?: string; + download?: string | boolean; + linkRef?: string; + target?: "_self" | "_blank" | "_parent" | "_top"; + type?: string; + onClick?: () => void; +} + +export type BreadcrumbItemProps = Pick< + BreadcrumbProps, + "gapLeft" | "primaryColor" | "secondaryColor" +> & + BreadcrumbLink & { + as?: string; + isLast: boolean; + separator?: string; + }; diff --git a/packages/react/src/ui/breadcrumb/index.ts b/packages/react/src/ui/breadcrumb/index.ts new file mode 100644 index 00000000..f29a4fc8 --- /dev/null +++ b/packages/react/src/ui/breadcrumb/index.ts @@ -0,0 +1 @@ +export { default } from "./breadcrumb"; diff --git a/packages/react/src/ui/button/button.css.ts b/packages/react/src/ui/button/button.css.ts new file mode 100644 index 00000000..6a07dc6f --- /dev/null +++ b/packages/react/src/ui/button/button.css.ts @@ -0,0 +1,337 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; +// import { baseLayer } from "../../styles/layers.css"; + +export const buttonBgVar = createVar(); +export const buttonHoverBgVar = createVar(); +export const buttonTextColorVar = createVar(); +export const buttonHoverTextColorVar = createVar(); + +export const baseButton = style({ + fontFamily: themeVars.font.body, + fontWeight: themeVars.fontWeight.semibold, + cursor: "pointer", + appearance: "none", + border: "none", + position: "relative", + userSelect: "none", + whiteSpace: "nowrap", + verticalAlign: "middle", + lineHeight: 1.2, + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform,filter", + transitionDuration: "200ms", + display: "flex", + justifyContent: "center", + alignItems: "center", + selectors: { + "&:focus": { + outline: "none", + }, + }, +}); + +export const baseAnchorButton = style([ + baseButton, + { display: "inline-flex", textDecoration: "none" }, +]); + +export const unstyledButton = style([ + baseButton, + { + background: "transparent", + color: "inherit", + }, +]); + +export const variants = styleVariants({ + solid: [ + { + borderRadius: themeVars.radii.md, + fontWeight: themeVars.fontWeight.semibold, + }, + ], + outlined: [ + { + borderRadius: themeVars.radii.md, + outlineWidth: "2px", + outlineStyle: "solid", + outlineColor: buttonTextColorVar, + outlineOffset: "-2px", + background: "none !important", + color: `${buttonTextColorVar} !important`, + selectors: { + "&:not([disabled]):hover": { + opacity: 0.8, + }, + "&:focus": { + outline: `2px solid ${buttonTextColorVar}`, + }, + }, + }, + ], + link: [], + ghost: [ + { + borderRadius: themeVars.radii.md, + fontWeight: themeVars.fontWeight.semibold, + backgroundColor: "transparent !important", + selectors: { + "&:not([disabled]):hover": { + backgroundColor: `${buttonHoverBgVar} !important`, + }, + }, + }, + ], + unstyled: [ + style({ + backgroundColor: "transparent !important", + color: `${themeVars.colors.text} !important`, + }), + ], +}); + +export const buttonSize = styleVariants({ + xs: { + padding: `0px ${themeVars.space["4"]}`, + fontSize: themeVars.fontSize.xs, + height: themeVars.space["10"], + minWidth: themeVars.space["10"], + borderRadius: themeVars.radii.base, + }, + sm: { + padding: `0px ${themeVars.space["6"]}`, + fontSize: themeVars.fontSize.sm, + height: themeVars.space["12"], + minWidth: themeVars.space["12"], + }, + md: { + padding: `0px ${themeVars.space["9"]}`, + fontSize: themeVars.fontSize.md, + height: themeVars.space["15"], + minWidth: themeVars.space["15"], + }, + lg: { + padding: `0px ${themeVars.space["10"]}`, + fontSize: themeVars.fontSize.lg, + height: themeVars.space["17"], + minWidth: themeVars.space["17"], + }, +}); + +// ==== Intents +const intentPrimaryBase = style({ + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:not([disabled]):hover": { + opacity: 0.8, + }, + }, +}); + +export const intentPrimary = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + [buttonBgVar]: themeVars.colors.primary, + [buttonHoverBgVar]: themeVars.colors.primary, + }, + }), + intentPrimaryBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + [buttonBgVar]: themeVars.colors.primary, + [buttonHoverBgVar]: themeVars.colors.primary, + }, + }), + intentPrimaryBase, + ], +}); + +// ==== +const intentSecondaryBase = style({ + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:not([disabled]):hover": { + color: buttonHoverTextColorVar, + backgroundColor: buttonHoverBgVar, + }, + }, +}); + +export const intentSecondary = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.gray600, + [buttonBgVar]: themeVars.colors.gray100, + [buttonHoverBgVar]: themeVars.colors.gray200, + [buttonHoverTextColorVar]: themeVars.colors.gray700, + }, + }), + intentSecondaryBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.text, + [buttonBgVar]: themeVars.colors.blackAlpha400, + [buttonHoverBgVar]: "rgba(143,153,168, .15)", + [buttonHoverTextColorVar]: themeVars.colors.text, + }, + }), + intentSecondaryBase, + ], +}); + +// ==== +const intentTertiaryBase = style({ + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:not([disabled]):hover": { + opacity: 0.8, + }, + }, +}); + +export const intentTertiary = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.inputBg, + [buttonBgVar]: themeVars.colors.text, + }, + }), + intentTertiaryBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.cardBg, + [buttonBgVar]: themeVars.colors.text, + }, + }), + intentTertiaryBase, + ], +}); + +// ==== +const intentTextBase = style({ + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:not([disabled]):hover": { + opacity: 0.8, + }, + }, +}); + +export const intentText = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.textSecondary, + [buttonBgVar]: themeVars.colors.cardBg, + }, + }), + intentTextBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.textSecondary, + [buttonBgVar]: themeVars.colors.cardBg, + }, + }), + intentTextBase, + ], +}); + +// ==== Intent warning +const intentColorfulBase = style({ + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:not([disabled]):hover": { + opacity: 0.8, + }, + }, +}); + +export const intentWarning = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + [buttonBgVar]: themeVars.colors.yellow500, + }, + }), + intentColorfulBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.blackPrimary, + [buttonBgVar]: themeVars.colors.yellow400, + }, + }), + intentColorfulBase, + ], +}); + +// ==== Intent success +export const intentSuccess = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + [buttonBgVar]: themeVars.colors.green500, + }, + }), + intentColorfulBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.text, + [buttonBgVar]: themeVars.colors.green400, + }, + }), + intentColorfulBase, + ], +}); + +// ==== Intent danger +export const intentDanger = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + [buttonBgVar]: themeVars.colors.red500, + }, + }), + intentColorfulBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.text, + [buttonBgVar]: themeVars.colors.red400, + }, + }), + intentColorfulBase, + ], +}); + +export const disabled = style({ + position: "relative", + opacity: 0.6, + cursor: "not-allowed !important", +}); diff --git a/packages/react/src/ui/button/button.helper.ts b/packages/react/src/ui/button/button.helper.ts new file mode 100644 index 00000000..bec56903 --- /dev/null +++ b/packages/react/src/ui/button/button.helper.ts @@ -0,0 +1,120 @@ +import clx from "clsx"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { ThemeVariant } from "../../models/system.model"; +import { + variants, + intentPrimary, + intentSecondary, + intentTertiary, + intentText, + intentWarning, + intentSuccess, + intentDanger, + disabled, + baseButton, + buttonBgVar, + buttonHoverBgVar, + buttonTextColorVar, + buttonHoverTextColorVar, + baseAnchorButton, +} from "./button.css"; +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { ButtonProps } from "./button.types"; + +export type ButtonSize = "xs" | "sm" | "md" | "lg"; +export type ButtonVariant = + | "solid" + | "outlined" + | "link" + | "ghost" + | "unstyled"; + +export type ButtonIntent = + | "primary" + | "secondary" + | "tertiary" + | "text" + | "warning" + | "success" + | "danger"; + +const buttonSize: Record = { + xs: { + px: "$4", + gap: "$2", + fontSize: "$xs", + height: "$10", + minWidth: "$10", + }, + sm: { + px: "$6", + gap: "$2", + fontSize: "$sm", + height: "$12", + minWidth: "$12", + }, + md: { + px: "$8", + gap: "$2", + fontSize: "$md", + height: "$14", + minWidth: "$14", + }, + lg: { + px: "$10", + gap: "$2", + fontSize: "$lg", + height: "$15", + minWidth: "$15", + }, +}; + +export function getSize(size: ButtonSize): Sprinkles { + return buttonSize[size]; +} + +export function recipe({ + as, + variant, + intent, + isDisabled, + theme, +}: { + as: ButtonProps["as"]; + variant: ButtonVariant; + intent: ButtonIntent; + isDisabled: boolean; + theme: ThemeVariant; +}) { + const intentMap: Record = { + primary: intentPrimary, + secondary: intentSecondary, + tertiary: intentTertiary, + warning: intentWarning, + success: intentSuccess, + danger: intentDanger, + text: intentText, + }; + + const intentVariants = intentMap[intent]; + const intentClass = intentVariants + ? intentVariants[theme] + : intentPrimary[theme]; + + return clx( + as === "a" ? baseAnchorButton : baseButton, + intentClass, + intent === "tertiary" && variant === "outlined" ? null : variants[variant], + isDisabled ? disabled : null, + ); +} + +export const buttonOverrides: ComponentOverrideSchema = { + name: "button", + overrides: [ + [buttonBgVar, "bg"], + [buttonHoverBgVar, "hoverBg"], + [buttonTextColorVar, "color"], + [buttonHoverTextColorVar, "hoverColor"], + ], +}; diff --git a/packages/react/src/ui/button/button.tsx b/packages/react/src/ui/button/button.tsx new file mode 100644 index 00000000..e2843f77 --- /dev/null +++ b/packages/react/src/ui/button/button.tsx @@ -0,0 +1,189 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import { assignInlineVars } from "@vanilla-extract/dynamic"; +import clx from "clsx"; +import Icon from "../icon"; +import Box from "../box"; +import Spinner from "../spinner"; +import { store, UIState } from "../../models/store"; +import { recipe, buttonOverrides } from "./button.helper"; +import { isDefaultAccent, getAccentHover } from "../../helpers/style"; +import { themeVars } from "../../styles/themes.css"; +import { fullWidth, fullWidthHeight } from "../shared/shared.css"; +import type { UnknownRecord } from "../../helpers/types"; +import type { ButtonProps } from "./button.types"; +import type { ThemeVariant } from "../../models/system.model"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import * as styles from "./button.css"; + +function Button(props: ButtonProps) { + const { + as = "button", + size = "md", + intent = "primary", + variant = "solid", + spinnerPlacement = "start", + } = props; + const cleanupRef = useRef<() => void>(null); + const [isMounted, setIsMounted] = useState(() => false); + const [_overrideManager, set_overrideManager] = useState(() => null); + const [_theme, set_theme] = useState(() => "light"); + const [_themeAccent, set_themeAccent] = useState(() => null); + function getStoreState() { + // This seems weird but it's a workaround for one minor bug from mitosis // If we have any variables in any function scope that has the same name as the store state, mitosis understands that it's the same variable + // and will attempt to transform those unwanted/unrelated variables into the ones in the state. + // So we need to name these values differently (e.g. _keyA: valueA) or inverse + return { + theme: store.getState().theme, + themeAccent: store.getState().themeAccent, + overrideStyleManager: store.getState().overrideStyleManager, + }; + } + function getVars() { + const accent = _themeAccent; + const isDefaultAppearance = isDefaultAccent(accent) && accent === "blue"; // Only allow accent customization for 'primary' Intent + const isPrimaryIntent = intent === "primary"; + return isDefaultAppearance || !isPrimaryIntent + ? _overrideManager?.applyOverrides(buttonOverrides.name) + : assignInlineVars({ + [styles.buttonBgVar]: themeVars.colors.accent, + [styles.buttonTextColorVar]: themeVars.colors.accentText, + [styles.buttonHoverBgVar]: getAccentHover(themeVars.colors.accent), + }); + } + function combinedClassName() { + return clx( + styles.buttonSize[size], + recipe({ + as: as, + variant: variant, + intent: intent ?? "primary", + isDisabled: props.disabled || props.isLoading, + theme: isMounted ? getStoreState().theme : "light", + }), + props.fluidWidth ? fullWidth : null, + props.fluid ? fullWidthHeight : null, + props.className + ); + } + function spreadAttributes() { + return Object.assign( + { as: as }, + { + attributes: { + ...props.attributes, + disabled: props.disabled, // style: state.getVars(), + ...props.domAttributes, + }, + } + ); + } + function eventHandlers() { + const handlers: Record void> = {}; + const eventProps = [ + "onClick", + "onDoubleClick", + "onMouseDown", + "onMouseUp", + "onMouseEnter", + "onMouseLeave", + "onMouseMove", + "onMouseOver", + "onMouseOut", + "onKeyDown", + "onKeyUp", + "onKeyPress", + "onFocus", + "onBlur", + "onInput", + "onChange", + "onSubmit", + "onReset", + "onScroll", + "onWheel", + "onDragStart", + "onDrag", + "onDragEnd", + "onDragEnter", + "onDragLeave", + "onDragOver", + "onDrop", + "onTouchStart", + "onTouchMove", + "onTouchEnd", + "onTouchCancel", + ]; + eventProps.forEach((eventName) => { + if (props[eventName]) { + handlers[eventName] = (event: any) => props[eventName](event); + } + }); + return handlers; + } + useEffect(() => { + const uiStore = getStoreState(); + setIsMounted(true); + set_theme(uiStore[0]); + set_themeAccent(uiStore[1]); + set_overrideManager(uiStore[2]); + cleanupRef.current = store.subscribe((newState, prevState) => { + set_theme(newState.theme); + set_themeAccent(newState.themeAccent); + set_overrideManager(newState.overrideStyleManager); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") { + cleanupRef.current(); + } + }; + }, []); + return ( + + + + {!props.isLoading ? <>{props.children} : null} + + + + ); +} +export default Button; diff --git a/packages/react/src/ui/button/button.types.tsx b/packages/react/src/ui/button/button.types.tsx new file mode 100644 index 00000000..afca8498 --- /dev/null +++ b/packages/react/src/ui/button/button.types.tsx @@ -0,0 +1,55 @@ +import { BaseComponentProps } from "../../models/components.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { IconProps } from "../icon/icon.types"; +import type { ButtonVariant, ButtonIntent, ButtonSize } from "./button.helper"; + +export interface ButtonProps extends BaseComponentProps { + as?: "button" | "a"; + href?: string; + variant?: ButtonVariant; + intent?: ButtonIntent; + size?: ButtonSize; + disabled?: boolean; + fluidWidth?: boolean; + fluid?: boolean; + iconSize?: IconProps["size"]; + leftIcon?: IconProps["name"]; + rightIcon?: IconProps["name"]; + buttonRef?: any; + attributes?: Sprinkles; + domAttributes?: any; + isLoading?: boolean; + spinnerPlacement?: "start" | "end"; + // Common DOM events with 'any' type for event parameters + onClick?: (event: any) => void; + onDoubleClick?: (event: any) => void; + onMouseDown?: (event: any) => void; + onMouseUp?: (event: any) => void; + onMouseEnter?: (event: any) => void; + onMouseLeave?: (event: any) => void; + onMouseMove?: (event: any) => void; + onMouseOver?: (event: any) => void; + onMouseOut?: (event: any) => void; + onKeyDown?: (event: any) => void; + onKeyUp?: (event: any) => void; + onKeyPress?: (event: any) => void; + onFocus?: (event: any) => void; + onBlur?: (event: any) => void; + onInput?: (event: any) => void; + onChange?: (event: any) => void; + onSubmit?: (event: any) => void; + onReset?: (event: any) => void; + onScroll?: (event: any) => void; + onWheel?: (event: any) => void; + onDragStart?: (event: any) => void; + onDrag?: (event: any) => void; + onDragEnd?: (event: any) => void; + onDragEnter?: (event: any) => void; + onDragLeave?: (event: any) => void; + onDragOver?: (event: any) => void; + onDrop?: (event: any) => void; + onTouchStart?: (event: any) => void; + onTouchMove?: (event: any) => void; + onTouchEnd?: (event: any) => void; + onTouchCancel?: (event: any) => void; +} diff --git a/packages/react/src/ui/button/index.ts b/packages/react/src/ui/button/index.ts new file mode 100644 index 00000000..8b4737ba --- /dev/null +++ b/packages/react/src/ui/button/index.ts @@ -0,0 +1 @@ +export { default } from "./button"; diff --git a/packages/react/src/ui/callout/callout.helpers.ts b/packages/react/src/ui/callout/callout.helpers.ts new file mode 100644 index 00000000..ddc13c63 --- /dev/null +++ b/packages/react/src/ui/callout/callout.helpers.ts @@ -0,0 +1,64 @@ +import { IntentValues } from "../../models/system.model"; +import { ThemeVariant } from "../../models/system.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export function getIntentColors( + intent: IntentValues, + colorMode: ThemeVariant +): { + color: Sprinkles["color"]; + bg: Sprinkles["bg"]; +} { + if (intent === "error") { + return colorMode === "light" + ? { + color: "$red600", + bg: "$red100", + } + : { + color: "$red300", + bg: "rgba(205,66,70,.2)", + }; + } + + if (intent === "success") { + return colorMode === "light" + ? { + color: "$green600", + bg: "$green100", + } + : { + color: "$green300", + bg: "rgba(35,133,81,.2)", + }; + } + + if (intent === "info") { + return colorMode === "light" + ? { + color: "$blue600", + bg: "$blue100", + } + : { + color: "$blue200", + bg: "rgba(45,114,210,.2)", + }; + } + + if (intent === "warning") { + return colorMode === "light" + ? { + color: "$yellow600", + bg: "$yellow100", + } + : { + color: "$yellow400", + bg: "rgba(200,118,25,.2)", + }; + } + + return { + color: "$text", + bg: "$cardBg", + }; +} diff --git a/packages/react/src/ui/callout/callout.tsx b/packages/react/src/ui/callout/callout.tsx new file mode 100644 index 00000000..ab9347ac --- /dev/null +++ b/packages/react/src/ui/callout/callout.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Text from "../text"; +import Box from "../box"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import type { ThemeVariant } from "../../models/system.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import { ALL_ICON_NAMES } from "../icon/icon.types"; +import { getIntentColors } from "./callout.helpers"; +import type { CalloutProps } from "./callout.types"; + +function Callout(props: CalloutProps) { + const { intent = "none" } = props; + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + function colorsProperties() { + return getIntentColors(intent, internalTheme as ThemeVariant); + } + function isValidIconName() { + return ALL_ICON_NAMES.includes(props.iconName); + } + useEffect(() => { + setInternalTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setInternalTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + + {!!props.iconName && isValidIconName() ? ( + + ) : null} + {!props.iconName && !isValidIconName() && props.iconRender ? ( + <>{props.iconRender} + ) : null} + + {props.title} + + + + {props.children} + + + + ); +} + +export default Callout; diff --git a/packages/react/src/ui/callout/callout.types.tsx b/packages/react/src/ui/callout/callout.types.tsx new file mode 100644 index 00000000..85a1bfa8 --- /dev/null +++ b/packages/react/src/ui/callout/callout.types.tsx @@ -0,0 +1,12 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { IntentValues } from "../../models/system.model"; +import type { IconName } from "../icon/icon.types"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export interface CalloutProps extends BaseComponentProps { + title: string; + intent?: IntentValues; + iconName?: IconName; + iconRender?: BaseComponentProps["children"]; + attributes?: Sprinkles; +} diff --git a/packages/react/src/ui/callout/index.ts b/packages/react/src/ui/callout/index.ts new file mode 100644 index 00000000..11b7a1a7 --- /dev/null +++ b/packages/react/src/ui/callout/index.ts @@ -0,0 +1 @@ +export { default } from "./callout"; diff --git a/packages/react/src/ui/carousel/carousel.css.ts b/packages/react/src/ui/carousel/carousel.css.ts new file mode 100644 index 00000000..cb397250 --- /dev/null +++ b/packages/react/src/ui/carousel/carousel.css.ts @@ -0,0 +1,10 @@ +import { style } from "@vanilla-extract/css"; + +export const innerContainer = style({ + scrollBehavior: "smooth", +}); + +export const fadeOutGradient = style({ + background: + "linear-gradient(to right, #FFF 0%, rgba(255, 255, 255, 0.00) 100%)", +}); diff --git a/packages/react/src/ui/carousel/carousel.tsx b/packages/react/src/ui/carousel/carousel.tsx new file mode 100644 index 00000000..e87f7652 --- /dev/null +++ b/packages/react/src/ui/carousel/carousel.tsx @@ -0,0 +1,145 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import Box from "../box"; +import ScrollIndicator from "../scroll-indicator"; +import { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { CarouselProps } from "./carousel.types"; +import * as styles from "./carousel.css"; +const INDICATOR_HEIGHT = 40; +const VERTICAL_ALIGN: Record< + CarouselProps["verticalAlign"], + Sprinkles["alignItems"] +> = { + start: "flex-start", + center: "center", + end: "flex-end", +}; + +function Carousel(props: CarouselProps) { + const { + gap = "20px", + width = "100%", + scrollOffset = 0, + indicatorsXOffset = 20, + showIndicatorsShadow = true, + verticalAlign = "start", + initialPosition = 0, + showIndicators = true, + showFadeOut = false, + fadeOutWidth = 156, + } = props; + const [scrollLeft, setScrollLeft] = useState(() => 0); + const [showLeftIndicator, setShowLeftIndicator] = useState(() => true); + const [showRightIndicator, setShowRightIndicator] = useState(() => true); + const [containerHeight, setContainerHeight] = useState(() => 0); + const [containerRef, setContainerRef] = useState(() => null); + function calcYOffset(_containerHeight: number, indicatorHeight: number) { + return _containerHeight / 2 - indicatorHeight / 2; + } + function assignRef(ref: HTMLElement) { + setContainerRef(ref); + } + function handleScroll(scrollDirection: "left" | "right") { + if (!containerRef) return; + const isScrollRight = scrollDirection === "right"; + const scrollDistance = containerRef.clientWidth - scrollOffset; + const scrollValue = isScrollRight ? scrollDistance : -scrollDistance; + const newPosition = containerRef.scrollLeft + scrollValue; + setScrollLeft(newPosition); + containerRef.scrollLeft = newPosition; + } + useEffect(() => { + if (!containerRef) return; + setTimeout(() => { + setScrollLeft(initialPosition); + setContainerHeight(containerRef.offsetHeight); + containerRef.scrollLeft = initialPosition; + }, 100); + }, [containerRef]); + useEffect(() => { + if (!containerRef || !containerHeight) return; + setShowLeftIndicator(scrollLeft > 0); + setShowRightIndicator( + scrollLeft + containerRef.clientWidth < containerRef.scrollWidth + ); + }, [containerRef, scrollLeft, containerHeight]); + return ( + + {showIndicators && showLeftIndicator ? ( + + handleScroll("left")} + showShadow={showIndicatorsShadow} + /> + + ) : null} + {showFadeOut ? ( + + ) : null} + + {props.children?.map((element) => ( + + {element} + + ))} + + {showFadeOut ? ( + + ) : null} + {showIndicators && showRightIndicator ? ( + + handleScroll("right")} + showShadow={showIndicatorsShadow} + /> + + ) : null} + + ); +} + +export default Carousel; diff --git a/packages/react/src/ui/carousel/carousel.types.tsx b/packages/react/src/ui/carousel/carousel.types.tsx new file mode 100644 index 00000000..8b560395 --- /dev/null +++ b/packages/react/src/ui/carousel/carousel.types.tsx @@ -0,0 +1,14 @@ +export interface CarouselProps { + children: any; + width?: string; + gap?: string; + verticalAlign?: "start" | "center" | "end"; + scrollOffset?: number; + showIndicators: boolean; + showIndicatorsShadow?: boolean; + indicatorsXOffset?: number; + indicatorsYOffset?: number; + initialPosition?: number; + showFadeOut?: boolean; + fadeOutWidth?: number; +} diff --git a/packages/react/src/ui/carousel/index.ts b/packages/react/src/ui/carousel/index.ts new file mode 100644 index 00000000..82a1ae44 --- /dev/null +++ b/packages/react/src/ui/carousel/index.ts @@ -0,0 +1 @@ +export { default } from "./carousel"; diff --git a/packages/react/src/ui/center/center.helper.ts b/packages/react/src/ui/center/center.helper.ts new file mode 100644 index 00000000..350e7b47 --- /dev/null +++ b/packages/react/src/ui/center/center.helper.ts @@ -0,0 +1,31 @@ +import { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import { CenterProps } from "./center.types"; + +const baseStyles: Sprinkles = { + display: "flex", + alignItems: "center", + justifyContent: "center", +}; + +const centerStyles: Record = { + horizontal: { + insetInlineStart: "50%", + transform: "translateX(-50%)", + }, + vertical: { + top: "50%", + transform: "translateY(-50%)", + }, + both: { + insetInlineStart: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + }, +}; + +export function getAxisStyles(axis: CenterProps["axis"]): Sprinkles { + return { + ...baseStyles, + ...centerStyles[axis ?? "both"], + }; +} diff --git a/packages/react/src/ui/center/center.tsx b/packages/react/src/ui/center/center.tsx new file mode 100644 index 00000000..b396cb79 --- /dev/null +++ b/packages/react/src/ui/center/center.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import Box from "../box"; +import type { CenterProps } from "./center.types"; +import { getAxisStyles } from "./center.helper"; + +function Center(props: CenterProps) { + const { as = "div", axis = "both" } = props; + return ( + + {props.children} + + ); +} + +export default Center; diff --git a/packages/react/src/ui/center/center.types.tsx b/packages/react/src/ui/center/center.types.tsx new file mode 100644 index 00000000..550dfe8e --- /dev/null +++ b/packages/react/src/ui/center/center.types.tsx @@ -0,0 +1,16 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +export interface CenterProps extends Omit { + as?: any; + className?: string; + children?: React.ReactNode; + forwardedRef?: any; + domAttributes?: any; + attributes?: Sprinkles; + axis?: "horizontal" | "vertical" | "both"; +} + +export const DEFAULT_VALUES = { + as: "div", +}; diff --git a/packages/react/src/ui/center/index.ts b/packages/react/src/ui/center/index.ts new file mode 100644 index 00000000..a874442b --- /dev/null +++ b/packages/react/src/ui/center/index.ts @@ -0,0 +1 @@ +export { default } from "./center"; diff --git a/packages/react/src/ui/chain-list-item/chain-list-item.css.ts b/packages/react/src/ui/chain-list-item/chain-list-item.css.ts new file mode 100644 index 00000000..61a8e546 --- /dev/null +++ b/packages/react/src/ui/chain-list-item/chain-list-item.css.ts @@ -0,0 +1,80 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +const menuItemBgVar = createVar(); +const menuItemBgHoveredVar = createVar(); +const menuItemBgActiveVar = createVar(); + +export const chainLogoBase = style({ + display: "block", + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: "50%", + background: themeVars.colors.skeletonBg, +}); + +export const chainLogoSizes = styleVariants({ + sm: [ + chainLogoBase, + style({ + width: "24px", + height: "24px", + }), + ], + md: [ + chainLogoBase, + style({ + width: "38px", + height: "38px", + }), + ], +}); + +export const listItem = styleVariants({ + light: [ + { + vars: { + [menuItemBgVar]: themeVars.colors.menuItemBg, + [menuItemBgHoveredVar]: themeVars.colors.menuItemBgHovered, + [menuItemBgActiveVar]: themeVars.colors.menuItemBgActive, + }, + willChange: "background-color", + backgroundColor: `${menuItemBgVar} !important`, + selectors: { + "&:hover": { + backgroundColor: `${menuItemBgHoveredVar} !important`, + }, + '&[data-is-active="true"]': { + backgroundColor: `${menuItemBgActiveVar} !important`, + }, + '&[data-is-selected="true"][data-is-active="true"]': { + backgroundColor: `${menuItemBgActiveVar} !important`, + }, + }, + }, + ], + dark: [ + { + vars: { + [menuItemBgVar]: themeVars.colors.menuItemBg, + [menuItemBgHoveredVar]: themeVars.colors.menuItemBgHovered, + [menuItemBgActiveVar]: themeVars.colors.menuItemBgActive, + }, + willChange: "background-color", + backgroundColor: `${menuItemBgVar} !important`, + selectors: { + "&:hover": { + backgroundColor: `${menuItemBgHoveredVar} !important`, + }, + '&[data-is-active="true"]': { + backgroundColor: `${menuItemBgActiveVar} !important`, + }, + '&[data-is-selected="true"][data-is-active="true"]': { + backgroundColor: `${menuItemBgActiveVar} !important`, + }, + }, + }, + ], +}); diff --git a/packages/react/src/ui/chain-list-item/chain-list-item.tsx b/packages/react/src/ui/chain-list-item/chain-list-item.tsx new file mode 100644 index 00000000..5ec91621 --- /dev/null +++ b/packages/react/src/ui/chain-list-item/chain-list-item.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import ListItem from "../list-item"; +import Box from "../box"; +import Text from "../text"; +import Stack from "../stack"; +import { store } from "../../models/store"; +import * as styles from "./chain-list-item.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { ChainListItemProps } from "./chain-list-item.types"; + +function ChainListItem(props: ChainListItemProps) { + const { isActive = false, size = "sm" } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + + {props.tokenName} + + {size === "md" ? ( + + {props.tokenName} + + ) : null} + {size === "md" ? ( + + {props.name} + + ) : null} + {size === "sm" ? ( + + {props.name} + + ) : null} + + + {props.amount !== null && + props.notionalValue !== null && + size !== "sm" ? ( + + + {props.amount} + + + {props.notionalValue} + + + ) : null} + + + ); +} + +export default ChainListItem; diff --git a/packages/react/src/ui/chain-list-item/chain-list-item.types.tsx b/packages/react/src/ui/chain-list-item/chain-list-item.types.tsx new file mode 100644 index 00000000..94d15dab --- /dev/null +++ b/packages/react/src/ui/chain-list-item/chain-list-item.types.tsx @@ -0,0 +1,17 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { ListItemProps } from "../list-item/list-item.types"; + +export interface ChainListItemProps extends BaseComponentProps { + isActive?: ListItemProps["isActive"]; + isSelected?: ListItemProps["isSelected"]; + size?: ListItemProps["size"]; + attributes?: ListItemProps["attributes"]; + sprinkles?: ListItemProps["sprinkles"]; + itemRef?: ListItemProps["itemRef"]; + // ==== Chain info props + iconUrl?: string; + name: string; + tokenName: string; + amount?: string; + notionalValue?: string; +} diff --git a/packages/react/src/ui/chain-list-item/index.ts b/packages/react/src/ui/chain-list-item/index.ts new file mode 100644 index 00000000..a43f8bd3 --- /dev/null +++ b/packages/react/src/ui/chain-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./chain-list-item"; diff --git a/packages/react/src/ui/chain-swap-combobox/chain-swap-combobox.css.ts b/packages/react/src/ui/chain-swap-combobox/chain-swap-combobox.css.ts new file mode 100644 index 00000000..4484f3ee --- /dev/null +++ b/packages/react/src/ui/chain-swap-combobox/chain-swap-combobox.css.ts @@ -0,0 +1,50 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "@/styles/themes.css"; +import { listboxStyleNoShadow } from "../select/select.css"; + +const spacingMd = style({ + paddingTop: themeVars.space[9], + paddingBottom: themeVars.space[9], + paddingLeft: themeVars.space[9], + paddingRight: themeVars.space[5], + maxHeight: "304px", + boxSizing: "border-box", + zIndex: themeVars.zIndex[100], + selectors: { + "& + &": { + marginTop: themeVars.space[2], + }, + }, +}); + +const spacingSm = style({ + paddingTop: themeVars.space[6], + paddingBottom: themeVars.space[6], + paddingLeft: themeVars.space[4], + paddingRight: themeVars.space[4], + maxHeight: "304px", + boxSizing: "border-box", + zIndex: themeVars.zIndex[100], + selectors: { + "& + &": { + marginTop: themeVars.space[2], + }, + }, +}); + +export const listBoxBg = styleVariants({ + light: { + backgroundColor: `${themeVars.colors.menuItemBg} !important`, + }, + dark: {}, +}); + +export const chainSwapListBox = styleVariants({ + light: [listboxStyleNoShadow.light, spacingMd, listBoxBg.light], + dark: [listboxStyleNoShadow.dark, spacingMd, listBoxBg.dark], +}); + +export const chainSwapListBoxSm = styleVariants({ + light: [listboxStyleNoShadow.light, spacingSm, listBoxBg.light], + dark: [listboxStyleNoShadow.dark, spacingSm, listBoxBg.dark], +}); diff --git a/packages/react/src/ui/chain-swap-combobox/chain-swap-combobox.tsx b/packages/react/src/ui/chain-swap-combobox/chain-swap-combobox.tsx new file mode 100644 index 00000000..1a96d6f6 --- /dev/null +++ b/packages/react/src/ui/chain-swap-combobox/chain-swap-combobox.tsx @@ -0,0 +1,482 @@ +import * as React from "react"; +import clx from "clsx"; +import { + autoUpdate, + size, + offset, + useClick, + useDismiss, + useFloating, + useInteractions, + useListNavigation, + useRole, + useTransitionStyles, + FloatingFocusManager, + FloatingList, + FloatingPortal, +} from "@floating-ui/react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +import Box from "@/ui/box"; +import ChainSwapInput from "@/ui/chain-swap-input"; +import ChainListItem from "@/ui/chain-list-item"; + +import * as styles from "./chain-swap-combobox.css"; +import type { ChainListItemProps } from "@/ui/chain-list-item/chain-list-item.types"; +import type { Sprinkles } from "@/styles/rainbow-sprinkles.css"; +import { overlays } from "@/ui/overlays-manager/overlays"; +import useTheme from "@/ui/hooks/use-theme"; +import { getOwnerDocument } from "@/helpers/platform"; +import { themeVars } from "@/styles/themes.css"; + +interface ItemProps { + isActive: boolean; + isSelected: boolean; + size: ChainListItemProps["size"]; + // ==== + iconUrl?: ChainListItemProps["iconUrl"]; + name: ChainListItemProps["name"]; + tokenName: ChainListItemProps["tokenName"]; + amount?: ChainListItemProps["amount"]; + notionalValue?: ChainListItemProps["notionalValue"]; +} + +const Item = React.forwardRef((props, ref) => { + const { + isActive, + size, + iconUrl, + name, + tokenName, + amount, + notionalValue, + isSelected, + } = props; + return ( + + ); +}); + +export type ComboboxOption = Omit< + ItemProps, + "isActive" | "size" | "isSelected" +>; + +export interface ChainSwapComboboxProps { + size: ChainListItemProps["size"]; + // Maximum height of the dropdown list + maxHeight?: number; + options: Array; + filterFn?: ( + options: Array, + query: string, + ) => Array; + defaultSelected?: ComboboxOption; + onItemSelected?: (selected: ComboboxOption) => void; + defaultOpen?: boolean; + endAddon?: React.ReactNode | undefined; + valueItem: ComboboxOption; + placeholder?: string; + className?: string; + rootNode?: HTMLElement; + inputClassName?: string; + attributes?: Sprinkles; + // Popover positioning props + offsetX?: number; + // Virtualization props + virtualization?: { + itemSize: number; + overscan: number; + }; +} + +export default function ChainSwapCombobox(props: ChainSwapComboboxProps) { + const { theme, themeClass } = useTheme(); + + const [open, setOpen] = React.useState(!!props.defaultOpen); + const [inputFocusing, setInputFocusing] = React.useState(false); + const [inputValue, setInputValue] = React.useState( + props.placeholder ?? props.defaultSelected?.tokenName ?? "", + ); + + const [selectedItem, setSelectedItem] = React.useState( + props.defaultSelected ?? null, + ); + const [activeIndex, setActiveIndex] = React.useState(null); + const [selectedIndex, setSelectedIndex] = React.useState(null); + const [pointer, setPointer] = React.useState(false); + + const wrapperRef = React.useRef(null); + const containerRef = React.useRef(null); + const listRef = React.useRef>([]); + const isTypingRef = React.useRef(false); + + const overlayId = React.useRef(overlays.generateId("chain-swap-combobox")); + + if (!open && pointer) { + setPointer(false); + } + + React.useEffect(() => { + if (open) { + overlays.pushOverlay(overlayId.current); + } + return () => { + if (open) { + overlays.popOverlay(overlayId.current); + } + }; + }, [open]); + + const rowVirtualizer = useVirtualizer({ + count: props.options.length, + getScrollElement: () => refs.floating.current, + estimateSize: () => 64, + overscan: 10, + }); + + const { refs, floatingStyles, context, isPositioned } = + useFloating({ + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + placement: "bottom-start", + middleware: [ + offset(({ rects }) => { + const containerX = containerRef.current.getBoundingClientRect().left; + const referenceX = rects.reference.x; + const offsetX = props.offsetX ?? 0; + + return { + crossAxis: containerX - referenceX - offsetX, + }; + }), + size({ + apply({ rects, availableHeight, elements }) { + const containerWidth = + containerRef.current.getBoundingClientRect().width; + Object.assign(elements.floating.style, { + width: `${containerWidth}px`, + maxHeight: `${Math.min(props.maxHeight ?? availableHeight, 500)}px`, + }); + }, + }), + ], + }); + + const { isMounted, styles: transitionStyles } = useTransitionStyles(context); + + const click = useClick(context); + const role = useRole(context, { role: "listbox" }); + const dismiss = useDismiss(context); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: false, + disabledIndices: [], + openOnArrowKeyDown: true, + allowEscape: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [click, role, dismiss, listNav], + ); + + function onChange(event: React.ChangeEvent) { + const value = event.target.value; + setInputValue(value); + + if (value) { + setOpen(true); + setActiveIndex(0); + } else { + setOpen(false); + } + } + + function defaultFilterOptions(options: Array) { + // Return all items when inputValue is empty string or input not focusing + if (!inputValue || !inputFocusing) { + return options; + } + return options.filter((item) => + item?.tokenName?.toLowerCase().startsWith(inputValue?.toLowerCase()), + ); + } + + function handleEmptyInputEscape() { + if (inputValue === "" && selectedItem) { + setInputValue(selectedItem.tokenName); + } + } + + const items = React.useMemo(() => { + return typeof props.filterFn === "function" + ? props.filterFn(props.options, inputValue) + : defaultFilterOptions(props.options); + }, [props.filterFn, props.options, inputValue]); + + function handleSelect() { + if (activeIndex !== null) { + const selected = items[activeIndex]; + setInputValue(selected.tokenName); + setSelectedIndex(activeIndex); + setActiveIndex(null); + setSelectedItem(selected); + setOpen(false); + // refs.domReference.current?.focus(); + props.onItemSelected?.(selected); + } + } + + React.useLayoutEffect(() => { + if (isPositioned && !pointer) { + // Scrolling is restored, but the item will be scrolled + // into view when necessary + if (activeIndex !== null) { + wrapperRef.current?.focus({ preventScroll: true }); + rowVirtualizer.scrollToIndex(activeIndex, { + // @ts-ignore + smoothScroll: false, + }); + } + } + }, [rowVirtualizer, isPositioned, activeIndex, selectedIndex, pointer, refs]); + + const [mountRoot, setMountRoot] = React.useState( + undefined, + ); + + React.useEffect(() => { + if (props.rootNode) { + return setMountRoot(props.rootNode); + } + if (!containerRef.current) return; + + const ownerDocument = getOwnerDocument(containerRef.current); + if (!ownerDocument) return; + + setMountRoot(overlays.getOrCreateOverlayRoot(ownerDocument)); + }, []); + + return ( + +
+ { + setOpen((isPrevOpen) => !isPrevOpen); + }} + endAddon={props.endAddon} + placeholder={props.placeholder} + {...selectedItem} + isOpen={open} + label={selectedItem?.name ?? null} + inputClassName={props.inputClassName} + inputAttributes={getReferenceProps({ + onChange, + value: inputValue, + "aria-autocomplete": "list", + onKeyDown(event) { + if ( + event.key === "Enter" && + activeIndex != null && + items[activeIndex] + ) { + const selected = items[activeIndex]; + setInputValue(selected.tokenName); + setActiveIndex(null); + setSelectedItem(selected); + setOpen(false); + props.onItemSelected?.(selected); + } + + if (event.key === "Escape") { + handleEmptyInputEscape(); + } + }, + onFocus() { + setInputFocusing(true); + }, + onBlur(e) { + setInputFocusing(false); + handleEmptyInputEscape(); + + // Nothing has been selected, reset scrolling upon open + if (activeIndex === null && selectedIndex === null) { + rowVirtualizer.scrollToIndex(0, { + // @ts-ignore + smoothScroll: false, + }); + } + }, + })} + /> +
+ + + {open && ( + +
+ +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = items[virtualRow.index]; + + return ( +
{ + listRef.current[virtualRow.index] = node; + }} + className={clx(themeClass, "virtual-row")} + style={{ + position: "absolute", + top: 0, + left: 0, + paddingLeft: getPadding(refs.floating.current) + .paddingLeft, + paddingRight: getPadding(refs.floating.current) + .paddingRight, + paddingTop: themeVars.space["4"], + paddingBottom: themeVars.space["4"], + width: "100%", + outline: "none", + border: "none", + boxShadow: "none", + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }} + {...getItemProps({ + onClick: (e) => { + e.stopPropagation(); + e.preventDefault(); + handleSelect(); + }, + })} + > + +
+ ); + })} +
+
+
+
+ )} +
+
+ ); +} +function getPadding(element: HTMLElement | null): { + paddingLeft: number; + paddingRight: number; +} { + const defaultPadding = { paddingLeft: 0, paddingRight: 0 }; + + if (typeof window === "undefined" || !element) { + return defaultPadding; + } + + const computedStyle = window.getComputedStyle(element); + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = parseFloat(computedStyle.paddingRight) || 0; + + return { paddingLeft, paddingRight }; +} diff --git a/packages/react/src/ui/chain-swap-combobox/index.ts b/packages/react/src/ui/chain-swap-combobox/index.ts new file mode 100644 index 00000000..d3c5be04 --- /dev/null +++ b/packages/react/src/ui/chain-swap-combobox/index.ts @@ -0,0 +1 @@ +export { default } from "./chain-swap-combobox"; diff --git a/packages/react/src/ui/chain-swap-input/chain-swap-input.css.ts b/packages/react/src/ui/chain-swap-input/chain-swap-input.css.ts new file mode 100644 index 00000000..1564d75b --- /dev/null +++ b/packages/react/src/ui/chain-swap-input/chain-swap-input.css.ts @@ -0,0 +1,143 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; +import { breakpoints } from "../../styles/tokens"; +import { transferItemRootContainer } from "../transfer-item/transfer-item.css"; + +export const container = style({ + display: "block", +}); + +export const chainSwapInputBase = style({ + display: "block", + border: "none", + outline: "none", + padding: 0, + height: "auto", + backgroundColor: "transparent", + fontFamily: themeVars.font.body, + flexShrink: 0, + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", + color: themeVars.colors.textPlaceholder, + selectors: { + [`&[data-input-value="true"]`]: { + color: themeVars.colors.text, + }, + "&:focus": { + color: themeVars.colors.text, + }, + }, +}); + +export const chainSwapInput = styleVariants({ + md: [ + chainSwapInputBase, + style({ + maxWidth: "100px", + height: themeVars.space[11], + fontSize: themeVars.fontSize["lg"], + fontWeight: themeVars.fontWeight.semibold, + selectors: { + "&[data-size='sm']:focus": { + fontSize: themeVars.fontSize["sm"], + }, + }, + "@container": { + [`${transferItemRootContainer} (min-width: 350px)`]: { + maxWidth: "160px", + }, + }, + "@supports": { + [`not (container-type: inline-size)`]: { + "@media": { + [`screen and (min-width: ${breakpoints.mdMobile}px)`]: { + fontSize: themeVars.fontSize["2xl"], + maxWidth: "160px", + }, + }, + }, + }, + }), + ], + sm: [ + chainSwapInputBase, + style({ + maxWidth: "100px", + height: themeVars.space[11], + fontSize: themeVars.fontSize["lg"], + fontWeight: themeVars.fontWeight.semibold, + selectors: { + "&[data-size='sm']:focus": { + fontSize: themeVars.fontSize["sm"], + }, + }, + "@container": { + [`${transferItemRootContainer} (min-width: 350px)`]: { + maxWidth: "160px", + }, + }, + "@supports": { + [`not (container-type: inline-size)`]: { + "@media": { + [`screen and (min-width: ${breakpoints.mdMobile}px)`]: { + fontSize: themeVars.fontSize["2xl"], + maxWidth: "160px", + }, + }, + }, + }, + }), + ], +}); + +export const logoMd = style({ + width: "100%", + height: "100%", + maxWidth: "50px", + maxHeight: "50px", + "@container": { + [`${transferItemRootContainer} (min-width: 0px)`]: { + width: "28px", + height: "28px", + }, + [`${transferItemRootContainer} (min-width: 284px)`]: { + width: "50px", + height: "50px", + }, + }, + "@supports": { + [`not (container-type: inline-size)`]: { + "@media": { + [`screen and (min-width: ${breakpoints.mobile}px)`]: { + width: "28px", + height: "28px", + }, + [`screen and (min-width: ${breakpoints.mdMobile}px)`]: { + width: "50px", + height: "50px", + }, + }, + }, + }, +}); + +export const logoSm = style({ + width: "28px", + height: "28px", +}); + +const logoBase = style({ + display: "block", + borderRadius: "50%", + background: themeVars.colors.skeletonBg, +}); + +export const chainSwapLogo = styleVariants({ + md: [logoBase, logoMd], + sm: [logoBase, logoSm], +}); + +export const rotate = style({ + transform: "rotate(180deg)", +}); diff --git a/packages/react/src/ui/chain-swap-input/chain-swap-input.tsx b/packages/react/src/ui/chain-swap-input/chain-swap-input.tsx new file mode 100644 index 00000000..217900a9 --- /dev/null +++ b/packages/react/src/ui/chain-swap-input/chain-swap-input.tsx @@ -0,0 +1,232 @@ +import * as React from "react"; +import { useState, useRef, forwardRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import Text from "../text"; +import Box from "../box"; +import Stack from "../stack"; +import Icon from "../icon"; +import { baseButton } from "../button/button.css"; +import { + container, + chainSwapInput, + chainSwapLogo, + logoMd, + logoSm, + rotate, +} from "./chain-swap-input.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { ChainSwapInputProps } from "./chain-swap-input.types"; + +const ChainSwapInput = forwardRef( + function ChainSwapInput( + props: ChainSwapInputProps, + containerRef: ChainSwapInputProps["containerRef"] + ) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
+ + + + + + {props.label} + + + + + + + + + + + + + + {props.label} + + + + + + + + + + + + + + + {props.amount} + + + {props.notionalValue} + + + + + {props.endAddon} + + + +
+ ); + } +); + +export default ChainSwapInput; diff --git a/packages/react/src/ui/chain-swap-input/chain-swap-input.types.tsx b/packages/react/src/ui/chain-swap-input/chain-swap-input.types.tsx new file mode 100644 index 00000000..a1303011 --- /dev/null +++ b/packages/react/src/ui/chain-swap-input/chain-swap-input.types.tsx @@ -0,0 +1,24 @@ +import type { + BaseComponentProps, + Children, +} from "../../models/components.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export interface ChainSwapInputProps extends BaseComponentProps { + size?: "sm" | "md"; + attributes?: any; + sprinkles?: Sprinkles; + containerRef?: any; + inputAttributes?: any; + inputClassName?: string; + isOpen?: boolean; + // ==== + value: string; + placeholder?: string; + label: string; + iconUrl?: string; + amount?: string; + notionalValue?: string; + onDropdownArrowClicked: (event?: any) => void; + endAddon?: Children | undefined; +} diff --git a/packages/react/src/ui/chain-swap-input/index.ts b/packages/react/src/ui/chain-swap-input/index.ts new file mode 100644 index 00000000..867bcb0a --- /dev/null +++ b/packages/react/src/ui/chain-swap-input/index.ts @@ -0,0 +1 @@ +export { default } from "./chain-swap-input"; diff --git a/packages/react/src/ui/change-chain-combobox/change-chain-combobox.css.ts b/packages/react/src/ui/change-chain-combobox/change-chain-combobox.css.ts new file mode 100644 index 00000000..87b92816 --- /dev/null +++ b/packages/react/src/ui/change-chain-combobox/change-chain-combobox.css.ts @@ -0,0 +1,18 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "@/styles/themes.css"; +import { listboxStyleNoShadow } from "../select/select.css"; + +const spacings = style({ + maxHeight: "304px", + zIndex: 5, + selectors: { + "& + &": { + marginTop: themeVars.space[2], + }, + }, +}); + +export const changeChainListBox = styleVariants({ + light: [listboxStyleNoShadow.light, spacings], + dark: [listboxStyleNoShadow.dark, spacings], +}); diff --git a/packages/react/src/ui/change-chain-combobox/change-chain-combobox.tsx b/packages/react/src/ui/change-chain-combobox/change-chain-combobox.tsx new file mode 100644 index 00000000..680d6b17 --- /dev/null +++ b/packages/react/src/ui/change-chain-combobox/change-chain-combobox.tsx @@ -0,0 +1,343 @@ +import * as React from "react"; +import clx from "clsx"; +import { + autoUpdate, + size, + flip, + offset, + useId, + useDismiss, + useFloating, + useInteractions, + useListNavigation, + useRole, + useTransitionStyles, + FloatingFocusManager, + FloatingList, + FloatingPortal, +} from "@floating-ui/react"; + +import Box from "@/ui/box"; +import type { BoxProps } from "@/ui/box/box.types"; +import ChangeChainInput from "@/ui/change-chain-input"; +import ChangeChainInputBold from "@/ui/change-chain-input/change-chain-input-bold"; +import { overlays } from "@/ui/overlays-manager/overlays"; +import { getOwnerDocument } from "@/helpers/platform"; + +import ChangeChainListItem from "@/ui/change-chain-list-item"; +import { changeChainListBox } from "./change-chain-combobox.css"; +import { listboxStyle } from "../select/select.css"; +import useTheme from "../hooks/use-theme"; +import type { ChangeChainListItemProps } from "@/ui/change-chain-list-item/change-chain-list-item.types"; + +interface ItemProps { + isActive: boolean; + size?: ChangeChainListItemProps["size"]; + // ==== + iconUrl?: ChangeChainListItemProps["iconUrl"]; + label: string; + value: string; +} + +const Item = React.forwardRef((props, ref) => { + const { isActive, size, iconUrl, label, value, ...rest } = props; + const id = useId(); + + return ( +
+ +
+ ); +}); + +type ComboboxOption = Omit; + +export interface ChangeChainCombobox { + id?: string; + appearance?: "bold" | "minimal"; + isLoading?: boolean; + isClearable?: boolean; + label?: string; + maxHeight?: number; + size?: ChangeChainListItemProps["size"]; + options: Array; + filterFn?: (options: Array) => Array; + defaultSelected?: ComboboxOption; + onItemSelected?: (selected: ComboboxOption | null) => void; + defaultOpen?: boolean; + valueItem?: ComboboxOption; + containerProp?: BoxProps; + rootNode?: HTMLElement; + // Virtualization props + virtualization?: { + itemSize: number; + overscan: number; + }; +} + +export default function ChangeChainCombobox(props: ChangeChainCombobox) { + const { theme, themeClass } = useTheme(); + + const [open, setOpen] = React.useState(!!props.defaultOpen); + const [showInputValue, setShowInputValue] = React.useState(false); + const [inputValue, setInputValue] = React.useState( + props.defaultSelected?.label ?? "", + ); + const [selectedItem, setSelectedItem] = React.useState( + props.defaultSelected ?? null, + ); + const [activeIndex, setActiveIndex] = React.useState(null); + const [pointer, setPointer] = React.useState(false); + + const listRef = React.useRef>([]); + + if (!open && pointer) { + setPointer(false); + } + + const { refs, floatingStyles, context } = useFloating({ + whileElementsMounted: autoUpdate, + open, + onOpenChange: setOpen, + middleware: [ + flip(), + offset(8), + size({ + apply({ rects, availableHeight, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + maxHeight: `${ + props.maxHeight + ? Math.min(availableHeight, props.maxHeight) + : availableHeight + }px`, + }); + }, + }), + ], + }); + + const { isMounted, styles: transitionStyles } = useTransitionStyles(context); + + const role = useRole(context, { role: "listbox" }); + const dismiss = useDismiss(context); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true, + openOnArrowKeyDown: true, + allowEscape: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [role, dismiss, listNav], + ); + + const wrapperRef = React.useRef(null); + const overlayId = React.useRef(overlays.generateId("change-chain-combobox")); + + React.useEffect(() => { + if (open) { + overlays.pushOverlay(overlayId.current); + } + return () => { + if (open) { + overlays.popOverlay(overlayId.current); + } + }; + }, [open]); + + const [mountRoot, setMountRoot] = React.useState( + undefined, + ); + + React.useEffect(() => { + if (props.rootNode) { + return setMountRoot(props.rootNode); + } + if (!refs.reference.current) return; + + const ownerDocument = getOwnerDocument( + refs.reference.current as HTMLElement, + ); + if (!ownerDocument) return; + + setMountRoot(overlays.getOrCreateOverlayRoot(ownerDocument)); + }, []); + + function onChange(event: React.ChangeEvent) { + const value = event.target.value; + setInputValue(value); + setShowInputValue(true); + + if (value) { + setOpen(true); + setActiveIndex(0); + } else { + setOpen(false); + } + } + + function defaultFilterOptions(options: Array) { + return options.filter( + (item) => + item?.value?.toLowerCase().startsWith(inputValue?.toLowerCase()) || + item?.label?.toLowerCase().startsWith(inputValue?.toLowerCase()), + ); + } + + const items = React.useMemo(() => { + if (!inputValue) return props.options; + + return typeof props.filterFn === "function" + ? props.filterFn(props.options) + : defaultFilterOptions(props.options); + }, [inputValue, props.options]); + + React.useEffect(() => { + if (props.valueItem) { + setSelectedItem(props?.valueItem); + } + setInputValue(props?.valueItem?.label); + }, [props.valueItem]); + + const InputComp = + props.appearance === "bold" ? ChangeChainInputBold : ChangeChainInput; + + return ( + +
+ { + setInputValue(""); + setSelectedItem(null); + }} + onDropdownArrowClicked={() => { + setOpen((isPrevOpen) => !isPrevOpen); + }} + inputAttributes={getReferenceProps({ + onChange, + "aria-autocomplete": "list", + onFocus() { + if (selectedItem) { + setShowInputValue(false); + } else { + setActiveIndex(null); + setShowInputValue(true); + } + }, + onBlur() { + setShowInputValue(false); + setActiveIndex(null); + setInputValue(""); + }, + onKeyDown(event) { + if ( + event.key === "Enter" && + activeIndex != null && + items[activeIndex] + ) { + const selected = items[activeIndex]; + setInputValue(selected.label); + setActiveIndex(null); + setSelectedItem(selected); + setShowInputValue(false); + setOpen(false); + props.onItemSelected?.(selected); + return; + } + + if (event.key === "Escape") { + setShowInputValue(false); + setActiveIndex(null); + setInputValue(""); + return; + } + + if (event.key === "Backspace" && selectedItem) { + setActiveIndex(null); + setSelectedItem(null); + setInputValue(""); + props.onItemSelected?.(null); + return; + } + }, + })} + /> +
+ + + {open && ( + +
0 ? "visible" : "hidden", + overflowY: "auto", + }, + })} + className={clx( + themeClass, + changeChainListBox[theme], + listboxStyle[theme], + )} + > + + {items.map((item, index) => ( + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/packages/react/src/ui/change-chain-combobox/index.ts b/packages/react/src/ui/change-chain-combobox/index.ts new file mode 100644 index 00000000..f5b6ef70 --- /dev/null +++ b/packages/react/src/ui/change-chain-combobox/index.ts @@ -0,0 +1 @@ +export { default } from "./change-chain-combobox"; diff --git a/packages/react/src/ui/change-chain-input/change-chain-input-bold.tsx b/packages/react/src/ui/change-chain-input/change-chain-input-bold.tsx new file mode 100644 index 00000000..83992f24 --- /dev/null +++ b/packages/react/src/ui/change-chain-input/change-chain-input-bold.tsx @@ -0,0 +1,182 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import Text from "../text"; +import Box from "../box"; +import FieldLabel from "../field-label"; +import Stack from "../stack"; +import Avatar from "../avatar"; +import Spinner from "../spinner"; +import Icon from "../icon"; +import { visuallyHidden } from "../shared/shared.css"; +import { baseButton } from "../button/button.css"; +import * as styles from "./change-chain-input.css"; +import type { ThemeVariant } from "../../models/system.model"; +import { + validTypes, + defaultInputModesForType, +} from "../text-field/text-field.types"; +import type { ChangeChainInputBoldProps } from "./change-chain-input.types"; + +function ChangeChainInputBold(props: ChangeChainInputBoldProps) { + const { placeholder = "Choose a chain" } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + const [isFocused, setIsFocused] = useState(() => false); + function isClearable() { + return ( + typeof props.onClear !== "undefined" && + !props.disabled && + typeof props.value === "string" && + props.value.length > 0 + ); + } + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + {props.label ? ( + + ) : null} +
+
+ {props.chainName ? ( + + + + {props.chainName} + + + ) : null} +
+ { + props.onChange?.(e); + props.inputAttributes?.onChange?.(e); + }} + onFocus={(e) => { + setIsFocused(true); + props.onFocus?.(e); + props.inputAttributes?.onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + props.onBlur?.(e); + props.inputAttributes?.onBlur?.(e); + }} + placeholder={ + !props.disabled && !props.chainName ? placeholder : undefined + } + inputMode={props.inputMode || defaultInputModesForType[props.type]} + className={clx( + styles.inputStyles[theme], + props.disabled + ? styles.inputIntent.disabled + : styles.inputIntent[props.intent] + )} + /> + + + + + + +
+
+ ); +} + +export default ChangeChainInputBold; diff --git a/packages/react/src/ui/change-chain-input/change-chain-input.css.ts b/packages/react/src/ui/change-chain-input/change-chain-input.css.ts new file mode 100644 index 00000000..fe830e97 --- /dev/null +++ b/packages/react/src/ui/change-chain-input/change-chain-input.css.ts @@ -0,0 +1,158 @@ +import { createVar, style, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; +import { unstyledButton } from "../button/button.css"; +import { baseTextStyles } from "../text/text.css"; + +export const textFieldRoot = style({ + position: "relative", +}); + +export const chainItem = style({ + position: "absolute", + zIndex: 2, + top: "50%", + transform: "translateY(-50%)", + left: "20px", +}); + +export const inputBorderVar = createVar(); +export const inputBgVar = createVar(); +export const inputTextVar = createVar(); + +export const rootInput = styleVariants({ + light: { + position: "relative", + display: "flex", + color: inputTextVar, + vars: { + [inputBorderVar]: "#E2E8F0", + [inputBgVar]: themeVars.colors.inputBg, + [inputTextVar]: themeVars.colors.text, + }, + selectors: { + "&:hover": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + "&[data-is-focused='true']": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + }, + }, + dark: { + position: "relative", + display: "flex", + color: inputTextVar, + vars: { + [inputBorderVar]: themeVars.colors.inputBorder, + [inputBgVar]: themeVars.colors.inputBg, + [inputTextVar]: themeVars.colors.text, + }, + selectors: { + "&:hover": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + "&[data-is-focused='true']": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + }, + }, +}); + +export const inputBorderAndShadow = style({ + borderStyle: "solid", + borderWidth: "1px", + borderRadius: "6px", + borderColor: inputBorderVar, + selectors: { + "&:hover": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + "&:focus-visible": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + }, +}); + +export const inputRootIntent = styleVariants({ + default: [], + error: [ + style({ + vars: { + [inputBorderVar]: themeVars.colors.inputDangerBorder, + [inputBgVar]: themeVars.colors.inputDangerBg, + }, + }), + ], + disabled: [ + style({ + vars: { + [inputBorderVar]: "none", + [inputBgVar]: themeVars.colors.inputDisabledBg, + }, + }), + ], +}); + +export const inputIntent = styleVariants({ + default: [], + error: [], + disabled: [ + style({ + color: themeVars.colors.inputDisabledText, + borderColor: inputBgVar, + }), + ], +}); + +const baseInputStyles = style([ + baseTextStyles, + inputBorderAndShadow, + style({ + height: themeVars.space[15], + paddingLeft: themeVars.space[10], + paddingRight: themeVars.space[15], + flex: "1", + outline: "none", + position: "relative", + appearance: "none", + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", + backgroundColor: inputBgVar, + color: "inherit", + selectors: { + "&::-webkit-outer-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + "&::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + }, + }), +]); + +export const inputStyles = styleVariants({ + light: [baseInputStyles], + dark: [ + baseInputStyles, + style({ + vars: { + [inputTextVar]: themeVars.colors.textSecondary, + }, + }), + ], +}); diff --git a/packages/react/src/ui/change-chain-input/change-chain-input.tsx b/packages/react/src/ui/change-chain-input/change-chain-input.tsx new file mode 100644 index 00000000..4c736dad --- /dev/null +++ b/packages/react/src/ui/change-chain-input/change-chain-input.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import { store } from "../../models/store"; +import Text from "../text"; +import Box from "../box"; +import Stack from "../stack"; +import Avatar from "../avatar"; +import Spinner from "../spinner"; +import Icon from "../icon"; +import TextFieldAddon from "../text-field-addon"; +import TextField from "../text-field"; +import { baseButton } from "../button/button.css"; +import { textFieldRoot, chainItem } from "./change-chain-input.css"; +import type { ChangeChainInputProps } from "./change-chain-input.types"; + +function ChangeChainInput(props: ChangeChainInputProps) { + const { size = "md", placeholder = "Choose a chain" } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + function getIconSize(size: ChangeChainInputProps["size"]) { + const sizes: Record = { + sm: "$sm", + md: "$md", + }; + return sizes[size ?? "sm"]; + } + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + props.onChange} + onBlur={(event) => props.onBlur} + onFocus={(event) => props.onFocus} + disabled={props.disabled} + inputContainer={textFieldRoot} + inputAttributes={props.inputAttributes} + className={props.className} + startAddon={ +
+ {props.chainName ? ( + + + + {props.chainName} + + + ) : null} +
+ } + endAddon={ + + + + {props.isLoading ? : null} + {!props.isLoading && + props.isClearable && + (props.chainName || props.value) ? ( + + ) : null} + + + + + } + /> + ); +} + +export default ChangeChainInput; diff --git a/packages/react/src/ui/change-chain-input/change-chain-input.types.tsx b/packages/react/src/ui/change-chain-input/change-chain-input.types.tsx new file mode 100644 index 00000000..5a70bc87 --- /dev/null +++ b/packages/react/src/ui/change-chain-input/change-chain-input.types.tsx @@ -0,0 +1,32 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { TextFieldProps } from "../text-field/text-field.types"; + +export interface ChangeChainInputProps + extends Omit< + TextFieldProps, + | "id" + | "inputContainer" + | "inputClassName" + | "startAddon" + | "endAddon" + | "clearLabel" + | "onChange" + > { + size?: TextFieldProps["size"]; + attributes?: any; + sprinkles?: Sprinkles; + containerRef?: any; + inputAttributes?: any; + // ==== + id?: string; + iconUrl?: string; + chainName?: string; + isLoading?: boolean; + isClearable?: boolean; + showSelectedItem?: boolean; + onChange?: (e: any) => void; + onClear?: (event?: any) => void; + onDropdownArrowClicked?: (event?: any) => void; +} + +export interface ChangeChainInputBoldProps extends ChangeChainInputProps {} diff --git a/packages/react/src/ui/change-chain-input/index.ts b/packages/react/src/ui/change-chain-input/index.ts new file mode 100644 index 00000000..9d6c7e7c --- /dev/null +++ b/packages/react/src/ui/change-chain-input/index.ts @@ -0,0 +1 @@ +export { default } from "./change-chain-input"; diff --git a/packages/react/src/ui/change-chain-list-item/change-chain-list-item.css.ts b/packages/react/src/ui/change-chain-list-item/change-chain-list-item.css.ts new file mode 100644 index 00000000..9acc4066 --- /dev/null +++ b/packages/react/src/ui/change-chain-list-item/change-chain-list-item.css.ts @@ -0,0 +1,7 @@ +import { style } from "@vanilla-extract/css"; + +export const changeChainListItem = style({ + display: "flex", + flexDirection: "column", + justifyContent: "center", +}); diff --git a/packages/react/src/ui/change-chain-list-item/change-chain-list-item.tsx b/packages/react/src/ui/change-chain-list-item/change-chain-list-item.tsx new file mode 100644 index 00000000..c62d2a91 --- /dev/null +++ b/packages/react/src/ui/change-chain-list-item/change-chain-list-item.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import ListItem from "../list-item"; +import Text from "../text"; +import Stack from "../stack"; +import Avatar from "../avatar"; +import { changeChainListItem } from "./change-chain-list-item.css"; +import type { AvatarSize } from "../avatar/avatar.types"; +import type { ChangeChainListItemProps } from "./change-chain-list-item.types"; + +function ChangeChainListItem(props: ChangeChainListItemProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function avatarSize() { + const sizeMap = { + sm: "xs", + md: "sm", + }; + return sizeMap[props.size ?? "sm"] as AvatarSize; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + {props.label} + + + + ); +} + +export default ChangeChainListItem; diff --git a/packages/react/src/ui/change-chain-list-item/change-chain-list-item.types.tsx b/packages/react/src/ui/change-chain-list-item/change-chain-list-item.types.tsx new file mode 100644 index 00000000..fc1629b1 --- /dev/null +++ b/packages/react/src/ui/change-chain-list-item/change-chain-list-item.types.tsx @@ -0,0 +1,6 @@ +import type { ListItemProps } from "../list-item/list-item.types"; + +export interface ChangeChainListItemProps extends ListItemProps { + iconUrl?: string; + label: string; +} diff --git a/packages/react/src/ui/change-chain-list-item/index.ts b/packages/react/src/ui/change-chain-list-item/index.ts new file mode 100644 index 00000000..9f353bb3 --- /dev/null +++ b/packages/react/src/ui/change-chain-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./change-chain-list-item"; diff --git a/packages/react/src/ui/circular-progress-bar/cicular-progress-bar.tsx b/packages/react/src/ui/circular-progress-bar/cicular-progress-bar.tsx new file mode 100644 index 00000000..913b4936 --- /dev/null +++ b/packages/react/src/ui/circular-progress-bar/cicular-progress-bar.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import Stack from "../stack"; +import Text from "../text"; +import * as styles from "./circular-progress-bar.css"; +import { CircularProgressBarProps } from "./circular-progress-bar.types"; + +function CicularProgressBar(props: CircularProgressBarProps) { + const { width = 80 } = props; + const [strokeWidth, setStrokeWidth] = useState(() => 0); + const [radius, setRadius] = useState(() => 0); + const [circumference, setCircumference] = useState(() => 0); + const [offset, setOffset] = useState(() => 0); + useEffect(() => { + const _strokeWidth = 4; + const _radius = width / 2; + const _circumference = _radius * 2 * Math.PI; + const _offset = _circumference - (props.progress / 100) * _circumference; + setStrokeWidth(_strokeWidth); + setRadius(_radius); + setCircumference(_circumference); + setOffset(_offset); + }, []); + useEffect(() => { + const updatedOffset = + circumference - (props.progress / 100) * circumference; + setOffset(updatedOffset); + }, [props.progress, circumference]); + return ( +
+ + + + + + + {props.progress}% + + +
+ ); +} + +export default CicularProgressBar; diff --git a/packages/react/src/ui/circular-progress-bar/circular-progress-bar.css.ts b/packages/react/src/ui/circular-progress-bar/circular-progress-bar.css.ts new file mode 100644 index 00000000..474806b5 --- /dev/null +++ b/packages/react/src/ui/circular-progress-bar/circular-progress-bar.css.ts @@ -0,0 +1,27 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const circle = style({ + fill: "transparent", + stroke: themeVars.colors.cardBg, +}); + +export const filledCircle = style({ + fill: "transparent", + stroke: themeVars.colors.text, + transform: "rotate(-90deg)", + transformOrigin: "50% 50%", + transition: "stroke-dashoffset 0.5s ease-out", +}); + +export const container = style({ + position: "relative", +}); + +export const percentText = style({ + position: "absolute", + left: 0, + right: 0, + top: "50%", + transform: "translateY(-50%)", +}); diff --git a/packages/react/src/ui/circular-progress-bar/circular-progress-bar.types.tsx b/packages/react/src/ui/circular-progress-bar/circular-progress-bar.types.tsx new file mode 100644 index 00000000..c0310d57 --- /dev/null +++ b/packages/react/src/ui/circular-progress-bar/circular-progress-bar.types.tsx @@ -0,0 +1,4 @@ +export interface CircularProgressBarProps { + progress: number; + width?: number; +} diff --git a/packages/react/src/ui/circular-progress-bar/index.ts b/packages/react/src/ui/circular-progress-bar/index.ts new file mode 100644 index 00000000..a34248b2 --- /dev/null +++ b/packages/react/src/ui/circular-progress-bar/index.ts @@ -0,0 +1 @@ +export { default } from "./cicular-progress-bar"; diff --git a/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.css.ts b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.css.ts new file mode 100644 index 00000000..f77635e5 --- /dev/null +++ b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.css.ts @@ -0,0 +1,70 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const borderColorVar = createVar(); +export const colorVar = createVar(); + +const containerStyleBase = style({ + cursor: "pointer", + position: "relative", + whiteSpace: "nowrap", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderWidth: "1px", + borderStyle: "solid", + gap: themeVars.space[4], + paddingInlineStart: themeVars.space[4], + paddingInlineEnd: themeVars.space[4], + paddingTop: themeVars.space[1], + paddingBottom: themeVars.space[1], + width: "100%", + height: "auto", + minHeight: themeVars.space[12], + borderRadius: themeVars.radii.full, + fontSize: themeVars.fontSize.sm, + fontWeight: themeVars.fontWeight.normal, + lineHeight: themeVars.lineHeight.normal, + borderColor: borderColorVar, + color: colorVar, +}); + +export const containerStyle = styleVariants({ + light: [ + style({ + vars: { + [borderColorVar]: themeVars.colors.gray200, + [colorVar]: themeVars.colors.gray500, + }, + }), + containerStyleBase, + ], + dark: [ + containerStyleBase, + style({ + vars: { + [borderColorVar]: themeVars.colors.whiteAlpha300, + [colorVar]: themeVars.colors.whiteAlpha600, + }, + }), + ], +}); + +export const textStyle = style({ + color: "inherit", + marginRight: themeVars.space[4], +}); + +export const truncateEndStyle = style({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +export const iconStyle = { + idle: style({ color: "inherit" }), + copied: styleVariants({ + light: [style({ color: themeVars.colors.green300 })], + dark: [style({ color: themeVars.colors.green400 })], + }), +}; diff --git a/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.helper.ts b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.helper.ts new file mode 100644 index 00000000..f6bb2e13 --- /dev/null +++ b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.helper.ts @@ -0,0 +1,10 @@ +import { borderColorVar, colorVar } from "./clipboard-copy-text.css"; +import { ComponentOverrideSchema } from "../../styles/override/override.types"; + +export const clipboardCopyTextOverrides: ComponentOverrideSchema = { + name: "clipboard-copy-text", + overrides: [ + [colorVar, "color"], + [borderColorVar, "borderColor"], + ], +}; diff --git a/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.tsx b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.tsx new file mode 100644 index 00000000..490d5723 --- /dev/null +++ b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import copy from "copy-to-clipboard"; +import Icon from "../icon"; +import { + containerStyle, + textStyle, + iconStyle, + truncateEndStyle, +} from "./clipboard-copy-text.css"; +import { store } from "../../models/store"; +import { truncateTextMiddle } from "../../helpers/string"; +import { clipboardCopyTextOverrides } from "./clipboard-copy-text.helper"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import type { ThemeVariant } from "../../models/system.model"; +import type { ClipboardCopyTextProps } from "./clipboard-copy-text.types"; +import Text from "../text"; + +function ClipboardCopyText(props: ClipboardCopyTextProps) { + const cleanupRef = useRef<() => void>(null); + const [idle, setIdle] = useState(() => true); + + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [overrideManager, setOverrideManager] = useState(() => null); + + function transform(text: string) { + if (props.truncate === "middle") { + const truncateLength = { + lg: 14, + md: 16, + sm: 18, + }; + return truncateTextMiddle( + text, + truncateLength[props.midTruncateLimit ?? "md"] + ); + } + return text; + } + + function handleOnClick(event?: any) { + const success = copy(props.text); + if (success) { + props.onCopied?.(event); + setIdle(false); + setTimeout(() => { + setIdle(true); + }, 1000); + } + } + + function getTruncateClass() { + return clx(textStyle, props.truncate === "end" && truncateEndStyle); + } + + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
handleOnClick(event)} + style={overrideManager?.applyOverrides(clipboardCopyTextOverrides.name)} + className={clx(containerStyle[internalTheme], props.className)} + > + + {transform(props.text)} + + {idle ? ( + + ) : ( + <> + + + )} +
+ ); +} + +export default ClipboardCopyText; diff --git a/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.types.tsx b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.types.tsx new file mode 100644 index 00000000..305177fa --- /dev/null +++ b/packages/react/src/ui/clipboard-copy-text/clipboard-copy-text.types.tsx @@ -0,0 +1,9 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface ClipboardCopyTextProps extends BaseComponentProps { + text: string; + truncate?: "middle" | "end"; + midTruncateLimit?: "sm" | "md" | "lg"; + onCopied?: (event?: any) => void; + className?: string; +} diff --git a/packages/react/src/ui/clipboard-copy-text/index.ts b/packages/react/src/ui/clipboard-copy-text/index.ts new file mode 100644 index 00000000..3481b7be --- /dev/null +++ b/packages/react/src/ui/clipboard-copy-text/index.ts @@ -0,0 +1 @@ +export { default } from "./clipboard-copy-text"; diff --git a/packages/react/src/ui/combobox/combobox.context.ts b/packages/react/src/ui/combobox/combobox.context.ts new file mode 100644 index 00000000..1cf0b451 --- /dev/null +++ b/packages/react/src/ui/combobox/combobox.context.ts @@ -0,0 +1,9 @@ +import * as React from "react"; + +export interface ComboboxContextValue { + size?: "sm" | "md"; +} + +export const ComboboxContext = React.createContext( + {} as ComboboxContextValue, +); diff --git a/packages/react/src/ui/combobox/combobox.css.ts b/packages/react/src/ui/combobox/combobox.css.ts new file mode 100644 index 00000000..aa394e13 --- /dev/null +++ b/packages/react/src/ui/combobox/combobox.css.ts @@ -0,0 +1,164 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { scrollBar } from "@/ui/shared/shared.css"; +import { themeVars } from "@/styles/themes.css"; +import { + inputBorderVar, + inputBgVar, + inputRingShadowVar, +} from "@/ui/text-field/text-field.css"; +import { baseTextStyles } from "@/ui/text/text.css"; +import { listBoxBaseWithShadow } from "@/ui/select/select.css"; +import { unstyledButton } from "@/ui/button/button.css"; + +export const inputBorderAndShadow = style({ + borderStyle: "solid", + borderWidth: "1px", + borderRadius: "6px", + borderColor: inputBorderVar, + vars: { + [inputBgVar]: themeVars.colors.inputBg, + [inputBorderVar]: themeVars.colors.inputBorder, + }, + selectors: { + "&:hover": { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, + }, + '&[data-focused="true"]': { + vars: { + [inputBorderVar]: themeVars.colors.inputBorderFocus, + [inputRingShadowVar]: `${themeVars.colors.inputBg} 0px 0px 0px 0px, ${themeVars.colors.textPlaceholder} 0px 0px 0px 1px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px`, + }, + outline: `2px solid transparent`, + outlineOffset: "2px", + boxShadow: inputRingShadowVar, + }, + }, +}); + +const baseInputStyles = style([ + baseTextStyles, + inputBorderAndShadow, + style({ + flex: "1", + outline: "none", + position: "relative", + appearance: "none", + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", + backgroundColor: inputBgVar, + color: "inherit", + selectors: { + "&::-webkit-outer-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + "&::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + }, + }), +]); + +export const comboboxInput = styleVariants({ + light: [ + baseInputStyles, + style({ + color: themeVars.colors.text, + }), + ], + dark: [ + baseInputStyles, + style({ + color: themeVars.colors.textSecondary, + }), + ], +}); + +export const comboboxInputElement = style({ + color: "inherit", + boxShadow: "none !important", + appearance: "none", +}); + +export const comboboxPopover = style({ + borderRadius: themeVars.radii.lg, +}); + +export const listboxStyle = styleVariants({ + light: [ + listBoxBaseWithShadow, + scrollBar.light, + style({ + borderColor: "#D1D6DD", + }), + ], + dark: [ + listBoxBaseWithShadow, + scrollBar.dark, + style({ + borderColor: "#434B55", + }), + ], +}); + +export const noStartPadding = style({ + paddingLeft: "0 !important", +}); + +export const noEndPadding = style({ + paddingLeft: "0 !important", +}); + +export const comboboxInlineButton = style([ + unstyledButton, + { + transitionProperty: "background-color,color", + transitionDuration: "200ms", + paddingLeft: themeVars.space[4], + paddingRight: themeVars.space[4], + flexShrink: 0, + position: "relative", + selectors: { + '&[data-hidden="true"]': { + visibility: "hidden", + }, + '&[data-hidden="false"]': { + visibility: "visible", + }, + '&[data-size="sm"]': { + fontSize: themeVars.fontSize["4xl"], + width: themeVars.space[13], + }, + '&[data-size="md"]': { + fontSize: themeVars.fontSize["4xl"], + width: themeVars.space[14], + }, + '&[data-bg="true"]:hover:before': { + position: "absolute", + zIndex: 0, + transitionProperty: "background-color,color", + transitionDuration: "200ms", + content: '""', + top: "50%", + bottom: 0, + left: "50%", + right: 0, + transform: "translate(-50%, -50%)", + borderRadius: "100%", + backgroundColor: themeVars.colors.menuItemBgHovered, + }, + '&[data-bg="true"][data-size="sm"]:hover:before': { + height: themeVars.space[12], + width: themeVars.space[12], + }, + '&[data-bg="true"][data-size="md"]:hover:before': { + height: themeVars.space[13], + width: themeVars.space[13], + }, + }, + }, +]); diff --git a/packages/react/src/ui/combobox/combobox.tsx b/packages/react/src/ui/combobox/combobox.tsx new file mode 100644 index 00000000..2a3ec6b5 --- /dev/null +++ b/packages/react/src/ui/combobox/combobox.tsx @@ -0,0 +1,265 @@ +import * as React from "react"; +import clx from "clsx"; +import type { ComboBoxProps } from "@react-types/combobox"; +import { + useComboBoxState, + useSearchFieldState, + Item, + Section, +} from "react-stately"; +import { useComboBox, useFilter, useButton, useSearchField } from "react-aria"; + +import Icon from "@/ui/icon"; +import Box from "@/ui/box"; +import useTheme from "@/ui/hooks/use-theme"; +import type { BoxProps } from "@/ui/box/box.types"; +import { inputSizes } from "@/ui/text-field/text-field.css"; +import { ComboboxContext } from "./combobox.context"; +import { ListBox } from "./list-box"; +import { Popover } from "./popover"; +import * as styles from "./combobox.css"; + +const DEFAULT_WIDTH: BoxProps["width"] = "$29"; + +interface ComboboxProps extends ComboBoxProps { + defaultIsOpen?: boolean; + openOnFocus?: boolean; + size?: "sm" | "md"; + styleProps?: BoxProps; + inputAddonStart?: React.ReactNode; + inputAddonEnd?: React.ReactNode; +} + +export default function Combobox(props: ComboboxProps) { + const { + size = "sm", + defaultIsOpen = false, + openOnFocus = false, + styleProps = {}, + inputAddonStart, + inputAddonEnd, + ...comboboxProps + } = props; + + const { themeClass, theme } = useTheme(); + const { contains } = useFilter({ sensitivity: "base" }); + const state = useComboBoxState({ ...comboboxProps, defaultFilter: contains }); + + const [isFocused, setIsFocused] = React.useState(false); + const containerRef = React.useRef(null); + const buttonRef = React.useRef(null); + const inputRef = React.useRef(null); + const listBoxRef = React.useRef(null); + const popoverRef = React.useRef(null); + + const { + buttonProps: triggerProps, + inputProps, + listBoxProps, + labelProps, + } = useComboBox( + { + ...comboboxProps, + onFocus: () => { + setIsFocused(true); + + if (openOnFocus) { + state.open(); + } + }, + onBlur: () => { + setIsFocused(false); + }, + inputRef, + buttonRef, + listBoxRef, + popoverRef, + }, + state, + ); + + const { buttonProps } = useButton(triggerProps, buttonRef); + + // Get props for the clear button from useSearchField + const searchProps = { + label: props.label, + value: state.inputValue, + onChange: (v: string) => state.setInputValue(v), + }; + + const searchState = useSearchFieldState(searchProps); + const { clearButtonProps } = useSearchField( + searchProps, + searchState, + inputRef, + ); + const clearButtonRef = React.useRef(null); + + const { buttonProps: clearButtonAriaProps } = useButton( + clearButtonProps, + clearButtonRef, + ); + + React.useEffect(() => { + if (defaultIsOpen) { + state.open(); + } + }, []); + + return ( + + + {props.label && ( + + {props.label} + + )} + + + {inputAddonStart && ( + + {inputAddonStart} + + )} + + + + {inputAddonEnd && ( + + {inputAddonEnd} + + )} + + + + + + + {state.isOpen && ( + + + + + + )} + + + ); +} + +Combobox.Item = Item; +Combobox.Section = Section; diff --git a/packages/react/src/ui/combobox/index.ts b/packages/react/src/ui/combobox/index.ts new file mode 100644 index 00000000..1a3bca19 --- /dev/null +++ b/packages/react/src/ui/combobox/index.ts @@ -0,0 +1 @@ +export { default } from "./combobox"; diff --git a/packages/react/src/ui/combobox/list-box.tsx b/packages/react/src/ui/combobox/list-box.tsx new file mode 100644 index 00000000..54fa93b2 --- /dev/null +++ b/packages/react/src/ui/combobox/list-box.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import clx from "clsx"; +import type { AriaListBoxOptions } from "@react-aria/listbox"; +import type { ListState } from "react-stately"; +import type { Node } from "@react-types/shared"; + +import { useListBox, useListBoxSection, useOption } from "react-aria"; +import Text from "@/ui/text"; +import Box from "@/ui/box"; +import type { BoxProps } from "@/ui/box/box.types"; + +import ListItem from "@/ui/list-item"; +import useTheme from "@/ui/hooks/use-theme"; +import { listboxStyle } from "./combobox.css"; +import { ComboboxContext } from "./combobox.context"; + +interface ListBoxProps extends AriaListBoxOptions { + listBoxRef?: React.RefObject; + state: ListState; + styleProps?: BoxProps; +} + +interface SectionProps { + section: Node; + state: ListState; +} + +interface OptionProps { + item: Node; + state: ListState; +} + +export function ListBox(props: ListBoxProps) { + const ref = React.useRef(null); + const { listBoxRef = ref, state } = props; + const { listBoxProps } = useListBox(props, state, listBoxRef); + const { theme } = useTheme(); + + return ( + + {[...state.collection].map((item) => + item.type === "section" ? ( + + ) : ( + + ); +} + +function ListBoxSection({ section, state }: SectionProps) { + const { itemProps, headingProps, groupProps } = useListBoxSection({ + heading: section.rendered, + "aria-label": section["aria-label"], + }); + + return ( + <> + + {section.rendered && ( + + {section.rendered} + + )} + +
    + {[...section.childNodes].map((node) => ( +
+
+ + ); +} + +function Option({ item, state }: OptionProps) { + const ref = React.useRef(null); + const { optionProps, isDisabled, isSelected, isFocused } = useOption( + { + key: item.key, + }, + state, + ref, + ); + const comboboxContext = React.useContext(ComboboxContext); + + return ( +
  • + + {item.rendered} + +
  • + ); +} diff --git a/packages/react/src/ui/combobox/popover.tsx b/packages/react/src/ui/combobox/popover.tsx new file mode 100644 index 00000000..6556ad99 --- /dev/null +++ b/packages/react/src/ui/combobox/popover.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import type { OverlayTriggerState } from "react-stately"; +import type { AriaPopoverProps } from "@react-aria/overlays"; +import { usePopover, DismissButton, Overlay } from "@react-aria/overlays"; +import Box from "@/ui/box"; + +interface PopoverProps extends Omit { + children: React.ReactNode; + state: OverlayTriggerState; + className?: string; + popoverRef?: React.RefObject; +} + +export function Popover(props: PopoverProps) { + const ref = React.useRef(null); + const { + popoverRef = ref, + triggerRef, + state, + children, + className, + isNonModal, + } = props; + + const { popoverProps, underlayProps } = usePopover( + { + ...props, + triggerRef, + popoverRef, + }, + state, + ); + + return ( + + {!isNonModal && ( + + )} + + + {!isNonModal && } + + {children} + + + + + ); +} diff --git a/packages/react/src/ui/connect-modal-head/connect-modal-head.css.ts b/packages/react/src/ui/connect-modal-head/connect-modal-head.css.ts new file mode 100644 index 00000000..ea36c06e --- /dev/null +++ b/packages/react/src/ui/connect-modal-head/connect-modal-head.css.ts @@ -0,0 +1,72 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const titleColorVar = createVar(); +export const buttonColorVar = createVar(); + +const modalHeaderTextBase = style({ + display: "block", + textAlign: "center", + fontSize: themeVars.fontSize.md, + fontWeight: themeVars.fontWeight.semibold, + width: "100%", + height: "100%", + margin: "0", + marginBlockEnd: 0, + marginBlockStart: 0, + color: titleColorVar, +}); + +export const modalHeaderText = styleVariants({ + light: [ + style({ + vars: { + [titleColorVar]: themeVars.colors.gray700, + }, + }), + modalHeaderTextBase, + ], + dark: [ + style({ + vars: { + [titleColorVar]: themeVars.colors.whiteAlpha900, + }, + }), + modalHeaderTextBase, + ], +}); + +export const modalHeader = style({ + fontFamily: themeVars.font.body, + paddingLeft: themeVars.space[8], + paddingRight: themeVars.space[8], + paddingBottom: themeVars.space[8], + paddingTop: themeVars.space[10], + position: "relative", + display: "flex", +}); + +export const modalBackButton = style({ + position: "absolute", + left: 0, + top: "50%", + transform: "translateY(-50%)", + marginTop: themeVars.space[2], + marginLeft: themeVars.space[8], +}); + +export const modalCloseButton = style({ + position: "absolute", + right: 0, + top: "50%", + transform: "translateY(-50%)", + marginTop: themeVars.space[2], + marginRight: themeVars.space[8], +}); + +export const headerButton = style({ + width: "32px", + height: "32px", + paddingLeft: "0 !important", + paddingRight: "0 !important", +}); diff --git a/packages/react/src/ui/connect-modal-head/connect-modal-head.helper.ts b/packages/react/src/ui/connect-modal-head/connect-modal-head.helper.ts new file mode 100644 index 00000000..5ff19890 --- /dev/null +++ b/packages/react/src/ui/connect-modal-head/connect-modal-head.helper.ts @@ -0,0 +1,7 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { titleColorVar } from "./connect-modal-head.css"; + +export const connectModalHeadTitleOverrides: ComponentOverrideSchema = { + name: "connect-modal-head-title", + overrides: [[titleColorVar, "color"]], +}; diff --git a/packages/react/src/ui/connect-modal-head/connect-modal-head.tsx b/packages/react/src/ui/connect-modal-head/connect-modal-head.tsx new file mode 100644 index 00000000..271841cc --- /dev/null +++ b/packages/react/src/ui/connect-modal-head/connect-modal-head.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Button from "../button"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import { connectModalHeadTitleOverrides } from "./connect-modal-head.helper"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import * as styles from "./connect-modal-head.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { ConnectModalHeadProps } from "./connect-modal-head.types"; + +function ConnectModalHead(props: ConnectModalHeadProps) { + const { hasBackButton = false, hasCloseButton = true } = props; + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + const [overrideManager, setOverrideManager] = useState(() => null); + function modalHeadTitleClassName() { + return styles.modalHeaderText[internalTheme]; + } + useEffect(() => { + setInternalTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( +
    + {hasBackButton ? ( +
    + +
    + ) : null} +

    + {props.title} +

    + {hasCloseButton ? ( +
    + +
    + ) : null} +
    + ); +} + +export default ConnectModalHead; diff --git a/packages/react/src/ui/connect-modal-head/connect-modal-head.types.tsx b/packages/react/src/ui/connect-modal-head/connect-modal-head.types.tsx new file mode 100644 index 00000000..c4cd0db7 --- /dev/null +++ b/packages/react/src/ui/connect-modal-head/connect-modal-head.types.tsx @@ -0,0 +1,13 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface ConnectModalHeadProps extends BaseComponentProps { + title: string; + hasBackButton?: boolean; + hasCloseButton?: boolean; + onClose: (event: any) => void; + onBack?: (event: any) => void; + // zagjs props, will be injected through scaffold modal + id?: string; + titleProps?: any; + closeButtonProps?: any; +} diff --git a/packages/react/src/ui/connect-modal-head/index.ts b/packages/react/src/ui/connect-modal-head/index.ts new file mode 100644 index 00000000..775bfe7d --- /dev/null +++ b/packages/react/src/ui/connect-modal-head/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-head"; diff --git a/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.css.ts b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.css.ts new file mode 100644 index 00000000..af300975 --- /dev/null +++ b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.css.ts @@ -0,0 +1,58 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { baseButton } from "../button/button.css"; +import { themeVars } from "../../styles/themes.css"; + +export const borderColorVar = createVar(); +export const bgVar = createVar(); +export const colorVar = createVar(); +export const shadowVar = createVar(); + +const spacings = style({ + paddingTop: themeVars.space[6], + paddingBottom: themeVars.space[6], + height: themeVars.space[15], +}); + +const colors = style({ + color: colorVar, + boxShadow: shadowVar, + backgroundColor: bgVar, + borderRadius: themeVars.radii.md, + borderWidth: "1px", + borderStyle: "solid", + borderColor: borderColorVar, + selectors: { + "&:hover": { + opacity: 0.8, + }, + }, +}); + +const baseInstallButton = style([baseButton, spacings, colors]); + +export const installButtonStyles = styleVariants({ + light: [ + style({ + vars: { + [bgVar]: "rgba(37, 57, 201, 0.1)", + [borderColorVar]: themeVars.colors.white, + [colorVar]: themeVars.colors.primary400, + [shadowVar]: "0 0 1px 2px rgba(37, 57, 201, 0.5)", + }, + }), + baseInstallButton, + ], + dark: [ + style({ + vars: { + [bgVar]: "rgba(40, 62, 219, 0.15)", + [borderColorVar]: themeVars.colors.gray800, + [colorVar]: themeVars.colors.primary100, + [shadowVar]: "0 0 1px 2px rgba(196, 203, 255, 0.5)", + }, + }), + baseInstallButton, + ], +}); + +export const fluidWidth = style({ width: "100% !important" }); diff --git a/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.helper.ts b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.helper.ts new file mode 100644 index 00000000..ddd6497f --- /dev/null +++ b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.helper.ts @@ -0,0 +1,17 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { + borderColorVar, + bgVar, + colorVar, + shadowVar, +} from "./connect-modal-install-button.css"; + +export const installButtonOverrides: ComponentOverrideSchema = { + name: "connect-modal-install-button", + overrides: [ + [bgVar, "bg"], + [borderColorVar, "borderColor"], + [colorVar, "color"], + [shadowVar, "shadow"], + ], +}; diff --git a/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.tsx b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.tsx new file mode 100644 index 00000000..4021eb8d --- /dev/null +++ b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import Box from "../box"; +import { + fluidWidth, + installButtonStyles, +} from "./connect-modal-install-button.css"; +import { installButtonOverrides } from "./connect-modal-install-button.helper"; +import type { ConnectModalInstallButtonProps } from "./connect-modal-install-button.types"; + +function ConnectModalInstallButton(props: ConnectModalInstallButtonProps) { + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [overrideManager, setOverrideManager] = useState(() => null); + + function getClassName() { + return clx( + installButtonStyles[internalTheme], + props.fluidWidth ? fluidWidth : "", + props.className + ); + } + + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + props.onClick?.(event), + onMouseEnter: (event) => props.onHoverStart?.(event), + onMouseLeave: (event) => props.onHoverEnd?.(event), + disabled: props.disabled, + ...props.domAttributes, + }} + className={getClassName()} + > + {props.children} + + ); +} + +export default ConnectModalInstallButton; diff --git a/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.types.tsx b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.types.tsx new file mode 100644 index 00000000..e04de2ba --- /dev/null +++ b/packages/react/src/ui/connect-modal-install-button/connect-modal-install-button.types.tsx @@ -0,0 +1,15 @@ +import { BaseComponentProps } from "../../models/components.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { IconProps } from "../icon/icon.types"; + +export interface ConnectModalInstallButtonProps extends BaseComponentProps { + disabled?: boolean; + fluidWidth?: boolean; + iconSize?: IconProps["size"]; + icon?: BaseComponentProps["children"]; + onClick?: (event: any) => void; + onHoverStart?: (event: any) => void; + onHoverEnd?: (event: any) => void; + attributes?: Sprinkles; + domAttributes?: any; +} diff --git a/packages/react/src/ui/connect-modal-install-button/index.ts b/packages/react/src/ui/connect-modal-install-button/index.ts new file mode 100644 index 00000000..841ed856 --- /dev/null +++ b/packages/react/src/ui/connect-modal-install-button/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-install-button"; diff --git a/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.css.ts b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.css.ts new file mode 100644 index 00000000..3adc6375 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.css.ts @@ -0,0 +1,100 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const qrcodeErrorContainer = style({ + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative", +}); + +export const qrcodeBlurBgVar = createVar(); + +const qrCodeBlurBase = style({ + position: "absolute", + background: qrcodeBlurBgVar, + filter: "blur(12px)", + width: "100%", + height: "100%", + zIndex: themeVars.zIndex[10], + borderRadius: themeVars.radii.lg, +}); + +export const qrcodeBlur = styleVariants({ + light: [ + qrCodeBlurBase, + style({ + vars: { + [qrcodeBlurBgVar]: themeVars.colors.whiteAlpha700, + }, + }), + ], + dark: [ + qrCodeBlurBase, + style({ + vars: { + [qrcodeBlurBgVar]: themeVars.colors.blackAlpha400, + }, + }), + ], +}); + +export const qrcodeReloadButtonContainer = style({ + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "absolute", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", +}); + +export const reloadButtonBgVar = createVar(); +export const reloadButtonFgVar = createVar(); +export const reloadButtonShadowVar = createVar(); + +const qrCodeReloadButtonBase = style({ + border: "none", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + appearance: "none", + position: "relative", + outline: "2px solid transparent", + verticalAlign: "middle", + outlineOffset: "2px", + padding: "0", + boxShadow: reloadButtonShadowVar, + background: reloadButtonBgVar, + color: reloadButtonFgVar, + borderRadius: themeVars.radii.full, + fontWeight: themeVars.fontWeight.semibold, + fontSize: themeVars.fontSize.lg, + width: themeVars.space[15], + height: themeVars.space[15], +}); + +export const qrcodeReloadButton = styleVariants({ + light: [ + qrCodeReloadButtonBase, + style({ + vars: { + [reloadButtonBgVar]: themeVars.colors.white, + [reloadButtonFgVar]: themeVars.colors.blackAlpha900, + [reloadButtonShadowVar]: + "rgba(0, 0, 0, 0.48) 0px 1px 4px 0px, rgba(0, 0, 0, 0.24) 0px 5px 12px 0px, rgba(255, 255, 255, 0.48) 0px 0px 25px 6px", + }, + }), + ], + dark: [ + qrCodeReloadButtonBase, + style({ + vars: { + [reloadButtonBgVar]: themeVars.colors.gray800, + [reloadButtonFgVar]: themeVars.colors.white, + [reloadButtonShadowVar]: + "rgba(0, 0, 0, 0.92) 0px 1px 4px 0px, rgba(0, 0, 0, 0.8) 0px 5px 12px 0px, rgba(255, 255, 255, 0.24) 0px 0px 25px 6px", + }, + }), + ], +}); diff --git a/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.helper.ts b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.helper.ts new file mode 100644 index 00000000..a2c0f693 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.helper.ts @@ -0,0 +1,21 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { + qrcodeBlurBgVar, + reloadButtonBgVar, + reloadButtonFgVar, + reloadButtonShadowVar, +} from "./connect-modal-qrcode-error.css"; + +export const connectModalQRCodeErrorOverrides: ComponentOverrideSchema = { + name: "connect-modal-qr-code-error", + overrides: [[qrcodeBlurBgVar, "bg"]], +}; + +export const connectModalQRCodeErrorButtonOverrides: ComponentOverrideSchema = { + name: "connect-modal-qr-code-error-button", + overrides: [ + [reloadButtonBgVar, "bg"], + [reloadButtonFgVar, "color"], + [reloadButtonShadowVar, "shadow"], + ], +}; diff --git a/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.tsx b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.tsx new file mode 100644 index 00000000..e34de163 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import type { ConnectModalQRCodeErrorProps } from "./connect-modal-qrcode-error.types"; +import QRCode from "../qrcode"; +import Icon from "../icon"; +import { + qrcodeErrorContainer, + qrcodeBlur, + qrcodeReloadButton, + qrcodeReloadButtonContainer, +} from "./connect-modal-qrcode-error.css"; +import { + qrCodeContainer, + qrCodeBgVar, + qrCodeFgVar, +} from "../connect-modal-qrcode/connect-modal-qrcode.css"; +import { store } from "../../models/store"; +import { + connectModalQRCodeErrorOverrides, + connectModalQRCodeErrorButtonOverrides, +} from "./connect-modal-qrcode-error.helper"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import type { ThemeVariant } from "../../models/system.model"; + +function ConnectModalQrCodeError(props: ConnectModalQRCodeErrorProps) { + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [overrideManager, setOverrideManager] = useState(() => null); + + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    +
    +
    + +
    + +
    + ); +} + +export default ConnectModalQrCodeError; diff --git a/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.types.tsx b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.types.tsx new file mode 100644 index 00000000..bdad5d66 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-error/connect-modal-qrcode-error.types.tsx @@ -0,0 +1,6 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface ConnectModalQRCodeErrorProps extends BaseComponentProps { + qrCodeSize?: number; + onRefresh?: (event?: any) => void; +} diff --git a/packages/react/src/ui/connect-modal-qrcode-error/index.ts b/packages/react/src/ui/connect-modal-qrcode-error/index.ts new file mode 100644 index 00000000..31f7fb5d --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-error/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-qrcode-error"; diff --git a/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.css.ts b/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.css.ts new file mode 100644 index 00000000..868caf6e --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.css.ts @@ -0,0 +1,61 @@ +import { + style, + keyframes, + styleVariants, + createVar, +} from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const skeletonStartVar = createVar(); +export const skeletonEndVar = createVar(); + +export const pulsingAnim = keyframes({ + "0%": { + borderColor: skeletonStartVar, + background: skeletonEndVar, + }, + "100%": { + borderColor: skeletonEndVar, + background: skeletonStartVar, + }, +}); + +const qrcodeSkeletonBase = style({ + boxShadow: "none", + userSelect: "none", + backgroundClip: "padding-box", + cursor: "default", + color: "transparent", + width: "192px", + height: "192px", + opacity: 0.7, + borderRadius: themeVars.radii.base, + background: skeletonStartVar, + borderColor: skeletonEndVar, + animationName: pulsingAnim, + animationDuration: "0.8s", + animationIterationCount: "infinite", + animationTimingFunction: "linear", + animationDirection: "alternate", +}); + +export const qrcodeSkeleton = styleVariants({ + light: [ + qrcodeSkeletonBase, + style({ + vars: { + [skeletonStartVar]: themeVars.colors.gray100, + [skeletonEndVar]: themeVars.colors.gray400, + }, + }), + ], + dark: [ + qrcodeSkeletonBase, + style({ + vars: { + [skeletonStartVar]: themeVars.colors.gray800, + [skeletonEndVar]: themeVars.colors.gray600, + }, + }), + ], +}); diff --git a/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.helper.ts b/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.helper.ts new file mode 100644 index 00000000..ca2151c5 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.helper.ts @@ -0,0 +1,13 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { + skeletonStartVar, + skeletonEndVar, +} from "./connect-modal-qrcode-skeleton.css"; + +export const connectModalQRCodeSkeletonOverrides: ComponentOverrideSchema = { + name: "connect-modal-qr-code-loading", + overrides: [ + [skeletonStartVar, "bg"], + [skeletonEndVar, "bg"], + ], +}; diff --git a/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.tsx b/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.tsx new file mode 100644 index 00000000..da3814e2 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-skeleton/connect-modal-qrcode-skeleton.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import { qrcodeSkeleton } from "./connect-modal-qrcode-skeleton.css"; +import { store } from "../../models/store"; +import { connectModalQRCodeSkeletonOverrides } from "./connect-modal-qrcode-skeleton.helper"; +import type { ThemeVariant } from "../../models/system.model"; +import type { OverrideStyleManager } from "../../styles/override/override"; + +function ConnectModalQrCodeSkeleton(props: any) { + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [overrideManager, setOverrideManager] = useState(() => null); + + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + +
    + + ); +} + +export default ConnectModalQrCodeSkeleton; diff --git a/packages/react/src/ui/connect-modal-qrcode-skeleton/index.ts b/packages/react/src/ui/connect-modal-qrcode-skeleton/index.ts new file mode 100644 index 00000000..f3eb2481 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode-skeleton/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-qrcode-skeleton"; diff --git a/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.css.ts b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.css.ts new file mode 100644 index 00000000..d261e085 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.css.ts @@ -0,0 +1,102 @@ +import { style, createVar, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const descriptionStyle = style({ + textAlign: "center", + opacity: 0.75, +}); + +export const qrCodeBgVar = createVar(); +export const qrCodeFgVar = createVar(); +export const qrCodeBorderColorVar = createVar(); +export const qrCodeBoxShadowVar = createVar(); + +const qrCodeContainerBase = style({ + border: "1px solid", + borderColor: qrCodeBorderColorVar, + boxShadow: qrCodeBoxShadowVar, + width: "100%", + padding: themeVars.space[9], + borderRadius: themeVars.radii.lg, + display: "flex", + justifyContent: "center", + alignItems: "center", + marginTop: themeVars.space[2], + marginLeft: "auto", + marginRight: "auto", + maxWidth: "272px", + maxHeight: "272px", +}); + +export const qrCodeContainer = styleVariants({ + light: [ + qrCodeContainerBase, + style({ + vars: { + [qrCodeBgVar]: themeVars.colors.white, + [qrCodeFgVar]: themeVars.colors.blackAlpha900, + [qrCodeBorderColorVar]: themeVars.colors.blackAlpha200, + [qrCodeBoxShadowVar]: "rgba(0, 0, 0, 0.16) 0px 2px 5px -1px", + }, + }), + ], + dark: [ + qrCodeContainerBase, + style({ + vars: { + [qrCodeBgVar]: themeVars.colors.gray800, + [qrCodeFgVar]: themeVars.colors.white, + [qrCodeBorderColorVar]: themeVars.colors.whiteAlpha200, + [qrCodeBoxShadowVar]: "rgba(0, 0, 0, 0.92) 0px 2px 5px -1px", + }, + }), + ], +}); + +export const qrCodeDesc = style([ + { + position: "relative", + }, +]); + +export const qrCodeDescContent = style({ + overflowX: "hidden", + overflowY: "auto", + textAlign: "center", + opacity: 0.75, + fontSize: themeVars.fontSize.sm, + fontWeight: themeVars.fontWeight.normal, +}); + +export const qrCodeDescBgVar = createVar(); + +const qrCodeDescShadowBase = style({ + height: "0px", + opacity: "0", + position: "absolute", + left: 0, + bottom: 0, + width: "100%", + background: qrCodeDescBgVar, +}); + +export const qrCodeDescShadow = styleVariants({ + light: [ + qrCodeDescShadowBase, + style({ + vars: { + [qrCodeDescBgVar]: + "linear-gradient(0deg, rgba(255,255,255,1) 6%, rgba(255,255,255,0.95) 16%, rgba(255,255,255,0.85) 24%, rgba(255,255,255,0.75) 32%, rgba(255,255,255,0.65) 48%, rgba(255,255,255,0.4) 65%, rgba(255,255,255,0.2) 80%, rgba(255,255,255,0.1) 95%)", + }, + }), + ], + dark: [ + qrCodeDescShadowBase, + style({ + vars: { + [qrCodeDescBgVar]: + "linear-gradient(0deg, rgba(45,55,72,1) 6%, rgba(45,55,72,0.95) 16%, rgba(45,55,72,0.85) 36%, rgba(45,55,72,0.75) 45%, rgba(45,55,72,0.65) 55%, rgba(45,55,72,0.4) 70%, rgba(45,55,72,0.2) 80%, rgba(45,55,72,0.1) 95%)", + }, + }), + ], +}); diff --git a/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.helper.ts b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.helper.ts new file mode 100644 index 00000000..20010d77 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.helper.ts @@ -0,0 +1,23 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { + qrCodeBgVar, + qrCodeFgVar, + qrCodeBorderColorVar, + qrCodeBoxShadowVar, + qrCodeDescBgVar, +} from "./connect-modal-qrcode.css"; + +export const connectQRCodeOverrides: ComponentOverrideSchema = { + name: "connect-modal-qr-code", + overrides: [ + [qrCodeBgVar, "bg"], + [qrCodeFgVar, "color"], + [qrCodeBorderColorVar, "borderColor"], + [qrCodeBoxShadowVar, "shadow"], + ], +}; + +export const connectQRCodeShadowOverrides: ComponentOverrideSchema = { + name: "connect-modal-qr-code-shadow", + overrides: [[qrCodeDescBgVar, "bg"]], +}; diff --git a/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.tsx b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.tsx new file mode 100644 index 00000000..cc4ec87f --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.tsx @@ -0,0 +1,192 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import anime from "animejs"; +import type { ConnectModalQRCodeProps } from "./connect-modal-qrcode.types"; +import QRCodeSkeleton from "../connect-modal-qrcode-skeleton"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import QRCode from "../qrcode"; +import QRCodeError from "../connect-modal-qrcode-error"; +import { + descriptionStyle, + qrCodeContainer, + qrCodeBgVar, + qrCodeFgVar, + qrCodeDesc, + qrCodeDescContent, + qrCodeDescShadow, +} from "./connect-modal-qrcode.css"; +import { store } from "../../models/store"; +import { + connectQRCodeOverrides, + connectQRCodeShadowOverrides, +} from "./connect-modal-qrcode.helper"; +import type { AnimeInstance } from "animejs"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import type { ThemeVariant } from "../../models/system.model"; + +function ConnectModalQRCode(props: ConnectModalQRCodeProps) { + const { qrCodeSize = 230 } = props; + const measureRef = useRef(null); + const shadowRef = useRef(null); + const animationRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const [displayBlur, setDisplayBlur] = useState(() => false); + const [internalTheme, setInternalTheme] = useState(() => "light"); + const [overrideManager, setOverrideManager] = useState(() => null); + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + if (measureRef.current) { + if (measureRef.current.clientHeight >= 64) { + setDisplayBlur(true); + } else { + setDisplayBlur(false); + } + const scrollHandler = () => { + const height = Math.abs( + measureRef.current.scrollHeight - + measureRef.current.clientHeight - + measureRef.current.scrollTop + ); + if (height < 1) { + setDisplayBlur(false); + } else { + setDisplayBlur(true); + } + }; + measureRef.current.addEventListener("scroll", scrollHandler); + const storeUnsub = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + cleanupRef.current = () => { + storeUnsub(); + if (measureRef.current) { + measureRef.current.removeEventListener("scroll", scrollHandler); + } + }; + } + }, []); + useEffect(() => { + if (!shadowRef.current) return; // Animation not init yet + if (shadowRef.current && !animationRef.current) { + animationRef.current = anime({ + targets: shadowRef.current, + opacity: [0, 1], + height: [0, 28], + delay: 50, + duration: 250, + direction: `alternate`, + loop: false, + autoplay: false, + easing: `easeInOutSine`, + }); + } + if (displayBlur) { + animationRef.current?.restart(); + } else { + animationRef.current?.reverse(); + } + }, [displayBlur, shadowRef.current]); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + {props.description} + + {props.status === "Pending" ? ( + <> + + + + ) : null} + {props.status === "Done" ? ( + <> + +
    + +
    + + ) : null} + {props.status === "Error" || props.status === "Expired" ? ( + <> + + props.onRefresh?.()} + qrCodeSize={qrCodeSize} + /> + + ) : null} + {!!props.errorTitle ? ( + <> + {props.status === "Error" ? ( + <> + + + {props.errorTitle} + + + ) : null} + {props.status === "Expired" ? ( + <> + + + {props.errorTitle} + + + ) : null} + + ) : null} + {!!props.errorDesc ? ( + <> + +
    +
    +

    {props.errorDesc}

    +
    +
    +
    + + ) : null} + + ); +} +export default ConnectModalQRCode; diff --git a/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.types.tsx b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.types.tsx new file mode 100644 index 00000000..dd80aa7c --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode/connect-modal-qrcode.types.tsx @@ -0,0 +1,13 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export type QRCodeStatus = "Pending" | "Done" | "Error" | "Expired"; + +export interface ConnectModalQRCodeProps extends BaseComponentProps { + status: QRCodeStatus; + link: string; + description: string; + qrCodeSize?: number; + errorTitle?: any; + errorDesc?: any; + onRefresh?: () => void; +} diff --git a/packages/react/src/ui/connect-modal-qrcode/index.ts b/packages/react/src/ui/connect-modal-qrcode/index.ts new file mode 100644 index 00000000..72868102 --- /dev/null +++ b/packages/react/src/ui/connect-modal-qrcode/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-qrcode"; diff --git a/packages/react/src/ui/connect-modal-status/connect-modal-status.css.ts b/packages/react/src/ui/connect-modal-status/connect-modal-status.css.ts new file mode 100644 index 00000000..80d8c52a --- /dev/null +++ b/packages/react/src/ui/connect-modal-status/connect-modal-status.css.ts @@ -0,0 +1,310 @@ +import { + style, + keyframes, + styleVariants, + createVar, +} from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +const logoFrameColorDisconnectedVar = createVar(); +const logoFrameColorConnectingVar = createVar(); +const logoFrameColorNotExistVar = createVar(); + +const modalStatusContainerBase = style({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + fontFamily: themeVars.font.body, + paddingTop: themeVars.space[12], + paddingBottom: 0, +}); + +export const modalStatusContainer = styleVariants({ + light: [ + modalStatusContainerBase, + style({ + backgroundColor: themeVars.colors.white, + }), + ], + dark: [ + modalStatusContainerBase, + style({ + backgroundColor: themeVars.colors.gray700, + }), + ], +}); + +export const statusLogo = style({ + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginInlineStart: "auto", + marginInlineEnd: "auto", + width: themeVars.space[19], + height: themeVars.space[19], +}); + +const disconnectedLogoFrameBase = style({ + position: "absolute", + borderRadius: "50%", + borderWidth: "2px", + borderStyle: "solid", + borderColor: logoFrameColorDisconnectedVar, + top: `calc(${themeVars.space[4]} * -1)`, + bottom: `calc(${themeVars.space[4]} * -1)`, + left: `calc(${themeVars.space[4]} * -1)`, + right: `calc(${themeVars.space[4]} * -1)`, +}); + +export const disconnectedLogoFrame = styleVariants({ + light: [ + disconnectedLogoFrameBase, + style({ + vars: { + [logoFrameColorDisconnectedVar]: themeVars.colors.orange300, + }, + }), + ], + dark: [ + disconnectedLogoFrameBase, + style({ + vars: { + [logoFrameColorDisconnectedVar]: themeVars.colors.orange400, + }, + }), + ], +}); + +const disconnectedDescBase = style({ + fontWeight: themeVars.fontWeight.semibold, + marginBottom: themeVars.space[7], +}); + +export const disconnectedDesc = styleVariants({ + light: [ + disconnectedDescBase, + style({ + color: themeVars.colors.orange300, + }), + ], + dark: [ + disconnectedDescBase, + style({ + color: themeVars.colors.orange500, + }), + ], +}); + +export const statusLogoImage = style({ + padding: themeVars.space[7], + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +export const statusLogoImageSvg = style({ + width: themeVars.space[19], + height: themeVars.space[19], + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +export const rotateAnim = keyframes({ + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, +}); + +const connectingLogoFrameBase = style({ + position: "absolute", + borderRadius: "50%", + borderWidth: "2px", + borderStyle: "solid", + borderTopColor: "transparent", + borderBottomColor: "transparent", + borderLeftColor: logoFrameColorConnectingVar, + borderRightColor: logoFrameColorConnectingVar, + top: `calc(${themeVars.space[4]} * -1)`, + bottom: `calc(${themeVars.space[4]} * -1)`, + left: `calc(${themeVars.space[4]} * -1)`, + right: `calc(${themeVars.space[4]} * -1)`, + animationName: rotateAnim, + animationDuration: "3s", + animationIterationCount: "infinite", + animationTimingFunction: "ease-in-out", +}); + +export const connectingLogoFrame = styleVariants({ + light: [ + connectingLogoFrameBase, + style({ + vars: { + [logoFrameColorConnectingVar]: themeVars.colors.purple300, + }, + }), + ], + dark: [ + connectingLogoFrameBase, + style({ + vars: { + [logoFrameColorConnectingVar]: themeVars.colors.purple400, + }, + }), + ], +}); + +const connectingHeaderBase = style({ + marginBottom: themeVars.space[1], + fontSize: themeVars.fontSize.md, + fontWeight: themeVars.fontWeight.semibold, +}); + +export const connectingHeader = styleVariants({ + light: [ + connectingHeaderBase, + style({ + color: themeVars.colors.gray700, + }), + ], + dark: [ + connectingHeaderBase, + style({ + color: themeVars.colors.white, + }), + ], +}); + +const notExistLogoFrameBase = style({ + position: "absolute", + borderRadius: "50%", + borderWidth: "2px", + borderStyle: "solid", + borderColor: logoFrameColorNotExistVar, + top: `calc(${themeVars.space[4]} * -1)`, + bottom: `calc(${themeVars.space[4]} * -1)`, + left: `calc(${themeVars.space[4]} * -1)`, + right: `calc(${themeVars.space[4]} * -1)`, +}); + +export const notExistLogoFrame = styleVariants({ + light: [ + notExistLogoFrameBase, + style({ + vars: { + [logoFrameColorNotExistVar]: themeVars.colors.red300, + }, + }), + ], + dark: [ + notExistLogoFrameBase, + style({ + vars: { + [logoFrameColorNotExistVar]: themeVars.colors.red400, + }, + }), + ], +}); + +export const errorDescription = style([ + { + lineHeight: 1.3, + opacity: 0.7, + whiteSpace: "pre-line", + overflowY: "scroll", + paddingInlineStart: themeVars.space[8], + paddingInlineEnd: themeVars.space[8], + paddingTop: themeVars.space[2], + paddingBottom: themeVars.space[2], + marginBottom: themeVars.space[8], + fontSize: themeVars.fontSize.sm, + maxHeight: themeVars.space[22], + scrollbarWidth: "none", + selectors: { + "&::-webkit-scrollbar": { + display: "none" /* Safari and Chrome */, + }, + }, + }, +]); + +export const widthContainer = style({ + width: "100%", + paddingLeft: themeVars.space[8], + paddingRight: themeVars.space[8], +}); + +const connectedInfoBase = style({ + fontSize: themeVars.fontSize.md, + fontWeight: themeVars.fontWeight.semibold, + marginLeft: themeVars.space[4], +}); + +export const connectedInfo = styleVariants({ + light: [ + connectedInfoBase, + style({ + color: themeVars.colors.gray700, + }), + ], + dark: [ + connectedInfoBase, + style({ + color: themeVars.colors.white, + }), + ], +}); + +export const dangerText = styleVariants({ + light: [ + style({ + fontWeight: themeVars.fontWeight.semibold, + marginBottom: themeVars.space[2], + color: themeVars.colors.red300, + }), + ], + dark: [ + style({ + fontWeight: themeVars.fontWeight.semibold, + marginBottom: themeVars.space[2], + color: themeVars.colors.red400, + }), + ], +}); + +export const descMaxWidth = style({ + maxWidth: "224px", +}); + +const descBase = style({ + fontSize: themeVars.fontSize.sm, + marginBottom: themeVars.space[4], + fontWeight: themeVars.fontWeight.normal, + textAlign: "center", +}); + +export const desc = styleVariants({ + light: [descBase, descMaxWidth, style({ color: themeVars.colors.gray600 })], + dark: [ + descBase, + descMaxWidth, + style({ color: themeVars.colors.whiteAlpha700 }), + ], +}); + +export const flexImg = style({ + maxHeight: "96px", + width: "100%", + height: "100%", +}); + +export const bottomLink = style({ + fontSize: themeVars.fontSize.sm, + color: themeVars.colors.body, + fontWeight: themeVars.fontWeight.normal, +}); + +export const copyText = style({ + marginBottom: themeVars.space[7], +}); diff --git a/packages/react/src/ui/connect-modal-status/connect-modal-status.tsx b/packages/react/src/ui/connect-modal-status/connect-modal-status.tsx new file mode 100644 index 00000000..95368932 --- /dev/null +++ b/packages/react/src/ui/connect-modal-status/connect-modal-status.tsx @@ -0,0 +1,292 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Box from "../box"; +import Button from "../button"; +import ClipboardCopyText from "../clipboard-copy-text"; +import InstallButton from "../connect-modal-install-button"; +import { + statusLogo, + disconnectedLogoFrame, + disconnectedDesc, + statusLogoImage, + modalStatusContainer, + connectingLogoFrame, + connectingHeader, + notExistLogoFrame, + dangerText, + errorDescription, + statusLogoImageSvg, + widthContainer, + connectedInfo, + desc, + descMaxWidth, + flexImg, + bottomLink, + copyText, +} from "./connect-modal-status.css"; +import { baseTextStyles } from "../text/text.css"; +import { bottomShadow } from "../shared/shared.css"; +import { store } from "../../models/store"; +import type { ThemeVariant } from "../../models/system.model"; +import type { ConnectModalStatusProps } from "./connect-modal-status.types"; + +function ConnectModalStatus(props: ConnectModalStatusProps) { + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + function getConnectedInfo() { + return props.connectedInfo; + } + + function getWallet() { + return props.wallet; + } + + useEffect(() => { + setInternalTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    + {props.status === "Disconnected" ? ( + <> + +
    +
    + {getWallet().name} +
    + +

    + Wallet is disconnected +

    +
    + +
    + {!!props.bottomLink ? ( + + ) : null} + + ) : null} + {props.status === "Connecting" ? ( + <> + +
    +
    + {getWallet().name} +
    + {" "} +

    + {props.contentHeader} +

    {" "} + + {props.contentDesc} + + + ) : null} + {props.status === "Connected" ? ( + <> + + {typeof getConnectedInfo().avatar === "string" ? ( +
    + {getConnectedInfo().name} +
    + ) : null} + {!!getConnectedInfo().avatar && + typeof getConnectedInfo().avatar !== "string" ? ( +
    + {getConnectedInfo().avatar} +
    + ) : null} +
    {" "} + + {getWallet().name} + {!!getConnectedInfo().name ? ( +

    + {getConnectedInfo().name} +

    + ) : null} +
    {" "} +
    + + + +
    {" "} +
    + + + +
    + + ) : null} + {props.status === "NotExist" ? ( + <> + +
    +
    + {getWallet().name} +
    + {" "} +

    {props.contentHeader}

    {" "} +

    + {props.contentDesc} +

    {" "} +
    + + props.onInstall?.()} + disabled={!!props.disableInstall} + > + + {props.installIcon} + + {" "} + Install {getWallet().prettyName ?? getWallet().name} + + + + +
    + + ) : null} + {props.status === "Rejected" ? ( + <> + +
    +
    + {getWallet().name} +
    + {" "} +

    {props.contentHeader}

    {" "} +

    {props.contentDesc}

    {" "} +
    + +
    + + ) : null} + {props.status === "Error" ? ( + <> + +
    +
    + {getWallet().name} +
    + {" "} +

    {props.contentHeader}

    {" "} + +
    +

    {props.contentDesc}

    +
    +
    + {" "} +
    + +
    + + ) : null} +
    + ); +} +export default ConnectModalStatus; diff --git a/packages/react/src/ui/connect-modal-status/connect-modal-status.types.tsx b/packages/react/src/ui/connect-modal-status/connect-modal-status.types.tsx new file mode 100644 index 00000000..08935166 --- /dev/null +++ b/packages/react/src/ui/connect-modal-status/connect-modal-status.types.tsx @@ -0,0 +1,32 @@ +import { BaseComponentProps } from "../../models/components.model"; +import type { Wallet } from "../connect-modal-wallet-list/connect-modal-wallet-list.types"; + +export interface ConnectedInfo { + name?: string; + avatar?: any; + address: string; +} + +export interface ConnectModalStatusProps extends BaseComponentProps { + wallet: Wallet; + status: + | "Disconnected" + | "Connecting" + | "Connected" + | "NotExist" + | "Rejected" + | "Error"; + contentHeader?: string; + contentDesc?: string; + bottomLink?: string; + // disconnected props + onConnect?: () => void; + // connected props + connectedInfo?: ConnectedInfo; + onDisconnect?: () => void; + onChangeWallet?: () => void; + // notExist props + disableInstall?: boolean; + onInstall?: () => void; + installIcon?: BaseComponentProps["children"]; +} diff --git a/packages/react/src/ui/connect-modal-status/index.ts b/packages/react/src/ui/connect-modal-status/index.ts new file mode 100644 index 00000000..9b1c8b51 --- /dev/null +++ b/packages/react/src/ui/connect-modal-status/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-status"; diff --git a/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.css.ts b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.css.ts new file mode 100644 index 00000000..b3b33a08 --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.css.ts @@ -0,0 +1,243 @@ +import { style, createVar, styleVariants } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { themeVars } from "../../styles/themes.css"; +import type { RecipeVariants } from "@vanilla-extract/recipes"; + +export const buttonHoverShadowVar = createVar(); +export const buttonFocusShadowVar = createVar(); +export const buttonTextColorVar = createVar(); +export const buttonBgVar = createVar(); +export const buttonHoverBgVar = createVar(); +export const buttonFocusBgVar = createVar(); + +const transitionProperty = + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform"; + +const connectButtonShapeVariants = { + square: style({ + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + minWidth: "100px", + maxWidth: "140px", + height: "140px", + padding: themeVars.space[2], + }), + list: style({ + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "center", + width: "auto", + height: "52px", + padding: themeVars.space[4], + }), +}; + +const connectButtonBase = style({ + fontFamily: themeVars.font.body, + fontWeight: themeVars.fontWeight.semibold, + borderRadius: themeVars.radii.md, + cursor: "pointer", + appearance: "none", + border: "none", + position: "relative", + userSelect: "none", + whiteSpace: "nowrap", + verticalAlign: "middle", + lineHeight: 1.2, + display: "flex", + vars: { + [buttonHoverShadowVar]: "0 0 0 1px #6A66FF", + [buttonFocusShadowVar]: "0 0 0 1px #6A66FF", + }, + selectors: { + "&:hover": { + transitionProperty: transitionProperty, + transitionDuration: "200ms", + boxShadow: buttonHoverShadowVar, + }, + "&:focus": { + transitionProperty: transitionProperty, + transitionDuration: "200ms", + boxShadow: buttonFocusShadowVar, + }, + }, +}); + +export const connectButtonStyle = styleVariants({ + light: [ + connectButtonBase, + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.blackAlpha800, + [buttonBgVar]: themeVars.colors.gray100, + [buttonHoverBgVar]: themeVars.colors.gray50, + [buttonFocusBgVar]: themeVars.colors.gray50, + }, + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:hover": { + backgroundColor: buttonHoverBgVar, + }, + "&:focus": { + backgroundColor: buttonFocusBgVar, + }, + }, + }), + ], + dark: [ + connectButtonBase, + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.whiteAlpha800, + [buttonBgVar]: themeVars.colors.blackAlpha500, + [buttonHoverBgVar]: themeVars.colors.blackAlpha600, + [buttonFocusBgVar]: themeVars.colors.blackAlpha600, + }, + color: buttonTextColorVar, + backgroundColor: buttonBgVar, + selectors: { + "&:hover": { + backgroundColor: buttonHoverBgVar, + }, + "&:focus": { + backgroundColor: buttonFocusBgVar, + }, + }, + }), + ], +}); + +export const connectButtonVariants = recipe({ + base: {}, + variants: { + variant: connectButtonShapeVariants, + }, + defaultVariants: { + variant: "square", + }, +}); + +export type ConnectButtonVariants = RecipeVariants< + typeof connectButtonVariants +>; + +const logoVariant = { + square: style({ + width: themeVars.space[16], + height: themeVars.space[16], + marginBottom: themeVars.space[5], + }), + list: style({ + width: themeVars.space[12], + height: themeVars.space[12], + marginRight: themeVars.space[8], + }), +}; + +export const logoVariants = recipe({ + base: style({ + position: "relative", + }), + variants: { + variant: logoVariant, + }, + defaultVariants: { + variant: "square", + }, +}); + +// ==== Button label +export const buttonLabelColorVar = createVar(); + +const buttonTextBase = style({ + fontFamily: themeVars.font.body, + textAlign: "left", + fontSize: themeVars.fontSize.sm, + fontWeight: themeVars.fontWeight.normal, + color: buttonLabelColorVar, +}); + +export const buttonTextStyle = styleVariants({ + light: [ + style({ + vars: { + [buttonLabelColorVar]: themeVars.colors.blackAlpha800, + }, + }), + buttonTextBase, + ], + dark: [ + style({ + vars: { + [buttonLabelColorVar]: themeVars.colors.whiteAlpha900, + }, + }), + buttonTextBase, + ], +}); + +export const buttonTextVariants = recipe({ + base: {}, + variants: { + variant: { + square: {}, + list: style({ + flex: 1, + }), + }, + }, + defaultVariants: { + variant: "square", + }, +}); + +export const buttonSublogoBgVar = createVar(); +export const buttonSublogoBorderVar = createVar(); + +// ==== Button sub logo +const subLogoBase = style({ + display: "flex", + position: "absolute", + justifyContent: "center", + alignItems: "center", + overflow: "hidden", + border: "2px solid", + right: "-8px", + bottom: "-8px", + borderRadius: themeVars.radii.full, + backgroundColor: buttonSublogoBgVar, + borderColor: buttonSublogoBorderVar, +}); + +export const subLogoSquare = styleVariants({ + light: [ + style({ + vars: { + [buttonSublogoBgVar]: themeVars.colors.gray100, + [buttonSublogoBorderVar]: themeVars.colors.gray100, + }, + }), + subLogoBase, + ], + dark: [ + style({ + vars: { + [buttonSublogoBgVar]: themeVars.colors.gray700, + [buttonSublogoBorderVar]: themeVars.colors.gray700, + }, + }), + subLogoBase, + ], +}); + +export const subLogoList = style({ + display: "flex", + justifyContent: "center", + alignItems: "center", + overflow: "hidden", + backgroundColor: "transparent", + borderRadius: themeVars.radii.full, + marginRight: themeVars.space[2], +}); diff --git a/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.helper.ts b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.helper.ts new file mode 100644 index 00000000..e6b36db2 --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.helper.ts @@ -0,0 +1,37 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { + buttonBgVar, + buttonFocusBgVar, + buttonHoverBgVar, + buttonFocusShadowVar, + buttonHoverShadowVar, + buttonTextColorVar, + buttonLabelColorVar, + buttonSublogoBgVar, + buttonSublogoBorderVar, +} from "./connect-modal-wallet-button.css"; + +export const buttonOverrides: ComponentOverrideSchema = { + name: "connect-modal-wallet-button", + overrides: [ + [buttonTextColorVar, "color"], + [buttonBgVar, "bg"], + [buttonFocusBgVar, "focusedBg"], + [buttonHoverBgVar, "hoverBg"], + [buttonFocusShadowVar, "focusedShadow"], + [buttonHoverShadowVar, "hoverShadow"], + ], +}; + +export const buttonLabelOverrides: ComponentOverrideSchema = { + name: "connect-modal-wallet-button-label", + overrides: [[buttonLabelColorVar, "color"]], +}; + +export const buttonSublogoOverrides: ComponentOverrideSchema = { + name: "connect-modal-wallet-button-sublogo", + overrides: [ + [buttonSublogoBgVar, "bg"], + [buttonSublogoBorderVar, "borderColor"], + ], +}; diff --git a/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.tsx b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.tsx new file mode 100644 index 00000000..489cca2a --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.tsx @@ -0,0 +1,277 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import type { ConnectModalWalletButtonProps } from "./connect-modal-wallet-button.types"; +import { + connectButtonVariants, + connectButtonStyle, + buttonTextStyle, + logoVariants, + buttonTextVariants, + subLogoSquare, + subLogoList, +} from "./connect-modal-wallet-button.css"; +import { store } from "../../models/store"; +import Box from "../box"; +import Avatar from "../avatar"; +import AvatarBadge from "../avatar-badge"; +import Icon from "../icon"; +import { + buttonOverrides, + buttonLabelOverrides, + buttonSublogoOverrides, +} from "./connect-modal-wallet-button.helper"; +import Text from "../text"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import type { ThemeVariant } from "../../models/system.model"; +import { WalletPluginSystem } from "../connect-modal-wallet-list"; + +function ConnectModalWalletButton(props: ConnectModalWalletButtonProps) { + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [overrideManager, setOverrideManager] = useState(() => null); + + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + ); +} + +export default ConnectModalWalletButton; diff --git a/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.types.tsx b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.types.tsx new file mode 100644 index 00000000..e2c13f47 --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-button/connect-modal-wallet-button.types.tsx @@ -0,0 +1,14 @@ +import { BaseComponentProps } from "../../models/components.model"; +import type { ConnectButtonVariants } from "./connect-modal-wallet-button.css"; +import type { LiteralUnion } from "../../helpers/types"; + +export interface ConnectModalWalletButtonProps extends BaseComponentProps { + variant?: ConnectButtonVariants["variant"]; + logo: string; + subLogo?: LiteralUnion<"walletConnect", string>; + btmLogo?: LiteralUnion<"MetaMask", string>; + name: string; + prettyName?: string; + badge?: string; + onClick?: any; +} diff --git a/packages/react/src/ui/connect-modal-wallet-button/index.ts b/packages/react/src/ui/connect-modal-wallet-button/index.ts new file mode 100644 index 00000000..b9fcadbf --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-button/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal-wallet-button"; diff --git a/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.css.ts b/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.css.ts new file mode 100644 index 00000000..3241fa76 --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.css.ts @@ -0,0 +1,39 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + position: "relative", +}); + +export const walletList = style({ + maxHeight: "320px", + height: "auto", + width: "100%", + overflow: "auto", + scrollbarWidth: "none", + paddingTop: themeVars.space[1], + selectors: { + "&::-webkit-scrollbar": { + display: "none" /* Safari and Chrome */, + }, + "&[data-has-list-wallets='true']": { + paddingBottom: themeVars.space[8], + }, + }, +}); + +export const squareWallets = style({ + columnGap: themeVars.space[5], + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + marginBottom: themeVars.space[5], + paddingLeft: themeVars.space[1], + paddingRight: themeVars.space[1], +}); + +export const listWallets = style({ + rowGap: themeVars.space[2], + gridTemplateColumns: "repeat(1, minmax(0, 1fr))", + paddingBottom: themeVars.space[4], + paddingLeft: themeVars.space[1], + paddingRight: themeVars.space[1], +}); diff --git a/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.tsx b/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.tsx new file mode 100644 index 00000000..73f91ae2 --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.tsx @@ -0,0 +1,163 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import anime from "animejs"; +import type { AnimeInstance } from "animejs"; +import clx from "clsx"; +import Box from "../box"; +import FadeIn from "../fade-in"; +import WalletButton from "../connect-modal-wallet-button"; +import { + walletList, + squareWallets, + listWallets, + container, +} from "./connect-modal-wallet-list.css"; +import { bottomShadow } from "../shared/shared.css"; +import { store } from "../../models/store"; +import type { ConnectModalWalletListProps } from "./connect-modal-wallet-list.types"; + +function ConnectModalWalletList(props: ConnectModalWalletListProps) { + const measureRef = useRef(null); + const shadowRef = useRef(null); + const animationRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const [displayBlur, setDisplayBlur] = useState(() => false); + + const [internalTheme, setInternalTheme] = useState(() => "light"); + + function onWalletItemClickAsync(exec) { + void (async function () { + await exec(); + })(); + } + + function getListShapeWallets() { + return props.wallets.filter((w) => w.shape === "list"); + } + + function showSquareShapeWallets() { + return props.wallets + .slice(0, 2) + .every((wallet) => wallet.shape === "square"); + } + + useEffect(() => { + setInternalTheme(store.getState().theme); + const unsubTheme = store.subscribe((newState) => { + setInternalTheme(newState.theme); + }); + if (measureRef.current) { + if (measureRef.current.clientHeight >= 320) { + setDisplayBlur(true); + } else { + setDisplayBlur(false); + } + const scrollHandler = () => { + const height = Math.abs( + measureRef.current.scrollHeight - + measureRef.current.clientHeight - + measureRef.current.scrollTop + ); + if (height < 1) { + setDisplayBlur(false); + } else { + setDisplayBlur(true); + } + }; + measureRef.current.addEventListener("scroll", scrollHandler); + cleanupRef.current = () => { + unsubTheme(); + if (measureRef.current) { + measureRef.current.removeEventListener("scroll", scrollHandler); + } + }; + } + }, []); + + useEffect(() => { + if (!shadowRef.current) return; + + // Animation not init yet + if (shadowRef.current && !animationRef.current) { + animationRef.current = anime({ + targets: shadowRef.current, + opacity: [0, 1], + height: [0, 36], + delay: 50, + duration: 250, + direction: `alternate`, + loop: false, + autoplay: false, + easing: `easeInOutSine`, + }); + } + if (displayBlur) { + animationRef.current?.restart(); + } else { + animationRef.current?.reverse(); + } + }, [displayBlur, shadowRef.current]); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    +
    0} + className={walletList} + > + {showSquareShapeWallets() ? ( + + + {props.wallets.slice(0, 2)?.map((wallet, index) => ( + + onWalletItemClickAsync(async () => + props.onWalletItemClick?.(wallet.originalWallet) + ) + } + /> + ))} + + + ) : null} + {getListShapeWallets().length > 0 ? ( + 0}> + + {getListShapeWallets()?.map((wallet, index) => ( + + onWalletItemClickAsync(async () => + props.onWalletItemClick?.(wallet.originalWallet) + ) + } + /> + ))} + + + ) : null} +
    +
    +
    + ); +} + +export default ConnectModalWalletList; diff --git a/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.types.tsx b/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.types.tsx new file mode 100644 index 00000000..d54731e0 --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-list/connect-modal-wallet-list.types.tsx @@ -0,0 +1,22 @@ +import { BaseComponentProps } from "../../models/components.model"; +import type { ConnectModalWalletButtonProps } from "../connect-modal-wallet-button/connect-modal-wallet-button.types"; + +export interface Wallet { + name: string; + prettyName?: string; + logo: string; + subLogo?: string; + btmLogo?: string; + mobileDisabled: boolean; + downloadUrl?: string; + rejectMessage?: string; + originalWallet?: any; + badge?: string; + shape?: ConnectModalWalletButtonProps["variant"]; +} + +export interface ConnectModalWalletListProps extends BaseComponentProps { + wallets: Wallet[]; + onWalletItemClick?: (wallet: any) => void; + className?: string; +} diff --git a/packages/react/src/ui/connect-modal-wallet-list/index.ts b/packages/react/src/ui/connect-modal-wallet-list/index.ts new file mode 100644 index 00000000..bfa2a4bf --- /dev/null +++ b/packages/react/src/ui/connect-modal-wallet-list/index.ts @@ -0,0 +1,8 @@ +export { default } from "./connect-modal-wallet-list"; + +export const WalletPluginSystem = { + MetaMask: { + name: "MetaMask Snaps", + logo: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI3LjAuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwNC44IDE5Mi40IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAyMDQuOCAxOTIuNDsiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8c3R5bGUgdHlwZT0idGV4dC9jc3MiPgoJLnN0MHtkaXNwbGF5Om5vbmU7fQoJLnN0MXtmaWxsOiNGNTg0MUY7fQoJLnN0MntmaWxsOiNFMjc2MjU7fQoJLnN0M3tmaWxsOiNEN0MxQjM7fQoJLnN0NHtmaWxsOiMyRjM0M0I7fQoJLnN0NXtmaWxsOiNDQzYyMjg7fQoJLnN0NntmaWxsOiNDMEFEOUU7fQoJLnN0N3tmaWxsOiM3NjNFMUE7fQo8L3N0eWxlPgo8ZyBpZD0iYmdfeDI4X2RvX25vdF9leHBvcnRfeDI5XyIgY2xhc3M9InN0MCI+CjwvZz4KPGcgaWQ9Ik1NX0hlYWRfYmFja2dyb3VuZF9feDI4X0RvX25vdF9lZGl0X3gyOV8iPgoJPGc+CgkJPHBhdGggY2xhc3M9InN0MSIgZD0iTTE2Ny40LDk2LjFsNi45LTguMWwtMy0yLjJsNC44LTQuNGwtMy43LTIuOGw0LjgtMy42bC0zLjEtMi40bDUtMjQuNGwtNy42LTIyLjYgTTE3MS41LDI1LjZsLTQ4LjgsMTguMWwwLDAKCQkJbDAsMEg4MmwwLDBMMzMuMiwyNS42bDAuMywwLjJsLTAuMy0wLjJsLTcuNiwyMi42bDUuMSwyNC40TDI3LjUsNzVsNC45LDMuNmwtMy43LDIuOGw0LjgsNC40bC0zLDIuMmw2LjksOC4xbC0xMC41LDMyLjRoMGwwLDAKCQkJbDkuNywzMy4xbDM0LjEtOS40bDAtMC4xbDAsMC4xbDAsMGwwLDBsMCwwdjBsMCwwbDAsMGwwLDBsNi42LDUuNGwxMy41LDkuMmgyMy4xbDEzLjUtOS4ybDYuNi01LjRsMCwwdjBsMCwwbDAsMGwzNC4yLDkuNAoJCQlsOS44LTMzLjFsMCwwaDBsLTEwLjYtMzIuNCBNNzAuNywxNTIuMUw3MC43LDE1Mi4xTDcwLjcsMTUyLjEiLz4KCTwvZz4KPC9nPgo8ZyBpZD0iTG9nb3MiPgoJPGc+CgkJPHBvbHlnb24gY2xhc3M9InN0MiIgcG9pbnRzPSIxNzEuNSwyNS42IDExMS42LDY5LjcgMTIyLjcsNDMuNyAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDIiIHBvaW50cz0iMzMuMiwyNS42IDkyLjYsNzAuMSA4Miw0My43IAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0MiIgcG9pbnRzPSIxNTAsMTI3LjkgMTM0LDE1Mi4xIDE2OC4yLDE2MS41IDE3OCwxMjguNCAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDIiIHBvaW50cz0iMjYuOSwxMjguNCAzNi42LDE2MS41IDcwLjcsMTUyLjEgNTQuOCwxMjcuOSAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDIiIHBvaW50cz0iNjguOSw4Ni45IDU5LjQsMTAxLjIgOTMuMiwxMDIuNyA5Mi4xLDY2LjUgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3QyIiBwb2ludHM9IjEzNS45LDg2LjkgMTEyLjMsNjYuMSAxMTEuNiwxMDIuNyAxNDUuNCwxMDEuMiAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDIiIHBvaW50cz0iNzAuNywxNTIuMSA5MS4yLDE0Mi4zIDczLjUsMTI4LjcgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3QyIiBwb2ludHM9IjExMy42LDE0Mi4zIDEzNCwxNTIuMSAxMzEuMiwxMjguNyAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDMiIHBvaW50cz0iMTM0LDE1Mi4xIDExMy42LDE0Mi4zIDExNS4zLDE1NS41IDExNS4xLDE2MS4xIAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0MyIgcG9pbnRzPSI3MC43LDE1Mi4xIDg5LjcsMTYxLjEgODkuNiwxNTUuNSA5MS4yLDE0Mi4zIAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0NCIgcG9pbnRzPSI5MCwxMTkuOSA3My4xLDExNSA4NS4xLDEwOS41IAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0NCIgcG9pbnRzPSIxMTQuNywxMTkuOSAxMTkuNywxMDkuNSAxMzEuNywxMTUgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3Q1IiBwb2ludHM9IjcwLjcsMTUyLjEgNzMuNywxMjcuOSA1NC44LDEyOC40IAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0NSIgcG9pbnRzPSIxMzEuMSwxMjcuOSAxMzQsMTUyLjEgMTUwLDEyOC40IAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0NSIgcG9pbnRzPSIxNDUuNCwxMDEuMiAxMTEuNiwxMDIuNyAxMTQuNywxMTkuOSAxMTkuNywxMDkuNSAxMzEuNywxMTUgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3Q1IiBwb2ludHM9IjczLjEsMTE1IDg1LjEsMTA5LjUgOTAsMTE5LjkgOTMuMiwxMDIuNyA1OS40LDEwMS4yIAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0MiIgcG9pbnRzPSI1OS40LDEwMS4yIDczLjUsMTI4LjcgNzMuMSwxMTUgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3QyIiBwb2ludHM9IjEzMS43LDExNSAxMzEuMiwxMjguNyAxNDUuNCwxMDEuMiAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDIiIHBvaW50cz0iOTMuMiwxMDIuNyA5MCwxMTkuOSA5NCwxNDAuMyA5NC45LDExMy41IAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0MiIgcG9pbnRzPSIxMTEuNiwxMDIuNyAxMDkuOSwxMTMuNCAxMTAuNywxNDAuMyAxMTQuNywxMTkuOSAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDEiIHBvaW50cz0iMTE0LjcsMTE5LjkgMTEwLjcsMTQwLjMgMTEzLjYsMTQyLjMgMTMxLjIsMTI4LjcgMTMxLjcsMTE1IAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0MSIgcG9pbnRzPSI3My4xLDExNSA3My41LDEyOC43IDkxLjIsMTQyLjMgOTQsMTQwLjMgOTAsMTE5LjkgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3Q2IiBwb2ludHM9IjExNS4xLDE2MS4xIDExNS4zLDE1NS41IDExMy43LDE1NC4yIDkxLDE1NC4yIDg5LjYsMTU1LjUgODkuNywxNjEuMSA3MC43LDE1Mi4xIDc3LjMsMTU3LjUgCgkJCTkwLjgsMTY2LjggMTEzLjksMTY2LjggMTI3LjQsMTU3LjUgMTM0LDE1Mi4xIAkJIi8+CgkJPHBvbHlnb24gY2xhc3M9InN0NCIgcG9pbnRzPSIxMTMuNiwxNDIuMyAxMTAuNywxNDAuMyA5NCwxNDAuMyA5MS4yLDE0Mi4zIDg5LjYsMTU1LjUgOTEsMTU0LjIgMTEzLjcsMTU0LjIgMTE1LjMsMTU1LjUgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3Q3IiBwb2ludHM9IjE3NC4xLDcyLjYgMTc5LjEsNDguMiAxNzEuNSwyNS42IDExMy42LDY4LjIgMTM1LjksODYuOSAxNjcuNCw5Ni4xIDE3NC4zLDg4IDE3MS4zLDg1LjggMTc2LjEsODEuNSAKCQkJMTcyLjQsNzguNiAxNzcuMiw3NSAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDciIHBvaW50cz0iMjUuNiw0OC4yIDMwLjcsNzIuNiAyNy41LDc1IDMyLjMsNzguNyAyOC42LDgxLjUgMzMuNCw4NS44IDMwLjQsODggMzcuNCw5Ni4xIDY4LjksODYuOSA5MS4yLDY4LjIgCgkJCTMzLjIsMjUuNiAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDEiIHBvaW50cz0iMTY3LjQsOTYuMSAxMzUuOSw4Ni45IDE0NS40LDEwMS4yIDEzMS4yLDEyOC43IDE1MCwxMjguNCAxNzgsMTI4LjQgCQkiLz4KCQk8cG9seWdvbiBjbGFzcz0ic3QxIiBwb2ludHM9IjY4LjksODYuOSAzNy40LDk2LjEgMjYuOSwxMjguNCA1NC44LDEyOC40IDczLjUsMTI4LjcgNTkuNCwxMDEuMiAJCSIvPgoJCTxwb2x5Z29uIGNsYXNzPSJzdDEiIHBvaW50cz0iMTExLjYsMTAyLjcgMTEzLjYsNjguMiAxMjIuNyw0My43IDgyLDQzLjcgOTEuMiw2OC4yIDkzLjIsMTAyLjcgOTQsMTEzLjUgOTQsMTQwLjMgMTEwLjcsMTQwLjMgCgkJCTExMC44LDExMy41IAkJIi8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==", + }, +} as const; diff --git a/packages/react/src/ui/connect-modal/connect-modal.css.ts b/packages/react/src/ui/connect-modal/connect-modal.css.ts new file mode 100644 index 00000000..5cd33291 --- /dev/null +++ b/packages/react/src/ui/connect-modal/connect-modal.css.ts @@ -0,0 +1,50 @@ +import { style, createVar, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const connectModalShadowVar = createVar(); +export const connectModalBgVar = createVar(); + +const modalContentBase = style({ + boxShadow: connectModalShadowVar, + backgroundColor: connectModalBgVar, + display: "flex", + height: "auto", + flexDirection: "column", + borderRadius: themeVars.radii.xl, +}); + +export const modalContent = styleVariants({ + light: [ + style({ + vars: { + [connectModalBgVar]: themeVars.colors.white, + [connectModalShadowVar]: + "0 10px 15px -3px rgba(0, 0, 0, 0.1),0 4px 6px -2px rgba(0, 0, 0, 0.05)", + }, + }), + modalContentBase, + ], + dark: [ + style({ + vars: { + [connectModalBgVar]: themeVars.colors.gray700, + [connectModalShadowVar]: + "rgba(0, 0, 0, 0.1) 0px 0px 0px 1px,rgba(0, 0, 0, 0.2) 0px 5px 10px,rgba(0, 0, 0, 0.4) 0px 15px 40px", + }, + }), + modalContentBase, + ], +}); + +export const modalAnimateContainer = style({ + minHeight: `clamp(100%, ${themeVars.space[30]}px, ${themeVars.space[30]}px)`, +}); + +export const modalChildren = style({ + width: "320px", + boxSizing: "border-box", + paddingLeft: themeVars.space[7], + paddingRight: themeVars.space[7], + paddingTop: themeVars.space[3], + paddingBottom: themeVars.space[10], +}); diff --git a/packages/react/src/ui/connect-modal/connect-modal.helper.ts b/packages/react/src/ui/connect-modal/connect-modal.helper.ts new file mode 100644 index 00000000..a9f31970 --- /dev/null +++ b/packages/react/src/ui/connect-modal/connect-modal.helper.ts @@ -0,0 +1,10 @@ +import { ComponentOverrideSchema } from "../../styles/override/override.types"; +import { connectModalBgVar, connectModalShadowVar } from "./connect-modal.css"; + +export const connectModalOverrides: ComponentOverrideSchema = { + name: "connect-modal", + overrides: [ + [connectModalBgVar, "bg"], + [connectModalShadowVar, "shadow"], + ], +}; diff --git a/packages/react/src/ui/connect-modal/connect-modal.tsx b/packages/react/src/ui/connect-modal/connect-modal.tsx new file mode 100644 index 00000000..7e96ae49 --- /dev/null +++ b/packages/react/src/ui/connect-modal/connect-modal.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import AnimateLayout from "../animate-layout"; +import { store } from "../../models/store"; +import type { ThemeVariant } from "../../models/system.model"; +import type { ConnectModalProps } from "./connect-modal.types"; +import { + modalContent, + modalChildren, + modalAnimateContainer, +} from "./connect-modal.css"; +import { connectModalOverrides } from "./connect-modal.helper"; +import type { OverrideStyleManager } from "../../styles/override/override"; +import Modal from "../modal"; + +function ConnectModal(props: ConnectModalProps) { + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [overrideManager, setOverrideManager] = useState(() => null); + + useEffect(() => { + setInternalTheme(store.getState().theme); + setOverrideManager(store.getState().overrideStyleManager); + cleanupRef.current = store.subscribe((newState) => { + setInternalTheme(newState.theme); + setOverrideManager(newState.overrideStyleManager); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + props.onOpen?.()} + onClose={(event) => props.onClose?.()} + header={props.header} + contentClassName={clx( + modalContent[internalTheme], + props.modalContentClassName + )} + contentStyles={{ + ...overrideManager?.applyOverrides(connectModalOverrides.name), + ...props.modalContentStyles, + }} + childrenClassName={clx(modalChildren, props.modalChildrenClassName)} + className={props.modalContainerClassName} + > + + {props.children} + + + ); +} + +export default ConnectModal; diff --git a/packages/react/src/ui/connect-modal/connect-modal.types.tsx b/packages/react/src/ui/connect-modal/connect-modal.types.tsx new file mode 100644 index 00000000..6900a7a3 --- /dev/null +++ b/packages/react/src/ui/connect-modal/connect-modal.types.tsx @@ -0,0 +1,14 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface ConnectModalProps extends BaseComponentProps { + isOpen: boolean; + onOpen?: (event?: any) => void; + onClose?: (event?: any) => void; + header: BaseComponentProps["children"]; + children?: BaseComponentProps["children"]; + className?: string; + modalContainerClassName?: string; + modalContentClassName?: string; + modalChildrenClassName?: string; + modalContentStyles?: any; +} diff --git a/packages/react/src/ui/connect-modal/index.ts b/packages/react/src/ui/connect-modal/index.ts new file mode 100644 index 00000000..4c4f8015 --- /dev/null +++ b/packages/react/src/ui/connect-modal/index.ts @@ -0,0 +1 @@ +export { default } from "./connect-modal"; diff --git a/packages/react/src/ui/connected-wallet/connected-wallet.tsx b/packages/react/src/ui/connected-wallet/connected-wallet.tsx new file mode 100644 index 00000000..4804b1f0 --- /dev/null +++ b/packages/react/src/ui/connected-wallet/connected-wallet.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Icon from "../icon"; +import ClipboardCopyText from "../clipboard-copy-text"; +import type { ConnectedWalletProps } from "./connected-wallet.types"; + +function ConnectedWallet(props: ConnectedWalletProps) { + const { + truncate = "middle", + midTruncateLimit = "sm", + btnText = "My Wallet", + } = props; + return ( + + + {props.avatar ? ( + + ) : ( + <> + + + )} + + {props.name} + + + props?.onCopied?.()} + /> + + + + + ); +} + +export default ConnectedWallet; diff --git a/packages/react/src/ui/connected-wallet/connected-wallet.types.tsx b/packages/react/src/ui/connected-wallet/connected-wallet.types.tsx new file mode 100644 index 00000000..df58a137 --- /dev/null +++ b/packages/react/src/ui/connected-wallet/connected-wallet.types.tsx @@ -0,0 +1,12 @@ +import { ClipboardCopyTextProps } from "../clipboard-copy-text/clipboard-copy-text.types"; + +export interface ConnectedWalletProps { + avatar: string; + name: string; + btnText?: string; + onClick: () => void; + address: ClipboardCopyTextProps["text"]; + truncate: ClipboardCopyTextProps["truncate"]; + midTruncateLimit: ClipboardCopyTextProps["midTruncateLimit"]; + onCopied?: ClipboardCopyTextProps["onCopied"]; +} diff --git a/packages/react/src/ui/connected-wallet/index.ts b/packages/react/src/ui/connected-wallet/index.ts new file mode 100644 index 00000000..026a6c70 --- /dev/null +++ b/packages/react/src/ui/connected-wallet/index.ts @@ -0,0 +1 @@ +export { default } from "./connected-wallet"; diff --git a/packages/react/src/ui/container/container.tsx b/packages/react/src/ui/container/container.tsx new file mode 100644 index 00000000..50ffd5ce --- /dev/null +++ b/packages/react/src/ui/container/container.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import Box from "../box"; +import type { ContainerProps } from "./container.types"; + +function Container(props: ContainerProps) { + const { maxWidth = "prose" } = props; + return ( + + {props.children} + + ); +} + +export default Container; diff --git a/packages/react/src/ui/container/container.types.tsx b/packages/react/src/ui/container/container.types.tsx new file mode 100644 index 00000000..5da1e04c --- /dev/null +++ b/packages/react/src/ui/container/container.types.tsx @@ -0,0 +1,10 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +export interface ContainerProps extends Omit { + as?: any; + className?: string; + children?: BaseComponentProps["children"]; + maxWidth?: Sprinkles["maxWidth"]; + attributes?: Sprinkles; +} diff --git a/packages/react/src/ui/container/index.ts b/packages/react/src/ui/container/index.ts new file mode 100644 index 00000000..3d520d78 --- /dev/null +++ b/packages/react/src/ui/container/index.ts @@ -0,0 +1 @@ +export { default } from "./container"; diff --git a/packages/react/src/ui/cross-chain/cross-chain.tsx b/packages/react/src/ui/cross-chain/cross-chain.tsx new file mode 100644 index 00000000..dc8c8522 --- /dev/null +++ b/packages/react/src/ui/cross-chain/cross-chain.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import AssetListHeader from "../asset-list-header"; +import AssetList from "../asset-list"; +import Box from "../box"; +import Text from "../text"; +import type { CrossChainProps } from "./cross-chain.types"; + +function CrossChain(props: CrossChainProps) { + return ( + + props.onDeposit?.()} + onWithdraw={(event) => props.onWithdraw?.()} + /> + + {props.listTitle} + + + + {props.otherListTitle} + + + + ); +} + +export default CrossChain; diff --git a/packages/react/src/ui/cross-chain/cross-chain.types.tsx b/packages/react/src/ui/cross-chain/cross-chain.types.tsx new file mode 100644 index 00000000..2d657f69 --- /dev/null +++ b/packages/react/src/ui/cross-chain/cross-chain.types.tsx @@ -0,0 +1,12 @@ +import type { AssetListHeaderProps } from "../asset-list-header/asset-list-header.types"; +import type { AssetListItemProps } from "../asset-list-item/asset-list-item.types"; + +export type CrossChainListItemProps = Omit; + +export interface CrossChainProps + extends Omit { + list: Array; + listTitle: string; + otherList: Array; + otherListTitle: string; +} diff --git a/packages/react/src/ui/cross-chain/index.ts b/packages/react/src/ui/cross-chain/index.ts new file mode 100644 index 00000000..4231042e --- /dev/null +++ b/packages/react/src/ui/cross-chain/index.ts @@ -0,0 +1 @@ +export { default } from "./cross-chain"; diff --git a/packages/react/src/ui/divider/divider.tsx b/packages/react/src/ui/divider/divider.tsx new file mode 100644 index 00000000..46d12713 --- /dev/null +++ b/packages/react/src/ui/divider/divider.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import Box from "../box"; +import { DividerProps } from "./divider.types"; + +function Divider(props: DividerProps) { + const { orientation = "horizontal" } = props; + return ( + + ); +} + +export default Divider; diff --git a/packages/react/src/ui/divider/divider.types.tsx b/packages/react/src/ui/divider/divider.types.tsx new file mode 100644 index 00000000..2f3b75be --- /dev/null +++ b/packages/react/src/ui/divider/divider.types.tsx @@ -0,0 +1,5 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export type DividerProps = { + orientation?: "horizontal" | "vertical"; +} & Partial; diff --git a/packages/react/src/ui/divider/index.ts b/packages/react/src/ui/divider/index.ts new file mode 100644 index 00000000..9c9b2ced --- /dev/null +++ b/packages/react/src/ui/divider/index.ts @@ -0,0 +1 @@ +export { default } from "./divider"; diff --git a/packages/react/src/ui/fade-in/fade-in.tsx b/packages/react/src/ui/fade-in/fade-in.tsx new file mode 100644 index 00000000..73b55416 --- /dev/null +++ b/packages/react/src/ui/fade-in/fade-in.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { useRef, useEffect } from "react"; +import anime from "animejs"; +import type { AnimeInstance } from "animejs"; +import type { FadeInProps } from "./fade-in.types"; + +function FadeIn(props: FadeInProps) { + const fadeInAnimationRef = useRef(null); + const fadeOutAnimationRef = useRef(null); + const elementRef = useRef(null); + + useEffect(() => { + const delaySetting = props.delayMs || 100; + const durationSetting = props.durationMs || 250; + + // Animation not init yet + if ( + elementRef.current && + !fadeInAnimationRef.current && + !fadeOutAnimationRef.current + ) { + fadeInAnimationRef.current = anime({ + targets: elementRef.current, + opacity: [0, 1], + delay: delaySetting, + duration: durationSetting, + direction: `alternate`, + loop: false, + autoplay: false, + easing: "spring(1, 80, 10, 0)", + }); + fadeOutAnimationRef.current = anime({ + targets: elementRef.current, + opacity: [1, 0], + delay: delaySetting, + duration: durationSetting, + direction: `alternate`, + loop: false, + autoplay: false, + easing: "spring(1, 80, 10, 0)", + }); + } + if (props.isVisible) { + fadeInAnimationRef.current?.restart(); + } else { + fadeOutAnimationRef.current?.restart(); + } + }, [props.delayMs, props.durationMs, props.isVisible]); + + return
    {props.children}
    ; +} + +export default FadeIn; diff --git a/packages/react/src/ui/fade-in/fade-in.types.tsx b/packages/react/src/ui/fade-in/fade-in.types.tsx new file mode 100644 index 00000000..53e2030e --- /dev/null +++ b/packages/react/src/ui/fade-in/fade-in.types.tsx @@ -0,0 +1,8 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface FadeInProps { + isVisible: boolean; + durationMs?: number; + delayMs?: number; + children: BaseComponentProps["children"]; +} diff --git a/packages/react/src/ui/fade-in/index.ts b/packages/react/src/ui/fade-in/index.ts new file mode 100644 index 00000000..282b93e4 --- /dev/null +++ b/packages/react/src/ui/fade-in/index.ts @@ -0,0 +1 @@ +export { default } from "./fade-in"; diff --git a/packages/react/src/ui/field-label/field-label.css.ts b/packages/react/src/ui/field-label/field-label.css.ts new file mode 100644 index 00000000..db1288f5 --- /dev/null +++ b/packages/react/src/ui/field-label/field-label.css.ts @@ -0,0 +1,30 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { baseTextStyles } from "../text/text.css"; +import { themeVars } from "../../styles/themes.css"; + +export const fieldlabelStyle = style([ + baseTextStyles, + style({ + lineHeight: "normal", + fontWeight: themeVars.fontWeight.semibold, + color: themeVars.colors.textSecondary, + }), +]); + +export const fieldLabelSizes = styleVariants({ + sm: [ + style({ + fontSize: themeVars.fontSize.sm, + }), + ], + md: [ + style({ + fontSize: themeVars.fontSize.md, + }), + ], + lg: [ + style({ + fontSize: themeVars.fontSize.xl, + }), + ], +}); diff --git a/packages/react/src/ui/field-label/field-label.tsx b/packages/react/src/ui/field-label/field-label.tsx new file mode 100644 index 00000000..32e709bf --- /dev/null +++ b/packages/react/src/ui/field-label/field-label.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "../box"; +import Stack from "../stack"; +import type { FieldLabelProps } from "./field-label.types"; +import { fieldLabelSizes, fieldlabelStyle } from "./field-label.css"; + +function FieldLabel(props: FieldLabelProps) { + const { size = "sm" } = props; + return ( + <> + {props.label ? ( + <> + + {props.label ? ( + + {props.htmlFor === false ? ( +

    + {props.label} +

    + ) : ( + + )} +
    + ) : null} +
    + + ) : null} + + ); +} +export default FieldLabel; diff --git a/packages/react/src/ui/field-label/field-label.types.tsx b/packages/react/src/ui/field-label/field-label.types.tsx new file mode 100644 index 00000000..ed299e1c --- /dev/null +++ b/packages/react/src/ui/field-label/field-label.types.tsx @@ -0,0 +1,13 @@ +import { BaseComponentProps } from "../../models/components.model"; + +export interface FieldLabelProps extends BaseComponentProps { + id?: string; + htmlFor: string | false; + label?: BaseComponentProps["children"]; + disabled?: boolean; + size?: "sm" | "md" | "lg"; + description?: BaseComponentProps["children"]; + descriptionId?: string; + attributes?: any; + labelAttributes?: any; +} diff --git a/packages/react/src/ui/field-label/index.ts b/packages/react/src/ui/field-label/index.ts new file mode 100644 index 00000000..1050db87 --- /dev/null +++ b/packages/react/src/ui/field-label/index.ts @@ -0,0 +1 @@ +export { default } from "./field-label"; diff --git a/packages/react/src/ui/governance-checkbox/governance-checkbox.tsx b/packages/react/src/ui/governance-checkbox/governance-checkbox.tsx new file mode 100644 index 00000000..6d9b6661 --- /dev/null +++ b/packages/react/src/ui/governance-checkbox/governance-checkbox.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { useToggleState } from "react-stately"; +import { useCheckbox, useFocusRing, VisuallyHidden } from "react-aria"; +import Box from "@/ui/box"; +import Stack from "@/ui/stack"; +import type { GovernanceCheckboxProps } from "./governance-checkbox.types"; + +export default function GovernanceCheckbox(props: GovernanceCheckboxProps) { + const state = useToggleState(props); + const ref = React.useRef(null); + const { inputProps } = useCheckbox(props, state, ref); + const { isFocusVisible, focusProps } = useFocusRing(); + const isSelected = + state.isSelected && !props.isIndeterminate && !props.isRejected; + + return ( + + + + + + + + + {props.children} + + + ); +} diff --git a/packages/react/src/ui/governance-checkbox/governance-checkbox.types.tsx b/packages/react/src/ui/governance-checkbox/governance-checkbox.types.tsx new file mode 100644 index 00000000..5cb40576 --- /dev/null +++ b/packages/react/src/ui/governance-checkbox/governance-checkbox.types.tsx @@ -0,0 +1,6 @@ +import { AriaCheckboxProps } from "react-aria"; + +export interface GovernanceCheckboxProps extends AriaCheckboxProps { + isRejected?: boolean; + children?: React.ReactNode; +} diff --git a/packages/react/src/ui/governance-checkbox/index.ts b/packages/react/src/ui/governance-checkbox/index.ts new file mode 100644 index 00000000..39a33af2 --- /dev/null +++ b/packages/react/src/ui/governance-checkbox/index.ts @@ -0,0 +1 @@ +export { default } from "./governance-checkbox"; diff --git a/packages/react/src/ui/governance-radio-group/governance-radio-group.context.tsx b/packages/react/src/ui/governance-radio-group/governance-radio-group.context.tsx new file mode 100644 index 00000000..8be75b9c --- /dev/null +++ b/packages/react/src/ui/governance-radio-group/governance-radio-group.context.tsx @@ -0,0 +1,4 @@ +import * as React from "react"; +import type { RadioGroupState } from "react-stately"; + +export const RadioContext = React.createContext(null); diff --git a/packages/react/src/ui/governance-radio-group/governance-radio-group.tsx b/packages/react/src/ui/governance-radio-group/governance-radio-group.tsx new file mode 100644 index 00000000..35a9a6b2 --- /dev/null +++ b/packages/react/src/ui/governance-radio-group/governance-radio-group.tsx @@ -0,0 +1,33 @@ +import Text from "@/ui/text"; +import { useRadioGroup, VisuallyHidden } from "react-aria"; +import { useRadioGroupState } from "react-stately"; +import { RadioContext } from "./governance-radio-group.context"; +import type { GovernanceRadioGroupProps } from "./governance-radio-group.types"; + +export default function GovernanceRadioGroup(props: GovernanceRadioGroupProps) { + const { children, label, description, errorMessage } = props; + const state = useRadioGroupState(props); + const { radioGroupProps, labelProps, descriptionProps, errorMessageProps } = + useRadioGroup(props, state); + + return ( +
    + + {label} + + + {children} + + {description && ( + + {description} + + )} + {errorMessage && state.isInvalid && ( + + {errorMessage as React.ReactNode} + + )} +
    + ); +} diff --git a/packages/react/src/ui/governance-radio-group/governance-radio-group.types.tsx b/packages/react/src/ui/governance-radio-group/governance-radio-group.types.tsx new file mode 100644 index 00000000..fe4a7916 --- /dev/null +++ b/packages/react/src/ui/governance-radio-group/governance-radio-group.types.tsx @@ -0,0 +1,5 @@ +import { AriaRadioGroupProps } from "react-aria"; + +export interface GovernanceRadioGroupProps extends AriaRadioGroupProps { + children?: React.ReactNode; +} diff --git a/packages/react/src/ui/governance-radio-group/index.ts b/packages/react/src/ui/governance-radio-group/index.ts new file mode 100644 index 00000000..6670cbc3 --- /dev/null +++ b/packages/react/src/ui/governance-radio-group/index.ts @@ -0,0 +1 @@ +export { default } from "./governance-radio-group"; diff --git a/packages/react/src/ui/governance-radio/governance-radio.css.ts b/packages/react/src/ui/governance-radio/governance-radio.css.ts new file mode 100644 index 00000000..a08c1e43 --- /dev/null +++ b/packages/react/src/ui/governance-radio/governance-radio.css.ts @@ -0,0 +1,14 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "@/styles/themes.css"; + +export const radioCircleDefault = style({ + stroke: themeVars.colors.textSecondary, +}); + +export const radioCircleSelected = style({ + stroke: themeVars.colors.text, +}); + +export const radioCircleDisabled = style({ + stroke: themeVars.colors.inputDisabledText, +}); diff --git a/packages/react/src/ui/governance-radio/governance-radio.tsx b/packages/react/src/ui/governance-radio/governance-radio.tsx new file mode 100644 index 00000000..4e9eda23 --- /dev/null +++ b/packages/react/src/ui/governance-radio/governance-radio.tsx @@ -0,0 +1,94 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "@/ui/box"; +import Text from "@/ui/text"; +import { useRadio, useFocusRing, VisuallyHidden } from "react-aria"; +import { RadioContext } from "../governance-radio-group/governance-radio-group.context"; +import * as styles from "./governance-radio.css"; +import { standardTransitionProperties } from "@/ui/shared/shared.css"; +import type { GovernanceRadioProps } from "./governance-radio.types"; + +const defaultCircle = { + cx: "8.33301", + cy: "8", + r: "7.33333", + strokeWidth: "1.33333", +}; + +const selectedCircle = { + cx: "8.66699", + cy: "8", + r: "6", + strokeWidth: "4", +}; + +export default function GovernanceRadio(props: GovernanceRadioProps) { + const { children } = props; + const state = React.useContext(RadioContext); + const ref = React.useRef(null); + const { inputProps, isSelected, isDisabled } = useRadio(props, state, ref); + const { isFocusVisible, focusProps } = useFocusRing(); + + return ( + + + + + + + + + {children} + + + ); +} diff --git a/packages/react/src/ui/governance-radio/governance-radio.types.tsx b/packages/react/src/ui/governance-radio/governance-radio.types.tsx new file mode 100644 index 00000000..070c4ad3 --- /dev/null +++ b/packages/react/src/ui/governance-radio/governance-radio.types.tsx @@ -0,0 +1,5 @@ +import { AriaRadioProps } from "react-aria"; + +export interface GovernanceRadioProps extends AriaRadioProps { + children?: React.ReactNode; +} diff --git a/packages/react/src/ui/governance-radio/index.ts b/packages/react/src/ui/governance-radio/index.ts new file mode 100644 index 00000000..0c48ab8c --- /dev/null +++ b/packages/react/src/ui/governance-radio/index.ts @@ -0,0 +1 @@ +export { default } from "./governance-radio"; diff --git a/packages/react/src/ui/governance/governance-proposal-item.tsx b/packages/react/src/ui/governance/governance-proposal-item.tsx new file mode 100644 index 00000000..1ee58d45 --- /dev/null +++ b/packages/react/src/ui/governance/governance-proposal-item.tsx @@ -0,0 +1,293 @@ +import * as React from "react"; +import Box from "../box"; +import Text from "../text"; +import Stack from "../stack"; +import Divider from "../divider"; +import Tooltip from "../tooltip"; +import type { + GovernanceProposalItemProps, + GovernanceProposalStatus, + GovernanceVoteType, +} from "./governance.types"; +import GovernanceCheckbox from "../governance-checkbox"; + +function GovernanceProposalItem(props: GovernanceProposalItemProps) { + const { + endTimeLabel = "Voting end time", + voteTypeLabels = { + yes: "Yes", + no: "No", + abstain: "Abstain", + noWithVeto: "No with veto", + }, + formatLegend = ( + voteType: GovernanceVoteType, + votes: number, + totalVotes: number + ) => { + const defaultLabels: Record = { + yes: "Yes", + no: "No", + abstain: "Abstain", + noWithVeto: "No with veto", + }; + return `${defaultLabels[voteType]} (${( + (votes / totalVotes) * + 100 + ).toFixed(2)}%)`; + }, + } = props; + function getStatusLabel() { + if (typeof props.statusLabel === "string") { + return props.statusLabel; + } + const defaultLabels: Record = { + pending: "Pending", + passed: "Passed", + rejected: "Rejected", + }; + return defaultLabels[props.status]; + } + function getVoteTypeLabel(voteKind: GovernanceVoteType) { + const vote = props.votes[voteKind]; + if (typeof formatLegend === "function") { + const total = + props.votes.abstain + + props.votes.no + + props.votes.noWithVeto + + props.votes.yes; + return formatLegend(voteKind, vote, total); + } + return voteTypeLabels[voteKind]; + } + function getWidthFor(voteKind: GovernanceVoteType) { + const total = + props.votes.abstain + + props.votes.no + + props.votes.noWithVeto + + props.votes.yes; + return `${(props.votes[voteKind] / total) * 100}%`; + } + return ( + + + + {props.status === "pending" ? ( + + {getStatusLabel()} + + ) : null} + {props.status === "passed" ? ( + + {getStatusLabel()} + + ) : null} + {props.status === "rejected" ? ( + + {getStatusLabel()} + + ) : null} + + + + + {props.status === "pending" ? ( + + {getStatusLabel()} + + ) : null} + {props.status === "passed" ? ( + + {getStatusLabel()} + + ) : null} + {props.status === "rejected" ? ( + + {getStatusLabel()} + + ) : null} + + + + {props.endTime} + + + + + + + + + {props.title} + + {props.id ? ( + + {props.id} + + ) : null} + + + + {getVoteTypeLabel("yes")} + + } + > + + + + + + {getVoteTypeLabel("abstain")} + + } + > + + + + + + {getVoteTypeLabel("no")} + + } + > + + + + + + {getVoteTypeLabel("noWithVeto")} + + } + > + + + + + + + + + {endTimeLabel} + + + {props.endTime} + + + + + ); +} + +export default GovernanceProposalItem; diff --git a/packages/react/src/ui/governance/governance-proposal-list.tsx b/packages/react/src/ui/governance/governance-proposal-list.tsx new file mode 100644 index 00000000..7a9fa268 --- /dev/null +++ b/packages/react/src/ui/governance/governance-proposal-list.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import Box from "../box"; +import Text from "../text"; +import GovernanceProposalItem from "./governance-proposal-item"; +import type { GovernanceProposalListProps } from "./governance.types"; + +function GovernanceProposalList(props: GovernanceProposalListProps) { + return ( + <> + {props.list?.map((proposalItem) => ( + + + {proposalItem.title} + + {proposalItem.proposals?.map((proposal, index) => ( + + ))} + + ))} + + ); +} + +export default GovernanceProposalList; diff --git a/packages/react/src/ui/governance/governance-result-card.tsx b/packages/react/src/ui/governance/governance-result-card.tsx new file mode 100644 index 00000000..d937e95e --- /dev/null +++ b/packages/react/src/ui/governance/governance-result-card.tsx @@ -0,0 +1,150 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import { store } from "../../models/store"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { GovernanceResultCardProps } from "./governance.types"; + +function GovernanceResultCard(props: GovernanceResultCardProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function getColors() { + const textColors: Record< + GovernanceResultCardProps["resultType"], + Sprinkles["color"] + > = { + passed: "$textSuccess", + rejected: theme === "light" ? "$textDanger" : "$red700", + info: "$text", + }; + const bgColors: Record< + GovernanceResultCardProps["resultType"], + Sprinkles["color"] + > = { + passed: "$rewardBg", + rejected: theme === "light" ? "$red100" : "$red200", + info: "$cardBg", + }; + return { + textColor: textColors[props.resultType], + bgColor: bgColors[props.resultType], + }; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + {props.resultType !== "info" ? ( + <> + {props.resultType === "passed" ? ( + + + + + ) : null} + {props.resultType === "rejected" ? ( + + + + + ) : null} + + ) : null} + {props.label} + + + {`${props.votePercentage}%`} + + + ); +} + +export default GovernanceResultCard; diff --git a/packages/react/src/ui/governance/governance-vote-breakdown.tsx b/packages/react/src/ui/governance/governance-vote-breakdown.tsx new file mode 100644 index 00000000..30ac3077 --- /dev/null +++ b/packages/react/src/ui/governance/governance-vote-breakdown.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { + GovernanceVoteBreakdownProps, + GovernanceVoteType, +} from "./governance.types"; + +function GovernanceVoteBreakdown(props: GovernanceVoteBreakdownProps) { + function getMeterColor() { + const COLORS: Record = { + yes: "$green200", + no: "#FE4A4A", + abstain: "#486A94", + noWithVeto: "#8F2828", + }; + return COLORS[props.voteType]; + } + + return ( + + + + {props.title} + + {`${props.votePercentage}%`} + + + + + + {props.description} + + + ); +} + +export default GovernanceVoteBreakdown; diff --git a/packages/react/src/ui/governance/governance-vote-form.tsx b/packages/react/src/ui/governance/governance-vote-form.tsx new file mode 100644 index 00000000..f6f65126 --- /dev/null +++ b/packages/react/src/ui/governance/governance-vote-form.tsx @@ -0,0 +1,137 @@ +import * as React from "react"; +import { useState } from "react"; +import { noop } from "lodash"; +import Box from "../box"; +import Text from "../text"; +import Stack from "../stack"; +import Button from "../button"; +import { fullWidth } from "../shared/shared.css"; +import type { + GovernanceVoteFormProps, + GovernanceVoteType, +} from "./governance.types"; +import GovernanceRadio from "../governance-radio"; +import GovernanceRadioGroup from "../governance-radio-group"; + +function GovernanceVoteForm(props: GovernanceVoteFormProps) { + const [showRadios, setShowRadios] = useState(() => false); + + const [selectedVote, setSelectedVote] = useState(() => undefined); + + function shouldShowRadios() { + if (props.status === "expired" || props.status === "voted") { + return true; + } + return showRadios; + } + + function getIsDisabled() { + return ( + props.isDisabled || props.status === "expired" || props.status === "voted" + ); + } + + function getButtonLabel() { + if (props.status === "pending" && showRadios) { + return props.confirmButtonLabels.needsConfirm; + } + return props.confirmButtonLabels[props.status]; + } + + function handleShowRadios() { + setShowRadios(true); + } + + function handleConfirm() { + if (!selectedVote) return; + props.onConfirmVote(selectedVote); + } + + function handleVoteChange(vote: GovernanceVoteType) { + setSelectedVote(vote); + } + + return ( + + + {props.timepoints?.map((timepoint) => ( + + + {timepoint.label} + + + {timepoint.timestamp} + + + ))} + + + + handleVoteChange(selected as GovernanceVoteType) + } + > + + Yes + No + No with veto + Abstain + + + + + + ); +} + +export default GovernanceVoteForm; diff --git a/packages/react/src/ui/governance/governance.types.tsx b/packages/react/src/ui/governance/governance.types.tsx new file mode 100644 index 00000000..a760198f --- /dev/null +++ b/packages/react/src/ui/governance/governance.types.tsx @@ -0,0 +1,81 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +// ==== Data +export type GovernanceProposalStatus = "pending" | "passed" | "rejected"; +export type GovernanceVoteType = "yes" | "abstain" | "no" | "noWithVeto"; +export type GovernanceVoteStructure = Record; +export type GovernanceVoteFormStatus = "pending" | "voted" | "expired"; + +export type LegendFormatter = ( + voteType: GovernanceVoteType, + votes: number, + totalVotes: number, +) => string; + +export type GovernanceProposalItem = { + status: GovernanceProposalStatus; + statusLabel?: string; + title: string | BaseComponentProps["children"]; + id?: string; + endTimeLabel?: string; + endTime: string; + votes: GovernanceVoteStructure; + voteTypeLabels?: Record; + formatLegend?: LegendFormatter; +}; + +export type GovernanceProposalListItem = { + title: string | BaseComponentProps["children"]; + proposals: Array; +}; + +export type GovernanceProposalList = Array; + +// ==== Component props +export interface GovernanceProposalItemProps + extends BaseComponentProps, + GovernanceProposalItem { + attributes?: Sprinkles; +} + +export interface GovernanceProposalListProps extends BaseComponentProps { + list: GovernanceProposalList; + voteTypeLabels?: Record; + formatLegend?: LegendFormatter; + attributes?: Sprinkles; +} + +export interface GovernanceVoteFormProps extends BaseComponentProps { + status: GovernanceVoteFormStatus; + defaultVote?: GovernanceVoteType; + timepoints: Array<{ + label: string; + timestamp: string; + }>; + radioLabels: Record; + isDisabled?: boolean; + confirmButtonLabels: { + pending: string; + needsConfirm: string; + expired: string; + voted: string; + }; + onConfirmVote: (vote: GovernanceVoteType) => void; + attributes?: Sprinkles; +} + +export interface GovernanceVoteBreakdownProps extends BaseComponentProps { + voteType: GovernanceVoteType; + title?: string; + description?: string; + votePercentage: number; + attributes?: Sprinkles; +} + +export interface GovernanceResultCardProps extends BaseComponentProps { + resultType: "passed" | "rejected" | "info"; + label: string; + votePercentage: number; + attributes?: Sprinkles; +} diff --git a/packages/react/src/ui/governance/index.ts b/packages/react/src/ui/governance/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/react/src/ui/hooks/use-color-mode-value/index.ts b/packages/react/src/ui/hooks/use-color-mode-value/index.ts new file mode 100644 index 00000000..5bbefb9f --- /dev/null +++ b/packages/react/src/ui/hooks/use-color-mode-value/index.ts @@ -0,0 +1 @@ +export { default } from "./use-color-mode-value"; diff --git a/packages/react/src/ui/hooks/use-color-mode-value/use-color-mode-value.ts b/packages/react/src/ui/hooks/use-color-mode-value/use-color-mode-value.ts new file mode 100644 index 00000000..8a8e0a51 --- /dev/null +++ b/packages/react/src/ui/hooks/use-color-mode-value/use-color-mode-value.ts @@ -0,0 +1,29 @@ +import * as React from "react"; +import useTheme from "../use-theme"; + +// TODO: fix type infer +// type SprinklesKeys = keyof Sprinkles; + +// type SprinklesValue = T extends string +// ? string +// : T extends Sprinkles[SprinklesKeys] +// ? Sprinkles[SprinklesKeys] +// : never; + +// type MaybeSprinklesValue = TValue extends `${string}` +// ? SprinklesValue +// : TValue extends UnknownRecord +// ? SprinklesValue +// : string; + +export default function useColorModeValue( + lightValue: TValue, + darkValue: TValue, +) { + const { colorMode } = useTheme(); + + return React.useMemo( + () => (colorMode === "light" ? lightValue : darkValue) as TValue, + [colorMode, lightValue, darkValue], + ); +} diff --git a/packages/react/src/ui/hooks/use-theme/index.ts b/packages/react/src/ui/hooks/use-theme/index.ts new file mode 100644 index 00000000..de9786d3 --- /dev/null +++ b/packages/react/src/ui/hooks/use-theme/index.ts @@ -0,0 +1 @@ +export { default } from "./use-theme"; diff --git a/packages/react/src/ui/hooks/use-theme/use-theme.ts b/packages/react/src/ui/hooks/use-theme/use-theme.ts new file mode 100644 index 00000000..87cefdaf --- /dev/null +++ b/packages/react/src/ui/hooks/use-theme/use-theme.ts @@ -0,0 +1,116 @@ +import * as React from "react"; + +import { ModePreference, ThemeVariant } from "@/models/system.model"; +import { store as interchainUIStore } from "@/models/store"; +import { StoreApi, useStore } from "zustand"; + +// Store helper +// More details: https://docs.pmnd.rs/zustand/guides/auto-generating-selectors#vanilla-store + +type WithSelectors = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +const createSelectors = >(_store: S) => { + const store = _store as WithSelectors; + store.use = {}; + for (const k of Object.keys(store.getState())) { + (store.use as any)[k] = () => + useStore(_store, (s) => s[k as keyof typeof s]); + } + + return store; +}; + +const useInterchainStore = createSelectors(interchainUIStore); + +type ThemeEvalMode = "force" | "normal"; + +export default function useTheme() { + const [hasHydrated, setHasHydrated] = React.useState(false); + + const theme = useInterchainStore.use.theme(); + const setThemeMode = useInterchainStore.use.setThemeMode(); + const themeClass = useInterchainStore.use.themeClass(); + const themeClasses = useInterchainStore.use.themeClasses(); + + // Rehydrate the store on page load + React.useEffect(() => { + useInterchainStore.persist.rehydrate(); + setHasHydrated(true); + }, []); + + const [themeEvalMode, setThemeEvalMode] = + React.useState("normal"); + + const setTheme = (mode: ModePreference) => { + setThemeMode(mode); + }; + + const toggleColorMode = () => { + setThemeMode(theme === "light" ? "dark" : "light"); + }; + + const themeEvalRef = React.useRef(null); + const forcedModeRef = React.useRef(null); + + function recursiveGetColorMode(node: HTMLElement | null) { + if (!node) return; + + let parent = node.parentElement; + while (parent) { + if (parent.dataset.interchainColorMode) { + return parent.dataset.interchainColorMode as ThemeVariant; + } + parent = parent.parentElement; + } + } + + function getForceThemeMode(): ThemeVariant { + if (themeEvalMode === "normal" || !themeEvalRef) return theme; + if (!!forcedModeRef.current) return forcedModeRef.current; + + // Get closest sentinel element and extract force theme value; + const forcedMode = recursiveGetColorMode(themeEvalRef.current); + + if (!forcedMode) return theme; + return forcedMode; + } + + const getThemeRef = React.useCallback((node: HTMLDivElement | null) => { + if (node == null) { + themeEvalRef.current = null; + return; + } + + // Try resolve forced theme if there is any + const forcedMode = recursiveGetColorMode(node); + + if (forcedMode === "light" || forcedMode === "dark") { + setThemeEvalMode("force"); + forcedModeRef.current = forcedMode; + } + + themeEvalRef.current = node; + }, []); + + return { + hasHydrated, + theme, + themeClass: + themeEvalMode === "force" + ? // Forced mode, resolve it ourselves + getForceThemeMode() === "light" + ? themeClasses[0] + : themeClasses[1] + : // Fallback normal behavior + themeClass, + setTheme, + // TODO: refactor to use colorMode naming instead of theme + // Aliasing for refactoring later + colorMode: themeEvalMode === "force" ? getForceThemeMode() : theme, + setColorMode: setTheme, + toggleColorMode, + getThemeRef, + }; +} diff --git a/packages/react/src/ui/i18n-provider/i18n-provider.tsx b/packages/react/src/ui/i18n-provider/i18n-provider.tsx new file mode 100644 index 00000000..fabf1377 --- /dev/null +++ b/packages/react/src/ui/i18n-provider/i18n-provider.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { useRef, useEffect } from "react"; +import BigNumber from "bignumber.js"; +import { + getCurrencyFormatter, + safelyFormatNumberWithFallback, +} from "../../helpers/number"; +import { store } from "../../models/store"; +import { I18nProviderProps } from "./i18n-provider.types"; +import { NumberFormatProps } from "../../models/system.model"; + +function I18nProvider(props: I18nProviderProps) { + const numberFormatterRef = useRef(null); + const formatNumberFnRef = useRef<(NumberFormatProps) => string>(null); + const initialCurrencyConfigRef = useRef({ + currency: props.currency, + currencySign: props.currencySign, + useGrouping: props.useGrouping, + minimumIntegerDigits: props.minimumIntegerDigits, + minimumFractionDigits: props.minimumFractionDigits, + maximumFractionDigits: props.maximumFractionDigits, + minimumSignificantDigits: props.minimumSignificantDigits, + maximumSignificantDigits: props.maximumSignificantDigits, + }); + + useEffect(() => { + numberFormatterRef.current = getCurrencyFormatter( + props.locale, + initialCurrencyConfigRef.current + ); + formatNumberFnRef.current = (subProps: NumberFormatProps): string => { + numberFormatterRef.current = getCurrencyFormatter( + props.locale, + Object.assign(initialCurrencyConfigRef.current, { + style: subProps.style, + }) + ); + return safelyFormatNumberWithFallback( + numberFormatterRef.current, + new BigNumber(subProps.value) + ); + }; + store.getState().setFormatNumberFn(formatNumberFnRef.current); + }, []); + + return
    {props.children}
    ; +} + +export default I18nProvider; diff --git a/packages/react/src/ui/i18n-provider/i18n-provider.types.tsx b/packages/react/src/ui/i18n-provider/i18n-provider.types.tsx new file mode 100644 index 00000000..e3498a51 --- /dev/null +++ b/packages/react/src/ui/i18n-provider/i18n-provider.types.tsx @@ -0,0 +1,5 @@ +import { BaseComponentProps } from "../../models/components.model"; +export interface I18nProviderProps extends Intl.NumberFormatOptions{ + locale?: string; + children: BaseComponentProps["children"] +} diff --git a/packages/react/src/ui/i18n-provider/index.ts b/packages/react/src/ui/i18n-provider/index.ts new file mode 100644 index 00000000..ef0bbecf --- /dev/null +++ b/packages/react/src/ui/i18n-provider/index.ts @@ -0,0 +1 @@ +export { default } from "./i18n-provider"; diff --git a/packages/react/src/ui/icon-button/icon-button.css.ts b/packages/react/src/ui/icon-button/icon-button.css.ts new file mode 100644 index 00000000..0ee7f6fb --- /dev/null +++ b/packages/react/src/ui/icon-button/icon-button.css.ts @@ -0,0 +1,5 @@ +import { style } from "@vanilla-extract/css"; + +export const container = style({ + padding: "0 !important", +}); diff --git a/packages/react/src/ui/icon-button/icon-button.tsx b/packages/react/src/ui/icon-button/icon-button.tsx new file mode 100644 index 00000000..0eac4249 --- /dev/null +++ b/packages/react/src/ui/icon-button/icon-button.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import clsx from "clsx"; +import Icon from "../icon"; +import Button from "../button"; +import * as styles from "./icon-button.css"; +import type { IconButtonProps } from "./icon-button.types"; + +function IconButton(props: IconButtonProps) { + function eventHandlers() { + const handlers: Record void> = {}; + const eventProps = [ + "onClick", + "onDoubleClick", + "onMouseDown", + "onMouseUp", + "onMouseEnter", + "onMouseLeave", + "onMouseMove", + "onMouseOver", + "onMouseOut", + "onKeyDown", + "onKeyUp", + "onKeyPress", + "onFocus", + "onBlur", + "onInput", + "onChange", + "onSubmit", + "onReset", + "onScroll", + "onWheel", + "onDragStart", + "onDrag", + "onDragEnd", + "onDragEnter", + "onDragLeave", + "onDragOver", + "onDrop", + "onTouchStart", + "onTouchMove", + "onTouchEnd", + "onTouchCancel", + ]; + eventProps.forEach((eventName) => { + if (props[eventName]) { + handlers[eventName] = (event: any) => props[eventName](event); + } + }); + return handlers; + } + + return ( + + ); +} + +export default IconButton; diff --git a/packages/react/src/ui/icon-button/icon-button.types.tsx b/packages/react/src/ui/icon-button/icon-button.types.tsx new file mode 100644 index 00000000..f228578a --- /dev/null +++ b/packages/react/src/ui/icon-button/icon-button.types.tsx @@ -0,0 +1,47 @@ +import { ButtonProps } from "../button/button.types"; +import type { IconProps } from "../icon/icon.types"; + +type OmittedProps = "leftIcon" | "rightIcon"; + +// Extract event handler types from ButtonProps +type ButtonEventHandlers = Pick< + ButtonProps, + | "onClick" + | "onDoubleClick" + | "onMouseDown" + | "onMouseUp" + | "onMouseEnter" + | "onMouseLeave" + | "onMouseMove" + | "onMouseOver" + | "onMouseOut" + | "onKeyDown" + | "onKeyUp" + | "onKeyPress" + | "onFocus" + | "onBlur" + | "onInput" + | "onChange" + | "onSubmit" + | "onReset" + | "onScroll" + | "onWheel" + | "onDragStart" + | "onDrag" + | "onDragEnd" + | "onDragEnter" + | "onDragLeave" + | "onDragOver" + | "onDrop" + | "onTouchStart" + | "onTouchMove" + | "onTouchEnd" + | "onTouchCancel" +>; + +interface BaseButtonProps extends Omit {} + +export interface IconButtonProps extends BaseButtonProps, ButtonEventHandlers { + isRound?: boolean; + icon: IconProps["name"]; +} diff --git a/packages/react/src/ui/icon-button/index.ts b/packages/react/src/ui/icon-button/index.ts new file mode 100644 index 00000000..f3ec9fbf --- /dev/null +++ b/packages/react/src/ui/icon-button/index.ts @@ -0,0 +1 @@ +export { default } from "./icon-button"; diff --git a/packages/react/src/ui/icon/icon.tsx b/packages/react/src/ui/icon/icon.tsx new file mode 100644 index 00000000..c1346acc --- /dev/null +++ b/packages/react/src/ui/icon/icon.tsx @@ -0,0 +1,858 @@ +import * as React from "react"; +import Box from "../box"; +import { IconProps } from "./icon.types"; + +function Icon(props: IconProps) { + function spreadAttributes() { + return { + ...props.attributes, + ...props.domAttributes, + }; + } + + return ( + + {!!props.title ? {props.title} : null} + {props.name === "chevronRight" ? ( + + + + ) : null} + {props.name === "arrowRightRounded" ? ( + + + + ) : null} + {props.name === "arrowDownload" ? ( + + + + ) : null} + {props.name === "discord" ? ( + + + + ) : null} + {props.name === "github" ? ( + + + + ) : null} + {props.name === "document" ? ( + + + + ) : null} + {props.name === "twitter" ? ( + + + + ) : null} + {props.name === "youtube" ? ( + + + + ) : null} + {props.name === "playFilled" ? ( + + + + ) : null} + {props.name === "playOutlinedThin" ? ( + + + + ) : null} + {props.name === "playOutlinedThick" ? ( + + + + ) : null} + {props.name === "previousOutlined" ? ( + + + + ) : null} + {props.name === "nextOutlined" ? ( + + + + ) : null} + {props.name === "rocket" ? ( + + + + ) : null} + {props.name === "monitor" ? ( + + + + ) : null} + {props.name === "lightning" ? ( + + + + ) : null} + {props.name === "truck" ? ( + + + + ) : null} + {props.name === "walletFilled" ? ( + + + + ) : null} + {props.name === "closeFilled" ? ( + + + + ) : null} + {props.name === "close" ? ( + + + + ) : null} + {props.name === "verticalMore" ? ( + + + + ) : null} + {props.name === "chromeBrowser" ? ( + + + + + + + ) : null} + {props.name === "copy" ? ( + + + + + ) : null} + {props.name === "checkboxCircle" ? ( + + + + ) : null} + {props.name === "arrowDownS" ? ( + + + + ) : null} + {props.name === "arrowUpS" ? ( + + + + ) : null} + {props.name === "add" ? ( + + + + ) : null} + {props.name === "subtract" ? ( + + + + ) : null} + {props.name === "mobileWallet" ? ( + + + + + + + + + + + ) : null} + {props.name === "mobileWalletCircle" ? ( + + + + + + + + + + ) : null} + {props.name === "restart" ? ( + + + + ) : null} + {props.name === "restartCircle" ? ( + + + + ) : null} + {props.name === "arrowLeftSLine" ? ( + + + + ) : null} + {props.name === "arrowRightLine" ? ( + + + + ) : null} + {props.name === "timeLine" ? ( + + + + ) : null} + {props.name === "jaggedCheck" ? ( + + + + ) : null} + {props.name === "priceTagLine" ? ( + + + + ) : null} + {props.name === "sendLine" ? ( + + + + ) : null} + {props.name === "fireLine" ? ( + + + + ) : null} + {props.name === "uploadLine" ? ( + + + + ) : null} + {props.name === "coinsLine" ? ( + + + + ) : null} + {props.name === "shoppingBagLine" ? ( + + + + ) : null} + {props.name === "informationLine" ? ( + + + + ) : null} + {props.name === "arrowDownLine" ? ( + + + + ) : null} + {props.name === "arrowUpDownLine" ? ( + + + + ) : null} + {props.name === "arrowLeftRightLine" ? ( + + + + ) : null} + {props.name === "settingFill" ? ( + + + + ) : null} + {props.name === "moonLine" ? ( + + + + ) : null} + {props.name === "sunLine" ? ( + + + + ) : null} + {props.name === "arrowDropDown" ? ( + + + + ) : null} + {props.name === "loaderLine" ? ( + + + + ) : null} + {props.name === "lock" ? ( + + + + ) : null} + {props.name === "bardFill" ? ( + + + + ) : null} + {props.name === "link" ? ( + + + + + + + ) : null} + {props.name === "errorWarningLine" ? ( + + + + ) : null} + {props.name === "errorWarningFill" ? ( + + + + ) : null} + {props.name === "checkLine" ? ( + + + + ) : null} + {props.name === "checkFill" ? ( + + + + ) : null} + {props.name === "informationLine" ? ( + + + + ) : null} + {props.name === "informationFill" ? ( + + + + ) : null} + {props.name === "loaderFill" ? ( + + + + ) : null} + {props.name === "pencilLine" ? ( + + + + ) : null} + {props.name === "astronaut" ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : null} + {props.name === "stargazePixel" ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : null} + {props.name === "externalLinkLine" ? ( + + + + ) : null} + {props.name === "timeLine" ? ( + + + + ) : null} + {props.name === "plusRound" ? ( + + + + ) : null} + {props.name === "minusRound" ? ( + + + + ) : null} + {props.name === "xCircle" ? ( + + + + ) : null} + {props.name === "magnifier" ? ( + + + + ) : null} + + + ); +} + +export default Icon; diff --git a/packages/react/src/ui/icon/icon.types.tsx b/packages/react/src/ui/icon/icon.types.tsx new file mode 100644 index 00000000..848f20a3 --- /dev/null +++ b/packages/react/src/ui/icon/icon.types.tsx @@ -0,0 +1,85 @@ +import { BaseComponentProps } from "../../models/components.model"; +import { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export const ALL_ICON_NAMES = [ + "walletFilled", + "chevronRight", + "closeFilled", + "close", + "copy", + "checkboxCircle", + "chromeBrowser", + "verticalMore", + "arrowDownS", + "arrowUpS", + "mobileWallet", + "mobileWalletCircle", + "restart", + "restartCircle", + "arrowLeftSLine", + "add", + "subtract", + "arrowRightLine", + "jaggedCheck", + "priceTagLine", + "sendLine", + "fireLine", + "uploadLine", + "coinsLine", + "shoppingBagLine", + "informationLine", + "arrowDownLine", + "arrowUpDownLine", + "arrowLeftRightLine", + "moonLine", + "sunLine", + "arrowDropDown", + "settingFill", + "loaderLine", + "errorWarningLine", + "errorWarningFill", + "checkLine", + "checkFill", + "informationLine", + "informationFill", + "loaderFill", + "lock", + "bardFill", + "pencilLine", + "arrowRightRounded", + "discord", + "github", + "document", + "twitter", + "youtube", + "astronaut", + "stargazePixel", + "link", + "playFilled", + "rocket", + "monitor", + "lightning", + "truck", + "playOutlinedThin", + "playOutlinedThick", + "previousOutlined", + "nextOutlined", + "externalLinkLine", + "timeLine", + "arrowDownload", + "plusRound", + "minusRound", + "xCircle", + "magnifier", +] as const; + +export type IconName = (typeof ALL_ICON_NAMES)[number]; + +export interface IconProps extends BaseComponentProps { + name: IconName; + title?: string; + size?: Sprinkles["fontSize"]; + color?: Sprinkles["color"]; + attributes?: Sprinkles; + domAttributes?: any; +} diff --git a/packages/react/src/ui/icon/index.ts b/packages/react/src/ui/icon/index.ts new file mode 100644 index 00000000..d6319ee6 --- /dev/null +++ b/packages/react/src/ui/icon/index.ts @@ -0,0 +1 @@ +export { default } from "./icon"; diff --git a/packages/react/src/ui/interchain-ui-provider/index.ts b/packages/react/src/ui/interchain-ui-provider/index.ts new file mode 100644 index 00000000..c7992097 --- /dev/null +++ b/packages/react/src/ui/interchain-ui-provider/index.ts @@ -0,0 +1 @@ +export { default } from "./interchain-ui-provider"; diff --git a/packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.tsx b/packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.tsx new file mode 100644 index 00000000..be805141 --- /dev/null +++ b/packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import ThemeProvider from "../theme-provider"; +import Toaster from "../toast/toaster"; +import type { InterchainUIProviderProps } from "./interchain-ui-provider.types"; + +function InterchainUIProvider(props: InterchainUIProviderProps) { + return ( + + {props.children} + + + ); +} + +export default InterchainUIProvider; diff --git a/packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.types.tsx b/packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.types.tsx new file mode 100644 index 00000000..23af93ed --- /dev/null +++ b/packages/react/src/ui/interchain-ui-provider/interchain-ui-provider.types.tsx @@ -0,0 +1,9 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { ThemeProviderProps } from "../theme-provider/theme-provider.types"; +import type { ToasterProps } from "../toast/toast.types"; + +export interface InterchainUIProviderProps { + themeOptions?: ThemeProviderProps; + toastOptions?: ToasterProps; + children?: BaseComponentProps["children"]; +} diff --git a/packages/react/src/ui/link/index.ts b/packages/react/src/ui/link/index.ts new file mode 100644 index 00000000..e72867bf --- /dev/null +++ b/packages/react/src/ui/link/index.ts @@ -0,0 +1 @@ +export { default } from "./link"; diff --git a/packages/react/src/ui/link/link.tsx b/packages/react/src/ui/link/link.tsx new file mode 100644 index 00000000..20cd288b --- /dev/null +++ b/packages/react/src/ui/link/link.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "../box"; +import type { LinkProps } from "./link.types"; + +function Link(props: LinkProps) { + const { as = "a", underline = true, rel = "noopener noreferrer" } = props; + return ( + + {props.children} + + ); +} + +export default Link; diff --git a/packages/react/src/ui/link/link.types.tsx b/packages/react/src/ui/link/link.types.tsx new file mode 100644 index 00000000..0a647d98 --- /dev/null +++ b/packages/react/src/ui/link/link.types.tsx @@ -0,0 +1,15 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +export interface LinkProps extends Omit { + as?: any; + href: string; + target?: string; + rel?: string; + underline?: boolean; + background?: boolean; + color?: Sprinkles["color"]; + className?: string; + children?: React.ReactNode; + attributes?: Sprinkles; +} diff --git a/packages/react/src/ui/liquid-staking/index.ts b/packages/react/src/ui/liquid-staking/index.ts new file mode 100644 index 00000000..73833542 --- /dev/null +++ b/packages/react/src/ui/liquid-staking/index.ts @@ -0,0 +1 @@ +export { default } from "./liquid-staking"; diff --git a/packages/react/src/ui/liquid-staking/liquid-staking.css.ts b/packages/react/src/ui/liquid-staking/liquid-staking.css.ts new file mode 100644 index 00000000..91d2ed60 --- /dev/null +++ b/packages/react/src/ui/liquid-staking/liquid-staking.css.ts @@ -0,0 +1,118 @@ +import { + style, + styleVariants, + createVar, + createContainer, + keyframes, +} from "@vanilla-extract/css"; +import { baseButton } from "../button/button.css"; +import { themeVars } from "../../styles/themes.css"; + +const EXPANDED_HEIGHT_PX = `458px`; +const CONTRACTED_HEIGHT_PX = `36px`; +export const liqStakingRootContainer = createContainer(); + +const textButtonBgVar = createVar(); +const textButtonColorVar = createVar(); + +export const root = style({ + containerName: liqStakingRootContainer, +}); + +const expandVertical = keyframes({ + "0%": { opacity: "0", height: CONTRACTED_HEIGHT_PX }, + "100%": { opacity: "1", height: EXPANDED_HEIGHT_PX }, +}); + +const expandVerticalReverse = keyframes({ + "0%": { height: EXPANDED_HEIGHT_PX }, + "100%": { height: CONTRACTED_HEIGHT_PX }, +}); + +export const accordionPanel = styleVariants({ + init: [ + { + position: "relative", + height: CONTRACTED_HEIGHT_PX, + overflow: "hidden", + }, + ], + expanded: [ + { + position: "relative", + height: EXPANDED_HEIGHT_PX, + overflow: "auto", + animation: `${expandVertical} 450ms cubic-bezier(0.22, 1, 0.36, 1)`, + }, + ], + contracted: [ + { + position: "relative", + height: CONTRACTED_HEIGHT_PX, + opacity: 1, + overflow: "hidden", + animation: `${expandVerticalReverse} 600ms cubic-bezier(0.22, 1, 0.36, 1)`, + }, + ], +}); + +const headerButtonBase = style({ + color: `${textButtonColorVar} !important`, + backgroundColor: `${textButtonBgVar} !important`, + borderRadius: themeVars.radii.base, + selectors: { + "&:hover": { + opacity: 0.89, + }, + }, +}); + +export const headerButton = styleVariants({ + light: [ + baseButton, + headerButtonBase, + style({ + vars: { + [textButtonColorVar]: themeVars.colors.white, + [textButtonBgVar]: themeVars.colors.textPlaceholder, + }, + }), + ], + dark: [ + baseButton, + headerButtonBase, + style({ + vars: { + [textButtonColorVar]: themeVars.colors.text, + [textButtonBgVar]: themeVars.colors.blackSecondary, + }, + }), + ], +}); + +export const numberInputBase = style({ + fontWeight: themeVars.fontWeight.semibold, + textAlign: "right", + height: themeVars.space["11"], + width: "100%", + paddingRight: "0 !important", + paddingLeft: "0 !important", +}); + +export const numberInputMd = style([ + numberInputBase, + { + fontSize: themeVars.fontSize["xl"], + }, +]); + +export const numberInputSm = style([ + numberInputBase, + { + fontSize: themeVars.fontSize["lg"], + }, +]); + +export const resetNumberInputBg = style({ + background: "transparent !important", +}); diff --git a/packages/react/src/ui/liquid-staking/liquid-staking.tsx b/packages/react/src/ui/liquid-staking/liquid-staking.tsx new file mode 100644 index 00000000..511ff977 --- /dev/null +++ b/packages/react/src/ui/liquid-staking/liquid-staking.tsx @@ -0,0 +1,641 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import { formatNumeric } from "../../helpers/number"; +import Text from "../text"; +import Box from "../box"; +import Button from "../button"; +import Icon from "../icon"; +import IconButton from "../icon-button"; +import Stack from "../stack"; +import Divider from "../divider"; +import Bignumber from "bignumber.js"; +import { toNumber } from "../../helpers/number"; +import * as styles from "./liquid-staking.css"; +import { scrollBar } from "../shared/shared.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { + LiquidStakingProps, + LiquidStakingToken, +} from "./liquid-staking.types"; +import NumberField from "../number-field"; + +function LiquidStaking(props: LiquidStakingProps) { + const { + decimals = 6, + rewardLabel = "What you'll get", + accordionLabel = "Learn more", + submitButtonLabel = "Liquid Stake", + stakeLabel = "Select amount", + footerLabel = "Powered by Cosmology", + halfButtonLabel = "Half", + maxButtonLabel = "Max", + availableLabel = "Available", + } = props; + const cleanupRef = useRef<() => void>(null); + const scrollRef = useRef(null); + const rootRef = useRef(null); + const resizeObserver = useRef(null); + const [theme, setTheme] = useState(() => "light"); + const [isMounted, setIsMounted] = useState(() => false); + const [isDirty, setIsDirty] = useState(() => false); + const [scrollOffset, setScrollOffset] = useState(() => 0); + const [expanded, setExpanded] = useState(() => false); + const [stakeToken, setStakeToken] = useState(() => null); + const [stakeAmount, setStakeAmount] = useState(() => 0); + const [rewardAmount, setRewardAmount] = useState(() => 0); + const [width, setWidth] = useState(() => 0); + function handleToggleExpand() { + if (!isDirty) { + setIsDirty(true); + } + if (expanded) { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + setExpanded(false); + } else { + setExpanded(true); + } + } + function handleStakeAmountChange(amount: number) { + setStakeAmount(amount); + props?.onChange?.(amount); + } + function handleStakeHalf() { + if (typeof props.onHalf === "function") { + return props.onHalf(); + } + const result = new Bignumber(props.stakeToken.available ?? 0) + .dividedBy(2) + .toNumber(); + props.onChange?.(result); + } + function handleStakeMax() { + if (typeof props.onMax === "function") { + return props.onMax(); + } + const result = new Bignumber(props.stakeToken.available ?? 0).toNumber(); + props.onChange?.(result); + } + function isAccordionVisible() { + return ( + Array.isArray(props.descriptionList) && props.descriptionList?.length > 0 + ); + } + function isSmallSize() { + return width < 326; + } + useEffect(() => { + setTheme(store.getState().theme); + setIsMounted(true); + function handleScroll(_event: Event) { + setScrollOffset(scrollRef.current.scrollTop); + } + scrollRef.current.addEventListener("scroll", handleScroll); + resizeObserver.current = new ResizeObserver((entries) => { + const rootWidth = entries[0]?.borderBoxSize[0]?.inlineSize ?? 0; + setWidth(rootWidth); + }); + resizeObserver.current.observe(rootRef.current, { box: "border-box" }); + const cleanupStore = store.subscribe((newState) => { + setTheme(newState.theme); + }); + cleanupRef.current = () => { + cleanupStore(); + if (rootRef.current instanceof Element) { + resizeObserver.current.unobserve(rootRef.current); + } + if (scrollRef.current) { + scrollRef.current.removeEventListener("scroll", handleScroll); + } + }; // Controlled prop + if (props.stakeToken) { + setStakeToken(props.stakeToken); + } + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + + + + {stakeLabel} + + + + + {availableLabel} + + + {formatNumeric( + props.stakeToken.available ?? 0, + props.precision + )} + + + + handleStakeHalf() }} + className={styles.headerButton[theme]} + > + + {halfButtonLabel} + + + handleStakeMax() }} + className={styles.headerButton[theme]} + > + + {maxButtonLabel} + + + + + + + + + + + + + {props.stakeToken.symbol} + + + {props.stakeToken.name} + + + + handleStakeAmountChange(value)} + onBlur={(event) => { + const target = event.target as HTMLInputElement; + props.onBlur?.(Number(target.value)); + }} + formatOptions={{ + minimumFractionDigits: 0, + maximumFractionDigits: props.precision, + }} + inputClassName={clx( + styles.resetNumberInputBg, + isSmallSize() ? styles.numberInputSm : styles.numberInputMd + )} + /> + + $ + {formatNumeric( + props.stakeToken.priceDisplayAmount, + props.precision + )} + + + + + + + + + {rewardLabel} + + + + + + + + + {props.reward.symbol} + + + {props.reward.name} + + + + + {formatNumeric(props.reward.rewardAmount, props.precision)} + + + $ + {formatNumeric( + props.reward.priceDisplayAmount, + props.precision + )} + + + + + + + + + + + 0 ? "100%" : "$fit", + marginLeft: "auto", + }} + domAttributes={{ "data-part-id": "accordion-button" }} + > + {!isSmallSize() ? ( + + {accordionLabel} + + ) : null} + {typeof props.renderAccordionButton === "function" ? ( + <> + {props.renderAccordionButton({ + expanded: expanded, + onClick: () => { + handleToggleExpand(); + }, + })} + + ) : null} + {typeof props.renderAccordionButton !== "function" ? ( + handleToggleExpand()} + /> + ) : null} + 0 + ? theme === "light" + ? "$inputBg" + : "$blackPrimary" + : "transparent" + } + boxShadow={ + scrollOffset > 0 + ? ` + 0px 3.8px 4.6px -5px rgba(0, 0, 0, 0.016), + 0px 10px 11.6px -5px rgba(0, 0, 0, 0.022), + 0px 21.4px 23.6px -5px rgba(0, 0, 0, 0.028), + 0px 47.8px 48.5px -5px rgba(0, 0, 0, 0.034), + 0px 200px 133px -5px rgba(0, 0, 0, 0.05) + ` + : "none" + } + /> + + +
    + + {props.descriptionList?.map((listItem) => ( + + + + {listItem.title} + + + {listItem.subtitle} + + + + {listItem.desc} + + + ))} + {props.bottomLink ? ( + + + + + {props.bottomLink.label} + + + + + + ) : null} + +
    +
    + + + {typeof props.renderSubmitButton === "function" ? ( + <>{props.renderSubmitButton()} + ) : null} + {typeof props.renderSubmitButton !== "function" ? ( + + ) : null} + + {footerLabel} + + + +
    + ); +} +export default LiquidStaking; diff --git a/packages/react/src/ui/liquid-staking/liquid-staking.types.tsx b/packages/react/src/ui/liquid-staking/liquid-staking.types.tsx new file mode 100644 index 00000000..b917a61b --- /dev/null +++ b/packages/react/src/ui/liquid-staking/liquid-staking.types.tsx @@ -0,0 +1,58 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { ButtonProps } from "../button/button.types"; + +export type LiquidStakingToken = AvailableItem; + +export interface LiquidStakingReward extends AvailableItem { + rewardAmount: number; +} + +type BottomLink = { + href: string; + label: string; +}; + +export type RewardDescriptionItem = { + title: string; + subtitle: string; + desc: string; +}; + +export interface LiquidStakingProps extends BaseComponentProps { + stakeToken?: AvailableItem | null; + stakeAmount: number; + reward: LiquidStakingReward; + bottomLink?: BottomLink; + decimals?: number; + descriptionList?: Array; + isSubmitDisabled?: boolean; + onChange?: (stakeAmount: number) => void; + onBlur?: (stakeAmount: number) => void; + onFocus?: () => void; + onSubmit: (event?: any) => void; + // Main button + submitButtonLabel?: string; + submitButtonProps?: ButtonProps; + // Half and max buttons + halfButtonLabel?: string; + maxButtonLabel?: string; + onHalf?: () => void; + onMax?: () => void; + // ==== Labels + timeEstimateLabel: string; + rewardLabel?: string; + stakeLabel?: string; + availableLabel?: string; + accordionLabel?: BaseComponentProps["children"]; + footerLabel?: BaseComponentProps["children"]; + // ==== Custom elements + renderSubmitButton?: (props?: any) => BaseComponentProps["children"]; + renderAccordionButton?: (props?: any) => BaseComponentProps["children"]; + // ==== Number format props + precision?: number; + // ==== Box props + attributes?: Sprinkles; + domAttributes?: any; +} diff --git a/packages/react/src/ui/list-for-sale/index.ts b/packages/react/src/ui/list-for-sale/index.ts new file mode 100644 index 00000000..2616974e --- /dev/null +++ b/packages/react/src/ui/list-for-sale/index.ts @@ -0,0 +1 @@ +export { default } from "./list-for-sale"; diff --git a/packages/react/src/ui/list-for-sale/list-for-sale.css.ts b/packages/react/src/ui/list-for-sale/list-for-sale.css.ts new file mode 100644 index 00000000..c9ed5bc9 --- /dev/null +++ b/packages/react/src/ui/list-for-sale/list-for-sale.css.ts @@ -0,0 +1,5 @@ +import { style } from "@vanilla-extract/css"; + +export const container = style({ + width: "472px", +}); diff --git a/packages/react/src/ui/list-for-sale/list-for-sale.tsx b/packages/react/src/ui/list-for-sale/list-for-sale.tsx new file mode 100644 index 00000000..44ba2a7d --- /dev/null +++ b/packages/react/src/ui/list-for-sale/list-for-sale.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { useState } from "react"; +import Tabs from "../tabs"; +import Box from "../box"; +import NftFixedPrice from "../nft-fixed-price"; +import NftAuction from "../nft-auction"; +import NftSellNow from "../nft-sell-now"; +import * as styles from "./list-for-sale.css"; +import type { TabProps } from "../tabs/tabs.types"; + +function ListForSale(props: any) { + const [tabs, setTabs] = useState(() => [ + { + label: "Fixed Price", + content: , + }, + { + label: "Auction", + content: , + }, + { + label: "Sell Now", + content: ( + + ), + }, + ]); + + return ( + + + + + + ); +} + +export default ListForSale; diff --git a/packages/react/src/ui/list-item/index.ts b/packages/react/src/ui/list-item/index.ts new file mode 100644 index 00000000..7aa90a36 --- /dev/null +++ b/packages/react/src/ui/list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./list-item"; diff --git a/packages/react/src/ui/list-item/list-item.css.ts b/packages/react/src/ui/list-item/list-item.css.ts new file mode 100644 index 00000000..107d0ab3 --- /dev/null +++ b/packages/react/src/ui/list-item/list-item.css.ts @@ -0,0 +1,60 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; +import { baseTextStyles } from "../text/text.css"; + +export const listItemBase = style([ + baseTextStyles, + { + listStyle: "none", + cursor: "pointer", + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", + color: themeVars.colors.text, + borderRadius: themeVars.radii.base, + boxSizing: "border-box", + backgroundColor: themeVars.colors.menuItemBg, + selectors: { + "&:hover": { + backgroundColor: themeVars.colors.menuItemBgHovered, + }, + '&[data-is-active="true"]': { + backgroundColor: themeVars.colors.background, + }, + '&[data-is-active="true"]:hover': { + backgroundColor: themeVars.colors.menuItemBgActive, + }, + '&[data-is-selected="true"]': { + backgroundColor: themeVars.colors.menuItemBgSelected, + }, + '&[data-is-disabled="true"]': { + cursor: "not-allowed", + color: themeVars.colors.textMuted, + }, + '&[data-is-disabled="true"]:hover': { + backgroundColor: `${themeVars.colors.menuItemBg} !important`, + }, + }, + }, +]); + +export const listItemSizes = styleVariants({ + md: [ + style({ + height: themeVars.space[17], + paddingTop: themeVars.space[4], + paddingBottom: themeVars.space[4], + paddingLeft: themeVars.space[6], + paddingRight: themeVars.space[6], + }), + ], + sm: [ + style({ + height: themeVars.space[14], + paddingTop: themeVars.space[4], + paddingBottom: themeVars.space[4], + paddingLeft: themeVars.space[6], + paddingRight: themeVars.space[6], + }), + ], +}); diff --git a/packages/react/src/ui/list-item/list-item.tsx b/packages/react/src/ui/list-item/list-item.tsx new file mode 100644 index 00000000..2477e72a --- /dev/null +++ b/packages/react/src/ui/list-item/list-item.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { useState, useRef, forwardRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import { listItemBase, listItemSizes } from "./list-item.css"; +import type { ListItemProps } from "./list-item.types"; +import type { ThemeVariant } from "../../models/system.model"; + +const ComboboxItem = forwardRef(function ComboboxItem( + props: ListItemProps, + itemRef +) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    + {props.children} +
    + ); +}); + +export default ComboboxItem; diff --git a/packages/react/src/ui/list-item/list-item.types.tsx b/packages/react/src/ui/list-item/list-item.types.tsx new file mode 100644 index 00000000..c1a7eb04 --- /dev/null +++ b/packages/react/src/ui/list-item/list-item.types.tsx @@ -0,0 +1,12 @@ +import { BaseComponentProps } from "../../models/components.model"; +import { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export interface ListItemProps extends BaseComponentProps { + isSelected?: boolean; + isActive?: boolean; + isDisabled?: boolean; + size?: "sm" | "md"; + attributes?: any; + sprinkles?: Sprinkles; + itemRef?: any; +} diff --git a/packages/react/src/ui/manage-liquidity-card/index.ts b/packages/react/src/ui/manage-liquidity-card/index.ts new file mode 100644 index 00000000..f1d4feaf --- /dev/null +++ b/packages/react/src/ui/manage-liquidity-card/index.ts @@ -0,0 +1 @@ +export { default } from "./manage-liquidity-card"; diff --git a/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.css.ts b/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.css.ts new file mode 100644 index 00000000..1cb634dd --- /dev/null +++ b/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.css.ts @@ -0,0 +1,34 @@ +import { style } from "@vanilla-extract/css"; +import { breakpoints } from "../../styles/tokens"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + backgroundColor: themeVars.colors.cardBg, + paddingTop: themeVars.space[9], + paddingRight: themeVars.space[10], + paddingBottom: themeVars.space[10], + paddingLeft: themeVars.space[9], + flexWrap: "nowrap", + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + padding: themeVars.space[9], + flexWrap: "wrap", + }, + }, +}); + +export const image = style({ + width: themeVars.space[8], + height: themeVars.space[8], +}); + +export const tokenContainer = style({ + marginLeft: "120px", + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + marginLeft: "0", + width: "100%", + marginTop: themeVars.space[11], + }, + }, +}); diff --git a/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.tsx b/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.tsx new file mode 100644 index 00000000..a624ec61 --- /dev/null +++ b/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.tsx @@ -0,0 +1,267 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import BigNumber from "bignumber.js"; +import Box from "../box"; +import Stack from "../stack"; +import Button from "../button"; +import Text from "../text"; +import { store } from "../../models/store"; +import type { ThemeVariant } from "../../models/system.model"; +import * as styles from "./manage-liquidity-card.css"; +import { ManageLiquidityCardProps } from "./manage-liquidity-card.types"; + +function ManageLiquidityCard(props: ManageLiquidityCardProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function hasTotalShares() { + return new BigNumber(props.totalShares || 0).gt(0); + } + + function hasLPTokenShares() { + return new BigNumber(props.lpTokenShares || 0).gt(0); + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + Your pool balance + + + + + $ + + + {store.getState().formatNumber({ + value: props.totalBalance || 0, + })} + + + + + + {new BigNumber(props.totalBalanceCoins[0]?.displayAmount || 0) + .decimalPlaces(4) + .toString()} + + + {props.totalBalanceCoins[0]?.symbol} + + + + + + {hasTotalShares() ? ( + {`${new BigNumber(props.totalShares) + .decimalPlaces(6) + .toString()} pool shares`} + ) : null} + {!hasTotalShares() ? No pool shares yet : null} + + + + + {new BigNumber(props.totalBalanceCoins[1]?.displayAmount || 0) + .decimalPlaces(4) + .toString()} + + + {props.totalBalanceCoins[1]?.symbol} + + + + + + + + + + + + + + + Available LP Tokens + + + + $ + + + {store.getState().formatNumber({ + value: props.lpTokenBalance || 0, + })} + + + {hasLPTokenShares() ? ( + {`${new BigNumber(props.lpTokenShares) + .decimalPlaces(6) + .toString()} pool shares`} + ) : null} + {!hasLPTokenShares() ? No pool shares yet : null} + + + + + + ); +} + +export default ManageLiquidityCard; diff --git a/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.types.tsx b/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.types.tsx new file mode 100644 index 00000000..836ffef3 --- /dev/null +++ b/packages/react/src/ui/manage-liquidity-card/manage-liquidity-card.types.tsx @@ -0,0 +1,15 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import { Coin, PoolDetailProps } from "../pool-list-item/pool-list-item.types"; + +export interface ManageLiquidityCardProps extends BaseComponentProps { + totalBalanceCoins: Coin[]; + totalBalance: PoolDetailProps["totalBalance"]; + totalShares: PoolDetailProps["totalShares"]; + lpTokenBalance: PoolDetailProps["lpTokenBalance"]; + lpTokenShares: PoolDetailProps["lpTokenShares"]; + isEarningLoading?: boolean; + onStartEarning: (event?: any) => void; + onAdd: (event?: any) => void; + onRemove: (event?: any) => void; + attributes?: any; +} diff --git a/packages/react/src/ui/mesh-modal/index.ts b/packages/react/src/ui/mesh-modal/index.ts new file mode 100644 index 00000000..d943495c --- /dev/null +++ b/packages/react/src/ui/mesh-modal/index.ts @@ -0,0 +1 @@ +export { default } from "./mesh-modal"; diff --git a/packages/react/src/ui/mesh-modal/mesh-modal.css.ts b/packages/react/src/ui/mesh-modal/mesh-modal.css.ts new file mode 100644 index 00000000..df080cb8 --- /dev/null +++ b/packages/react/src/ui/mesh-modal/mesh-modal.css.ts @@ -0,0 +1,89 @@ +import { style, createVar, styleVariants } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; +// import { themeLayer } from "../../styles/layers.css"; + +export const connectModalShadowVar = createVar(); +export const connectModalBgVar = createVar(); + +const modalContentBase = style([ + style({ + boxShadow: connectModalShadowVar, + backgroundColor: connectModalBgVar, + maxHeight: "100%", + overflow: "auto !important", + display: "flex", + flexDirection: "column", + height: "auto", + borderRadius: themeVars.radii["xl"], + borderWidth: "1px", + borderStyle: "solid", + borderColor: themeVars.colors.divider, + selectors: { + "&::-webkit-scrollbar": { + display: "none", + }, + }, + }), +]); + +export const modalContent = style([ + modalContentBase, + { + vars: { + [connectModalBgVar]: themeVars.colors.cardBg, + [connectModalShadowVar]: + "rgba(0, 0, 0, 0.1) 0px 0px 0px 1px,rgba(0, 0, 0, 0.2) 0px 5px 10px,rgba(0, 0, 0, 0.4) 0px 15px 40px", + }, + }, +]); + +export const modalBackdropBg = styleVariants({ + light: { + position: `fixed`, + top: 0, + left: 0, + bottom: 0, + right: 0, + width: `100%`, + height: `100%`, + opacity: 0.99, + backgroundColor: themeVars.colors.gray600, + backdropFilter: "blur(20px) opacity(20%)", + }, + dark: { + position: `fixed`, + top: 0, + left: 0, + bottom: 0, + right: 0, + width: `100%`, + height: `100%`, + opacity: 0.99, + backgroundColor: "rgba(17, 17, 19, 0.8)", + backdropFilter: "blur(20px) opacity(20%)", + }, +}); + +export const modalChildren = style([ + { + minWidth: `min(calc(100dvw - 2 * ${themeVars.space["11"]}), ${themeVars.space.containerSm})`, + paddingLeft: themeVars.space["11"], + paddingRight: themeVars.space["11"], + paddingBottom: themeVars.space["12"], + }, +]); + +export const modalHeader = style({ + position: "relative", + paddingTop: themeVars.space["14"], + paddingLeft: themeVars.space["14"], + paddingRight: themeVars.space["14"], + paddingBottom: "0", +}); + +export const modalCloseButton = style({ + position: "absolute", + top: themeVars.space["14"], + right: themeVars.space["14"], + padding: "0", +}); diff --git a/packages/react/src/ui/mesh-modal/mesh-modal.tsx b/packages/react/src/ui/mesh-modal/mesh-modal.tsx new file mode 100644 index 00000000..fe7f6c19 --- /dev/null +++ b/packages/react/src/ui/mesh-modal/mesh-modal.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import autoAnimate from "@formkit/auto-animate"; +import { store } from "../../models/store"; +import Stack from "../stack"; +import Text from "../text"; +import Icon from "../icon"; +import MeshButton from "../mesh-staking/mesh-button"; +import { + modalHeader, + modalContent, + modalChildren, + modalCloseButton, + modalBackdropBg, +} from "./mesh-modal.css"; +import { + meshDarkThemeClass, + meshLightThemeClass, +} from "../../styles/themes.css"; +import type { MeshModalProps } from "./mesh-modal.types"; +import Modal from "../modal"; + +function MeshModal(props: MeshModalProps) { + const { closeOnClickaway = false } = props; + const cleanupRef = useRef<(() => void) | null>(null); + const parentRef = useRef(null); + const [theme, setTheme] = useState(() => "light"); + function isControlled() { + return props.themeMode != null; + } + function modalThemeMode() { + if (isControlled()) return props.themeMode; + return theme; + } + useEffect(() => { + // Controlled theme mode + if (isControlled()) return; + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + if (parentRef.current) { + autoAnimate(parentRef.current); + } + }, [parentRef.current]); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + props.onOpen?.()} + onClose={(event) => props.onClose?.()} + preventScroll + renderTrigger={props.renderTrigger} + themeClassName={ + modalThemeMode() === "dark" ? meshDarkThemeClass : meshLightThemeClass + } + backdropClassName={ + modalThemeMode() === "light" + ? modalBackdropBg.light + : modalBackdropBg.dark + } + contentClassName={clsx(modalContent, props?.modalContentClassName)} + childrenClassName={clsx( + props.modalChildrenClassName ? null : modalChildren + )} + className={props.modalContainerClassName} + header={ + + {props.title && typeof props.title === "string" ? ( + + {props?.title} + + ) : null} + {props.title && typeof props.title !== "string" ? ( + <>{props.title} + ) : null} + {typeof props.renderCloseButton === "function" ? ( + <>{props.renderCloseButton({ onClose: props.onClose })} + ) : ( + { + props.onClose?.(e); + }} + className={modalCloseButton} + > + + + )} + + } + > +
    {props.children}
    +
    + ); +} + +export default MeshModal; diff --git a/packages/react/src/ui/mesh-modal/mesh-modal.types.tsx b/packages/react/src/ui/mesh-modal/mesh-modal.types.tsx new file mode 100644 index 00000000..c67f6214 --- /dev/null +++ b/packages/react/src/ui/mesh-modal/mesh-modal.types.tsx @@ -0,0 +1,6 @@ +import type { BasicModalProps } from "../basic-modal/basic-modal.types"; +import type { ThemeProviderProps } from "../theme-provider/theme-provider.types"; + +export interface MeshModalProps extends BasicModalProps { + themeMode?: ThemeProviderProps["themeMode"]; +} diff --git a/packages/react/src/ui/mesh-staking/index.ts b/packages/react/src/ui/mesh-staking/index.ts new file mode 100644 index 00000000..ecc06d49 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/index.ts @@ -0,0 +1 @@ +export { default } from "./mesh-provider"; diff --git a/packages/react/src/ui/mesh-staking/mesh-button.tsx b/packages/react/src/ui/mesh-staking/mesh-button.tsx new file mode 100644 index 00000000..b5973a07 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-button.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import Box from "../box"; +import clx from "clsx"; +import { baseButton } from "../button/button.css"; +import type { MeshButtonProps } from "./mesh-staking.types"; + +function MeshButton(props: MeshButtonProps) { + const { variant = "solid", colorScheme = "primary" } = props; + return ( + <> + {variant === "solid" ? ( + props.onClick?.(event), + }} + className={clx(baseButton, props.className)} + > + {props.children} + + ) : null} + {variant === "text" ? ( + props.onClick?.(event), + }} + className={clx(baseButton, props.className)} + > + {props.children} + + ) : null} + + ); +} + +export default MeshButton; diff --git a/packages/react/src/ui/mesh-staking/mesh-footer-info-item.tsx b/packages/react/src/ui/mesh-staking/mesh-footer-info-item.tsx new file mode 100644 index 00000000..a3d341d5 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-footer-info-item.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import Box from "../box"; +import Text from "../text"; +import clx from "clsx"; +import type { MeshFooterInfoItemProps } from "./mesh-staking.types"; + +function MeshFooterInfoItem(props: MeshFooterInfoItemProps) { + return ( + + + {props.title} + + + {props.description} + + {props.subDescription ? ( + + {props.subDescription} + + ) : null} + + ); +} + +export default MeshFooterInfoItem; diff --git a/packages/react/src/ui/mesh-staking/mesh-provider.tsx b/packages/react/src/ui/mesh-staking/mesh-provider.tsx new file mode 100644 index 00000000..9a61ffad --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-provider.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import { + meshDarkThemeClass, + meshLightThemeClass, +} from "../../styles/themes.css"; +import type { MeshProviderProps } from "./mesh-staking.types"; + +function MeshProvider(props: MeshProviderProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function isControlled() { + return props.themeMode != null; + } + + function providerThemeMode() { + if (isControlled()) return props.themeMode; + return theme; + } + + useEffect(() => { + // Controlled theme mode + if (isControlled()) return; + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    + {props.children} +
    + ); +} + +export default MeshProvider; diff --git a/packages/react/src/ui/mesh-staking/mesh-staking-slider-info.tsx b/packages/react/src/ui/mesh-staking/mesh-staking-slider-info.tsx new file mode 100644 index 00000000..19a64a62 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-staking-slider-info.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import type { MeshStakingSliderInfoProps } from "./mesh-staking.types"; + +function MeshStakingSliderInfo(props: MeshStakingSliderInfoProps) { + return ( + + + + + {props.tokenName} + + + {props.tokenAPR} + + + + ); +} + +export default MeshStakingSliderInfo; diff --git a/packages/react/src/ui/mesh-staking/mesh-staking.css.ts b/packages/react/src/ui/mesh-staking/mesh-staking.css.ts new file mode 100644 index 00000000..9da2859a --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-staking.css.ts @@ -0,0 +1,114 @@ +import { style, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const tableRow = style({ + selectors: { + "&:hover": { + backgroundColor: `rgb(from ${themeVars.colors.text} r g b / 0.1)`, + }, + }, +}); + +export const tableCell = style({ + height: themeVars.space["14"], + selectors: { + [`${tableRow} &:first-child`]: { + borderTopLeftRadius: themeVars.radii["base"], + borderBottomLeftRadius: themeVars.radii["base"], + }, + [`${tableRow} &:last-child`]: { + borderTopRightRadius: themeVars.radii["base"], + borderBottomRightRadius: themeVars.radii["base"], + }, + }, +}); + +export const tableBody = style({ + selectors: { + "&:before": { + content: '""', + display: "block", + height: themeVars.space["9"], + }, + "&:after": { + content: '""', + display: "block", + height: themeVars.space["9"], + }, + }, +}); +export const borderedTableCell = style({ + position: "relative", + height: "calc(40px + 1px)", + selectors: { + "&:after": { + content: "", + position: "absolute", + bottom: "-1px", + left: 0, + right: 0, + width: "100%", + height: "1px", + backgroundColor: themeVars.colors.divider, + }, + }, +}); + +export const firstRowCell = style({ + paddingTop: themeVars.space["4"], +}); + +export const lastRowCell = style({ + paddingBottom: themeVars.space["9"], +}); + +export const bottomShadow = style({ + height: "45px", + position: "relative", + width: "100%", + backgroundColor: `rgb(from ${themeVars.colors.cardBg} r g b / 0.75)`, + backdropFilter: "blur(1px)", + opacity: 0.99, + zIndex: 10, + selectors: { + "&:after": { + content: '""', + position: "absolute", + width: "100%", + bottom: 0, + left: 0, + right: 0, + height: "45px", + background: themeVars.colors.overflowShadowBg, + }, + }, +}); + +export const scrollBarThumbBgVar = createVar(); + +const scrollBarBase = style({ + // Firefox + scrollbarWidth: "thin" /* "auto" or "thin" */, + scrollbarColor: `${scrollBarThumbBgVar} transparent` /* scroll thumb and track */, + selectors: { + "&::-webkit-scrollbar": { + width: themeVars.space[3], + }, + "&::-webkit-scrollbar-track": { + background: "transparent", + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: scrollBarThumbBgVar, + borderRadius: themeVars.space[2], + }, + }, +}); + +export const scrollBar = style([ + scrollBarBase, + { + vars: { + [scrollBarThumbBgVar]: themeVars.colors.inputBorder, + }, + }, +]); diff --git a/packages/react/src/ui/mesh-staking/mesh-staking.types.tsx b/packages/react/src/ui/mesh-staking/mesh-staking.types.tsx new file mode 100644 index 00000000..65b687f4 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-staking.types.tsx @@ -0,0 +1,119 @@ +import type { + BaseComponentProps, + GridColumn, +} from "../../models/components.model"; +import type { ThemeProviderProps } from "../theme-provider/theme-provider.types"; +import type { BoxProps } from "../box/box.types"; +import type { TextProps } from "../text/text.types"; +import type { ButtonProps } from "../button/button.types"; + +export interface MeshProviderProps extends BaseComponentProps { + themeMode?: ThemeProviderProps["themeMode"]; +} + +export interface MeshStakingProps extends BaseComponentProps {} + +type BaseButtonProps = Omit< + ButtonProps, + | "variant" + | "intent" + | "size" + | "iconSize" + | "leftIcon" + | "rightIcon" + | "isLoading" + | "spinnerPlacement" +>; + +export interface MeshButtonProps extends BaseButtonProps { + color?: BoxProps["color"]; + width?: BoxProps["width"]; + height?: BoxProps["height"]; + px?: BoxProps["px"]; + py?: BoxProps["py"]; + borderRadius?: BoxProps["borderRadius"]; + borderTopLeftRadius?: BoxProps["borderTopLeftRadius"]; + borderTopRightRadius?: BoxProps["borderTopRightRadius"]; + borderBottomRightRadius?: BoxProps["borderBottomRightRadius"]; + borderBottomLeftRadius?: BoxProps["borderBottomLeftRadius"]; + variant?: "text" | "solid"; + colorScheme?: "primary" | "secondary"; +} + +export interface MeshTagButtonProps extends BaseButtonProps {} + +export interface MeshTabProps extends BaseButtonProps { + isActive?: boolean; + onClick?: (event?: any) => void; +} + +export interface MeshStakingSliderInfoProps extends BaseComponentProps { + tokenName: string; + tokenSymbol: string; + tokenImgSrc: string; + tokenAPR: string; + isActive?: boolean; + attributes?: BoxProps; +} + +export interface MeshFooterInfoItemProps extends BaseComponentProps { + title: string; + description: string; + subDescription?: string; + subDescriptionProps?: TextProps; + attributes?: BoxProps; +} + +export interface MeshValidatorSquadEmptyProps extends BaseComponentProps { + thumbnailSrcs: string[]; + count: number; + onDecrease?: () => void; + onIncrease?: () => void; + onRandomize?: () => void; + attributes?: BoxProps; +} + +// ==== Mesh table +export interface MeshTableProps extends BaseComponentProps { + columns?: GridColumn[]; + data: Array<{ id: string } & object>; + pinnedIds?: string[]; + maxPinnedRows?: number; + // ==== Style props + borderless?: boolean; + rowHeight?: BoxProps["height"]; + containerProps?: BoxProps; + tableProps?: BoxProps; +} + +export interface MeshTableHeaderActionProps extends BaseComponentProps { + type?: "stake" | "unstake"; + stakeLabel?: string; + unstakeLabel?: string; + tokenName: string; + tokenImgSrc: string; + tokenAmount: string; + onClick?: () => void; + attributes?: BoxProps; +} + +// ==== Mesh table cells +export interface MeshTableChainCellProps extends BaseComponentProps { + size?: "xs" | "sm" | "md"; + name: string; + imgSrc: string; + attributes?: BoxProps; +} + +export interface MeshTableAPRCellProps extends BaseComponentProps { + value: string; + attributes?: BoxProps; +} + +export interface MeshTableValidatorsCellProps extends BaseComponentProps { + validators: Array<{ + name: string; + imgSrc: string; + }>; + attributes?: BoxProps; +} diff --git a/packages/react/src/ui/mesh-staking/mesh-tab.tsx b/packages/react/src/ui/mesh-staking/mesh-tab.tsx new file mode 100644 index 00000000..ca761501 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-tab.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import Box from "../box"; +import clx from "clsx"; +import { baseButton } from "../button/button.css"; +import type { MeshTabProps } from "./mesh-staking.types"; + +function MeshTab(props: MeshTabProps) { + const { isActive = false } = props; + return ( + + props.onClick?.(event), + }} + className={clx(baseButton, props.className)} + > + {props.children} + + + + ); +} + +export default MeshTab; diff --git a/packages/react/src/ui/mesh-staking/mesh-table-apr-cell.tsx b/packages/react/src/ui/mesh-staking/mesh-table-apr-cell.tsx new file mode 100644 index 00000000..f2179898 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-table-apr-cell.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import Text from "../text"; +import type { MeshTableAPRCellProps } from "./mesh-staking.types"; + +function MeshTableAPRCell(props: MeshTableAPRCellProps) { + return ( + + {props.value} + + ); +} + +export default MeshTableAPRCell; diff --git a/packages/react/src/ui/mesh-staking/mesh-table-chain-cell.tsx b/packages/react/src/ui/mesh-staking/mesh-table-chain-cell.tsx new file mode 100644 index 00000000..3d591d1c --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-table-chain-cell.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import Box from "../box"; +import Text from "../text"; +import Stack from "../stack"; +import type { MeshTableChainCellProps } from "./mesh-staking.types"; + +function MeshTableChainCell(props: MeshTableChainCellProps) { + const { size = "md" } = props; + return ( + <> + {size === "xs" ? ( + + + + {props.name} + + + ) : null} + {size === "sm" ? ( + + + + {props.name} + + + ) : null} + {size === "md" ? ( + + + + {props.name} + + + ) : null} + + ); +} + +export default MeshTableChainCell; diff --git a/packages/react/src/ui/mesh-staking/mesh-table-header-action.tsx b/packages/react/src/ui/mesh-staking/mesh-table-header-action.tsx new file mode 100644 index 00000000..9b8ff4bb --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-table-header-action.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import Box from "../box"; +import Text from "../text"; +import Icon from "../icon"; +import Stack from "../stack"; +import { baseButton } from "../button/button.css"; +import type { MeshTableHeaderActionProps } from "./mesh-staking.types"; + +function MeshTableHeaderAction(props: MeshTableHeaderActionProps) { + const { + type = "stake", + stakeLabel = "Stake", + unstakeLabel = "Unstake", + } = props; + return ( + + props.onClick?.() }} + className={baseButton} + > + {type === "stake" ? ( + + + {stakeLabel} + + ) : null} + {type === "unstake" ? ( + + + {unstakeLabel} + + ) : null} + + + + + {props.tokenAmount} + + + {props.tokenName} + + + + ); +} + +export default MeshTableHeaderAction; diff --git a/packages/react/src/ui/mesh-staking/mesh-table-validators-cell.tsx b/packages/react/src/ui/mesh-staking/mesh-table-validators-cell.tsx new file mode 100644 index 00000000..4c7323f5 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-table-validators-cell.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import Box from "../box"; +import type { MeshTableValidatorsCellProps } from "./mesh-staking.types"; + +function MeshTableValidatorsCell(props: MeshTableValidatorsCellProps) { + return ( + + {props.validators?.map((validator, index) => ( + + + + ))} + + ); +} + +export default MeshTableValidatorsCell; diff --git a/packages/react/src/ui/mesh-staking/mesh-table.tsx b/packages/react/src/ui/mesh-staking/mesh-table.tsx new file mode 100644 index 00000000..0151e393 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-table.tsx @@ -0,0 +1,434 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Box from "../box"; +import Divider from "../divider"; +import Text from "../text"; +import Table from "../table/table"; +import TableHead from "../table/table-head"; +import TableBody from "../table/table-body"; +import TableRow from "../table/table-row"; +import TableCell from "../table/table-cell"; +import TableColumnHeaderCell from "../table/table-column-header-cell"; +import TableRowHeaderCell from "../table/table-row-header-cell"; +import { standardTransitionProperties } from "../shared/shared.css"; +import * as styles from "./mesh-staking.css"; +import { store } from "../../models/store"; +import anime from "animejs"; +import type { MeshTableProps } from "./mesh-staking.types"; + +function MeshTable(props: MeshTableProps) { + const measureRef = useRef(null); + const shadowRef = useRef(null); + const pinnedTableMeasureRef = useRef(null); + const pinnedTableShadowRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "dark"); + + const [displayBottomShadow, setDisplayBottomShadow] = useState(() => false); + + const [displayPinnedTableBottomShadow, setDisplayPinnedTableBottomShadow] = + useState(() => false); + + const [pinnedRows, setPinnedRows] = useState(() => []); + + const [unpinnedRows, setUnpinnedRows] = useState(() => props.data ?? []); + + function shouldSplitPinnedTable() { + const DEFAULT_SPLIT_THRESHOLD = 4; + const threshold = props.maxPinnedRows ?? DEFAULT_SPLIT_THRESHOLD; + return pinnedRows.length > 0 && pinnedRows.length > threshold; + } + + function shouldPinHeader() { + if ( + props.pinnedIds == null || + (Array.isArray(props.pinnedIds) && (props.pinnedIds ?? []).length === 0) + ) { + return false; + } + return props.pinnedIds.length > 0 && pinnedRows.length > 0; + } + + useEffect(() => { + setTheme(store.getState().theme); + let cleanupStore = store.subscribe((newState) => { + setTheme(newState.theme); + }); + let cleanupRef1 = () => {}; + let cleanupRef2 = () => {}; + if (measureRef.current) { + const scrollHandler1 = () => { + const isScrollable1 = + measureRef.current.scrollHeight > measureRef.current.clientHeight; + if (!isScrollable1) { + return setDisplayBottomShadow(false); + } + if (measureRef.current.scrollTop === 0) { + setDisplayBottomShadow(false); + } else { + setDisplayBottomShadow(true); + } + }; + scrollHandler1(); + measureRef.current.addEventListener("scroll", scrollHandler1); + cleanupRef1 = () => { + if (measureRef.current) { + measureRef.current.removeEventListener("scroll", scrollHandler1); + } + }; + } + if (pinnedTableMeasureRef.current) { + const scrollHandler2 = () => { + const isScrollable2 = + pinnedTableMeasureRef.current.scrollHeight > + pinnedTableMeasureRef.current.clientHeight; + if (!isScrollable2) { + return setDisplayPinnedTableBottomShadow(false); + } + if (pinnedTableMeasureRef.current.scrollTop === 0) { + setDisplayPinnedTableBottomShadow(false); + } else { + setDisplayPinnedTableBottomShadow(true); + } + }; + scrollHandler2(); + pinnedTableMeasureRef.current.addEventListener("scroll", scrollHandler2); + cleanupRef2 = () => { + if (pinnedTableMeasureRef.current) { + pinnedTableMeasureRef.current.removeEventListener( + "scroll", + scrollHandler2 + ); + } + }; + } + cleanupRef.current = () => { + if (cleanupStore) { + cleanupStore(); + } + cleanupRef1(); + cleanupRef2(); + }; + }, []); + + useEffect(() => { + if (!props.pinnedIds) return; + let newPinnedRows = []; + let newUnpinnedRows = []; + if (!props.pinnedIds || (props.pinnedIds ?? []).length === 0) { + setPinnedRows([]); + } else { + newPinnedRows = props.data.filter((row) => + props.pinnedIds.includes(row.id) + ); + } + if (!props.pinnedIds || (props.pinnedIds ?? []).length === 0) { + setUnpinnedRows(props.data); + } else { + newUnpinnedRows = props.data.filter( + (row) => !props.pinnedIds.includes(row.id) + ); + } + setPinnedRows(newPinnedRows); + setUnpinnedRows(newUnpinnedRows); + }, [props.data, props.pinnedIds]); + useEffect(() => { + if (!shadowRef.current) return; + const playAnimation = (isShown: boolean, elementRef: any) => { + const opacity = isShown ? [0, 1] : [1, 0]; + const height = isShown ? [0, 45] : [45, 0]; + anime({ + targets: elementRef, + opacity: opacity, + height: height, + delay: 50, + duration: 250, + direction: `alternate`, + loop: false, + autoplay: false, + easing: `easeInOutSine`, + }); + }; + playAnimation(displayBottomShadow, shadowRef.current); + if (!pinnedTableShadowRef.current) return; + playAnimation(displayPinnedTableBottomShadow, pinnedTableShadowRef.current); + }, [ + displayBottomShadow, + shadowRef.current, + displayPinnedTableBottomShadow, + pinnedTableShadowRef.current, + ]); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + + {props.columns?.map((column, colIndex) => ( + + + {column.label} + + + ))} + + {pinnedRows?.map((pinnedRow, pinnedRowIndex) => ( + + {props.columns?.map((column) => ( + + {!!column.render ? ( + <>{column.render(pinnedRow, column, true)} + ) : ( + + {pinnedRow[column.id]} + + )} + + ))} + + ))} + +
    + +
    + + + {shouldSplitPinnedTable() ? ( + + + + ) : null} + + + + {!shouldSplitPinnedTable() ? ( + + {props.columns?.map((column, colIndex) => ( + + + {column.label} + + + ))} + + ) : null} + {shouldPinHeader() && !shouldSplitPinnedTable() ? ( + <> + {pinnedRows?.map((pinnedRow, pinnedRowIndex) => ( + + {props.columns?.map((column) => ( + + {!!column.render ? ( + <>{column.render(pinnedRow, column, true)} + ) : ( + + {pinnedRow[column.id]} + + )} + + ))} + + ))} + + ) : null} + + + {unpinnedRows?.map((row) => ( + + {props.columns?.map((column, index) => ( + <> + {index === 0 ? ( + + {!!column.render ? ( + <>{column.render(row, column)} + ) : ( + + {row[column.id]} + + )} + + ) : null} + {index > 0 ? ( + + {!!column.render ? ( + <>{column.render(row, column)} + ) : ( + + {row[column.id]} + + )} + + ) : null} + + ))} + + ))} + +
    + +
    + + + + ); +} + +export default MeshTable; diff --git a/packages/react/src/ui/mesh-staking/mesh-tag-button.tsx b/packages/react/src/ui/mesh-staking/mesh-tag-button.tsx new file mode 100644 index 00000000..40e336fc --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-tag-button.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import Box from "../box"; +import clx from "clsx"; +import { baseButton } from "../button/button.css"; +import type { MeshTagButtonProps } from "./mesh-staking.types"; + +function MeshTagButton(props: MeshTagButtonProps) { + return ( + props.onClick?.(event), + }} + className={clx(baseButton, props.className)} + > + {props.children} + + ); +} + +export default MeshTagButton; diff --git a/packages/react/src/ui/mesh-staking/mesh-validator-squad-empty.tsx b/packages/react/src/ui/mesh-staking/mesh-validator-squad-empty.tsx new file mode 100644 index 00000000..e9bf29c9 --- /dev/null +++ b/packages/react/src/ui/mesh-staking/mesh-validator-squad-empty.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import Box from "../box"; +import Icon from "../icon"; +import Stack from "../stack"; +import MeshButton from "./mesh-button"; +import type { MeshValidatorSquadEmptyProps } from "./mesh-staking.types"; + +function MeshValidatorSquadEmpty(props: MeshValidatorSquadEmptyProps) { + return ( + + {Array.isArray(props.thumbnailSrcs) && props.thumbnailSrcs.length > 0 ? ( + + {props.thumbnailSrcs?.map((thumbnailSrc, index) => ( + + + + ))} + + ) : null} + + + props.onDecrease?.()} + > + + + + {props.count} + + props.onDecrease?.()} + > + + + + props.onRandomize?.()} + > + + + + + ); +} + +export default MeshValidatorSquadEmpty; diff --git a/packages/react/src/ui/modal/index.ts b/packages/react/src/ui/modal/index.ts new file mode 100644 index 00000000..735eee97 --- /dev/null +++ b/packages/react/src/ui/modal/index.ts @@ -0,0 +1 @@ +export { default } from "./modal"; diff --git a/packages/react/src/ui/modal/modal.css.ts b/packages/react/src/ui/modal/modal.css.ts new file mode 100644 index 00000000..b824da19 --- /dev/null +++ b/packages/react/src/ui/modal/modal.css.ts @@ -0,0 +1,65 @@ +import { style } from "@vanilla-extract/css"; +import { globalStyle } from "@vanilla-extract/css"; + +export const modalRoot = style({ + position: "fixed", + inset: 0, + display: "flex", + justifyContent: "center", + alignItems: "center", + zIndex: 0, +}); + +export const modalContainer = style({ + overflow: `hidden`, + display: `flex`, + alignItems: `center`, + justifyContent: `center`, + transition: `opacity 300ms ease-in-out`, + selectors: { + '&[data-modal-open="true"]': { + opacity: 1, + visibility: `visible`, + }, + '&[data-modal-open="false"]': { + opacity: 0, + visibility: `hidden`, + }, + }, +}); + +export const modalBackdrop = style({ + position: `fixed`, + top: 0, + left: 0, + bottom: 0, + right: 0, + width: `100%`, + height: `100%`, + backgroundColor: `rgba(17, 20, 24, .7)`, +}); + +export const modalContent = style({ + position: `relative`, + zIndex: 1, + overflow: "hidden", +}); + +export const modalHeader = style({ + position: "relative", + display: "flex", +}); + +export const modalCloseButton = style({ + position: "absolute", + right: 0, + top: "50%", + transform: "translateY(-50%)", +}); + +globalStyle( + `${modalContainer} *, ${modalContainer} *::before, ${modalContainer} *::after`, + { + boxSizing: `border-box`, + }, +); diff --git a/packages/react/src/ui/modal/modal.tsx b/packages/react/src/ui/modal/modal.tsx new file mode 100644 index 00000000..02809bde --- /dev/null +++ b/packages/react/src/ui/modal/modal.tsx @@ -0,0 +1,303 @@ +import { + FloatingFocusManager, + FloatingOverlay, + FloatingPortal, + useClick, + useDismiss, + useFloating, + useId, + useInteractions, + useMergeRefs, + useRole, + useTransitionStyles, +} from "@floating-ui/react"; +import clx from "clsx"; +import React, { cloneElement, forwardRef } from "react"; +import useTheme from "../hooks/use-theme"; +import { overlays } from "@/ui/overlays-manager/overlays"; +import * as styles from "./modal.css"; + +interface DialogOptions { + initialOpen?: boolean; + open?: boolean; + closeOnClickaway?: boolean; + onOpenChange?: (open: boolean) => void; +} + +function useClickAway(cb: (e: Event) => void) { + const ref = React.useRef(null); + const refCb = React.useRef(cb); + + React.useEffect(() => { + refCb.current = cb; + }); + + React.useEffect(() => { + const handler = (e: Event) => { + const element = ref.current; + if (element && !element.contains(e.target)) { + refCb.current(e); + } + }; + + document.addEventListener("mousedown", handler); + document.addEventListener("touchstart", handler); + + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("touchstart", handler); + }; + }, []); + + return ref; +} + +function useDialog({ + initialOpen = false, + open: controlledOpen, + closeOnClickaway, + onOpenChange: setControlledOpen, +}: DialogOptions = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + const [labelId, setLabelId] = React.useState(); + const [descriptionId, setDescriptionId] = React.useState< + string | undefined + >(); + + const [rootRef, setRootRef] = React.useState(null); + const overlayId = React.useRef(overlays.generateId("modal")); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + const clickawayRef = useClickAway((event) => { + if ( + closeOnClickaway && + rootRef && + !rootRef.contains(event.target as Node) && + overlays.isTopMostOverlay(overlayId.current) + ) { + setOpen(false); + } + }); + + const data = useFloating({ + open, + onOpenChange: setOpen, + }); + + const context = data.context; + + const click = useClick(context, { + enabled: controlledOpen == null, + }); + const dismiss = useDismiss(context, { outsidePress: true, escapeKey: true }); + const role = useRole(context); + + const interactions = useInteractions([click, dismiss, role]); + + React.useEffect(() => { + if (open) { + overlays.pushOverlay(overlayId.current); + } + return () => { + if (open) { + overlays.popOverlay(overlayId.current); + } + }; + }, [open]); + + return React.useMemo( + () => ({ + open, + setOpen, + clickawayRef, + rootRef: setRootRef, + ...interactions, + ...data, + labelId, + descriptionId, + setLabelId, + setDescriptionId, + }), + [open, setOpen, setRootRef, interactions, data, labelId, descriptionId], + ); +} + +export interface ModalProps { + isOpen: boolean; + initialOpen?: boolean; + onOpen?: (event?: React.SyntheticEvent) => void; + onClose?: (event?: React.SyntheticEvent) => void; + initialFocusRef?: React.MutableRefObject; + renderTrigger?: (props: unknown) => React.ReactNode; + header: React.ReactNode; + children?: React.ReactNode; + closeOnClickaway?: boolean; + preventScroll?: boolean; + role?: "dialog" | "alertdialog"; + root?: HTMLElement | null | React.MutableRefObject; + className?: string; + // This is the themeClass created by using vanilla css createTheme function + themeClassName?: string; + contentStyles?: React.CSSProperties; + contentClassName?: string; + backdropClassName?: string; + childrenClassName?: string; +} + +const Modal = forwardRef((props, forwardedRef) => { + const { themeClass } = useTheme(); + + const { + isOpen, + initialOpen, + onOpen, + onClose, + children, + renderTrigger, + header, + initialFocusRef, + root, + closeOnClickaway = true, + preventScroll = true, + role = `dialog`, + className, + themeClassName, + backdropClassName, + contentClassName, + contentStyles, + childrenClassName, + } = props; + + const [defaultRoot, setDefaultRoot] = React.useState( + null, + ); + + const dialog = useDialog({ + initialOpen, + open: isOpen, + onOpenChange: (isOpen) => { + if (isOpen) { + onOpen(); + } else { + onClose(); + } + }, + closeOnClickaway, + }); + + const id = useId(); + + const { styles: transitionStyles } = useTransitionStyles(dialog.context); + + React.useEffect(() => { + // User-provided root + if (root) { + return; + } + + // Default lib root + setDefaultRoot(overlays.getOrCreateOverlayRoot(window.document)); + }, []); + + React.useEffect(() => { + dialog.setLabelId(id); + return () => dialog.setLabelId(undefined); + }, [id, dialog.setLabelId]); + + const onCloseButtonClick = (event: React.SyntheticEvent) => { + dialog.setOpen(false); + onClose?.(event); + }; + + const dialogRef = useMergeRefs([dialog.refs.setFloating, forwardedRef]); + const triggerProps = dialog.getReferenceProps({ + ref: dialog.refs.setReference, + }); + + return ( + <> + {typeof renderTrigger === "function" ? renderTrigger(triggerProps) : null} + + {dialog.open && ( + + + +
    +
    +
    +
    + {header && React.isValidElement(header) + ? cloneElement(header, { + // @ts-expect-error + id, + closeButtonProps: { + onClick: onCloseButtonClick, + }, + }) + : null} + +
    + {children} +
    +
    +
    +
    + +
    +
    + + + + )} + + ); +}); + +export default Modal; diff --git a/packages/react/src/ui/nft-auction/index.ts b/packages/react/src/ui/nft-auction/index.ts new file mode 100644 index 00000000..b0f5b0b8 --- /dev/null +++ b/packages/react/src/ui/nft-auction/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-auction"; diff --git a/packages/react/src/ui/nft-auction/nft-auction.tsx b/packages/react/src/ui/nft-auction/nft-auction.tsx new file mode 100644 index 00000000..07495291 --- /dev/null +++ b/packages/react/src/ui/nft-auction/nft-auction.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import Stack from "../stack"; +import Button from "../button"; +import TokenInput from "../token-input"; +import StarText from "../star-text"; +import NftFees from "../nft-fees"; +import Box from "../box"; +import type { NftAuctionProps } from "./nft-auction.types"; + +function NftAuction(props: NftAuctionProps) { + const [starList, setStarList] = useState(() => [ + { + label: "Floor Price", + value: "", + }, + { + label: "Highest Offer", + value: "", + }, + ]); + + useEffect(() => { + setStarList( + starList.map((item, index) => { + let value = index === 0 ? props?.floorPrice : props?.highestOffer; + return { + label: item.label, + value: `${value}`, + }; + }) + ); + }, [props?.floorPrice, props?.highestOffer]); + + return ( + + + + {starList?.map((item) => ( + + ))} + + + + + + ); +} + +export default NftAuction; diff --git a/packages/react/src/ui/nft-auction/nft-auction.types.tsx b/packages/react/src/ui/nft-auction/nft-auction.types.tsx new file mode 100644 index 00000000..d4f11e05 --- /dev/null +++ b/packages/react/src/ui/nft-auction/nft-auction.types.tsx @@ -0,0 +1,4 @@ +export interface NftAuctionProps { + floorPrice: number; + highestOffer: number; +} diff --git a/packages/react/src/ui/nft-detail-activity-list-item/index.ts b/packages/react/src/ui/nft-detail-activity-list-item/index.ts new file mode 100644 index 00000000..59cec8c2 --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-detail-activity-list-item"; diff --git a/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.css.ts b/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.css.ts new file mode 100644 index 00000000..8571eab7 --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.css.ts @@ -0,0 +1,7 @@ +import { style } from "@vanilla-extract/css"; +import { breakpoints } from "../../styles/tokens"; + +export const container = style({ + minWidth: `${breakpoints.tablet}px`, + boxSizing: "border-box", +}); diff --git a/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.tsx b/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.tsx new file mode 100644 index 00000000..739583d7 --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import Stack from "../stack"; +import Text from "../text"; +import Icon from "../icon"; +import Box from "../box"; +import * as styles from "./nft-detail-activity-list-item.css"; +import type { NftDetailActivityListItemProps } from "./nft-detail-activity-list-item.types"; + +function NftDetailActivityListItem(props: NftDetailActivityListItemProps) { + return ( + + + + + + {props?.event} + + {`${props?.price} ${props.tokenName}`} + + + + + + {props.fromLabel ?? "from"} + + + {props?.from} + + + + + + + {props.toLabel ?? "from"} + + + {props?.to ?? "---"} + + + + + + {props?.date} + + + + ); +} + +export default NftDetailActivityListItem; diff --git a/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.types.tsx b/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.types.tsx new file mode 100644 index 00000000..fb04edd6 --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list-item/nft-detail-activity-list-item.types.tsx @@ -0,0 +1,13 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export interface NftDetailActivityListItemProps extends BaseComponentProps { + event: string; + price: number; + from?: string; + to?: string; + date: string; + tokenName: string; + // ==== Labels + fromLabel?: string; + toLabel?: string; +} diff --git a/packages/react/src/ui/nft-detail-activity-list/index.ts b/packages/react/src/ui/nft-detail-activity-list/index.ts new file mode 100644 index 00000000..b82acccb --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-detail-activity-list"; diff --git a/packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.tsx b/packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.tsx new file mode 100644 index 00000000..9304d486 --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import { NftDetailActivityListItemProps } from "../nft-detail-activity-list-item/nft-detail-activity-list-item.types"; +import NftDetailActivityListItem from "../nft-detail-activity-list-item"; +import type { NftDetailActivityListProps } from "./nft-detail-activity-list.types"; + +function NftDetailActivityList(props: NftDetailActivityListProps) { + return ( + + + {props.title ?? "Activity"} + + + + {props?.list?.map((item, index) => ( + + ))} + + + + ); +} + +export default NftDetailActivityList; diff --git a/packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.types.tsx b/packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.types.tsx new file mode 100644 index 00000000..dbe6aea4 --- /dev/null +++ b/packages/react/src/ui/nft-detail-activity-list/nft-detail-activity-list.types.tsx @@ -0,0 +1,7 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { NftDetailActivityListItemProps } from "../nft-detail-activity-list-item/nft-detail-activity-list-item.types"; + +export interface NftDetailActivityListProps extends BaseComponentProps { + title?: string; + list: NftDetailActivityListItemProps[]; +} diff --git a/packages/react/src/ui/nft-detail-info/index.ts b/packages/react/src/ui/nft-detail-info/index.ts new file mode 100644 index 00000000..a2227a18 --- /dev/null +++ b/packages/react/src/ui/nft-detail-info/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-detail-info"; diff --git a/packages/react/src/ui/nft-detail-info/nft-detail-info.tsx b/packages/react/src/ui/nft-detail-info/nft-detail-info.tsx new file mode 100644 index 00000000..ac29ada6 --- /dev/null +++ b/packages/react/src/ui/nft-detail-info/nft-detail-info.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import Icon from "../icon"; +import StarText from "../star-text"; +import type { NftDetailInfoProps } from "./nft-detail-info.type"; +import { isNumber } from "lodash"; + +function NftDetailInfo(props: NftDetailInfoProps) { + return ( + + + Info + + + {!!props.price ? ( + + + Price + + + + ) : null} + {!!props.lastSale ? ( + + + Last sale + + {`${ + isNumber(props.lastSale) ? `${props.lastSale} STARS` : "---" + }`} + + ) : null} + {!!props.owner ? ( + + + Owner + + + + {props.owner} + + + + + ) : null} + {!!props.topOffer ? ( + + + Top offer + + + + ) : null} + {!!props.floorPrice ? ( + + + Floor price + + + + ) : null} + + + ); +} + +export default NftDetailInfo; diff --git a/packages/react/src/ui/nft-detail-info/nft-detail-info.type.tsx b/packages/react/src/ui/nft-detail-info/nft-detail-info.type.tsx new file mode 100644 index 00000000..c9bab93f --- /dev/null +++ b/packages/react/src/ui/nft-detail-info/nft-detail-info.type.tsx @@ -0,0 +1,8 @@ +export interface NftDetailInfoProps { + price?: number; + lastSale?: number; + owner?: string; + topOffer?: number; + floorPrice?: number; + isNameVerified: boolean; +} diff --git a/packages/react/src/ui/nft-detail-top-offers/index.ts b/packages/react/src/ui/nft-detail-top-offers/index.ts new file mode 100644 index 00000000..70e35f48 --- /dev/null +++ b/packages/react/src/ui/nft-detail-top-offers/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-detail-top-offers"; diff --git a/packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.tsx b/packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.tsx new file mode 100644 index 00000000..6c467458 --- /dev/null +++ b/packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import StarText from "../star-text"; +import type { NftDetailTopOfferProps } from "./nft-detail-top-offers.types"; + +function NftDetailTopOffer(props: NftDetailTopOfferProps) { + return ( + + + Top offers + + + + + Price + + + + + + Floor price (%Δ) + + {props?.floorPrice} + + + + Expires + + {props?.expires} + + + + From + + {props?.from} + + + + ); +} + +export default NftDetailTopOffer; diff --git a/packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.types.tsx b/packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.types.tsx new file mode 100644 index 00000000..51f5c4b2 --- /dev/null +++ b/packages/react/src/ui/nft-detail-top-offers/nft-detail-top-offers.types.tsx @@ -0,0 +1,6 @@ +export interface NftDetailTopOfferProps { + price: number; + floorPrice: string; + expires: string; + from: string; +} diff --git a/packages/react/src/ui/nft-detail/index.ts b/packages/react/src/ui/nft-detail/index.ts new file mode 100644 index 00000000..e17b6ec2 --- /dev/null +++ b/packages/react/src/ui/nft-detail/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-detail"; diff --git a/packages/react/src/ui/nft-detail/nft-detail.tsx b/packages/react/src/ui/nft-detail/nft-detail.tsx new file mode 100644 index 00000000..734295be --- /dev/null +++ b/packages/react/src/ui/nft-detail/nft-detail.tsx @@ -0,0 +1,357 @@ +import * as React from "react"; +import Stack from "../stack"; +import Text from "../text"; +import Box from "../box"; +import Button from "../button"; +import IconButton from "../icon-button"; +import NftTraitList from "../nft-trait-list"; +import StarText from "../star-text"; +import { store } from "../../models/store"; +import NftDetailInfo from "../nft-detail-info"; +import NftDetailTopOffer from "../nft-detail-top-offers"; +import NftDetailActivityList from "../nft-detail-activity-list"; +import type { + NftDetailProps, + ListForSale, + MakeOffer, + BuyNow, +} from "./nft-detail.types"; + +function NftDetail(props: NftDetailProps) { + return ( + + + + + + + + {props.collectionName} + + + {props.name} + + + + Created by + + + {props.creatorName} + + + + {props.collectionDesc} + + {!!props.mintPrice ? ( + + ) : null} + {!!props.detailInfo?.lastSale ? ( + + ) : null} + + + Owned by + + + {props.ownerName} + + + {!!props.price ? ( + + ) : null} + {props.type === "listForSale" ? ( + <> + + + + + + + + + + + ) : null} + {props.type === "makeOffer" ? ( + + ) : null} + {props.type === "buyNow" ? ( + + + + + ) : null} + {props.type === "custom" ? <>{props.children} : null} + + + + Rank + + {store.getState()?.formatNumber?.({ + value: props.rarityOrder, + })} + + {`of ${store.getState()?.formatNumber?.({ + value: props.tokensCount, + })}`} + + + + props.onShare?.()} + /> + + {!!props.traits ? : null} + {!!props.detailInfo ? ( + <> + + + + ) : null} + {!!props.detailTopOffer ? ( + <> + + + + ) : null} + {!!props.detailActivity ? ( + <> + + + + ) : null} + + ); +} + +export default NftDetail; diff --git a/packages/react/src/ui/nft-detail/nft-detail.types.tsx b/packages/react/src/ui/nft-detail/nft-detail.types.tsx new file mode 100644 index 00000000..8ca8586b --- /dev/null +++ b/packages/react/src/ui/nft-detail/nft-detail.types.tsx @@ -0,0 +1,60 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { NftTraitListItemProps } from "../nft-trait-list-item/nft-trait-list-item.types"; +import type { NftDetailInfoProps } from "../nft-detail-info/nft-detail-info.type"; +import type { NftDetailTopOfferProps } from "../nft-detail-top-offers/nft-detail-top-offers.types"; +import type { NftDetailActivityListProps } from "../nft-detail-activity-list/nft-detail-activity-list.types"; + +export type DetailType = "listForSale" | "makeOffer" | "buyNow" | "custom"; + +export type TokenInfo = { + tokenName?: string; + iconSrc?: string; +}; + +interface BaseNftDetailProps extends BaseComponentProps { + collectionName: string; + name: string; + creatorName: string; + collectionDesc: string; + mintPrice?: string; + rarityOrder: number; + tokensCount: number; + ownerName: string; + imgSrc: string; + price?: number; + tokenInfo?: TokenInfo; + traits?: NftTraitListItemProps[]; + detailInfo?: NftDetailInfoProps; + detailTopOffer?: NftDetailTopOfferProps; + detailActivity?: NftDetailActivityListProps; + onDownload: (event?: any) => void; + onShare: (event?: any) => void; + attributes?: any; + children?: BaseComponentProps["children"]; +} + +export type ListForSale = { + type: "listForSale"; + onTransfer: (event?: any) => void; + onBurn: (event?: any) => void; + onListForSale: (event?: any) => void; +}; + +export type MakeOffer = { + type: "makeOffer"; + onMakeOffer: (event?: any) => void; +}; + +export type BuyNow = { + type: "buyNow"; + onBuyNow: (event?: any) => void; + onMakeOffer: (event?: any) => void; +}; + +export type Custom = { + type: "custom"; +}; + +export type NftDetailVariant = ListForSale | MakeOffer | BuyNow | Custom; + +export type NftDetailProps = NftDetailVariant & BaseNftDetailProps; diff --git a/packages/react/src/ui/nft-fees/index.ts b/packages/react/src/ui/nft-fees/index.ts new file mode 100644 index 00000000..48c72a07 --- /dev/null +++ b/packages/react/src/ui/nft-fees/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-fees"; diff --git a/packages/react/src/ui/nft-fees/nft-fees.tsx b/packages/react/src/ui/nft-fees/nft-fees.tsx new file mode 100644 index 00000000..709a63f6 --- /dev/null +++ b/packages/react/src/ui/nft-fees/nft-fees.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import { isNil, cloneDeep } from "lodash"; +import Stack from "../stack"; +import Text from "../text"; +import Tooltip from "../tooltip"; +import Icon from "../icon"; +import Box from "../box"; +import { NftFeesProps, NftFeeItemProps } from "./nft-fees.types"; + +function NftFees(props: NftFeesProps) { + const [fees, setFees] = useState(() => [ + { + feeName: "Listing Fee", + amount: "", + desc: "50% burned, 50% to stakers", + show: false, + amountKey: "listFee", + }, + { + feeName: "Creator Royalties", + amount: "", + desc: "Paid to creators when selling your item", + show: false, + amountKey: "royalities", + }, + { + feeName: "Fair Burn", + amount: "", + desc: "50% burned, 50% to stakers", + show: false, + amountKey: "fairBurn", + }, + { + feeName: "Proceeds", + amount: "", + desc: "Proceeds = Sale Price - Fees", + show: false, + amountKey: "proceeds", + }, + ]); + + useEffect(() => { + let list = []; + cloneDeep(fees).forEach((item: NftFeeItemProps) => { + if (!isNil(props[item.amountKey])) { + item.amount = props[item.amountKey] ?? 0; + list.push(item); + } + }); + setFees(list); + }, [props.listFee, props.royalities, props.fairBurn, props.proceeds]); + + return ( + + + {props.title} + + + {fees?.map((item, index) => ( + + + + + {item.feeName} + + + + + + {`${item?.amount} ${props.symbol}`} + + + ))} + + + ); +} + +export default NftFees; diff --git a/packages/react/src/ui/nft-fees/nft-fees.types.tsx b/packages/react/src/ui/nft-fees/nft-fees.types.tsx new file mode 100644 index 00000000..ea18799c --- /dev/null +++ b/packages/react/src/ui/nft-fees/nft-fees.types.tsx @@ -0,0 +1,18 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export type NftFeeItemProps = { + feeName: string; + amount: number | string | undefined; + desc: string; + show?: boolean; + amountKey?: string; +}; + +export interface NftFeesProps extends BaseComponentProps { + title: string; + listFee?: number | string; + royalities?: number | string; + fairBurn?: number | string; + proceeds?: number | string; + symbol: string; +} diff --git a/packages/react/src/ui/nft-fixed-price/index.ts b/packages/react/src/ui/nft-fixed-price/index.ts new file mode 100644 index 00000000..d2fa22ad --- /dev/null +++ b/packages/react/src/ui/nft-fixed-price/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-fixed-price"; diff --git a/packages/react/src/ui/nft-fixed-price/nft-fixed-price.tsx b/packages/react/src/ui/nft-fixed-price/nft-fixed-price.tsx new file mode 100644 index 00000000..330e3450 --- /dev/null +++ b/packages/react/src/ui/nft-fixed-price/nft-fixed-price.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import Stack from "../stack"; +import Button from "../button"; +import Box from "../box"; +import TokenInput from "../token-input"; +import StarText from "../star-text"; +import NftFees from "../nft-fees"; +import type { NftFixedPriceProps } from "./nft-fixed-price.types"; + +function NftFixedPrice(props: NftFixedPriceProps) { + const [starList, setStarList] = useState(() => [ + { + label: "Floor Price", + value: "", + }, + { + label: "Highest Offer", + value: "", + }, + ]); + + useEffect(() => { + setStarList( + starList.map((item, index) => { + let value = index === 0 ? props?.floorPrice : props?.highestOffer; + return { + label: item.label, + value: `${value}`, + }; + }) + ); + }, [props?.floorPrice, props?.highestOffer]); + + return ( + + + + {starList?.map((item) => ( + + ))} + + + + + + ); +} + +export default NftFixedPrice; diff --git a/packages/react/src/ui/nft-fixed-price/nft-fixed-price.types.tsx b/packages/react/src/ui/nft-fixed-price/nft-fixed-price.types.tsx new file mode 100644 index 00000000..484a33c3 --- /dev/null +++ b/packages/react/src/ui/nft-fixed-price/nft-fixed-price.types.tsx @@ -0,0 +1,4 @@ +export interface NftFixedPriceProps { + floorPrice: number; + highestOffer: number; +} diff --git a/packages/react/src/ui/nft-make-offer/index.ts b/packages/react/src/ui/nft-make-offer/index.ts new file mode 100644 index 00000000..fbf54d25 --- /dev/null +++ b/packages/react/src/ui/nft-make-offer/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-make-offer"; diff --git a/packages/react/src/ui/nft-make-offer/nft-make-offer.css.ts b/packages/react/src/ui/nft-make-offer/nft-make-offer.css.ts new file mode 100644 index 00000000..39e01ddd --- /dev/null +++ b/packages/react/src/ui/nft-make-offer/nft-make-offer.css.ts @@ -0,0 +1,5 @@ +import {style} from '@vanilla-extract/css' + +export const container = style({ + width: "320px" +}) diff --git a/packages/react/src/ui/nft-make-offer/nft-make-offer.tsx b/packages/react/src/ui/nft-make-offer/nft-make-offer.tsx new file mode 100644 index 00000000..8198b771 --- /dev/null +++ b/packages/react/src/ui/nft-make-offer/nft-make-offer.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import TokenInput from "../token-input"; +import * as styles from "./nft-make-offer.css"; +import type { NftMakeOfferProps } from "./nft-make-offer.types"; + +function NftMakeOffer(props: NftMakeOfferProps) { + const { + symbol = "STARS", + makeOfferLabel = "Make Offer", + cancelLabel = "Cancel", + } = props; + return ( + + + + for + {props.tokenName} + + + props?.onChange?.(value)} + /> + + + + + + + ); +} + +export default NftMakeOffer; diff --git a/packages/react/src/ui/nft-make-offer/nft-make-offer.types.tsx b/packages/react/src/ui/nft-make-offer/nft-make-offer.types.tsx new file mode 100644 index 00000000..342e33c4 --- /dev/null +++ b/packages/react/src/ui/nft-make-offer/nft-make-offer.types.tsx @@ -0,0 +1,15 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export interface NftMakeOfferProps extends BaseComponentProps { + imgSrc: string; + tokenName: string; + onChange?: (value: number) => void; + onMakeOffer?: (event?: any) => void; + onCancel?: (event?: any) => void; + value?: number; + attributes?: any; + // === Labels + makeOfferLabel?: string; + cancelLabel?: string; + symbol?: string; +} diff --git a/packages/react/src/ui/nft-minimum-offer/index.ts b/packages/react/src/ui/nft-minimum-offer/index.ts new file mode 100644 index 00000000..aaac9159 --- /dev/null +++ b/packages/react/src/ui/nft-minimum-offer/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-minimum-offer"; diff --git a/packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.tsx b/packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.tsx new file mode 100644 index 00000000..0031180f --- /dev/null +++ b/packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import Stack from "../stack"; +import Button from "../button"; +import Box from "../box"; +import TokenInput from "../token-input"; +import StarText from "../star-text"; +import NftFees from "../nft-fees"; +import type { NftMinimumOfferProps } from "./nft-minimum-offer.types"; + +function NftMinimumOffer(props: NftMinimumOfferProps) { + const [starList, setStarList] = useState(() => [ + { + label: "Floor Price", + value: "", + }, + { + label: "Highest Offer", + value: "", + }, + ]); + + useEffect(() => { + setStarList( + starList.map((item, index) => { + let value = index === 0 ? props?.floorPrice : props?.highestOffer; + return { + label: item.label, + value: `${value}`, + }; + }) + ); + }, [props?.floorPrice, props?.highestOffer]); + + return ( + + props?.onChange?.(value)} + /> + + {starList?.map((item) => ( + + ))} + + + + + + + + ); +} + +export default NftMinimumOffer; diff --git a/packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.types.tsx b/packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.types.tsx new file mode 100644 index 00000000..8f455d27 --- /dev/null +++ b/packages/react/src/ui/nft-minimum-offer/nft-minimum-offer.types.tsx @@ -0,0 +1,12 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import { NftFeesProps } from "../nft-fees/nft-fees.types"; + +export interface NftMinimumOfferProps extends BaseComponentProps { + floorPrice: number; + highestOffer: number; + onList?: (event?: any) => void; + onCancel?: (event?: any) => void; + onChange?: (value: number) => void; + value?: number; + fees: NftFeesProps; +} diff --git a/packages/react/src/ui/nft-mint/index.ts b/packages/react/src/ui/nft-mint/index.ts new file mode 100644 index 00000000..fe53ccb2 --- /dev/null +++ b/packages/react/src/ui/nft-mint/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-mint"; diff --git a/packages/react/src/ui/nft-mint/nft-mint.css.ts b/packages/react/src/ui/nft-mint/nft-mint.css.ts new file mode 100644 index 00000000..2be628d6 --- /dev/null +++ b/packages/react/src/ui/nft-mint/nft-mint.css.ts @@ -0,0 +1,13 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const baseInput = style({ + height: '100%', + fontSize: themeVars.fontSize["lg"], + fontWeight: themeVars.fontWeight["semibold"], +}) + +export const starContainer = style({ + top: "50%", + transform: "translateY(-50%)" +}) diff --git a/packages/react/src/ui/nft-mint/nft-mint.tsx b/packages/react/src/ui/nft-mint/nft-mint.tsx new file mode 100644 index 00000000..0a71b0a0 --- /dev/null +++ b/packages/react/src/ui/nft-mint/nft-mint.tsx @@ -0,0 +1,411 @@ +import * as React from "react"; +import { useState } from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Icon from "../icon"; +import Box from "../box"; +import { store } from "../../models/store"; +import { toNumber } from "../../helpers/number"; +import * as styles from "./nft-mint.css"; +import type { NftMintProps } from "./nft-mint.types"; +import NumberField from "../number-field"; + +function NftMint(props: NftMintProps) { + const { + mintButtonLabel = "Mint", + mintButtonDisabledLabel = "Insufficient balance", + tokenName = "STAR", + } = props; + const [amount, setAmount] = useState(() => 0); + function isControlled() { + return typeof props.amount !== "undefined"; + } + function handleAmountChange(value: number) { + props?.onChange?.(value); // Update internal amount if uncontrolled + if (!isControlled()) { + setAmount(value); + } + } + return ( + + + {props.title} + + + + + + {props?.tag} + + + + + + {props?.name} + + + {props?.description} + + + + + Quantity + + + {props?.quantity} + + + + + Royalties + + + {new BigNumber(props?.royalties).decimalPlaces(2).toString()}% + + + + + Minted + + + {new BigNumber(props?.minted).decimalPlaces(2).toString()}% + + + + + + + + + + + + + + {props?.tag} + + + {props?.name} + + {props?.description} + + + + Quantity + + + {props?.quantity} + + + + + Royalties + + + {new BigNumber(props?.royalties).decimalPlaces(2).toString()}% + + + + + Minted + + + {new BigNumber(props?.minted).decimalPlaces(2).toString()}% + + + + + + + + + + + Select amount + + + + + Available + + {`${props?.available} ${tokenName}`} + + + + props?.onInput?.(event)} + onChange={(value) => handleAmountChange(value)} + inputClassName={styles.baseInput} + formatOptions={{ + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }} + /> + + + {`${ + props.tokenAmount ?? 0 + } ${tokenName}`} + {!!props.notionalAmount ? ( + {`≈ $${props.notionalAmount}`} + ) : null} + + + + props?.onInput?.(event)} + onChange={(value) => handleAmountChange(value)} + inputClassName={styles.baseInput} + formatOptions={{ + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }} + /> + + + {`${props.tokenAmount ?? 0} ${tokenName}`} + {!!props.notionalAmount ? ( + {`≈ $${props.notionalAmount}`} + ) : null} + + + + + + Price: + + {`${store + .getState() + ?.formatNumber?.({ + value: props?.priceDisplayAmount, + })} ${tokenName}`} + + {`Limited to ${store + .getState() + ?.formatNumber?.({ value: props?.limited })} tokens`} + + + + + + + + + + ); +} +export default NftMint; diff --git a/packages/react/src/ui/nft-mint/nft-mint.types.tsx b/packages/react/src/ui/nft-mint/nft-mint.types.tsx new file mode 100644 index 00000000..2e120c6e --- /dev/null +++ b/packages/react/src/ui/nft-mint/nft-mint.types.tsx @@ -0,0 +1,31 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export interface NftMintProps extends BaseComponentProps { + title: string; + tag: string; + name: string; + description: string; + quantity: number | string; + royalties: number | string; + minted: number | string; + available: number | string; + priceDisplayAmount: number | string; + limited: number | string; + imgSrc: string; + // ==== Token props + tokenName?: string; + pricePerToken: number | string; + // ==== Amount to bid for NFT + amount?: number; + defaultAmount?: number; + tokenAmount?: number; + notionalAmount?: number; + onChange?: (value: number) => void; + onInput?: (event?: any) => void; + // ==== Mint button props + isMintButtonDisabled?: boolean; + isMintLoading?: boolean; + mintButtonLabel?: string; + mintButtonDisabledLabel?: string; + onMint: (event?: any) => void; +} diff --git a/packages/react/src/ui/nft-profile-card-list/index.ts b/packages/react/src/ui/nft-profile-card-list/index.ts new file mode 100644 index 00000000..6b07c0c6 --- /dev/null +++ b/packages/react/src/ui/nft-profile-card-list/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-profile-card-list"; diff --git a/packages/react/src/ui/nft-profile-card-list/nft-profile-card-list-types.tsx b/packages/react/src/ui/nft-profile-card-list/nft-profile-card-list-types.tsx new file mode 100644 index 00000000..7b667892 --- /dev/null +++ b/packages/react/src/ui/nft-profile-card-list/nft-profile-card-list-types.tsx @@ -0,0 +1,8 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { NftProfileCardProps } from "../nft-profile-card/nft-profile-card.types"; + +export interface NftProfileCardListProps extends BaseComponentProps { + thumbnailBehavior?: NftProfileCardProps["thumbnailBehavior"]; + list: NftProfileCardProps[]; + attributes?: any; +} diff --git a/packages/react/src/ui/nft-profile-card-list/nft-profile-card-list.tsx b/packages/react/src/ui/nft-profile-card-list/nft-profile-card-list.tsx new file mode 100644 index 00000000..eaeec9b9 --- /dev/null +++ b/packages/react/src/ui/nft-profile-card-list/nft-profile-card-list.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import Box from "../box"; +import NftProfileCard from "../nft-profile-card"; +import type { NftProfileCardProps } from "../nft-profile-card/nft-profile-card.types"; +import type { NftProfileCardListProps } from "./nft-profile-card-list-types"; + +function NftProfileCardList(props: NftProfileCardListProps) { + return ( + 1 + ? "repeat(auto-fit, minmax(232px, 1fr))" + : "repeat(auto-fit, 232px)" + } + {...props.attributes} + className={props.className} + > + {props.list?.map((item, index) => ( + + item.onClick(event)} + thumbnailBehavior={props.thumbnailBehavior} + /> + + ))} + + ); +} + +export default NftProfileCardList; diff --git a/packages/react/src/ui/nft-profile-card/index.ts b/packages/react/src/ui/nft-profile-card/index.ts new file mode 100644 index 00000000..58a3805c --- /dev/null +++ b/packages/react/src/ui/nft-profile-card/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-profile-card"; diff --git a/packages/react/src/ui/nft-profile-card/nft-profile-card.tsx b/packages/react/src/ui/nft-profile-card/nft-profile-card.tsx new file mode 100644 index 00000000..c79bb289 --- /dev/null +++ b/packages/react/src/ui/nft-profile-card/nft-profile-card.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import clsx from "clsx"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import StarText from "../star-text"; +import type { NftProfileCardProps } from "./nft-profile-card.types"; + +function NftProfileCard(props: NftProfileCardProps) { + const { thumbnailBehavior = "contain" } = props; + return ( + props.onClick?.(event), + }} + className={clsx(props.className)} + > + + {thumbnailBehavior === "full" ? ( + + + + + ) : null} + {thumbnailBehavior === "contain" ? ( + + + + ) : null} + {props.name} + {props.priceItems?.map((priceItem, index) => ( + priceItem.onClick} + /> + ))} + + + ); +} + +export default NftProfileCard; diff --git a/packages/react/src/ui/nft-profile-card/nft-profile-card.types.tsx b/packages/react/src/ui/nft-profile-card/nft-profile-card.types.tsx new file mode 100644 index 00000000..d34285d2 --- /dev/null +++ b/packages/react/src/ui/nft-profile-card/nft-profile-card.types.tsx @@ -0,0 +1,23 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export type ProfileCardPriceItem = { + label: string; + value: string | number; + iconSrc?: string; + tokenName?: string; + onClick?: (event?: any) => void; +}; + +export interface NftProfileCardProps extends BaseComponentProps { + // ==== Controls the thumbnail display behavior + // "full" means the img will have its original aspect ratio fit within the square + // "contain" means the img will have a fixed aspect ratio to fit the square + thumbnailBehavior?: "full" | "contain"; + width?: Sprinkles["width"]; + imgSrc: string; + name: string; + priceItems: [ProfileCardPriceItem, ProfileCardPriceItem]; + onClick?: (event?: any) => void; + attributes?: any; +} diff --git a/packages/react/src/ui/nft-profile/index.ts b/packages/react/src/ui/nft-profile/index.ts new file mode 100644 index 00000000..63a20a02 --- /dev/null +++ b/packages/react/src/ui/nft-profile/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-profile"; diff --git a/packages/react/src/ui/nft-profile/nft-profile.tsx b/packages/react/src/ui/nft-profile/nft-profile.tsx new file mode 100644 index 00000000..9b9df344 --- /dev/null +++ b/packages/react/src/ui/nft-profile/nft-profile.tsx @@ -0,0 +1,143 @@ +import * as React from "react"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Box from "../box"; +import Icon from "../icon"; +import NftProfileCardList from "../nft-profile-card-list"; +import type { NftProfileProps } from "./nft-profile.types"; + +function NftProfile(props: NftProfileProps) { + return ( + + + {props.title} + + + + + {props.name} + + {props.isVerified ? ( + + + + ) : null} + + + + + + + + + + {props.meta?.map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + + {props.children == null && Array.isArray(props.list) ? ( + + ) : null} + {props.children != null ? <>{props.children} : null} + + ); +} + +export default NftProfile; diff --git a/packages/react/src/ui/nft-profile/nft-profile.types.tsx b/packages/react/src/ui/nft-profile/nft-profile.types.tsx new file mode 100644 index 00000000..c812aba3 --- /dev/null +++ b/packages/react/src/ui/nft-profile/nft-profile.types.tsx @@ -0,0 +1,19 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { NftProfileCardProps } from "../nft-profile-card/nft-profile-card.types"; + +type NftMeta = { + label: string; + value: string; +}; + +export interface NftProfileProps extends BaseComponentProps { + title: string; + headerButtonLabel: string; + meta: Array; + name: string; + isVerified: boolean; + list: NftProfileCardProps[]; + thumbnailBehavior?: NftProfileCardProps["thumbnailBehavior"]; + onView: (event?: any) => void; + attributes?: any; +} diff --git a/packages/react/src/ui/nft-sell-now/index.ts b/packages/react/src/ui/nft-sell-now/index.ts new file mode 100644 index 00000000..5c3d1f4c --- /dev/null +++ b/packages/react/src/ui/nft-sell-now/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-sell-now"; diff --git a/packages/react/src/ui/nft-sell-now/nft-sell-now.tsx b/packages/react/src/ui/nft-sell-now/nft-sell-now.tsx new file mode 100644 index 00000000..caa22e84 --- /dev/null +++ b/packages/react/src/ui/nft-sell-now/nft-sell-now.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Box from "../box"; +import Icon from "../icon"; +import NftFees from "../nft-fees"; +import { NftSellNowProps } from "./nft-sell-now.types"; + +function NftSellNow(props: NftSellNowProps) { + return ( + + + Best Offer + + + {`${props.bestOffer} STARS`} + + + + + This offer is + + {`${props?.offerToFloorPriceRatio}`} + + the floor price of + + {`${props?.floorPrice} STARS`} + + + + + + + + ); +} + +export default NftSellNow; diff --git a/packages/react/src/ui/nft-sell-now/nft-sell-now.types.tsx b/packages/react/src/ui/nft-sell-now/nft-sell-now.types.tsx new file mode 100644 index 00000000..677836af --- /dev/null +++ b/packages/react/src/ui/nft-sell-now/nft-sell-now.types.tsx @@ -0,0 +1,10 @@ +import { NftFeesProps } from "../nft-fees/nft-fees.types"; + +export interface NftSellNowProps { + bestOffer: number; + offerToFloorPriceRatio: string; + floorPrice: number; + fees?: NftFeesProps; + onList?: (event?: any) => void; + onCancel?: (event?: any) => void; +} diff --git a/packages/react/src/ui/nft-trait-list-item/index.ts b/packages/react/src/ui/nft-trait-list-item/index.ts new file mode 100644 index 00000000..7282ec83 --- /dev/null +++ b/packages/react/src/ui/nft-trait-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-trait-list-item"; diff --git a/packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.tsx b/packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.tsx new file mode 100644 index 00000000..cbc9b3ed --- /dev/null +++ b/packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import type { NftTraitListItemProps } from "./nft-trait-list-item.types"; + +function NftTraitListItem(props: NftTraitListItemProps) { + return ( + + + + {props?.name} + + {props?.value} + + + {props?.rarityPercent}% + + + ); +} + +export default NftTraitListItem; diff --git a/packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.types.tsx b/packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.types.tsx new file mode 100644 index 00000000..a0ad33e7 --- /dev/null +++ b/packages/react/src/ui/nft-trait-list-item/nft-trait-list-item.types.tsx @@ -0,0 +1,5 @@ +export interface NftTraitListItemProps { + name: string; + value: string; + rarityPercent: number; +} diff --git a/packages/react/src/ui/nft-trait-list/index.ts b/packages/react/src/ui/nft-trait-list/index.ts new file mode 100644 index 00000000..556182c1 --- /dev/null +++ b/packages/react/src/ui/nft-trait-list/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-trait-list"; diff --git a/packages/react/src/ui/nft-trait-list/nft-trait-list.tsx b/packages/react/src/ui/nft-trait-list/nft-trait-list.tsx new file mode 100644 index 00000000..b76778a4 --- /dev/null +++ b/packages/react/src/ui/nft-trait-list/nft-trait-list.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import Box from "../box"; +import NftTraitListItem from "../nft-trait-list-item"; +import { NftTraitListItemProps } from "../nft-trait-list-item/nft-trait-list-item.types"; +import { NftTraitListProps } from "./nft-trait-list.types"; + +function NftTraitList(props: NftTraitListProps) { + return ( + + {props.list?.map((item, index) => ( + + + + ))} + + ); +} + +export default NftTraitList; diff --git a/packages/react/src/ui/nft-trait-list/nft-trait-list.types.tsx b/packages/react/src/ui/nft-trait-list/nft-trait-list.types.tsx new file mode 100644 index 00000000..f62185a9 --- /dev/null +++ b/packages/react/src/ui/nft-trait-list/nft-trait-list.types.tsx @@ -0,0 +1,7 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { NftTraitListItemProps } from "../nft-trait-list-item/nft-trait-list-item.types"; + +export interface NftTraitListProps extends BaseComponentProps { + list: NftTraitListItemProps[]; + attributes?: any; +} diff --git a/packages/react/src/ui/nft-transfer/index.ts b/packages/react/src/ui/nft-transfer/index.ts new file mode 100644 index 00000000..dbe154c4 --- /dev/null +++ b/packages/react/src/ui/nft-transfer/index.ts @@ -0,0 +1 @@ +export { default } from "./nft-transfer"; diff --git a/packages/react/src/ui/nft-transfer/nft-transfer.tsx b/packages/react/src/ui/nft-transfer/nft-transfer.tsx new file mode 100644 index 00000000..180dc83c --- /dev/null +++ b/packages/react/src/ui/nft-transfer/nft-transfer.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { useState } from "react"; +import Box from "../box"; +import Text from "../text"; +import TextField from "../text-field"; +import Button from "../button"; +import type { NftTransferProps } from "./nft-transfer.types"; + +function NftTransfer(props: NftTransferProps) { + const { + placeholder = "Enter address", + transferLabel = "Transfer", + cancelLabel = "Cancel", + label = "Recipient", + } = props; + const [address, setAddress] = useState(() => ""); + return ( + + + {label} + + { + setAddress(e.target.value); + props.onChange?.(e.target.value); + }} + /> + + + + + ); +} + +export default NftTransfer; diff --git a/packages/react/src/ui/nft-transfer/nft-transfer.types.tsx b/packages/react/src/ui/nft-transfer/nft-transfer.types.tsx new file mode 100644 index 00000000..0cdf4b62 --- /dev/null +++ b/packages/react/src/ui/nft-transfer/nft-transfer.types.tsx @@ -0,0 +1,13 @@ +import type { BaseComponentProps } from "../../models/components.model"; + +export interface NftTransferProps extends BaseComponentProps { + onChange?: (event?: any) => void; + onTransfer?: (event?: any) => void; + onCancel?: (event?: any) => void; + id?: string; + disabled?: boolean; + label?: string; + placeholder?: string; + transferLabel?: string; + cancelLabel?: string; +} diff --git a/packages/react/src/ui/noble-chain-combobox/index.ts b/packages/react/src/ui/noble-chain-combobox/index.ts new file mode 100644 index 00000000..deadbe4d --- /dev/null +++ b/packages/react/src/ui/noble-chain-combobox/index.ts @@ -0,0 +1 @@ +export { default } from "./noble-chain-combobox"; diff --git a/packages/react/src/ui/noble-chain-combobox/list-box.tsx b/packages/react/src/ui/noble-chain-combobox/list-box.tsx new file mode 100644 index 00000000..8775760f --- /dev/null +++ b/packages/react/src/ui/noble-chain-combobox/list-box.tsx @@ -0,0 +1,123 @@ +import * as React from "react"; +import clx from "clsx"; +import type { AriaListBoxOptions } from "@react-aria/listbox"; +import type { ListState } from "react-stately"; +import type { Node } from "@react-types/shared"; + +import { useListBox, useListBoxSection, useOption } from "react-aria"; +import Text from "@/ui/text"; +import Box from "@/ui/box"; +import type { BoxProps } from "@/ui/box/box.types"; + +import ListItem from "@/ui/list-item"; +import useTheme from "@/ui/hooks/use-theme"; +import { listboxStyle } from "./noble-chain-combobox.css"; + +interface ListBoxProps extends AriaListBoxOptions { + listBoxRef?: React.RefObject; + state: ListState; + styleProps?: BoxProps; +} + +interface SectionProps { + section: Node; + state: ListState; +} + +interface OptionProps { + item: Node; + state: ListState; +} + +export function ListBox(props: ListBoxProps) { + const ref = React.useRef(null); + const { listBoxRef = ref, state } = props; + const { listBoxProps } = useListBox(props, state, listBoxRef); + const { theme } = useTheme(); + + return ( + + {[...state.collection].map((item) => + item.type === "section" ? ( + + ) : ( + + ); +} + +function ListBoxSection({ section, state }: SectionProps) { + const { itemProps, headingProps, groupProps } = useListBoxSection({ + heading: section.rendered, + "aria-label": section["aria-label"], + }); + + return ( + <> + + {section.rendered && ( + + {section.rendered} + + )} + +
      + {[...section.childNodes].map((node) => ( +
    +
    + + ); +} + +function Option({ item, state }: OptionProps) { + const ref = React.useRef(null); + const { optionProps, isSelected, isFocused, isDisabled } = useOption( + { + key: item.key, + }, + state, + ref, + ); + + return ( +
  • + + {item.rendered} + +
  • + ); +} diff --git a/packages/react/src/ui/noble-chain-combobox/noble-chain-combobox.css.ts b/packages/react/src/ui/noble-chain-combobox/noble-chain-combobox.css.ts new file mode 100644 index 00000000..5f1bfdf8 --- /dev/null +++ b/packages/react/src/ui/noble-chain-combobox/noble-chain-combobox.css.ts @@ -0,0 +1,80 @@ +import { ComplexStyleRule, style, styleVariants } from "@vanilla-extract/css"; +import { scrollBarThumbBgVar, scrollBar } from "@/ui/shared/shared.css"; +import { themeVars } from "@/styles/themes.css"; +import { listBoxBaseWithShadow } from "@/ui/select/select.css"; + +export const baseInputStyles = style({ + outline: "none", + appearance: "none", + fontFamily: themeVars.font.body, + selectors: { + "&::-webkit-outer-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + "&::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + }, +}); + +export const comboboxInputElement = style({ + color: "inherit", + boxShadow: "none !important", + appearance: "none", +}); + +export const comboboxPopover = style({ + backgroundColor: themeVars.colors.inputBg, + borderBottomLeftRadius: themeVars.radii.lg, + borderBottomRightRadius: themeVars.radii.lg, +}); + +export const hide = style({ + display: "none", + pointerEvents: "none", +}); + +export const label = style({ + borderWidth: "0", + height: "1px", + width: "1px", + margin: "-1px", + overflow: "hidden", + padding: 0, + position: "absolute", + whiteSpace: "nowrap", +}); + +const overrides: ComplexStyleRule = { + backgroundColor: `${themeVars.colors.inputBg} !important`, + maxHeight: "235px !important", + boxShadow: `none !important`, + borderTopWidth: `0 !important`, + borderTopLeftRadius: `0 !important`, + borderTopRightRadius: `0 !important`, +}; + +export const listboxStyle = styleVariants({ + light: [ + scrollBar.light, + listBoxBaseWithShadow, + style({ + vars: { + [scrollBarThumbBgVar]: themeVars.colors.gray500, + }, + ...overrides, + }), + ], + dark: [ + scrollBar.dark, + listBoxBaseWithShadow, + style({ + vars: { + [scrollBarThumbBgVar]: themeVars.colors.blue500, + }, + ...overrides, + }), + ], +}); diff --git a/packages/react/src/ui/noble-chain-combobox/noble-chain-combobox.tsx b/packages/react/src/ui/noble-chain-combobox/noble-chain-combobox.tsx new file mode 100644 index 00000000..a5fb29e2 --- /dev/null +++ b/packages/react/src/ui/noble-chain-combobox/noble-chain-combobox.tsx @@ -0,0 +1,203 @@ +import * as React from "react"; +import clx from "clsx"; +import type { ComboBoxProps } from "@react-types/combobox"; +import { + useComboBoxState, + useSearchFieldState, + Item, + Section, +} from "react-stately"; +import { useComboBox, useFilter, useSearchField } from "react-aria"; + +import Icon from "@/ui/icon"; +import Box from "@/ui/box"; +import type { BoxProps } from "@/ui/box/box.types"; +import { ListBox } from "./list-box"; +import * as styles from "./noble-chain-combobox.css"; + +const DEFAULT_WIDTH: BoxProps["width"] = "$29"; + +interface ComboboxProps extends ComboBoxProps { + defaultIsOpen?: boolean; + styleProps?: BoxProps; +} + +function useMeasure() { + const [dimensions, setDimensions] = React.useState<{ + width: number | null; + height: number | null; + }>({ + width: null, + height: null, + }); + + const previousObserver = React.useRef(null); + + const customRef = React.useCallback((node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.borderBoxSize) { + const { inlineSize: width, blockSize: height } = + entry.borderBoxSize[0]; + + setDimensions({ width, height }); + } + }); + + observer.observe(node); + previousObserver.current = observer; + } + }, []); + + return [customRef, dimensions] as const; +} + +export default function NobleChainCombobox( + props: ComboboxProps, +) { + const { + styleProps, + defaultIsOpen = true, + label = "Select chain", + ...comboboxProps + } = props; + const { contains } = useFilter({ sensitivity: "base" }); + const state = useComboBoxState({ + ...comboboxProps, + defaultFilter: contains, + allowsEmptyCollection: true, + }); + + const [isFocused, setIsFocused] = React.useState(false); + const containerRef = React.useRef(null); + const buttonRef = React.useRef(null); + const inputRef = React.useRef(null); + const listBoxRef = React.useRef(null); + const [popoverRef] = React.useState( + document.createElement("div"), + ); + + const { inputProps, listBoxProps, labelProps } = useComboBox( + { + ...comboboxProps, + label, + onFocus: () => { + setIsFocused(true); + }, + onBlur: () => { + setIsFocused(false); + }, + inputRef, + buttonRef, + listBoxRef, + popoverRef: { current: popoverRef }, + }, + state, + ); + + // Get props for the clear button from useSearchField + const searchProps = { + label: props.label ?? "Select chain", + value: state.inputValue, + onChange: (v: string) => state.setInputValue(v), + }; + + const searchState = useSearchFieldState(searchProps); + + const [measureRef, dimensions] = useMeasure(); + + useSearchField(searchProps, searchState, inputRef); + + // Keep the listbox open + React.useEffect(() => { + if (defaultIsOpen) { + state.open(); + } + }, []); + + const inputBorderBottomRadius = state.isOpen + ? state.collection.size === 0 + ? "8px" + : "$none" + : "8px"; + + return ( + + + +
    + + + + + + + +
    + + {state.isOpen && ( + + )} +
    + ); +} + +NobleChainCombobox.Item = Item; +NobleChainCombobox.Section = Section; diff --git a/packages/react/src/ui/noble-chain-combobox/popover.tsx b/packages/react/src/ui/noble-chain-combobox/popover.tsx new file mode 100644 index 00000000..9b3e7f4b --- /dev/null +++ b/packages/react/src/ui/noble-chain-combobox/popover.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import type { OverlayTriggerState } from "react-stately"; +import type { AriaPopoverProps } from "@react-aria/overlays"; +import { usePopover, DismissButton, Overlay } from "@react-aria/overlays"; +import Box from "@/ui/box"; +import NobleProvider from "@/ui/noble/noble-provider"; + +interface PopoverProps extends Omit { + children: React.ReactNode; + state: OverlayTriggerState; + className?: string; + popoverRef?: React.RefObject; +} + +export function Popover(props: PopoverProps) { + const ref = React.useRef(null); + const { popoverRef = ref, state, children, className, isNonModal } = props; + + const { popoverProps, underlayProps } = usePopover( + { + ...props, + popoverRef, + }, + state, + ); + + return ( + + + {!isNonModal && ( + + )} + + + {!isNonModal && } + + {children} + + + + + + ); +} diff --git a/packages/react/src/ui/noble/index.ts b/packages/react/src/ui/noble/index.ts new file mode 100644 index 00000000..cffe2661 --- /dev/null +++ b/packages/react/src/ui/noble/index.ts @@ -0,0 +1 @@ +export { default } from "./noble-provider"; diff --git a/packages/react/src/ui/noble/noble-button.tsx b/packages/react/src/ui/noble/noble-button.tsx new file mode 100644 index 00000000..e203c26e --- /dev/null +++ b/packages/react/src/ui/noble/noble-button.tsx @@ -0,0 +1,213 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import type { + NobleButtonProps, + NobleButtonVariant, + NobleButtonSize, +} from "./noble.types"; +import type { BoxProps } from "../box/box.types"; + +function NobleButton(props: NobleButtonProps) { + const { size = "lg", variant = "solid" } = props; + const cleanupRef = useRef<(() => void) | null>(null); + const [theme, setTheme] = useState(() => "light"); + function buttonProps() { + const { + leftIcon, + rightIcon, + iconSize, + variant, + size, + isActive, + ...otherProps + } = props; + return otherProps; + } + function variantStyles() { + const variantStylesMap: Record = { + solid: { + bg: { base: "$primary", hover: "$blue700" }, + fontWeight: "$semibold", + color: "$white", + ...getSizeStyles(size), + ...getDisabledStyles(), + }, + text: { + bg: "transparent", + color: "$textSecondary", + fontSize: "$sm", + fontWeight: "$normal", + lineHeight: "$base", + }, + outlined: { + color: "$text", + bg: props.isActive + ? theme === "light" + ? "$gray800" + : "$blue300" + : { base: "$cardBg", hover: "$body" }, + borderRadius: "$lg", + borderWidth: props.borderless ? "$none" : "1px", + borderStyle: "$solid", + borderColor: "$progressBg", + fontSize: "$md", + fontWeight: "$normal", + lineHeight: "$base", + ...getSizeStyles(size), + ...getDisabledStyles(), + }, + tag: { + bg: props.isActive + ? theme === "light" + ? "$gray400" + : "$blue700" + : { + base: "$progressBg", + hover: theme === "light" ? "$gray600" : "$blue200", + active: theme === "light" ? "$gray400" : "$blue700", + }, + color: props.isActive + ? theme === "light" + ? "$textInverse" + : "$blue200" + : { + base: theme === "light" ? "$gray400" : "$textSecondary", + hover: theme === "light" ? "$gray400" : "$textSecondary", + active: theme === "light" ? "$textInverse" : "$blue200", + }, + borderRadius: "$base", + fontWeight: "$semibold", + lineHeight: "$base", + ...getSizeStyles(size), + ...getDisabledStyles(), + }, + }; + return variantStylesMap[variant]; + } + function getDisabledStyles() { + const isLightTheme = theme === "light"; + if (variant === "solid") { + return props.disabled + ? ({ + bg: isLightTheme + ? { base: "$gray700", hover: "$gray700" } + : { base: "$blue100", hover: "$blue100" }, + color: isLightTheme ? "$gray600" : "$blue400", + cursor: "not-allowed", + } as BoxProps) + : {}; + } + if (variant === "tag") { + return props.disabled + ? ({ opacity: 0.5, cursor: "not-allowed" } as BoxProps) + : {}; + } // For text and outlined variants + return props.disabled + ? ({ + bg: "$transparent", + color: "$progressBg", + cursor: "not-allowed", + } as BoxProps) + : {}; + } + function getSizeStyles(size: NobleButtonSize) { + const sizeStylesMap: Record = { + xs: { + height: "$11", + px: "$4", + py: "$2", + borderRadius: "$base", + fontSize: "$sm", + }, + sm: { + height: "38px", + px: "$7", + py: "$5", + borderRadius: "$base", + fontSize: "$sm", + }, + md: { + height: "70px", + px: "$10", + py: "$10", + borderRadius: "$base", + fontSize: "$md", + }, + lg: { + height: "$17", + width: "$full", + borderRadius: "$md", + fontSize: "$lg", + }, + xl: { + height: "$21", + p: "$10", + width: "$full", + borderRadius: "$md", + fontSize: "$lg", + }, + }; + return sizeStylesMap[size]; + } + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + props.onClick?.(event), + }} + className={props.className} + > + + {props.children} + + + ); +} + +export default NobleButton; diff --git a/packages/react/src/ui/noble/noble-input.tsx b/packages/react/src/ui/noble/noble-input.tsx new file mode 100644 index 00000000..88f10a4e --- /dev/null +++ b/packages/react/src/ui/noble/noble-input.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Box from "../box"; +import Stack from "../stack"; +import FieldLabel from "../field-label"; +import { + validTypes, + defaultInputModesForType, +} from "../text-field/text-field.types"; +import { store } from "../../models/store"; +import * as styles from "./noble.css"; +import type { NobleInputProps } from "./noble.types"; + +function NobleInput(props: NobleInputProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + const [isFocused, setIsFocused] = useState(() => false); + + function inputVariantProps() { + if (props.size === "md") { + return { + fontSize: "$3xl", + fontWeight: "$semibold", + height: "$21", + px: "$10", + py: "$10", + }; + } + return { + fontSize: "$sm", + fontWeight: "$normal", + height: "$16", + px: "$8", + py: "$8", + }; + } + + function borderProps() { + const hasIntent = props.intent != null; + const borderColorDefault = isFocused + ? "$inputBorderFocus" + : { + base: "$inputBorder", + hover: "$textSecondary", + }; + const borderColorIntents = { + success: "$textSuccess", + error: "$textDanger", + }; + return { + borderRadius: "$lg", + borderWidth: hasIntent ? "$base" : "$sm", + borderStyle: "$solid", + borderColor: hasIntent + ? borderColorIntents[props.intent] + : borderColorDefault, + }; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + {props.label && typeof props.label === "string" ? ( + + ) : null} + {props.label && typeof props.label !== "string" ? ( + <>{props.label} + ) : null} + {props.labelExtra ? <>{props.labelExtra} : null} + + + {props.startAddon ? ( + + {props.startAddon} + + ) : null} + { + props.onChange?.(e); + props.inputAttributes?.onChange?.(e); + }} + onFocus={(e) => { + setIsFocused(true); + props.onFocus?.(e); + props.inputAttributes?.onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + props.onBlur?.(e); + props.inputAttributes?.onBlur?.(e); + }} + placeholder={!props.disabled ? props.placeholder : undefined} + inputMode={props.inputMode || defaultInputModesForType[props.type]} + data-size={props.size ?? "md"} + data-disabled={props.disabled} + data-align={props.inputTextAlign} + className={clx(styles.inputBase, props.inputClassName)} + /> + {props.endAddon ? <>{props.endAddon} : null} + + {!!props.helperText ? <>{props.helperText} : null} + + ); +} + +export default NobleInput; diff --git a/packages/react/src/ui/noble/noble-page-title-bar.tsx b/packages/react/src/ui/noble/noble-page-title-bar.tsx new file mode 100644 index 00000000..877cd50e --- /dev/null +++ b/packages/react/src/ui/noble/noble-page-title-bar.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import { store } from "../../models/store"; +import { NoblePageTitleBarProps } from "./noble.types"; +import NobleButton from "./noble-button"; + +function NoblePageTitleBar(props: NoblePageTitleBarProps) { + const { showBackButton = true } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + {showBackButton ? ( + props.onBackButtonClick?.(event)} + color={theme === "light" ? "$gray500" : "$gray600"} + attributes={{ + borderWidth: "1px", + borderStyle: "solid", + borderColor: theme === "light" ? "$gray700" : "$blue300", + borderRadius: "$base", + bg: theme === "light" ? "$white" : "$blue200", + width: "$12", + height: "$12", + transform: "rotate(180deg)", + }} + /> + ) : null} + + {props.title} + + + ); +} + +export default NoblePageTitleBar; diff --git a/packages/react/src/ui/noble/noble-provider.tsx b/packages/react/src/ui/noble/noble-provider.tsx new file mode 100644 index 00000000..2b7120db --- /dev/null +++ b/packages/react/src/ui/noble/noble-provider.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import { store } from "../../models/store"; +import { ThemeVariant } from "../../models/system.model"; +import { + nobleDarkThemeClass, + nobleLightThemeClass, +} from "../../styles/themes.css"; +import type { NobleProviderProps } from "./noble.types"; + +function NobleProvider(props: NobleProviderProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function isControlled() { + return props.themeMode != null; + } + + function providerThemeMode() { + if (isControlled()) return props.themeMode; + return theme; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    + {props.children} +
    + ); +} + +export default NobleProvider; diff --git a/packages/react/src/ui/noble/noble-select-network-button.tsx b/packages/react/src/ui/noble/noble-select-network-button.tsx new file mode 100644 index 00000000..a375c511 --- /dev/null +++ b/packages/react/src/ui/noble/noble-select-network-button.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import NobleButton from "./noble-button"; +import { store } from "../../models/store"; +import type { NobleSelectNetworkButtonProps } from "./noble.types"; + +function NobleSelectNetworkButton(props: NobleSelectNetworkButtonProps) { + const cleanupRef = useRef<(() => void) | null>(null); + const [theme, setTheme] = useState(() => "light"); + + function buttonProps() { + const { size, title, subTitle, actionLabel, logoUrl, ...otherProps } = + props; + return otherProps; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + + {props.title} + + + {props.subTitle} + + + + + {props.actionLabel} + + + + + ); +} + +export default NobleSelectNetworkButton; diff --git a/packages/react/src/ui/noble/noble-select-token-button.tsx b/packages/react/src/ui/noble/noble-select-token-button.tsx new file mode 100644 index 00000000..c6b0f633 --- /dev/null +++ b/packages/react/src/ui/noble/noble-select-token-button.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import NobleButton from "./noble-button"; +import NobleTokenAvatar from "./noble-token-avatar"; +import { store } from "../../models/store"; +import type { NobleSelectTokenButtonProps } from "./noble.types"; + +function NobleSelectTokenButton(props: NobleSelectTokenButtonProps) { + const cleanupRef = useRef<(() => void) | null>(null); + const [theme, setTheme] = useState(() => "light"); + + function buttonProps() { + const { size, ...otherProps } = props; + return otherProps; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + + + {props.token.symbol} + + + {props.token.tokenAmount} + + + + + {props.token.network} + + + {props.token.notionalValue} + + + + + + ); +} + +export default NobleSelectTokenButton; diff --git a/packages/react/src/ui/noble/noble-select-wallet-button.tsx b/packages/react/src/ui/noble/noble-select-wallet-button.tsx new file mode 100644 index 00000000..af6d0d6b --- /dev/null +++ b/packages/react/src/ui/noble/noble-select-wallet-button.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import NobleButton from "./noble-button"; +import { store } from "../../models/store"; +import * as styles from "./noble.css"; +import type { NobleSelectWalletButtonProps } from "./noble.types"; + +function NobleSelectWalletButton(props: NobleSelectWalletButtonProps) { + const cleanupRef = useRef<(() => void) | null>(null); + const [theme, setTheme] = useState(() => "light"); + + function buttonProps() { + const { logoUrl, logoAlt, title, subTitle, ...otherProps } = props; + return otherProps; + } + + function textColor() { + return props.disabled ? "inherit" : "$text"; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + + + {props.title} + + + {props.subTitle} + + + + ); +} + +export default NobleSelectWalletButton; diff --git a/packages/react/src/ui/noble/noble-token-avatar.tsx b/packages/react/src/ui/noble/noble-token-avatar.tsx new file mode 100644 index 00000000..3875738c --- /dev/null +++ b/packages/react/src/ui/noble/noble-token-avatar.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import { store } from "../../models/store"; +import type { NobleTokenAvatarProps } from "./noble.types"; + +function NobleTokenAvatar(props: NobleTokenAvatarProps) { + const { isRound = true } = props; + const cleanupRef = useRef<(() => void) | null>(null); + const [theme, setTheme] = useState(() => "light"); + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + {props.isLoadingSubLogo ? ( + + ) : ( + <> + + + )} + + ); +} + +export default NobleTokenAvatar; diff --git a/packages/react/src/ui/noble/noble-tx-chain-route.tsx b/packages/react/src/ui/noble/noble-tx-chain-route.tsx new file mode 100644 index 00000000..78519509 --- /dev/null +++ b/packages/react/src/ui/noble/noble-tx-chain-route.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import { NobleTxChainRouteProps } from "./noble.types"; + +function NobleTxChainRoute(props: NobleTxChainRouteProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + + ); +} + +export default NobleTxChainRoute; diff --git a/packages/react/src/ui/noble/noble-tx-direction-card.tsx b/packages/react/src/ui/noble/noble-tx-direction-card.tsx new file mode 100644 index 00000000..e37b5da0 --- /dev/null +++ b/packages/react/src/ui/noble/noble-tx-direction-card.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import { useState } from "react"; +import copy from "copy-to-clipboard"; +import Box from "../box"; +import Text from "../text"; +import Icon from "../icon"; +import { NobleTxDirectionCardProps } from "./noble.types"; +import { truncateTextMiddle } from "../../helpers/string"; + +function NobleTxDirectionCard(props: NobleTxDirectionCardProps) { + const { addressDisplayLength = 18 } = props; + const [copied, setCopied] = useState(() => false); + function onCopy() { + const success = copy(props.address); + if (success) { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1000); + } + } + function truncate(text: string) { + return truncateTextMiddle(text, addressDisplayLength); + } + return ( + + + {props.direction} + + + {props.chainName} + + + + + {truncate(props.address)} + + {!copied ? ( + + + + ) : ( + <> + + + )} + + + ); +} + +export default NobleTxDirectionCard; diff --git a/packages/react/src/ui/noble/noble-tx-estimate.tsx b/packages/react/src/ui/noble/noble-tx-estimate.tsx new file mode 100644 index 00000000..3019ccf1 --- /dev/null +++ b/packages/react/src/ui/noble/noble-tx-estimate.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import Icon from "../icon"; +import NobleTxChainRoute from "./noble-tx-chain-route"; +import { store } from "../../models/store"; +import { NobleTxEstimateProps } from "./noble.types"; + +function NobleTxEstimate(props: NobleTxEstimateProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + + + + {props.timeEstimateLabel} + + + + + {props.feeEstimateLabel} + + + ); +} + +export default NobleTxEstimate; diff --git a/packages/react/src/ui/noble/noble-tx-history-overview-item.tsx b/packages/react/src/ui/noble/noble-tx-history-overview-item.tsx new file mode 100644 index 00000000..fbffa50a --- /dev/null +++ b/packages/react/src/ui/noble/noble-tx-history-overview-item.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import { NobleTxHistoryOverviewItemProps, NobleTxStatus } from "./noble.types"; + +function NobleTxHistoryOverviewItem(props: NobleTxHistoryOverviewItemProps) { + const { + mainLogoSrc = "https://raw.githubusercontent.com/cosmos/chain-registry/master/_non-cosmos/ethereum/images/usdc.svg", + amountUnit = "USDC", + isExpanded = false, + } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + function statusText() { + const statusTextMap: Record = { + processing: "Processing", + successful: "Successful", + }; + return props?.customStatus?.text ?? statusTextMap[props.status]; + } + function statusColor() { + const statusColorMap: Record = { + processing: theme === "light" ? "$blue500" : "$blue700", + successful: "$textSuccess", + }; + return props?.customStatus?.color ?? statusColorMap[props.status]; + } + function chevronColor() { + return theme === "light" ? "$gray400" : "$blue500"; + } + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + + + + + + + + + + + + {`${props.amount} ${amountUnit}`} + + {statusText()} + + + + + + ); +} + +export default NobleTxHistoryOverviewItem; diff --git a/packages/react/src/ui/noble/noble-tx-progress-bar.tsx b/packages/react/src/ui/noble/noble-tx-progress-bar.tsx new file mode 100644 index 00000000..031f4324 --- /dev/null +++ b/packages/react/src/ui/noble/noble-tx-progress-bar.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import { debounce } from "lodash"; +import Box from "../box"; +import { NobleTxProgressBarProps } from "./noble.types"; + +function NobleTxProgressBar(props: NobleTxProgressBarProps) { + const { width = "$full", mx = "$0", mt = "$0", mb = "$0" } = props; + const resizeHandlerRef = useRef<() => void>(null); + const progressTrackRef = useRef(null); + const [trackWidth, setTrackWidth] = useState(() => 0); + const [barWidth, setBarWidth] = useState(() => 0); + function calcBarWidth() { + const MAX_PROGRESS = 100; + const MIN_PROGRESS = 0; + const progress = Math.min( + Math.max(props.progress, MIN_PROGRESS), + MAX_PROGRESS + ); + return (progress / MAX_PROGRESS) * trackWidth; + } + useEffect(() => { + resizeHandlerRef.current = debounce(() => { + if (!progressTrackRef.current) return; + setTrackWidth(progressTrackRef.current.clientWidth); + }, 100); + window.addEventListener("resize", resizeHandlerRef.current); + resizeHandlerRef.current(); + }, []); + useEffect(() => { + setBarWidth(calcBarWidth()); + }, [trackWidth, props.progress]); + useEffect(() => { + return () => { + if (window) { + window.removeEventListener("resize", resizeHandlerRef.current); + } + }; + }, []); + return ( + + + + ); +} + +export default NobleTxProgressBar; diff --git a/packages/react/src/ui/noble/noble-tx-step-item.tsx b/packages/react/src/ui/noble/noble-tx-step-item.tsx new file mode 100644 index 00000000..b49fae17 --- /dev/null +++ b/packages/react/src/ui/noble/noble-tx-step-item.tsx @@ -0,0 +1,180 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Text from "../text"; +import Stack from "../stack"; +import { store } from "../../models/store"; +import type { NobleTxStepItemProps } from "./noble.types"; +import * as styles from "./noble.css"; + +function NobleTxStepItem(props: NobleTxStepItemProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function getIconColor() { + return theme === "light" ? "$gray500" : "$blue500"; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + {props.status === "completed" ? ( + + + + ) : null} + {props.status === "processing" ? ( + + + + + + + + + + + + + + + + ) : null} + {props.status === "pending" ? : null} + + {props.step} + + + ); +} + +export default NobleTxStepItem; diff --git a/packages/react/src/ui/noble/noble.css.ts b/packages/react/src/ui/noble/noble.css.ts new file mode 100644 index 00000000..5b023d74 --- /dev/null +++ b/packages/react/src/ui/noble/noble.css.ts @@ -0,0 +1,70 @@ +import { style, keyframes } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +const rotate = keyframes({ + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, +}); + +export const processingIcon = style({ + animation: `${rotate} 1s linear infinite`, +}); + +export const walletImg = style({ + display: "block", +}); + +export const walletImgContainer = style({ + position: "relative", + display: "inline-block", + selectors: { + "&[data-disabled='true']::after": { + position: "absolute", + content: '""', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: themeVars.colors.body, + mixBlendMode: "hue", + backdropFilter: "grayscale(0.4) opacity(0.5)", + }, + }, +}); + +export const inputBase = style({ + width: "100%", + height: "100%", + flex: 1, + border: "none", + outline: "none", + position: "relative", + appearance: "none", + fontFamily: themeVars.font.body, + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", + color: "inherit", + fontWeight: "inherit", + fontSize: "inherit", + letterSpacing: "inherit", + backgroundColor: "transparent", + borderRadius: themeVars.radii.lg, + padding: 0, + selectors: { + "&::-webkit-outer-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + "&::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + '&[data-align="left"]': { + textAlign: "left", + }, + '&[data-align="right"]': { + textAlign: "right", + }, + }, +}); diff --git a/packages/react/src/ui/noble/noble.types.tsx b/packages/react/src/ui/noble/noble.types.tsx new file mode 100644 index 00000000..94c4c430 --- /dev/null +++ b/packages/react/src/ui/noble/noble.types.tsx @@ -0,0 +1,145 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { BoxProps } from "../box/box.types"; +import type { ButtonProps } from "../button/button.types"; +import type { ThemeProviderProps } from "../theme-provider/theme-provider.types"; +import type { TextFieldProps } from "../text-field/text-field.types"; + +export interface NobleProviderProps extends BaseComponentProps { + themeMode?: ThemeProviderProps["themeMode"]; +} + +export interface NobleTxDirectionCardProps extends BaseComponentProps { + direction: string; + chainName: string; + address: string; + logoUrl: string; + addressDisplayLength?: number; +} + +export interface NobleTxProgressBarProps extends BaseComponentProps { + progress: number; + width?: BoxProps["width"]; + mx?: BoxProps["mx"]; + mt?: BoxProps["mt"]; + mb?: BoxProps["mb"]; +} + +export interface NobleTxStepItemProps extends BaseComponentProps { + step: string; + status: "completed" | "processing" | "pending"; +} + +type BaseButtonProps = Omit< + ButtonProps, + "variant" | "intent" | "size" | "isLoading" | "spinnerPlacement" +>; + +export type NobleButtonVariant = "text" | "solid" | "outlined" | "tag"; +export type NobleButtonSize = "xs" | "sm" | "md" | "lg" | "xl"; + +export interface NobleButtonProps extends BaseButtonProps { + color?: BoxProps["color"]; + width?: BoxProps["width"]; + height?: BoxProps["height"]; + variant?: NobleButtonVariant; + size?: NobleButtonSize; + fontSize?: BoxProps["fontSize"]; + fontWeight?: BoxProps["fontWeight"]; + lineHeight?: BoxProps["lineHeight"]; + isActive?: boolean; + borderless?: boolean; +} + +export interface NobleTokenAvatarProps { + mainLogoUrl: string; + mainLogoAlt?: string; + subLogoUrl: string; + subLogoAlt?: string; + isRound?: boolean; + isLoadingSubLogo?: boolean; +} + +type DisplayToken = { + symbol: string; + network: string; + tokenAmount: string; + notionalValue: string; +} & NobleTokenAvatarProps; + +export interface NobleSelectTokenButtonProps extends BaseButtonProps { + width?: BoxProps["width"]; + height?: BoxProps["height"]; + size?: NobleButtonSize; + borderless?: boolean; + isActive?: boolean; + token: DisplayToken; +} + +export interface NobleSelectNetworkButtonProps extends BaseButtonProps { + width?: BoxProps["width"]; + height?: BoxProps["height"]; + size?: NobleButtonSize; + logoUrl: string; + title: string; + subTitle: string; + actionLabel: string; +} + +export interface NobleSelectWalletButtonProps extends BaseButtonProps { + logoUrl: string; + logoAlt?: string; + title: string; + subTitle: string; +} + +export type NobleTxStatus = "processing" | "successful"; + +export interface NobleTxHistoryOverviewItemProps { + amount: string; + status: NobleTxStatus; + sourceChainLogoSrc: string; + destinationChainLogoSrc: string; + mainLogoSrc?: string; + amountUnit?: string; + isExpanded?: boolean; + disabled?: boolean; + customStatus?: { + text: string; + color: BoxProps["color"]; + }; +} + +export interface NoblePageTitleBarProps { + title: string; + showBackButton?: boolean; + onBackButtonClick?: (event: any) => void; + mt?: BoxProps["mt"]; + mb?: BoxProps["mb"]; + mx?: BoxProps["mx"]; +} + +export interface NobleInputProps + extends Omit { + labelContainerProps?: BoxProps; + labelExtra?: BaseComponentProps["children"]; + helperText?: BaseComponentProps["children"]; + size?: "sm" | "md"; + intent?: "success" | "error"; + inputTextAlign?: BoxProps["textAlign"]; + inputContainerProps?: BoxProps; +} + +export interface NobleTxChainRouteProps { + srcChainLogoUrl: string; + srcChainLogoAlt?: string; + destChainLogoUrl: string; + destChainLogoAlt?: string; + containerProps?: BoxProps; +} + +export interface NobleTxEstimateProps + extends Omit { + timeEstimateLabel: string; + feeEstimateLabel: string; + containerProps?: BoxProps; +} diff --git a/packages/react/src/ui/number-field/index.ts b/packages/react/src/ui/number-field/index.ts new file mode 100644 index 00000000..95ea7a95 --- /dev/null +++ b/packages/react/src/ui/number-field/index.ts @@ -0,0 +1 @@ +export { default } from "./number-field"; diff --git a/packages/react/src/ui/number-field/number-field.css.ts b/packages/react/src/ui/number-field/number-field.css.ts new file mode 100644 index 00000000..a53dc87c --- /dev/null +++ b/packages/react/src/ui/number-field/number-field.css.ts @@ -0,0 +1,22 @@ +import { style } from "@vanilla-extract/css"; + +export const borderless = style({ + border: "none !important", + paddingTop: "0 !important", + paddingBottom: "0 !important", + selectors: { + "&:focus": { + boxShadow: "none !important", + }, + }, +}); + +export const withDecrementButton = style({ + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, +}); + +export const withIncrementButton = style({ + borderTopRightRadius: 0, + borderBottomRightRadius: 0, +}); diff --git a/packages/react/src/ui/number-field/number-field.tsx b/packages/react/src/ui/number-field/number-field.tsx new file mode 100644 index 00000000..b227f038 --- /dev/null +++ b/packages/react/src/ui/number-field/number-field.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect, useId, forwardRef, useMemo } from "react"; +import { useNumberFieldState } from "react-stately"; +import { useNumberField, useLocale, AriaNumberFieldProps } from "react-aria"; +import { mergeRefs } from "@react-aria/utils"; + +import clx from "clsx"; +import { + inputStyles, + inputSizes, + inputIntent, + inputRootIntent, + rootInput, + rootInputFocused, +} from "@/ui/text-field/text-field.css"; +import FieldLabel from "@/ui/field-label"; +import Stack from "@/ui/stack"; +import Box from "@/ui/box"; +import useTheme from "../hooks/use-theme"; +import * as styles from "./number-field.css"; +import type { NumberInputProps } from "./number-field.types"; + +function usePrevious(value: T): T { + const ref = React.useRef(undefined); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + +const defaultFormatOptions: AriaNumberFieldProps["formatOptions"] = { + style: "decimal", + minimumFractionDigits: 0, + maximumFractionDigits: 20, +}; + +const NumberInput = forwardRef( + (props, forwardedRef) => { + const { + id = useId(), + label, + isDisabled, + size = "sm", + intent = "default", + clampValueOnBlur = true, + formatOptions = defaultFormatOptions, + } = props; + + const { theme } = useTheme(); + const { locale } = useLocale(); + const [internalValue, setInternalValue] = useState( + () => props.value ?? props.defaultValue ?? null, + ); + const lastValidValue = usePrevious(internalValue); + + const [strValue, setStrValue] = useState(() => + (props.value && props.defaultValue) == null + ? "" + : String(props.value ?? props.defaultValue), + ); + const [isFocused, setIsFocused] = useState(false); + + const state = useNumberFieldState({ + ...props, + locale, + onFocusChange(focused) { + setIsFocused(focused); + }, + }); + + useEffect(() => { + if (props.value !== undefined) { + setInternalValue(props.value); + } + }, [props.value]); + + const inputRef = React.useRef(null); + const handleRef = mergeRefs(inputRef, forwardedRef); + + const { + labelProps, + groupProps, + inputProps, + incrementButtonProps, + decrementButtonProps, + } = useNumberField(props, state, inputRef); + + const formatValue = (value: number | null): string => { + if (value === null) return ""; + return new Intl.NumberFormat(locale, formatOptions).format(value); + }; + + const parseValue = (value: string): number | null => { + if (value === "") { + return null; + } + + // Remove all non-numeric characters except decimal point and minus sign + const numericValue = value.replace(/[^\d.-]/g, ""); + return parseFloat(numericValue); + }; + + const applyFormatting = (val: number) => { + if (inputRef.current) { + state.setInputValue(formatValue(val)); + inputRef.current.value = formatValue(val); + } + }; + + const updateValue = (val: number) => { + setInternalValue(val); + setStrValue(formatValue(val)); + state.setNumberValue(val); + props.onChange?.(val); + }; + + const getClampedValue = (val: number) => { + const { + minValue = Number.NEGATIVE_INFINITY, + maxValue = Number.POSITIVE_INFINITY, + } = props; + + if (typeof val !== "number") { + val = 0; + } + + return Math.min(Math.max(val, minValue), maxValue); + }; + + const isNotNumeric = (valStr: string) => { + return isNaN(parseValue(valStr) ?? undefined); + }; + + const handleBlur = (e: React.FocusEvent) => { + let newValue = internalValue; + + // Default mode, fallback to react-aria logic + if (clampValueOnBlur) { + newValue = state.numberValue; + applyFormatting(newValue); + inputProps.onBlur?.(e); + return; + } + + if (!clampValueOnBlur) { + // Snap back to the last valid numeric value + // if the input is empty or invalid + if (isNotNumeric(strValue)) { + newValue = getClampedValue(0); + + if (newValue !== lastValidValue) { + updateValue(newValue); + } + applyFormatting(newValue); + inputProps.onBlur?.(e); + return; + } else { + newValue = getClampedValue(parseValue(strValue)); + if (newValue !== lastValidValue) { + updateValue(newValue); + } + applyFormatting(newValue); + inputProps.onBlur?.(e); + } + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const parsedValue = parseValue(e.target.value); + const isNotNumeric = isNaN(parsedValue ?? undefined); + + // String representation of the value, always update + setStrValue(e.target.value); + + // If the value is incomplete/invalid, don't update the state + // wait til it's valid and update onBlur + if (isNotNumeric) { + return; + } + + if (parsedValue == null) { + setInternalValue(props.minValue ?? 0); + } else { + setInternalValue(parsedValue); + } + + state.setInputValue(formatValue(parsedValue)); + state.setNumberValue(parsedValue); + props.onChange?.(parsedValue ?? props.minValue ?? 0); + }; + + const inputValue = useMemo(() => { + if (clampValueOnBlur) { + return state.inputValue; + } else if (internalValue !== null) { + if (isNotNumeric(strValue)) { + return strValue; + } + return formatValue(internalValue); + } else { + return strValue; + } + }, [state.inputValue, formatValue, internalValue, strValue]); + + return ( + + + {label && } + +
    + {props.canDecrement && React.isValidElement(props.decrementButton) + ? React.cloneElement(props.decrementButton, decrementButtonProps) + : props?.decrementButton} + + + + {props.canIncrement && React.isValidElement(props.incrementButton) + ? React.cloneElement(props.incrementButton, incrementButtonProps) + : props?.incrementButton} +
    +
    +
    + ); + }, +); + +export default NumberInput; diff --git a/packages/react/src/ui/number-field/number-field.types.tsx b/packages/react/src/ui/number-field/number-field.types.tsx new file mode 100644 index 00000000..400eea04 --- /dev/null +++ b/packages/react/src/ui/number-field/number-field.types.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from "react"; +import type { Sprinkles } from "@/styles/rainbow-sprinkles.css"; +import type { AriaNumberFieldProps } from "react-aria"; + +export interface NumberInputProps { + // ==== Core logic props + defaultValue?: number; + value?: number; + minValue?: number; + maxValue?: number; + incrementButton?: ReactNode; + decrementButton?: ReactNode; + canIncrement?: boolean; + canDecrement?: boolean; + clampValueOnBlur?: boolean; + // ==== Form field props + id?: string; + isDisabled?: boolean; + isReadOnly?: boolean; + autoFocus?: boolean; + formatOptions?: AriaNumberFieldProps["formatOptions"]; + name?: string; + label?: string; + onChange?: (value: number) => void; + onInput?: AriaNumberFieldProps["onInput"]; + onBlur?: AriaNumberFieldProps["onBlur"]; + onFocus?: AriaNumberFieldProps["onFocus"]; + onFocusChange?: (isFocused: boolean) => void; + onKeyDown?: AriaNumberFieldProps["onKeyDown"]; + onKeyUp?: AriaNumberFieldProps["onKeyUp"]; + // ==== Style props + textAlign?: Sprinkles["textAlign"]; + fontSize?: Sprinkles["fontSize"]; + attributes?: Sprinkles & React.HTMLAttributes; + size?: "sm" | "md" | "lg"; + placeholder?: string | undefined; + intent?: "default" | "error"; + className?: string; + inputContainer?: string; + inputClassName?: string; + borderless?: boolean; +} diff --git a/packages/react/src/ui/overlays-manager/index.ts b/packages/react/src/ui/overlays-manager/index.ts new file mode 100644 index 00000000..74e391c5 --- /dev/null +++ b/packages/react/src/ui/overlays-manager/index.ts @@ -0,0 +1 @@ +export { default } from "./overlays-manager"; diff --git a/packages/react/src/ui/overlays-manager/overlays-manager.tsx b/packages/react/src/ui/overlays-manager/overlays-manager.tsx new file mode 100644 index 00000000..3cb9665b --- /dev/null +++ b/packages/react/src/ui/overlays-manager/overlays-manager.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; +import { useRef, useEffect } from "react"; +import { overlays } from "./overlays"; +import { OverlaysManagerProps } from "./overlays-manager.types"; + +function OverlaysManager(props: OverlaysManagerProps) { + const containerRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (containerRef.current) { + const ownerDocument = containerRef.current.ownerDocument; + const overlayRoot = overlays.getOrCreateOverlayRoot(ownerDocument); + + // Append children to the overlay root + while (containerRef.current.firstChild) { + overlayRoot.appendChild(containerRef.current.firstChild); + } + let zIndexCounter = 1; + + // Function to apply styles to direct children + const applyStylesToChildren = () => { + Array.from(overlayRoot.children).forEach((child, index) => { + if (child instanceof HTMLElement) { + child.style.position = "relative"; + child.style.zIndex = (index + 1).toString(); + } + }); + zIndexCounter = overlayRoot.children.length + 1; + }; + + // Apply initial styles + applyStylesToChildren(); + + // Set up MutationObserver to watch for changes in children + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "childList") { + applyStylesToChildren(); + } + }); + }); + observer.observe(overlayRoot, { + childList: true, + }); + + // Cleanup function + cleanupRef.current = () => { + observer.disconnect(); + if (!containerRef.current) { + return; + } + while (containerRef.current.firstChild) { + containerRef.current.removeChild(containerRef.current.firstChild); + } + }; + } + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( +
    + ); +} + +export default OverlaysManager; diff --git a/packages/react/src/ui/overlays-manager/overlays-manager.types.tsx b/packages/react/src/ui/overlays-manager/overlays-manager.types.tsx new file mode 100644 index 00000000..95e766e8 --- /dev/null +++ b/packages/react/src/ui/overlays-manager/overlays-manager.types.tsx @@ -0,0 +1 @@ +export interface OverlaysManagerProps {} diff --git a/packages/react/src/ui/overlays-manager/overlays.tsx b/packages/react/src/ui/overlays-manager/overlays.tsx new file mode 100644 index 00000000..7fb734c4 --- /dev/null +++ b/packages/react/src/ui/overlays-manager/overlays.tsx @@ -0,0 +1,73 @@ +import uniqueId from "lodash/uniqueId"; + +export const OVERLAYS_MANAGER_ID = "interchain-ui-overlays-manager"; + +class Overlays { + private static instance: Overlays; + private overlayRoot: HTMLElement | null = null; + private overlayStack: string[] = []; + + private constructor() {} + + private static isBrowser(): boolean { + return typeof window !== "undefined"; + } + + public static getInstance(): Overlays { + if (!Overlays.instance) { + Overlays.instance = new Overlays(); + } + return Overlays.instance; + } + + get overlays(): string[] { + return this.overlayStack; + } + + public getOrCreateOverlayRoot(ownerDocument?: Document): HTMLElement | null { + if (!Overlays.isBrowser()) { + return null; + } + + let doc = ownerDocument || document; + + if (!this.overlayRoot) { + const root = document.createElement("div"); + root.id = OVERLAYS_MANAGER_ID; + doc.body.appendChild(root); + this.overlayRoot = root; + } + return this.overlayRoot; + } + + public pushOverlay(id: string): void { + this.overlayStack.push(id); + } + + public popOverlay(id: string): void { + const index = this.overlayStack.lastIndexOf(id); + if (index !== -1) { + this.overlayStack = this.overlayStack.slice(0, index); + } + } + + public isTopMostOverlay(id: string): boolean { + return this.overlayStack[this.overlayStack.length - 1] === id; + } + + public cleanup(): void { + if (!Overlays.isBrowser() || !this.overlayRoot) { + return; + } + + document.body.removeChild(this.overlayRoot); + this.overlayRoot = null; + this.overlayStack = []; + } + + public generateId(prefix: string = "overlay"): string { + return uniqueId(`${prefix}-`); + } +} + +export const overlays = Overlays.getInstance(); diff --git a/packages/react/src/ui/overview-transfer/index.ts b/packages/react/src/ui/overview-transfer/index.ts new file mode 100644 index 00000000..ebcc42e6 --- /dev/null +++ b/packages/react/src/ui/overview-transfer/index.ts @@ -0,0 +1 @@ +export { default } from "./overview-transfer"; diff --git a/packages/react/src/ui/overview-transfer/overview-transfer.css.ts b/packages/react/src/ui/overview-transfer/overview-transfer.css.ts new file mode 100644 index 00000000..76e12737 --- /dev/null +++ b/packages/react/src/ui/overview-transfer/overview-transfer.css.ts @@ -0,0 +1,27 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +const buttonTextColorVar = createVar(); + +const btnTextBase = style({ + color: buttonTextColorVar, +}); + +export const btnText = styleVariants({ + light: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.white, + }, + }), + btnTextBase, + ], + dark: [ + style({ + vars: { + [buttonTextColorVar]: themeVars.colors.cardBg, + }, + }), + btnTextBase, + ], +}); diff --git a/packages/react/src/ui/overview-transfer/overview-transfer.tsx b/packages/react/src/ui/overview-transfer/overview-transfer.tsx new file mode 100644 index 00000000..07812a36 --- /dev/null +++ b/packages/react/src/ui/overview-transfer/overview-transfer.tsx @@ -0,0 +1,119 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Icon from "../icon"; +import TransferItem from "../transfer-item"; +import * as styles from "./overview-transfer.css"; +import { store } from "../../models/store"; +import type { OverviewTransferProps } from "./overview-transfer.types"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; +import type { ThemeVariant } from "../../models/system.model"; + +function OverviewTransfer(props: OverviewTransferProps) { + const { + transferLabel = "Transfer", + cancelLabel = "Cancel", + inputLabel = "Select amount", + } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + const [selectedItem, setSelectedItem] = useState(() => null); + const [amount, setAmount] = useState(() => 0); + function handleTransferChange(item: AvailableItem, value: number) { + setSelectedItem(item); + setAmount(value); + props.onChange?.(item, value); + } + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + handleTransferChange(item, value)} + onItemSelected={(selectedItem) => { + setSelectedItem(selectedItem); + props.onChange?.(selectedItem, amount); + }} + /> + + + + + + + + + ); +} + +export default OverviewTransfer; diff --git a/packages/react/src/ui/overview-transfer/overview-transfer.types.tsx b/packages/react/src/ui/overview-transfer/overview-transfer.types.tsx new file mode 100644 index 00000000..a950dff8 --- /dev/null +++ b/packages/react/src/ui/overview-transfer/overview-transfer.types.tsx @@ -0,0 +1,20 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; + +export type TransferType = "withdraw" | "deposit"; + +export interface OverviewTransferProps extends BaseComponentProps { + inputLabel?: string; + dropdownList: AvailableItem[]; + selectedItem?: AvailableItem; + defaultSelected?: AvailableItem; + isSubmitDisabled?: boolean; + fromChainLogoUrl: string; + toChainLogoUrl: string; + transferLabel?: string; + cancelLabel?: string; + timeEstimateLabel?: string; + onTransfer: (event?: any) => void; + onChange: (selectedItem: AvailableItem, value: number) => void; + onCancel: (event?: any) => void; +} diff --git a/packages/react/src/ui/pool-card-list/index.ts b/packages/react/src/ui/pool-card-list/index.ts new file mode 100644 index 00000000..ee64b11a --- /dev/null +++ b/packages/react/src/ui/pool-card-list/index.ts @@ -0,0 +1 @@ +export { default } from "./pool-card-list"; diff --git a/packages/react/src/ui/pool-card-list/pool-card-list.tsx b/packages/react/src/ui/pool-card-list/pool-card-list.tsx new file mode 100644 index 00000000..6cafc089 --- /dev/null +++ b/packages/react/src/ui/pool-card-list/pool-card-list.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import PoolCard from "../pool-card"; +import { PoolCardListProps } from "./pool-card-list.types"; +import { PoolCardProps } from "../pool-card/pool-card.types"; + +function PoolCardList(props: PoolCardListProps) { + return ( + + + Highlighted Pools + + + {props.list?.map((item, index) => ( + item.onClick()} + /> + ))} + + + ); +} + +export default PoolCardList; diff --git a/packages/react/src/ui/pool-card-list/pool-card-list.types.tsx b/packages/react/src/ui/pool-card-list/pool-card-list.types.tsx new file mode 100644 index 00000000..88d41ada --- /dev/null +++ b/packages/react/src/ui/pool-card-list/pool-card-list.types.tsx @@ -0,0 +1,4 @@ +import { PoolCardProps } from "../pool-card/pool-card.types" +export interface PoolCardListProps { + list: PoolCardProps[], +} diff --git a/packages/react/src/ui/pool-card/index.ts b/packages/react/src/ui/pool-card/index.ts new file mode 100644 index 00000000..c2c7faa8 --- /dev/null +++ b/packages/react/src/ui/pool-card/index.ts @@ -0,0 +1 @@ +export { default } from "./pool-card"; diff --git a/packages/react/src/ui/pool-card/pool-card.css.ts b/packages/react/src/ui/pool-card/pool-card.css.ts new file mode 100644 index 00000000..af0cb7d1 --- /dev/null +++ b/packages/react/src/ui/pool-card/pool-card.css.ts @@ -0,0 +1,37 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { breakpoints } from "../../styles/tokens"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + backgroundColor: themeVars.colors.cardBg, + padding: themeVars.space[10], + borderRadius: themeVars.radii.lg, + boxSizing: "border-box", + height: "fit-content", + "@media": { + [`screen and (min-width: ${breakpoints.tablet}px)`]: { + width: "236px", + }, + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + width: "100%", + minWidth: "236px", + }, + }, +}); + +export const hoverStyle = style({ + cursor: "pointer", +}) + +export const divider = styleVariants({ + light: [ + { + backgroundColor: "#D1D6DD", + }, + ], + dark: [ + { + backgroundColor: "#434B55", + }, + ], +}); diff --git a/packages/react/src/ui/pool-card/pool-card.tsx b/packages/react/src/ui/pool-card/pool-card.tsx new file mode 100644 index 00000000..ffad02db --- /dev/null +++ b/packages/react/src/ui/pool-card/pool-card.tsx @@ -0,0 +1,157 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import BigNumber from "bignumber.js"; +import { store } from "../../models/store"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import PoolName from "../pool/components/pool-name"; +import * as styles from "./pool-card.css"; +import type { PoolCardProps } from "./pool-card.types"; +import type { ThemeVariant } from "../../models/system.model"; + +function PoolCard(props: PoolCardProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + props?.onClick?.(), + }} + className={clsx(styles.container, { + [styles.hoverStyle]: !!props.onClick, + })} + > + + + + + APR + + {new BigNumber(props.apr).decimalPlaces(2).toString()}% + + + + Liquidity + + $ + {store.getState().formatNumber({ + value: props.liquidity, + })} + + + + 7D Fees + + $ + {store.getState().formatNumber({ + value: props.fees7D, + })} + + + + {!!props.myLiquidity ? ( + + Your Liquidity + + $ + {store.getState().formatNumber({ + value: props.myLiquidity, + })} + + + ) : null} + + Bonded + + $ + {store.getState().formatNumber({ + value: props.unbondedBalance, + })} + + + + ); +} + +export default PoolCard; diff --git a/packages/react/src/ui/pool-card/pool-card.types.tsx b/packages/react/src/ui/pool-card/pool-card.types.tsx new file mode 100644 index 00000000..28305b94 --- /dev/null +++ b/packages/react/src/ui/pool-card/pool-card.types.tsx @@ -0,0 +1,5 @@ +import { PoolDetailProps, PoolListItemProps } from "../pool-list-item/pool-list-item.types"; +export interface PoolCardProps extends PoolListItemProps { + myLiquidity: PoolDetailProps["myLiquidity"]; + unbondedBalance: PoolDetailProps["unbondedBalance"] +} diff --git a/packages/react/src/ui/pool-info-header/index.ts b/packages/react/src/ui/pool-info-header/index.ts new file mode 100644 index 00000000..682ca0bd --- /dev/null +++ b/packages/react/src/ui/pool-info-header/index.ts @@ -0,0 +1 @@ +export { default } from "./pool-info-header"; diff --git a/packages/react/src/ui/pool-info-header/pool-info-header.css.ts b/packages/react/src/ui/pool-info-header/pool-info-header.css.ts new file mode 100644 index 00000000..5ef66023 --- /dev/null +++ b/packages/react/src/ui/pool-info-header/pool-info-header.css.ts @@ -0,0 +1,60 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { breakpoints } from "../../styles/tokens"; +import { themeVars } from "../../styles/themes.css"; + +export const poolInfoHeader = style({ + minWidth: "350px", +}); + +export const imageBox = style({ + position: "relative", + minWidth: themeVars.space[18], + height: themeVars.space[14], + marginRight: themeVars.space[10], +}); + +export const imgBase = style({ + position: "absolute", + width: themeVars.space[15], + height: themeVars.space[15], +}); + +export const image1 = style([ + imgBase, + { + left: 0, + }, +]); + +export const image2 = style([ + imgBase, + { + right: 0, + }, +]); + +export const longText = style([ + style({ + width: "calc(50% - 96px)", + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + width: "calc(100% - 96px)", + }, + }, + }), +]); + +export const shortText = style({ + width: themeVars.space[21], +}); + +export const onlysm = style({ + "@media": { + [`screen and (min-width: ${breakpoints.tablet}px)`]: { + display: "none", + }, + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + display: "block", + }, + }, +}); diff --git a/packages/react/src/ui/pool-info-header/pool-info-header.tsx b/packages/react/src/ui/pool-info-header/pool-info-header.tsx new file mode 100644 index 00000000..5c01fb3a --- /dev/null +++ b/packages/react/src/ui/pool-info-header/pool-info-header.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import { store } from "../../models/store"; +import * as styles from "./pool-info-header.css"; +import { PoolInfoHeaderProps } from "./pool-info-header.types"; + +function PoolsHeader(props: PoolInfoHeaderProps) { + return ( + + + + Pool #{props.id} + + + + + + + + + Pool liquidity + + + $ + + + {store.getState()?.formatNumber?.({ + value: props.liquidity, + })} + + + + + + Swap fee + + + {props.swapFee} + + % + + + + 24h trading volume + + + $ + + + {store.getState()?.formatNumber?.({ + value: props.volume24H, + })} + + + + + + ); +} + +export default PoolsHeader; diff --git a/packages/react/src/ui/pool-info-header/pool-info-header.types.tsx b/packages/react/src/ui/pool-info-header/pool-info-header.types.tsx new file mode 100644 index 00000000..76648466 --- /dev/null +++ b/packages/react/src/ui/pool-info-header/pool-info-header.types.tsx @@ -0,0 +1,8 @@ +import { Coin } from "../pool-list-item/pool-list-item.types"; +export interface PoolInfoHeaderProps { + id: string; + liquidity: number | string; + swapFee: number | string; + volume24H: number | string; + coins: Coin[] +} diff --git a/packages/react/src/ui/pool-list-item/apr.tsx b/packages/react/src/ui/pool-list-item/apr.tsx new file mode 100644 index 00000000..98c71f80 --- /dev/null +++ b/packages/react/src/ui/pool-list-item/apr.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +export interface APRProps { + className?: string; + apr: string; + innerClassName: string; + title?: string; +} + +import Stack from "../stack"; +import Text from "../text"; +import Box from "../box"; +import IconButton from "../icon-button"; + +function APR(props: APRProps) { + return ( + + + {!!props?.title ? ( + + {props?.title} + + ) : null} + + {props.apr}% + + + + + ); +} + +export default APR; diff --git a/packages/react/src/ui/pool-list-item/cell-with-title.tsx b/packages/react/src/ui/pool-list-item/cell-with-title.tsx new file mode 100644 index 00000000..a5cfb2ca --- /dev/null +++ b/packages/react/src/ui/pool-list-item/cell-with-title.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +export interface CellWithTitleProps { + title: string; + className?: string; + innerClassName?: string; + children?: React.ReactNode; +} + +import Stack from "../stack"; +import Text from "../text"; +import Box from "../box"; + +function CellWithTitle(props: CellWithTitleProps) { + return ( + + + + {props.title} + + {props.children} + + + ); +} + +export default CellWithTitle; diff --git a/packages/react/src/ui/pool-list-item/index.ts b/packages/react/src/ui/pool-list-item/index.ts new file mode 100644 index 00000000..170b1e35 --- /dev/null +++ b/packages/react/src/ui/pool-list-item/index.ts @@ -0,0 +1 @@ +export { default } from "./pool-list-item"; diff --git a/packages/react/src/ui/pool-list-item/pool-list-item.css.ts b/packages/react/src/ui/pool-list-item/pool-list-item.css.ts new file mode 100644 index 00000000..bcc20fd4 --- /dev/null +++ b/packages/react/src/ui/pool-list-item/pool-list-item.css.ts @@ -0,0 +1,136 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { breakpoints } from "../../styles/tokens"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + marginBottom: themeVars.space[10], + marginRight: themeVars.space[9], + display: "flex", + width: "752px", + flexWrap: "nowrap", + justifyContent: "flex-start", + alignItems: "center", + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + width: "100%", + minWidth: "400px", + flexWrap: "wrap", + justifyContent: "space-between", + }, + }, +}); + +export const hoverStyle = style({ + cursor: "pointer", + padding: themeVars.space[1], + borderRadius: themeVars.radii.base, + selectors: { + "&:hover": { + backgroundColor: themeVars.colors.cardBg, + }, + }, +}); + +export const contentContainer = style({ + width: "712px", +}); + +export const rank = style({ + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + marginLeft: "-46px", + }, + }, +}); + +export const responsiveText = style({ + width: "calc(100% / 3)", + "@media": { + [`screen and (min-width: ${breakpoints.desktop}px)`]: { + width: "calc(100% / 5)", + }, + }, +}); + +export const nameContainer = style({ + "@media": { + [`screen and (min-width: ${breakpoints.tablet}px)`]: { + minWidth: "calc(72px + 20%)", + }, + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + width: "calc(88px + 33.33%)", + }, + }, +}); + +export const imageBox = style({ + position: "relative", + minWidth: themeVars.space[18], + height: themeVars.space[14], + marginRight: themeVars.space[8], +}); + +export const imgBase = style({ + position: "absolute", + width: themeVars.space[14], + height: themeVars.space[14], +}); + +export const image1 = style([ + imgBase, + { + left: 0, + }, +]); + +export const image2 = style([ + imgBase, + { + right: 0, + }, +]); + +export const smAPR = style({ + justifyContent: "space-between", +}); + +export const lgAPR = style({ + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + display: "none !important", + }, + }, +}); + +export const onlySm = style({ + "@media": { + [`screen and (min-width: ${breakpoints.tablet}px)`]: { + display: "none !important", + }, + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + display: "flex !important", + }, + }, +}); + +const baseIcon = style({ + width: "38px", + height: "38px", + cursor: "pointer", + borderRadius: themeVars.radii.base, +}); + +export const iconContainer = styleVariants({ + light: [ + baseIcon, + { + backgroundColor: "#EEF2F8", + }, + ], + dark: [ + baseIcon, + { + backgroundColor: "#1D2024", + }, + ], +}); diff --git a/packages/react/src/ui/pool-list-item/pool-list-item.tsx b/packages/react/src/ui/pool-list-item/pool-list-item.tsx new file mode 100644 index 00000000..78dd5015 --- /dev/null +++ b/packages/react/src/ui/pool-list-item/pool-list-item.tsx @@ -0,0 +1,132 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import BigNumber from "bignumber.js"; +import clsx from "clsx"; +import Box from "../box"; +import Text from "../text"; +import PoolName from "../pool/components/pool-name"; +import APR from "./apr"; +import CellWithTitle from "./cell-with-title"; +import { store } from "../../models/store"; +import * as styles from "./pool-list-item.css"; +import { standardTransitionProperties } from "../shared/shared.css"; +import type { PoolListItemProps } from "./pool-list-item.types"; +import type { ThemeVariant } from "../../models/system.model"; + +function PoolListItem(props: PoolListItemProps) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + function apr() { + return new BigNumber(props?.apr || 0).decimalPlaces(2).toString(); + } + + function isInteractive() { + return !!props.onClick && typeof props.onClick === "function"; + } + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + props?.onClick?.(), + }} + className={clsx(styles.container, standardTransitionProperties, { + [styles.hoverStyle]: !!props.onClick, + })} + > + + + + + + + + {store.getState().formatNumber({ + value: props?.liquidity, + style: "currency", + })} + + + + + {store.getState().formatNumber({ + value: props?.volume24H, + style: "currency", + })} + + + + + {store.getState().formatNumber({ + value: props?.fees7D, + style: "currency", + })} + + + + + + ); +} + +export default PoolListItem; diff --git a/packages/react/src/ui/pool-list-item/pool-list-item.types.tsx b/packages/react/src/ui/pool-list-item/pool-list-item.types.tsx new file mode 100644 index 00000000..86492551 --- /dev/null +++ b/packages/react/src/ui/pool-list-item/pool-list-item.types.tsx @@ -0,0 +1,55 @@ +import type { AddLiquidityProps } from "../add-liquidity/add-liquidity.types"; +import type { BondingListItemSmProps } from "../bonding-list-item-sm/bonding-list-item-sm.types"; +import type { ManageLiquidityCardProps } from "../manage-liquidity-card/manage-liquidity-card.types"; +import type { RemoveLiquidityProps } from "../remove-liquidity/remove-liquidity.types"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; +import type { BaseComponentProps } from "../../models/components.model"; + +export type APR = { + totalApr: string; + swapFeeApr: { + swapFeeValuePerDay: string; + apr: string; + }; + bondedShares: string | number; + bondedValue: string | number; // unbond should be disabled when bondedValue < 0 + superfluidApr?: string; + amount?: string; +}; + +export interface Coin { + symbol: string; + imgSrc: string; + displayAmount?: string; +} + +export interface PoolListItemProps extends BaseComponentProps { + id: string; + poolAssets: AvailableItem[]; + liquidity: number | string; + apr: string; + fees7D?: number; + volume24H?: number | string; + onClick?: (event?: any) => void; +} + +export interface PoolDetailProps { + bonded: number | string; + myLiquidity: string; // Remove liquidity should be disabled when myLiquidty < 0 + swapFee: number | string; + totalBalance: string; // Your pool balance + totalShares: string; + lpTokenBalance: string; + lpTokenShares: string; + totalBalanceCoins: Coin[]; // show in pool detail + unbondedBalance: string; // pass to remove liquiditypage + unbondedShares: string; // pass to remove liquiditypage + myLiquidityCoins: Coin[]; // pass to remove liquiditypage + + // function + onAddLiquidity: AddLiquidityProps["onAddLiquidity"]; + onRemoveLiquidity: RemoveLiquidityProps["onRemoveLiquidity"]; + onBond: BondingListItemSmProps["onBond"]; + onUnbond: BondingListItemSmProps["onUnbond"]; + onStartEarning: ManageLiquidityCardProps["onStartEarning"]; +} diff --git a/packages/react/src/ui/pool-list/index.ts b/packages/react/src/ui/pool-list/index.ts new file mode 100644 index 00000000..7ffacdad --- /dev/null +++ b/packages/react/src/ui/pool-list/index.ts @@ -0,0 +1 @@ +export { default } from "./pool-list"; diff --git a/packages/react/src/ui/pool-list/pool-list.css.ts b/packages/react/src/ui/pool-list/pool-list.css.ts new file mode 100644 index 00000000..64a6501a --- /dev/null +++ b/packages/react/src/ui/pool-list/pool-list.css.ts @@ -0,0 +1,28 @@ +import { style } from "@vanilla-extract/css"; +import { breakpoints } from "../../styles/tokens"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + width: "752px", + paddingTop: themeVars.space[10], + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + width: "100%", + }, + }, +}); + +export const titleContainer = style({ + paddingLeft: "88px", + marginTop: themeVars.space[9], + marginBottom: themeVars.space[9], + "@media": { + [`screen and (max-width: ${breakpoints.tablet}px)`]: { + display: "none !important", + }, + }, +}); + +export const title = style({ + width: "calc(100% / 5)", +}); diff --git a/packages/react/src/ui/pool-list/pool-list.tsx b/packages/react/src/ui/pool-list/pool-list.tsx new file mode 100644 index 00000000..3fd06649 --- /dev/null +++ b/packages/react/src/ui/pool-list/pool-list.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { useState } from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import PoolListItem from "../pool-list-item"; +import * as styles from "./pool-list.css"; +import type { PoolListProps } from "./pool-list.types"; + +function PoolList(props: PoolListProps) { + const [titles, setTitles] = useState(() => [ + "Pool", + "Liquidity", + "24H Volume", + "7D Fees", + "APR", + ]); + + return ( + + + {props.title} + + + {titles?.map((item, index) => ( + + {item} + + ))} + + + {props.list?.map((item, index) => ( + item.onClick()} + /> + ))} + + + ); +} + +export default PoolList; diff --git a/packages/react/src/ui/pool-list/pool-list.types.tsx b/packages/react/src/ui/pool-list/pool-list.types.tsx new file mode 100644 index 00000000..0c787e43 --- /dev/null +++ b/packages/react/src/ui/pool-list/pool-list.types.tsx @@ -0,0 +1,6 @@ +import { PoolListItemProps } from "../pool-list-item/pool-list-item.types"; + +export interface PoolListProps { + title: string, + list: PoolListItemProps[]; +} diff --git a/packages/react/src/ui/pool/components/pool-name/index.ts b/packages/react/src/ui/pool/components/pool-name/index.ts new file mode 100644 index 00000000..4df2bd9e --- /dev/null +++ b/packages/react/src/ui/pool/components/pool-name/index.ts @@ -0,0 +1 @@ +export { default } from "./pool-name"; diff --git a/packages/react/src/ui/pool/components/pool-name/pool-name.css.ts b/packages/react/src/ui/pool/components/pool-name/pool-name.css.ts new file mode 100644 index 00000000..e9534826 --- /dev/null +++ b/packages/react/src/ui/pool/components/pool-name/pool-name.css.ts @@ -0,0 +1,33 @@ +import { style } from "@vanilla-extract/css"; +import { breakpoints } from "../../../../styles/tokens"; +import { themeVars } from "../../../../styles/themes.css"; + +export const imageBox = style({ + position: "relative", + minWidth: themeVars.space[18], + height: themeVars.space[14], +}); + +export const imgBase = style({ + position: "absolute", + width: themeVars.space[14], + height: themeVars.space[14], +}); + +export const image1 = style([ + imgBase, + { + left: 0, + }, +]); + +export const image2 = style([ + imgBase, + { + right: 0, + }, +]); + +export const nameContainer = style({ + width: "133px", +}); diff --git a/packages/react/src/ui/pool/components/pool-name/pool-name.tsx b/packages/react/src/ui/pool/components/pool-name/pool-name.tsx new file mode 100644 index 00000000..5ddc3b42 --- /dev/null +++ b/packages/react/src/ui/pool/components/pool-name/pool-name.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import Stack from "../../../stack"; +import Box from "../../../box"; +import Text from "../../../text"; +import type { PoolNameProps } from "./pool-name.types"; +import * as styles from "./pool-name.css"; + +function PoolName(props: PoolNameProps) { + return ( + + + + + + + {`${props.coins[0]?.symbol} / ${props.coins[1].symbol}`} + Pool #{props.id} + + + ); +} + +export default PoolName; diff --git a/packages/react/src/ui/pool/components/pool-name/pool-name.types.tsx b/packages/react/src/ui/pool/components/pool-name/pool-name.types.tsx new file mode 100644 index 00000000..9dbf52cd --- /dev/null +++ b/packages/react/src/ui/pool/components/pool-name/pool-name.types.tsx @@ -0,0 +1,7 @@ +import { Coin } from "../../../pool-list-item/pool-list-item.types"; + +export interface PoolNameProps { + className?: string; + id: string; + coins: Coin[]; +} diff --git a/packages/react/src/ui/pools-header/index.ts b/packages/react/src/ui/pools-header/index.ts new file mode 100644 index 00000000..f175bf47 --- /dev/null +++ b/packages/react/src/ui/pools-header/index.ts @@ -0,0 +1 @@ +export { default } from "./pools-header"; diff --git a/packages/react/src/ui/pools-header/pools-header.css.ts b/packages/react/src/ui/pools-header/pools-header.css.ts new file mode 100644 index 00000000..48beb043 --- /dev/null +++ b/packages/react/src/ui/pools-header/pools-header.css.ts @@ -0,0 +1,59 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; +import { breakpoints } from "../../styles/tokens/breakpoints"; + +const base = style({ + paddingLeft: themeVars.space[8], + paddingRight: themeVars.space[8], + display: "flex", + alignItems: "center", + borderRadius: "7px", + height: "92px", + boxSizing: "border-box", +}); + +export const mb3 = style({ + marginBottom: "3px", +}); + +export const image = style({ + width: "53px", + height: "53px", + marginRight: "21px", + "@media": { + [`screen and (max-width: 900px)`]: { + width: "40px", + height: "40px", + marginRight: "13px", + }, + }, +}); + +export const semocolon = style({ + margin: "0 8px", +}); + +export const dollar = style({ + marginBottom: "5px", +}); + +export const baseBox = style([ + base, + { + backgroundColor: themeVars.colors.cardBg, + width: "100%", + }, +]); + +export const rewardBox = style([ + base, + { + backgroundColor: themeVars.colors.rewardBg, + color: themeVars.colors.rewardContent, + width: "100%", + }, +]); + +export const osom = style({ + margin: "0 14px 3px 2px", +}); diff --git a/packages/react/src/ui/pools-header/pools-header.tsx b/packages/react/src/ui/pools-header/pools-header.tsx new file mode 100644 index 00000000..73eda9be --- /dev/null +++ b/packages/react/src/ui/pools-header/pools-header.tsx @@ -0,0 +1,189 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import { store } from "../../models/store"; +import * as styles from "./pools-header.css"; +import { standardTransitionProperties } from "../shared/shared.css"; +import type { PoolsHeaderProps } from "./pools-header.types"; +import type { ThemeVariant } from "../../models/system.model"; + +function PoolsHeader(props: PoolsHeaderProps) { + const { title = "Liquidity Pools" } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + {title} + + + + + {props.tokenData.title} + + + {props.tokenData.title} + + + + $ + + + {store + .getState() + ?.formatNumber?.({ value: props.tokenData.price })} + + + + + + + + + {props.rewardCountdownData.title} + + + {props.rewardCountdownData.hours} + + : + + {props.rewardCountdownData.minutes} + + : + + {props.rewardCountdownData.seconds} + + + + + + + {props.rewardData.title} + + + + {props.rewardData.rewardAmount} + + + {props.rewardData.rewardTokenName} + + + $ + {store + .getState() + .formatNumber({ + value: props.rewardData.rewardNotionalValue, + })} + + + + + + + ); +} +export default PoolsHeader; diff --git a/packages/react/src/ui/pools-header/pools-header.types.tsx b/packages/react/src/ui/pools-header/pools-header.types.tsx new file mode 100644 index 00000000..5ef96a6f --- /dev/null +++ b/packages/react/src/ui/pools-header/pools-header.types.tsx @@ -0,0 +1,30 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +export type TokenDataCard = { + title: string; + price: string; + iconUrl: string; +}; + +export type RewardCountdownCard = { + title: string; + seconds: string; + minutes: string; + hours: string; +}; + +export type RewardDataCard = { + title: string; + rewardAmount: string; + rewardTokenName: string; + rewardNotionalValue: string; +}; + +export interface PoolsHeaderProps extends BaseComponentProps { + title?: string; + tokenData: TokenDataCard; + rewardCountdownData: RewardCountdownCard; + rewardData: RewardDataCard; + attributes?: Sprinkles; +} diff --git a/packages/react/src/ui/popover-content/index.ts b/packages/react/src/ui/popover-content/index.ts new file mode 100644 index 00000000..bc61e583 --- /dev/null +++ b/packages/react/src/ui/popover-content/index.ts @@ -0,0 +1 @@ +export { default } from "./popover-content"; diff --git a/packages/react/src/ui/popover-content/popover-content.css.ts b/packages/react/src/ui/popover-content/popover-content.css.ts new file mode 100644 index 00000000..48dfab73 --- /dev/null +++ b/packages/react/src/ui/popover-content/popover-content.css.ts @@ -0,0 +1,14 @@ +import { style } from "@vanilla-extract/css"; + +export const arrow = style({ + zIndex: 999, +}); + +export const contentWrapper = style({ + zIndex: 999, + selectors: { + "&:focus": { + outline: "none", + }, + }, +}); diff --git a/packages/react/src/ui/popover-content/popover-content.tsx b/packages/react/src/ui/popover-content/popover-content.tsx new file mode 100644 index 00000000..f7d0cbc1 --- /dev/null +++ b/packages/react/src/ui/popover-content/popover-content.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + useTransitionStyles, + FloatingArrowProps, + UseTransitionStylesProps, + FloatingFocusManager, + FloatingArrow, +} from "@floating-ui/react"; + +import { usePopoverContext } from "../popover/popover.context"; +import * as styles from "./popover-content.css"; + +export interface PopoverContentProps { + children: React.ReactNode; + showArrow?: boolean; + arrowStyles?: Pick< + FloatingArrowProps, + "width" | "height" | "tipRadius" | "fill" + >; + transitionStyles?: UseTransitionStylesProps; +} + +const PopoverContent = ({ + children, + showArrow = true, + arrowStyles, + transitionStyles, +}: PopoverContentProps) => { + const { context, refs, floatingStyles, getFloatingProps, arrowRef, modal } = + usePopoverContext(); + + const { isMounted, styles: _transitionStyles } = useTransitionStyles( + context, + { + duration: 200, + common: { + transformOrigin: "top", + }, + initial: { + opacity: 0, + transform: "scaleY(0.85)", + }, + ...transitionStyles, + }, + ); + + if (!isMounted) return null; + + return ( + +
    +
    + {showArrow && ( + + )} + {children} +
    +
    +
    + ); +}; + +export default PopoverContent; diff --git a/packages/react/src/ui/popover-trigger/index.ts b/packages/react/src/ui/popover-trigger/index.ts new file mode 100644 index 00000000..66e234da --- /dev/null +++ b/packages/react/src/ui/popover-trigger/index.ts @@ -0,0 +1 @@ +export { default } from "./popover-trigger"; diff --git a/packages/react/src/ui/popover-trigger/popover-trigger.css.ts b/packages/react/src/ui/popover-trigger/popover-trigger.css.ts new file mode 100644 index 00000000..c21e166f --- /dev/null +++ b/packages/react/src/ui/popover-trigger/popover-trigger.css.ts @@ -0,0 +1,5 @@ +import { style } from "@vanilla-extract/css"; + +export const trigger = style({ + width: "fit-content", +}); diff --git a/packages/react/src/ui/popover-trigger/popover-trigger.tsx b/packages/react/src/ui/popover-trigger/popover-trigger.tsx new file mode 100644 index 00000000..9526425e --- /dev/null +++ b/packages/react/src/ui/popover-trigger/popover-trigger.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { usePopoverContext } from "../popover/popover.context"; +import * as styles from "./popover-trigger.css"; + +export interface PopoverTriggerProps { + children: React.ReactNode; +} + +const PopoverTrigger = ({ children }: PopoverTriggerProps) => { + const { refs, getReferenceProps } = usePopoverContext(); + + return ( +
    + {children} +
    + ); +}; + +export default PopoverTrigger; diff --git a/packages/react/src/ui/popover/index.ts b/packages/react/src/ui/popover/index.ts new file mode 100644 index 00000000..f5cae366 --- /dev/null +++ b/packages/react/src/ui/popover/index.ts @@ -0,0 +1 @@ +export { default } from "./popover"; diff --git a/packages/react/src/ui/popover/popover.context.ts b/packages/react/src/ui/popover/popover.context.ts new file mode 100644 index 00000000..bc22d7cd --- /dev/null +++ b/packages/react/src/ui/popover/popover.context.ts @@ -0,0 +1,14 @@ +import React from "react"; +import type { UsePopoverReturnValue } from "./popover.types"; + +export type PopoverContextType = UsePopoverReturnValue | null; + +export const PopoverContext = React.createContext(null); + +export const usePopoverContext = () => { + const context = React.useContext(PopoverContext); + if (context == null) { + throw new Error("Popover components must be wrapped in "); + } + return context; +}; diff --git a/packages/react/src/ui/popover/popover.tsx b/packages/react/src/ui/popover/popover.tsx new file mode 100644 index 00000000..e6d3fd75 --- /dev/null +++ b/packages/react/src/ui/popover/popover.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { UsePopoverReturnValue } from "./popover.types"; +import { PopoverContext } from "./popover.context"; +import { usePopover } from "./use-popover"; + +export type PopoverProps = { + children: React.ReactNode; +} & Partial; + +const Popover = ({ children, ...popoverOptions }: PopoverProps) => { + const popover = usePopover(popoverOptions); + + return ( + + {children} + + ); +}; + +export default Popover; diff --git a/packages/react/src/ui/popover/popover.types.ts b/packages/react/src/ui/popover/popover.types.ts new file mode 100644 index 00000000..4bbd585d --- /dev/null +++ b/packages/react/src/ui/popover/popover.types.ts @@ -0,0 +1,29 @@ +import { Dispatch, SetStateAction } from "react"; +import { + useInteractions, + useFloating, + Placement, + OffsetOptions, +} from "@floating-ui/react"; + +// Workaround for TS bug "The inferred type of 'T' cannot be named without a refence to 'X'" +// https://github.com/microsoft/TypeScript/issues/42873 +import type {} from "@floating-ui/react-dom"; + +export interface PopoverOptions { + modal?: boolean; + triggerType?: "hover" | "click"; + placement?: Placement; + isOpen?: boolean; + setIsOpen?: Dispatch>; + initialOpen?: boolean; + offset?: OffsetOptions; +} + +export type UseInteractionsValue = ReturnType; +export type UseFloatingValue = ReturnType; + +export type UsePopoverReturnValue = PopoverOptions & { + arrowRef?: React.RefObject; +} & UseFloatingValue & + UseInteractionsValue; diff --git a/packages/react/src/ui/popover/use-popover.ts b/packages/react/src/ui/popover/use-popover.ts new file mode 100644 index 00000000..8f5e1b3b --- /dev/null +++ b/packages/react/src/ui/popover/use-popover.ts @@ -0,0 +1,85 @@ +import { + useFloating, + autoUpdate, + offset as floatingOffset, + flip, + shift, + useHover, + useInteractions, + useClick, + arrow, + safePolygon, + useDismiss, + useRole, +} from "@floating-ui/react"; +import * as React from "react"; +import type { PopoverOptions } from "./popover.types"; + +export const usePopover = (props: PopoverOptions) => { + const { + modal = false, + triggerType = "hover", + placement = "top", + isOpen: controlledOpen, + setIsOpen: setControlledOpen, + initialOpen = false, + offset, + } = props; + + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + + const arrowRef = React.useRef(null); + const isOpen = controlledOpen ?? uncontrolledOpen; + const setIsOpen = setControlledOpen ?? setUncontrolledOpen; + + const data = useFloating({ + placement, + whileElementsMounted: autoUpdate, + middleware: [ + floatingOffset(offset), + flip({ + crossAxis: placement.includes("-"), + fallbackAxisSideDirection: "end", + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + ], + open: isOpen, + onOpenChange: setIsOpen, + }); + + const isHover = triggerType === "hover"; + + const hover = useHover(data.context, { + handleClose: safePolygon({ + buffer: -Infinity, + blockPointerEvents: true, + }), + enabled: isHover, + }); + + const click = useClick(data.context, { + enabled: !isHover, + }); + + const dismiss = useDismiss(data.context, { + enabled: !isHover, + }); + + const role = useRole(data.context); + + const interactions = useInteractions([role, hover, click, dismiss]); + + return React.useMemo( + () => ({ + isOpen, + setIsOpen, + arrowRef, + modal, + ...data, + ...interactions, + }), + [isOpen, setIsOpen, arrowRef, modal, data, interactions], + ); +}; diff --git a/packages/react/src/ui/progress-bar/index.ts b/packages/react/src/ui/progress-bar/index.ts new file mode 100644 index 00000000..137ea1db --- /dev/null +++ b/packages/react/src/ui/progress-bar/index.ts @@ -0,0 +1 @@ +export { default } from "./progress-bar"; diff --git a/packages/react/src/ui/progress-bar/progress-bar.css.ts b/packages/react/src/ui/progress-bar/progress-bar.css.ts new file mode 100644 index 00000000..b17796b9 --- /dev/null +++ b/packages/react/src/ui/progress-bar/progress-bar.css.ts @@ -0,0 +1,84 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +// Rewrite prgress-bar with input[type="range"] +/* +const base = sprinkles({ + borderRadius: "xl", + height: "2", +}); + +export const bar = style([ + base, + sprinkles({ + backgroundColor: "cardBg", + }), + { + width: "100%", + }, +]); + +export const filledBar = style([ + base, + sprinkles({ + backgroundColor: "textSecondary", + }), + { + transition: "width 0.5s ease-out", + position: "relative", + }, +]); + +export const dot = style([ + sprinkles({ + backgroundColor: "text", + width: "10", + height: "10", + borderRadius: "2xl", + }), + { + position: "absolute", + right: "-12px", + bottom: "-10px", + cursor: "pointer", + selectors: { + "&:active": { + width: "28px", + height: "28px", + borderRadius: "14px", + right: "-14px", + bottom: "-12px", + }, + }, + }, +]); + +*/ + +export const range = style({ + height: themeVars.space[2], + borderRadius: themeVars.radii.xl, + backgroundColor: themeVars.colors.cardBg, + outline: "none", + width: "100%", + cursor: "pointer", + appearance: "none", + WebkitAppearance: "none", + backgroundImage: `linear-gradient(${themeVars.colors.text}, ${themeVars.colors.text})`, + backgroundRepeat: "no-repeat", + "::-webkit-slider-thumb": { + width: "24px", + height: "24px", + border: "0", + backgroundColor: themeVars.colors.text, + borderRadius: "50%", + appearance: "none", + WebkitAppearance: "none", + }, + selectors: { + "&:active::-webkit-slider-thumb": { + width: "28px", + height: "28px", + }, + }, +}); diff --git a/packages/react/src/ui/progress-bar/progress-bar.tsx b/packages/react/src/ui/progress-bar/progress-bar.tsx new file mode 100644 index 00000000..3852cb50 --- /dev/null +++ b/packages/react/src/ui/progress-bar/progress-bar.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { useRef, useEffect } from "react"; +import * as styles from "./progress-bar.css"; +import { ProgressBarProps } from "./progress-bar.types"; + +function ProgressBar(props: ProgressBarProps) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current.style.backgroundSize = `${props.progress}% 100%`; + }, []); + + useEffect(() => { + inputRef.current.style.backgroundSize = `${props.progress}% 100%`; + }, [props.progress]); + + return ( +
    + { + let min = Number(e.target.min); + let max = Number(e.target.max); + let val = Number(e.target.value); + const result = ((val - min) * 100) / (max - min); + e.target.style.backgroundSize = `${result}% 100%`; + props.onProgressChange(result); + }} + value={props.progress} + className={styles.range} + /> +
    + ); +} + +export default ProgressBar; diff --git a/packages/react/src/ui/progress-bar/progress-bar.types.tsx b/packages/react/src/ui/progress-bar/progress-bar.types.tsx new file mode 100644 index 00000000..418b41f1 --- /dev/null +++ b/packages/react/src/ui/progress-bar/progress-bar.types.tsx @@ -0,0 +1,4 @@ +export interface ProgressBarProps { + progress: number; + onProgressChange: (progress: number) => void; +} diff --git a/packages/react/src/ui/qrcode/index.ts b/packages/react/src/ui/qrcode/index.ts new file mode 100644 index 00000000..5b92a762 --- /dev/null +++ b/packages/react/src/ui/qrcode/index.ts @@ -0,0 +1 @@ +export { default } from "./qrcode"; diff --git a/packages/react/src/ui/qrcode/qrcode.helpers.ts b/packages/react/src/ui/qrcode/qrcode.helpers.ts new file mode 100644 index 00000000..a7c2d651 --- /dev/null +++ b/packages/react/src/ui/qrcode/qrcode.helpers.ts @@ -0,0 +1,120 @@ +import { + SPEC_MARGIN_SIZE, + DEFAULT_MARGIN_SIZE, + DEFAULT_IMG_SCALE, +} from "./qrcode.types"; +import type { Modules, Excavation, ImageSettings } from "./qrcode.types"; + +export function generatePath(modules: Modules, margin: number = 0): string { + const ops: Array = []; + modules.forEach(function (row, y) { + let start: number | null = null; + row.forEach(function (cell, x) { + if (!cell && start !== null) { + // M0 0h7v1H0z injects the space with the move and drops the comma, + // saving a char per operation + ops.push( + `M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z` + ); + start = null; + return; + } + + // end of row, clean up or skip + if (x === row.length - 1) { + if (!cell) { + // We would have closed the op above already so this can only mean + // 2+ light modules in a row. + return; + } + if (start === null) { + // Just a single dark module. + ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); + } else { + // Otherwise finish the current line. + ops.push( + `M${start + margin},${y + margin} h${x + 1 - start}v1H${ + start + margin + }z` + ); + } + return; + } + + if (cell && start === null) { + start = x; + } + }); + }); + return ops.join(""); +} + +// We could just do this in generatePath, except that we want to support +// non-Path2D canvas, so we need to keep it an explicit step. +export function excavateModules( + modules: Modules, + excavation: Excavation +): Modules { + return modules.slice().map((row, y) => { + if (y < excavation.y || y >= excavation.y + excavation.h) { + return row; + } + return row.map((cell, x) => { + if (x < excavation.x || x >= excavation.x + excavation.w) { + return cell; + } + return false; + }); + }); +} + +export function getImageSettings( + cells: Modules, + size: number, + margin: number, + imageSettings?: ImageSettings +): null | { + x: number; + y: number; + h: number; + w: number; + excavation: Excavation | null; +} { + if (imageSettings == null) { + return null; + } + const numCells = cells.length + margin * 2; + const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE); + const scale = numCells / size; + const w = (imageSettings.width || defaultSize) * scale; + const h = (imageSettings.height || defaultSize) * scale; + const x = + imageSettings.x == null + ? cells.length / 2 - w / 2 + : imageSettings.x * scale; + const y = + imageSettings.y == null + ? cells.length / 2 - h / 2 + : imageSettings.y * scale; + + let excavation = null; + if (imageSettings.excavate) { + let floorX = Math.floor(x); + let floorY = Math.floor(y); + let ceilW = Math.ceil(w + x - floorX); + let ceilH = Math.ceil(h + y - floorY); + excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH }; + } + + return { x, y, h, w, excavation }; +} + +export function getMarginSize( + includeMargin: boolean, + marginSize?: number +): number { + if (marginSize != null) { + return Math.floor(marginSize); + } + return includeMargin ? SPEC_MARGIN_SIZE : DEFAULT_MARGIN_SIZE; +} diff --git a/packages/react/src/ui/qrcode/qrcode.tsx b/packages/react/src/ui/qrcode/qrcode.tsx new file mode 100644 index 00000000..10745c98 --- /dev/null +++ b/packages/react/src/ui/qrcode/qrcode.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { useState, useEffect } from "react"; +import qrcodegen from "./qrcodegen/qrcodegen"; +import { + DEFAULT_SIZE, + DEFAULT_LEVEL, + DEFAULT_BGCOLOR, + DEFAULT_FGCOLOR, + DEFAULT_INCLUDEMARGIN, + ERROR_LEVEL_MAP, +} from "./qrcode.types"; +import type { QRProps } from "./qrcode.types"; +import { + excavateModules, + generatePath, + getImageSettings, + getMarginSize, +} from "./qrcode.helpers"; + +function QRCode(props: QRProps) { + const { + size = DEFAULT_SIZE, + level = DEFAULT_LEVEL, + bgColor = DEFAULT_BGCOLOR, + fgColor = DEFAULT_FGCOLOR, + includeMargin = DEFAULT_INCLUDEMARGIN, + } = props; + const [available, setAvailable] = useState(() => false); + const [cells, setCells] = useState(() => null); + const [calculatedImageSettings, setCalculatedImageSettings] = useState( + () => null + ); + const [margin, setMargin] = useState(() => null); + const [fgPath, setFgPath] = useState(() => null); + function generateNewPath(newMargin: number) { + // Drawing strategy: instead of a rect per module, we're going to create a // single path for the dark modules and layer that on top of a light rect, // for a total of 2 DOM nodes. We pay a bit more in string concat but that's + // way faster than DOM ops. + // For level 1, 441 nodes -> 2 + // For level 40, 31329 -> 2 + return generatePath(cells ?? [], newMargin); + } + function genCells() { + return qrcodegen.QrCode.encodeText( + props.value, + ERROR_LEVEL_MAP[level] + ).getModules(); + } + function numCells() { + return (cells?.length ?? 0) + (margin ?? 0) * 2; + } + useEffect(() => { + setCells(genCells()); + }, []); + useEffect(() => { + if (props.imageSettings != null && calculatedImageSettings != null) { + if (calculatedImageSettings.excavation != null) { + setCells(excavateModules(cells, calculatedImageSettings.excavation)); + } + if (!available) { + setAvailable(true); + } + } + }, [props.imageSettings, calculatedImageSettings]); + useEffect(() => { + const newMargin = getMarginSize(includeMargin, props.marginSize); + setMargin(newMargin); + setCalculatedImageSettings( + getImageSettings(cells, size, newMargin, props.imageSettings) + ); + setFgPath(generateNewPath(newMargin)); + }, [size, props.imageSettings, includeMargin, props.marginSize, cells]); + return ( + + {!!props.title ? {props.title} : null} + + + {available && !!props.imageSettings?.src ? ( + + ) : null} + + ); +} +export default QRCode; diff --git a/packages/react/src/ui/qrcode/qrcode.types.tsx b/packages/react/src/ui/qrcode/qrcode.types.tsx new file mode 100644 index 00000000..9fde24ae --- /dev/null +++ b/packages/react/src/ui/qrcode/qrcode.types.tsx @@ -0,0 +1,49 @@ +import qrcodegen from "./qrcodegen/qrcodegen"; +import type { BaseComponentProps } from "../../models/components.model"; + +export type Modules = ReturnType; +export type Excavation = { x: number; y: number; w: number; h: number }; + +export type ImageSettings = { + src: string; + height: number; + width: number; + excavate: boolean; + x?: number; + y?: number; +}; + +export interface QRProps extends BaseComponentProps { + value: string; + size?: number; + // Should be a real enum, but doesn't seem to be compatible with real code. + level?: string; + bgColor?: string; + fgColor?: string; + includeMargin?: boolean; + marginSize?: number; + imageSettings?: ImageSettings; + title?: string; +} + +// Constants +export const DEFAULT_SIZE = 128; +export const DEFAULT_LEVEL = "L"; +export const DEFAULT_BGCOLOR = "#FFFFFF"; +export const DEFAULT_FGCOLOR = "#000000"; +export const DEFAULT_INCLUDEMARGIN = false; +export const SPEC_MARGIN_SIZE = 4; +export const DEFAULT_MARGIN_SIZE = 0; + +// This is *very* rough estimate of max amount of QRCode allowed to be covered. +// It is "wrong" in a lot of ways (area is a terrible way to estimate, it +// really should be number of modules covered), but if for some reason we don't +// get an explicit height or width, I'd rather default to something than throw. +export const DEFAULT_IMG_SCALE = 0.1; + +export const ERROR_LEVEL_MAP: { [index: string]: qrcodegen.QrCode.Ecc } = { + L: qrcodegen.QrCode.Ecc.LOW, + M: qrcodegen.QrCode.Ecc.MEDIUM, + Q: qrcodegen.QrCode.Ecc.QUARTILE, + H: qrcodegen.QrCode.Ecc.HIGH, +}; diff --git a/packages/react/src/ui/qrcode/qrcodegen/README.md b/packages/react/src/ui/qrcode/qrcodegen/README.md new file mode 100644 index 00000000..a0409d20 --- /dev/null +++ b/packages/react/src/ui/qrcode/qrcodegen/README.md @@ -0,0 +1,11 @@ +# QR Code generator library (TypeScript) + +Copyright (c) Project Nayuki. (MIT License) + +https://www.nayuki.io/page/qr-code-generator-library + +Obtained via https://github.com/nayuki/QR-Code-generator/blob/942f4319a6ba913dbc6775d8e665ccf18f401d83/typescript-javascript/qrcodegen.ts + +## Modifications: +- Export for use as a module +- Added `getModules` method to `QrCode` class, to bypass excessive calls to `getModule`. diff --git a/packages/react/src/ui/qrcode/qrcodegen/qrcodegen.ts b/packages/react/src/ui/qrcode/qrcodegen/qrcodegen.ts new file mode 100644 index 00000000..7adda2e1 --- /dev/null +++ b/packages/react/src/ui/qrcode/qrcodegen/qrcodegen.ts @@ -0,0 +1,1043 @@ +/** + * @license QR Code generator library (TypeScript) + * Copyright (c) Project Nayuki. + * SPDX-License-Identifier: MIT + */ + +"use strict"; + +namespace qrcodegen { + type bit = number; + type byte = number; + type int = number; + + /*---- QR Code symbol class ----*/ + + /* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ + export class QrCode { + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { + const segs: Array = qrcodegen.QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary( + data: Readonly>, + ecl: QrCode.Ecc + ): QrCode { + const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly>, + ecl: QrCode.Ecc, + minVersion: int = 1, + maxVersion: int = 40, + mask: int = -1, + boostEcl: boolean = true + ): QrCode { + if ( + !( + QrCode.MIN_VERSION <= minVersion && + minVersion <= maxVersion && + maxVersion <= QrCode.MAX_VERSION + ) || + mask < -1 || + mask > 7 + ) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = + QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable + } + if (version >= maxVersion) + // All versions in the range could not fit the given data + throw new RangeError("Data too long"); + } + + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [ + QrCode.Ecc.MEDIUM, + QrCode.Ecc.QUARTILE, + QrCode.Ecc.HIGH, + ]) { + // From low to high + if ( + boostEcl && + dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8 + ) + ecl = newEcl; + } + + // Concatenate all segments to create the data bit string + let bb: Array = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) bb.push(b); + } + assert(bb.length == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = + QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for ( + let padByte = 0xec; + bb.length < dataCapacityBits; + padByte ^= 0xec ^ 0x11 + ) + appendBits(padByte, 8, bb); + + // Pack bits into bytes in big endian + let dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); + bb.forEach( + (b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7))) + ); + + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } + + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + public readonly version: int, + + // The error correction level used in this QR Code. + public readonly errorCorrectionLevel: QrCode.Ecc, + + dataCodewords: Readonly>, + + msk: int + ) { + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + let row: Array = []; + for (let i = 0; i < this.size; i++) row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { + // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); // Undoes the mask due to XOR + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits + + this.isFunction = []; + } + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return ( + 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x] + ); + } + + // Modified to expose modules for easy access + public getModules() { + return this.modules; + } + + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if ( + !( + (i == 0 && j == 0) || + (i == 0 && j == numAlign - 1) || + (i == numAlign - 1 && j == 0) + ) + ) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits: int = (this.version << 12) | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + (i % 3); + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule( + x + dx, + y + dy, + Math.max(Math.abs(dx), Math.abs(dy)) != 1 + ); + } + } + + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: QrCode.Ecc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords: int = Math.floor( + QrCode.getNumRawDataModules(ver) / 8 + ); + const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + let blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat: Array = data.slice( + k, + k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1) + ); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + let result: Array = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if ( + data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8) + ) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right == 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == data.length * 8); + } + + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) == 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + default: + throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) result += QrCode.PENALTY_N1; + else if (runX > 5) result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += + this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += + this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * + QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) result += QrCode.PENALTY_N1; + else if (runY > 5) result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += + this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += + this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * + QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y][x]; + if ( + color == this.modules[y][x + 1] && + color == this.modules[y + 1][x] && + color == this.modules[y + 1][x + 1] + ) + result += QrCode.PENALTY_N2; + } + } + + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = + this.version == 32 + ? 26 + : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] + ); + } + + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result: Array = []; + for (let i = 0; i < degree - 1; i++) result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 0x02); + } + return result; + } + + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder( + data: Readonly>, + divisor: Readonly> + ): Array { + let result: Array = divisor.map((_) => 0); + for (const b of data) { + // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach( + (coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor)) + ); + } + return result; + } + + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } + + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && + runHistory[2] == n && + runHistory[3] == n * 3 && + runHistory[4] == n && + runHistory[5] == n; + return ( + (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + currentRunLength: int, + runHistory: Array + ): int { + if (currentRunColor) { + // Terminate dark run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory( + currentRunLength: int, + runHistory: Array + ): void { + if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, + 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, + 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, + 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, + 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, + 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, + 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, + 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, + 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, + 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, + 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, + 74, 77, 81, + ], // High + ]; + } + + // Appends the given number of low-order bits of the given value + // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. + function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) + bb.push((val >>> i) & 1); + } + + // Returns true iff the i'th bit of x is set to 1. + function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; + } + + // Throws an exception if the given condition is false. + function assert(cond: boolean): void { + if (!cond) throw new Error("Assertion error"); + } + + /*---- Data segment class ----*/ + + /* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ + export class QrSegment { + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + let bb: Array = []; + for (const b of data) appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb: Array = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError( + "String contains unencodable characters in alphanumeric mode" + ); + let bb: Array = []; + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: int = + QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + // 1 character remaining + appendBits( + QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), + 6, + bb + ); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") return []; + else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) + return [QrSegment.makeAlphanumeric(text)]; + else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + let bb: Array = []; + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + public readonly mode: QrSegment.Mode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + public readonly numChars: int, + + // The data bits of this segment. Accessed through getData(). + private readonly bitData: Array + ) { + if (numChars < 0) throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits( + segs: Readonly>, + version: int + ): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + let result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + + /*-- Constants --*/ + + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = + /^[A-Z0-9 $%*+.\/:-]*$/; + + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + } +} + +/*---- Public helper enumeration ----*/ + +namespace qrcodegen.QrCode { + type int = number; + + /* + * The error correction level in a QR Code symbol. Immutable. + */ + export class Ecc { + /*-- Constants --*/ + + public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + + /*-- Constructor and fields --*/ + + private constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + public readonly ordinal: int, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + public readonly formatBits: int + ) {} + } +} + +/*---- Public helper enumeration ----*/ + +namespace qrcodegen.QrSegment { + type int = number; + + /* + * Describes how a segment's data bits are interpreted. Immutable. + */ + export class Mode { + /*-- Constants --*/ + + public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); + public static readonly BYTE = new Mode(0x4, [8, 16, 16]); + public static readonly KANJI = new Mode(0x8, [8, 10, 12]); + public static readonly ECI = new Mode(0x7, [0, 0, 0]); + + /*-- Constructor and fields --*/ + + private constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + public readonly modeBits: int, + // Number of character count bits for three different version ranges. + private readonly numBitsCharCount: [int, int, int] + ) {} + + /*-- Method --*/ + + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + } +} + +// Modification to export for actual use +export default qrcodegen; diff --git a/packages/react/src/ui/remove-liquidity/index.ts b/packages/react/src/ui/remove-liquidity/index.ts new file mode 100644 index 00000000..76b5775e --- /dev/null +++ b/packages/react/src/ui/remove-liquidity/index.ts @@ -0,0 +1 @@ +export { default } from "./remove-liquidity"; diff --git a/packages/react/src/ui/remove-liquidity/remove-liquidity.css.ts b/packages/react/src/ui/remove-liquidity/remove-liquidity.css.ts new file mode 100644 index 00000000..cb344bdc --- /dev/null +++ b/packages/react/src/ui/remove-liquidity/remove-liquidity.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const container = style({ + width: "464px" +}) + +export const img = style({ + width: themeVars.space[8], + height: themeVars.space[8], + borderRadius: themeVars.radii.lg, +}); diff --git a/packages/react/src/ui/remove-liquidity/remove-liquidity.tsx b/packages/react/src/ui/remove-liquidity/remove-liquidity.tsx new file mode 100644 index 00000000..7f0a4d69 --- /dev/null +++ b/packages/react/src/ui/remove-liquidity/remove-liquidity.tsx @@ -0,0 +1,202 @@ +import * as React from "react"; +import { useState } from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Text from "../text"; +import Button from "../button"; +import Box from "../box"; +import ProgressBar from "../progress-bar"; +import { store } from "../../models/store"; +import * as styles from "./remove-liquidity.css"; +import type { RemoveLiquidityProps } from "./remove-liquidity.types"; + +function RemoveLiquidity(props: RemoveLiquidityProps) { + const [progress, setProgress] = useState(() => 50); + + function handeProgressClick(value: number) { + setProgress(value); + props?.onChange(value); + } + + function removedBalance() { + return new BigNumber(progress) + .dividedBy(100) + .multipliedBy(props.unbondedBalance) + .decimalPlaces(6) + .toString(); + } + + function removedShares() { + return new BigNumber(progress) + .dividedBy(100) + .multipliedBy(props.unbondedShares) + .decimalPlaces(6) + .toString(); + } + + function removedAmount0() { + return new BigNumber(progress) + .dividedBy(100) + .multipliedBy(props.myLiquidityCoins[0]?.displayAmount || 0) + .decimalPlaces(6) + .toString(); + } + + function removedAmount1() { + return new BigNumber(progress) + .dividedBy(100) + .multipliedBy(props.myLiquidityCoins[1]?.displayAmount || 0) + .decimalPlaces(6) + .toString(); + } + + return ( + + + + + {props?.myLiquidityCoins[0]?.symbol} + + + / + + + {props?.myLiquidityCoins[1]?.symbol} + + + + + + $ + + + {store.getState().formatNumber({ + value: removedBalance() || 0, + })} + + + + + {removedBalance()} + + pool shares + + + + + + {removedAmount0()} + + + {props?.myLiquidityCoins[0]?.symbol} + + + + + + {removedAmount1()} + + + {props?.myLiquidityCoins[1]?.symbol} + + + + handeProgressClick(v)} + /> + + {[25, 50, 75, 100]?.map((value, index) => ( + + ))} + + + + + ); +} + +export default RemoveLiquidity; diff --git a/packages/react/src/ui/remove-liquidity/remove-liquidity.types.tsx b/packages/react/src/ui/remove-liquidity/remove-liquidity.types.tsx new file mode 100644 index 00000000..d8bd6837 --- /dev/null +++ b/packages/react/src/ui/remove-liquidity/remove-liquidity.types.tsx @@ -0,0 +1,14 @@ +import { + Coin, + PoolDetailProps, +} from "../pool-list-item/pool-list-item.types"; + +export interface OnRemoveLiquidityDetail {} +export interface RemoveLiquidityProps { + unbondedBalance: PoolDetailProps["unbondedBalance"]; + unbondedShares: PoolDetailProps["unbondedShares"]; + myLiquidityCoins: Coin[]; + isLoading?: boolean; + onRemoveLiquidity: (event?: any) => void; + onChange: (progress: number) => void; +} diff --git a/packages/react/src/ui/reveal/index.ts b/packages/react/src/ui/reveal/index.ts new file mode 100644 index 00000000..ac9c726c --- /dev/null +++ b/packages/react/src/ui/reveal/index.ts @@ -0,0 +1 @@ +export { default } from "./reveal"; diff --git a/packages/react/src/ui/reveal/reveal.css.ts b/packages/react/src/ui/reveal/reveal.css.ts new file mode 100644 index 00000000..8c4dbd40 --- /dev/null +++ b/packages/react/src/ui/reveal/reveal.css.ts @@ -0,0 +1,21 @@ +import { style, styleVariants } from "@vanilla-extract/css"; + +export const container = style({ + position: "relative", + overflow: "hidden", +}); + +export const shadow = styleVariants({ + light: [ + { + backgroundImage: + "linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))", + }, + ], + dark: [ + { + backgroundImage: + "linear-gradient(rgba(52, 58, 66, 0), rgba(52, 58, 66, 1))", + }, + ], +}); diff --git a/packages/react/src/ui/reveal/reveal.tsx b/packages/react/src/ui/reveal/reveal.tsx new file mode 100644 index 00000000..b4d996fd --- /dev/null +++ b/packages/react/src/ui/reveal/reveal.tsx @@ -0,0 +1,170 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import anime from "animejs"; +import type { AnimeInstance } from "animejs"; +import { debounce } from "lodash"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import type { RevealProps } from "./reveal.types"; +import * as styles from "./reveal.css"; +import { ThemeVariant } from "../../models/system.model"; + +function Reveal(props: RevealProps) { + const { + hideThresholdHeight = 500, + showMoreLabel = "Show more", + showLessLabel = "Show less", + } = props; + const eleHeight = useRef(null); + const isVisibleRef = useRef(false); + const animationRef = useRef(null); + const elementRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const resizeListenerRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + const [isVisible, setIsVisible] = useState(() => false); + function toggle() { + isVisibleRef.current = !isVisible; + setIsVisible(!isVisible); + } + function updateAnimationRef() { + animationRef.current = anime({ + targets: elementRef.current, + height: [hideThresholdHeight, eleHeight.current], + duration: 250, + direction: `alternate`, + loop: false, + autoplay: false, + easing: `easeInOutSine`, + }); + setIsVisible(false); + } + useEffect(() => { + setInternalTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState, prevState) => { + setInternalTheme(newState.theme); + }); + setTimeout(() => { + if (!elementRef.current) return; + const TOGGLE_SPACE = 40; + if (elementRef.current.offsetHeight > hideThresholdHeight) { + // Listen the resize event to get container height + resizeListenerRef.current = debounce(() => { + if (!elementRef.current) return; + elementRef.current.style.height = "auto"; + eleHeight.current = elementRef.current.offsetHeight + TOGGLE_SPACE; + elementRef.current.style.height = isVisibleRef.current + ? `${eleHeight.current}px` + : `${hideThresholdHeight}px`; + updateAnimationRef(); + }, 100); + window.addEventListener("resize", resizeListenerRef.current); // Simulate useLayoutEffect + setTimeout(() => { + if (!eleHeight.current) { + eleHeight.current = elementRef.current.offsetHeight + TOGGLE_SPACE; + } + updateAnimationRef(); + }, 100); + } + }, 100); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + if (window) { + window.removeEventListener("resize", resizeListenerRef.current); + } + }; + }, []); + return ( +
    + {props.children} + + + { + toggle(); + animationRef.current?.play(); + }, + "data-part-id": "reveal-showmore", + }} + > + + {showMoreLabel} + + + + + + { + toggle(); + animationRef.current?.reverse(); + animationRef.current?.play(); + }, + "data-part-id": "reveal-showless", + }} + > + + {showLessLabel} + + + + +
    + ); +} + +export default Reveal; diff --git a/packages/react/src/ui/reveal/reveal.types.tsx b/packages/react/src/ui/reveal/reveal.types.tsx new file mode 100644 index 00000000..47452b23 --- /dev/null +++ b/packages/react/src/ui/reveal/reveal.types.tsx @@ -0,0 +1,13 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export interface RevealProps extends BaseComponentProps { + hideThresholdHeight?: number; + showMoreLabel?: string; + showLessLabel?: string; + fadeBackground?: { + light: Sprinkles["backgroundColor"]; + dark: Sprinkles["backgroundColor"]; + }; + children: BaseComponentProps["children"]; +} diff --git a/packages/react/src/ui/scroll-indicator/index.ts b/packages/react/src/ui/scroll-indicator/index.ts new file mode 100644 index 00000000..44ab3cc3 --- /dev/null +++ b/packages/react/src/ui/scroll-indicator/index.ts @@ -0,0 +1 @@ +export { default } from "./scroll-indicator"; diff --git a/packages/react/src/ui/scroll-indicator/scroll-indicator.css.ts b/packages/react/src/ui/scroll-indicator/scroll-indicator.css.ts new file mode 100644 index 00000000..a4e43f0d --- /dev/null +++ b/packages/react/src/ui/scroll-indicator/scroll-indicator.css.ts @@ -0,0 +1,10 @@ +import { style } from "@vanilla-extract/css"; + +export const indicator = style({ + fill: "#FFF", + cursor: "pointer", +}); + +export const shadow = style({ + filter: "drop-shadow(0px 2px 18px #2C3137)", +}); diff --git a/packages/react/src/ui/scroll-indicator/scroll-indicator.tsx b/packages/react/src/ui/scroll-indicator/scroll-indicator.tsx new file mode 100644 index 00000000..2674fb33 --- /dev/null +++ b/packages/react/src/ui/scroll-indicator/scroll-indicator.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "../box"; +import Icon from "../icon"; +import * as styles from "./scroll-indicator.css"; +import { ScrollIndicatorProps } from "./scroll-indicator.types"; + +function ScrollIndicator(props: ScrollIndicatorProps) { + const { showShadow = true } = props; + return ( + props.onClick() }} + transform={`rotate(${props.direction === "left" ? 180 : 0}deg)`} + className={clx(styles.indicator, showShadow ? styles.shadow : null)} + > + + + ); +} + +export default ScrollIndicator; diff --git a/packages/react/src/ui/scroll-indicator/scroll-indicator.types.tsx b/packages/react/src/ui/scroll-indicator/scroll-indicator.types.tsx new file mode 100644 index 00000000..f34a06ac --- /dev/null +++ b/packages/react/src/ui/scroll-indicator/scroll-indicator.types.tsx @@ -0,0 +1,5 @@ +export interface ScrollIndicatorProps { + direction: "left" | "right"; + onClick: (event?: any) => void; + showShadow?: boolean; +} diff --git a/packages/react/src/ui/select-button/index.ts b/packages/react/src/ui/select-button/index.ts new file mode 100644 index 00000000..bfdb4ae8 --- /dev/null +++ b/packages/react/src/ui/select-button/index.ts @@ -0,0 +1 @@ +export { default } from "./select-button"; diff --git a/packages/react/src/ui/select-button/select-button.css.ts b/packages/react/src/ui/select-button/select-button.css.ts new file mode 100644 index 00000000..4a7c3526 --- /dev/null +++ b/packages/react/src/ui/select-button/select-button.css.ts @@ -0,0 +1,50 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { unstyledButton } from "../button/button.css"; +import { + inputSizes, + inputStyles, + rootInput, + inputRootIntent, +} from "../text-field/text-field.css"; +import { themeVars } from "../../styles/themes.css"; + +const hideShadow = style({ + selectors: { + "&:focus": { + boxShadow: "none", + }, + }, +}); + +const buttonBase = style([ + unstyledButton, + hideShadow, + style({ + fontSize: themeVars.fontSize.sm, + width: "100%", + fontWeight: themeVars.fontWeight.normal, + }), +]); + +export const buttonStyles = styleVariants({ + light: [inputStyles.light, buttonBase], + dark: [inputStyles.dark, buttonBase], +}); + +export const selectSizes = inputSizes; + +export const buttonRoot = rootInput; + +export const buttonIntent = inputRootIntent; + +export const arrowDropDown = style({ + fontSize: themeVars.fontSize["3xl"], + color: themeVars.colors.textPlaceholder, +}); + +export const buttonContent = style({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + flex: 1, +}); diff --git a/packages/react/src/ui/select-button/select-button.tsx b/packages/react/src/ui/select-button/select-button.tsx new file mode 100644 index 00000000..1ced4c5b --- /dev/null +++ b/packages/react/src/ui/select-button/select-button.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { useState, useRef, forwardRef, useEffect } from "react"; +import clx from "clsx"; +import Icon from "../icon"; +import Box from "../box"; +import { store } from "../../models/store"; +import { + buttonStyles, + buttonRoot, + buttonIntent, + selectSizes, + arrowDropDown, + buttonContent, +} from "./select-button.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { SelectButtonProps } from "./select-button.types"; + +const SelectButton = forwardRef(function SelectButton( + props: SelectButtonProps, + buttonRef: SelectButtonProps["buttonRef"] +) { + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + + ); +}); + +export default SelectButton; diff --git a/packages/react/src/ui/select-button/select-button.types.tsx b/packages/react/src/ui/select-button/select-button.types.tsx new file mode 100644 index 00000000..aba6d370 --- /dev/null +++ b/packages/react/src/ui/select-button/select-button.types.tsx @@ -0,0 +1,16 @@ +import type { TextFieldProps } from "../text-field/text-field.types"; +import type { BaseComponentProps } from "../../models/components.model"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export interface SelectButtonProps extends BaseComponentProps { + placeholder?: string; + disabled?: boolean; + intent?: TextFieldProps["intent"]; + onClick?: (event?: any) => void; + size?: "sm" | "md"; + attributes?: any; + buttonAttributes?: any; + buttonRef?: any; + valueProps?: any; + _css?: Sprinkles; +} diff --git a/packages/react/src/ui/select-option/index.ts b/packages/react/src/ui/select-option/index.ts new file mode 100644 index 00000000..ada0d86a --- /dev/null +++ b/packages/react/src/ui/select-option/index.ts @@ -0,0 +1 @@ +export { default } from "./select-option"; diff --git a/packages/react/src/ui/select-option/select-option.tsx b/packages/react/src/ui/select-option/select-option.tsx new file mode 100644 index 00000000..a095f550 --- /dev/null +++ b/packages/react/src/ui/select-option/select-option.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import clx from "clsx"; +import { useListItem } from "@floating-ui/react"; +import ListItem from "@/ui/list-item"; +import { baseButton } from "@/ui/button/button.css"; +import { SelectContext } from "../select/select.context"; + +export interface SelectOptionProps { + optionKey: string; + label: string; + isDisabled?: boolean; + children?: React.ReactNode; + className?: string; +} + +export default function SelectOption(props: SelectOptionProps) { + const { activeIndex, selectedItem, getItemProps, handleSelect } = + React.useContext(SelectContext); + + const { ref, index } = useListItem({ label: props.label }); + + const selectedIndex = selectedItem?.index ?? null; + const isActive = activeIndex === index; + const isSelected = selectedIndex === index; + + return ( + + ); +} diff --git a/packages/react/src/ui/select/index.ts b/packages/react/src/ui/select/index.ts new file mode 100644 index 00000000..fb33d4be --- /dev/null +++ b/packages/react/src/ui/select/index.ts @@ -0,0 +1 @@ +export { default } from "./select"; diff --git a/packages/react/src/ui/select/select.context.ts b/packages/react/src/ui/select/select.context.ts new file mode 100644 index 00000000..7f12b89b --- /dev/null +++ b/packages/react/src/ui/select/select.context.ts @@ -0,0 +1,19 @@ +import React from "react"; +import { useInteractions } from "@floating-ui/react"; + +export type Item = { + key: string; + label: string; + index: number; +}; + +export interface SelectContextValue { + activeIndex: number | null; + selectedItem: Item | null; + getItemProps: ReturnType["getItemProps"]; + handleSelect: (selectedItem: Item | null) => void; +} + +export const SelectContext = React.createContext( + {} as SelectContextValue +); diff --git a/packages/react/src/ui/select/select.css.ts b/packages/react/src/ui/select/select.css.ts new file mode 100644 index 00000000..1c37b3ac --- /dev/null +++ b/packages/react/src/ui/select/select.css.ts @@ -0,0 +1,72 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { themeVars } from "@/styles/themes.css"; +import { scrollBar } from "@/ui/shared/shared.css"; + +export const listBoxWidthVar = createVar(); + +export const listBoxBase = style({ + overflow: "auto", + outline: "2px solid transparent", + outlineOffset: "2px", + maxHeight: "304px", + margin: 0, + display: "flex", + flexDirection: "column", + borderRadius: themeVars.radii.md, + backgroundColor: themeVars.colors.menuItemBg, +}); + +export const listBoxDimensions = style({ + vars: { + [listBoxWidthVar]: "100%", + }, + width: listBoxWidthVar, +}); + +export const listBoxBaseWithShadow = style([ + listBoxBase, + { + borderWidth: "1px", + borderStyle: "solid", + boxShadow: + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + }, +]); + +export const listboxStyle = styleVariants({ + light: [ + listBoxBaseWithShadow, + listBoxDimensions, + scrollBar.light, + style({ + borderColor: "#D1D6DD", + }), + ], + dark: [ + listBoxBaseWithShadow, + listBoxDimensions, + scrollBar.dark, + style({ + borderColor: "#434B55", + }), + ], +}); + +export const listboxStyleNoShadow = styleVariants({ + light: [listBoxBase, listBoxDimensions, scrollBar.light], + dark: [listBoxBase, listBoxDimensions, scrollBar.dark], +}); + +export const selectRoot = style({ + position: "relative", + display: "inline-flex", + flexDirection: "column", +}); + +export const selectFullWidth = style({ + width: "100%", +}); + +export const selectButton = style({ + position: "relative", +}); diff --git a/packages/react/src/ui/select/select.tsx b/packages/react/src/ui/select/select.tsx new file mode 100644 index 00000000..d192df6f --- /dev/null +++ b/packages/react/src/ui/select/select.tsx @@ -0,0 +1,315 @@ +import React from "react"; +import clx from "clsx"; +import { assignInlineVars } from "@vanilla-extract/dynamic"; +import { + autoUpdate, + flip, + offset, + useFloating, + useInteractions, + useListNavigation, + useTypeahead, + useClick, + useDismiss, + useRole, + useTransitionStyles, + FloatingPortal, + FloatingFocusManager, + FloatingList, +} from "@floating-ui/react"; +import useTheme from "../hooks/use-theme"; +import FieldLabel from "@/ui/field-label"; +import SelectButton from "@/ui/select-button"; +import { + listBoxWidthVar, + selectRoot, + selectButton, + listboxStyle, + selectFullWidth, +} from "./select.css"; +import { Item, SelectContext, SelectContextValue } from "./select.context"; +import { overlays } from "@/ui/overlays-manager/overlays"; + +const DEFAULT_LIST_WIDTH = "220"; + +function useMeasure() { + const ref = React.useRef(null); + const [rect, setRect] = React.useState<{ + width: number; + height: number; + }>({ + width: 0, + height: 0, + }); + + React.useEffect(() => { + if (!ref.current) return; + + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.contentRect) { + setRect({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + + setRect({ + width: ref.current.getBoundingClientRect().width, + height: ref.current.getBoundingClientRect().height, + }); + + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, []); + + return [ref, rect] as [ + typeof ref, + { + width: number; + height: number; + }, + ]; +} + +export interface SelectProps { + id?: string | undefined; + fullWidth?: boolean; + width?: number | string; + optionsWidth?: number | string; + size?: "sm" | "md" | "lg"; + label?: React.ReactNode; + placeholder?: string; + defaultSelectedItem?: Item; + selectedIndex?: number; + onSelectItem?: (item: Item | null) => void; + children?: React.ReactNode; + className?: string; +} + +export default function Select(props: SelectProps) { + const { theme, themeClass } = useTheme(); + + const [measureRef, measureRect] = useMeasure(); + const [isOpen, setIsOpen] = React.useState(false); + const [pointer, setPointer] = React.useState(false); + const isTypingRef = React.useRef(false); + + const [selectedItem, setSelectedItem] = React.useState(null); + const [activeIndex, setActiveIndex] = React.useState(null); + const isControlled = React.useRef( + typeof props.selectedIndex !== "undefined", + ); + + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-start", + open: isOpen, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [flip(), offset(8)], + }); + + const { isMounted, styles: transitionStyles } = useTransitionStyles(context); + + const elementsRef = React.useRef>([]); + const wrapperRef = React.useRef(null); + const labelsRef = React.useRef>([]); + + const handleSelect = React.useCallback((item: Item | null) => { + setSelectedItem(item); + setIsOpen(false); + + if (item !== null && typeof props.onSelectItem === "function") { + props.onSelectItem(item); + } + }, []); + + const handleSelectIndex = React.useCallback( + (index: number | null) => { + const element = elementsRef[index]; + if (!element) return; + + const optionKey = element.dataset.selectKey; + const optionLabel = element.dataset.selectLabel; + + handleSelect({ + index, + key: optionKey, + label: optionLabel, + }); + }, + [elementsRef, handleSelect], + ); + + const [defaultRoot, setDefaultRoot] = React.useState( + null, + ); + + const overlayId = React.useRef(overlays.generateId("chain-swap-combobox")); + + React.useEffect(() => { + if (isOpen) { + overlays.pushOverlay(overlayId.current); + } + return () => { + if (isOpen) { + overlays.popOverlay(overlayId.current); + } + }; + }, [isOpen]); + + React.useEffect(() => { + // Default lib root + setDefaultRoot(overlays.getOrCreateOverlayRoot(window.document)); + }, []); + + React.useEffect(() => { + if (!!props.defaultSelectedItem) { + handleSelect(props.defaultSelectedItem); + } + }, []); + + React.useEffect(() => { + // Controlled usage + if (isControlled.current) { + handleSelectIndex(props.selectedIndex); + } + }, [props.selectedIndex]); + + function handleTypeaheadMatch(index: number | null) { + if (isOpen) { + setActiveIndex(index); + } else { + handleSelectIndex(index); + } + } + + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + selectedIndex: selectedItem?.index, + onNavigate: setActiveIndex, + }); + + const typeahead = useTypeahead(context, { + listRef: labelsRef, + activeIndex, + selectedIndex: selectedItem?.index, + onMatch: handleTypeaheadMatch, + }); + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [listNav, typeahead, click, dismiss, role], + ); + + const selectContext: SelectContextValue = React.useMemo( + () => ({ + activeIndex, + selectedItem, + getItemProps, + handleSelect, + }), + [activeIndex, selectedItem, getItemProps, handleSelect], + ); + + return ( +
    + {props.label ? ( + + ) : null} + +
    + +
    + + + +
    + {isOpen && ( + +
    + + {props.children} + +
    +
    + )} +
    +
    +
    +
    + ); +} diff --git a/packages/react/src/ui/shared/shared.css.ts b/packages/react/src/ui/shared/shared.css.ts new file mode 100644 index 00000000..ac3f5d24 --- /dev/null +++ b/packages/react/src/ui/shared/shared.css.ts @@ -0,0 +1,138 @@ +import { + style, + styleVariants, + createVar, + keyframes, + globalStyle, +} from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +const listBottomShadowBgVar = createVar(); + +const bottomShadowBase = style({ + height: "36px", + position: "absolute", + left: 0, + bottom: 0, + width: "100%", + background: listBottomShadowBgVar, +}); + +export const bottomShadow = styleVariants({ + light: [ + style({ + vars: { + [listBottomShadowBgVar]: + "linear-gradient(0deg, rgba(255,255,255,1) 6%, rgba(255,255,255,0.95) 16%, rgba(255,255,255,0.85) 24%, rgba(255,255,255,0.75) 32%, rgba(255,255,255,0.65) 48%, rgba(255,255,255,0.4) 65%, rgba(255,255,255,0.2) 80%, rgba(255,255,255,0.1) 95%)", + }, + }), + bottomShadowBase, + ], + dark: [ + style({ + vars: { + [listBottomShadowBgVar]: + "linear-gradient(0deg, rgba(45,55,72,1) 6%, rgba(45,55,72,0.95) 16%, rgba(45,55,72,0.85) 36%, rgba(45,55,72,0.75) 45%, rgba(45,55,72,0.65) 55%, rgba(45,55,72,0.4) 70%, rgba(45,55,72,0.2) 80%, rgba(45,55,72,0.1) 95%)", + }, + }), + bottomShadowBase, + ], +}); + +export const fullWidthHeight = style({ + width: "100%", + height: "100%", +}); + +export const fullWidth = style({ + width: "100%", +}); + +export const scrollBarThumbBgVar = createVar(); + +const scrollBarBase = style({ + // Firefox + scrollbarWidth: "thin" /* "auto" or "thin" */, + scrollbarColor: `${scrollBarThumbBgVar} transparent` /* scroll thumb and track */, + selectors: { + "&::-webkit-scrollbar": { + width: themeVars.space[3], + }, + "&::-webkit-scrollbar-track": { + background: "transparent", + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: scrollBarThumbBgVar, + borderRadius: themeVars.space[2], + }, + }, +}); + +export const scrollBar = styleVariants({ + light: [ + style({ + vars: { + [scrollBarThumbBgVar]: "#A2AEBB", + }, + }), + scrollBarBase, + ], + dark: [ + style({ + vars: { + [scrollBarThumbBgVar]: "#697584", + }, + }), + scrollBarBase, + ], +}); + +export const standardTransitionProperties = style({ + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", +}); + +export const visuallyHidden = style({ + borderWidth: "0", + height: "1px", + width: "1px", + margin: "-1px", + overflow: "hidden", + padding: 0, + position: "absolute", + whiteSpace: "nowrap", +}); + +const skeletonGlow = keyframes({ + "0%": { + background: "rgba(211,216,222,.2)", + borderColor: "rgba(211,216,222,.2)", + }, + "100%": { + background: "rgba(95,107,124,.2)", + borderColor: "rgba(95,107,124,.2)", + }, +}); + +export const skeleton = style({ + animation: `${skeletonGlow} 1s linear infinite alternate`, + background: "rgba(211,216,222,.2)", + backgroundClip: `padding-box !important`, + borderColor: "rgba(211,216,222,.2) !important", + boxShadow: "none !important", + color: "transparent !important", + cursor: "default", + pointerEvents: "none", + userSelect: "none", +}); + +globalStyle(`${skeleton} *, ${skeleton}:after, ${skeleton}:before`, { + // @ts-ignore + visibility: "hidden !important", +}); + +export const rotateUpsideDown = keyframes({ + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(180deg)" }, +}); diff --git a/packages/react/src/ui/single-chain/index.ts b/packages/react/src/ui/single-chain/index.ts new file mode 100644 index 00000000..629fc2cd --- /dev/null +++ b/packages/react/src/ui/single-chain/index.ts @@ -0,0 +1 @@ +export { default } from "./single-chain"; diff --git a/packages/react/src/ui/single-chain/single-chain.tsx b/packages/react/src/ui/single-chain/single-chain.tsx new file mode 100644 index 00000000..8f322e6f --- /dev/null +++ b/packages/react/src/ui/single-chain/single-chain.tsx @@ -0,0 +1,112 @@ +import * as React from "react"; +import AssetListHeader from "../asset-list-header"; +import AssetList from "../asset-list"; +import Box from "../box"; +import Text from "../text"; +import Skeleton from "../skeleton"; +import Reveal from "../reveal"; +import type { SingleChainProps } from "./single-chain.types"; + +function SingleChain(props: SingleChainProps) { + return ( + + {!props.isLoading ? ( + <> + props.onDeposit?.()} + onWithdraw={(event) => props.onWithdraw?.()} + /> + + {props.listTitle} + + {props.list.length > 4 ? ( + + + + ) : null} + {props.list.length <= 4 ? ( + + ) : null} + + ) : null} + {!!props.isLoading ? ( + <> + + + {props.title} + + + + + + {props.listTitle} + + + + {[...Array(5).keys()]?.map((item) => ( + + + + + + + + + + + + ))} + + + ) : null} + + ); +} + +export default SingleChain; diff --git a/packages/react/src/ui/single-chain/single-chain.types.tsx b/packages/react/src/ui/single-chain/single-chain.types.tsx new file mode 100644 index 00000000..84ccff5a --- /dev/null +++ b/packages/react/src/ui/single-chain/single-chain.types.tsx @@ -0,0 +1,14 @@ +import type { AssetListHeaderProps } from "../asset-list-header/asset-list-header.types"; +import type { AssetListItemProps } from "../asset-list-item/asset-list-item.types"; + +export type SingleChainListItemProps = Omit< + AssetListItemProps, + "isOtherChains" +>; + +export interface SingleChainProps + extends Omit { + isLoading?: boolean; + list: Array; + listTitle: string; +} diff --git a/packages/react/src/ui/skeleton/index.ts b/packages/react/src/ui/skeleton/index.ts new file mode 100644 index 00000000..a4f7e683 --- /dev/null +++ b/packages/react/src/ui/skeleton/index.ts @@ -0,0 +1 @@ +export { default } from "./skeleton"; diff --git a/packages/react/src/ui/skeleton/skeleton.tsx b/packages/react/src/ui/skeleton/skeleton.tsx new file mode 100644 index 00000000..3221beb0 --- /dev/null +++ b/packages/react/src/ui/skeleton/skeleton.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "../box"; +import { skeleton } from "../shared/shared.css"; +import type { SkeletonProps } from "./skeleton.types"; + +function Skeleton(props: SkeletonProps) { + return ; +} + +export default Skeleton; diff --git a/packages/react/src/ui/skeleton/skeleton.types.ts b/packages/react/src/ui/skeleton/skeleton.types.ts new file mode 100644 index 00000000..142aa0f8 --- /dev/null +++ b/packages/react/src/ui/skeleton/skeleton.types.ts @@ -0,0 +1,3 @@ +import type { BoxProps } from "../box/box.types"; + +export interface SkeletonProps extends BoxProps {} diff --git a/packages/react/src/ui/slider/index.ts b/packages/react/src/ui/slider/index.ts new file mode 100644 index 00000000..6d61b9c1 --- /dev/null +++ b/packages/react/src/ui/slider/index.ts @@ -0,0 +1 @@ +export { default } from "./slider"; diff --git a/packages/react/src/ui/slider/slider.css.ts b/packages/react/src/ui/slider/slider.css.ts new file mode 100644 index 00000000..9bd5c80f --- /dev/null +++ b/packages/react/src/ui/slider/slider.css.ts @@ -0,0 +1,152 @@ +import { themeVars } from "@/styles/themes.css"; +import { style, createVar } from "@vanilla-extract/css"; + +const thumbColorVar = createVar(); + +export const slider = style({ + display: "flex", +}); + +export const sliderHorizontal = style({ + flexDirection: "column", +}); + +export const sliderVertical = style({ + height: "150px", +}); + +export const labelContainer = style({ + display: "flex", + justifyContent: "space-between", +}); + +const sliderTrackBase = style({ + position: "relative", + selectors: { + '&[data-disabled="true"]': { + opacity: 0.4, + }, + }, +}); + +export const trackProgress = style({ + height: "6px", + borderRadius: "4px", + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + zIndex: 1, +}); + +export const trackPreviewProgress = style({ + height: "4px", + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + zIndex: 0, + borderLeft: `2px solid ${themeVars.colors.black}`, + borderRight: `4px solid ${themeVars.colors.black}`, +}); + +export const horizontalTrack = style([ + sliderTrackBase, + { + height: "16px", + width: "100%", + selectors: { + // Track line + "&:before": { + content: "attr(x)", + borderRadius: "4px", + display: "block", + position: "absolute", + background: themeVars.colors.inputBorder, + height: "4px", + width: "100%", + top: "50%", + transform: "translateY(-50%)", + }, + "&[data-has-preview-track='true']:before": { + background: themeVars.colors.gray600, + }, + }, + }, +]); + +export const verticalTrack = style([ + sliderTrackBase, + { + width: "16px", + height: "100%", + selectors: { + "&:before": { + content: "attr(x)", + borderRadius: "4px", + display: "block", + position: "absolute", + background: "gray", + width: "3px", + height: "100%", + left: "50%", + transform: "translateX(-50%)", + }, + }, + }, +]); + +export const sliderThumb = style({ + width: themeVars.space["11"], + height: themeVars.space["11"], + borderRadius: "50%", + background: thumbColorVar, + position: "relative", + zIndex: 2, + vars: { + [thumbColorVar]: themeVars.colors.menuItemBg, + }, + selectors: { + '&[data-theme="light"]': { + vars: { + [thumbColorVar]: themeVars.colors.inputBg, + }, + }, + '&[data-dragging="true"]': { + background: "dimgray", + }, + '&[data-focus="true"]': { + background: "orange", + }, + '&[data-direction="horizontal"]': { + top: "50%", + }, + '&[data-direction="vertical"]': { + left: "50%", + }, + // Thumb inner circle + "&:after": { + content: "attr(x)", + borderRadius: "50%", + display: "block", + position: "absolute", + background: thumbColorVar, + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "10px", + height: "10px", + }, + // Thumb outer circle + "&:before": { + content: "attr(x)", + borderRadius: "50%", + display: "block", + position: "absolute", + background: themeVars.colors.text, + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "18px", + height: "18px", + }, + }, +}); diff --git a/packages/react/src/ui/slider/slider.tsx b/packages/react/src/ui/slider/slider.tsx new file mode 100644 index 00000000..c0ae31f5 --- /dev/null +++ b/packages/react/src/ui/slider/slider.tsx @@ -0,0 +1,185 @@ +import clx from "clsx"; +import * as React from "react"; +import { SliderState, useSliderState } from "react-stately"; + +import Box from "@/ui/box"; +import { BoxProps } from "@/ui/box/box.types"; +import useTheme from "@/ui/hooks/use-theme"; +import { + AriaSliderProps, + AriaSliderThumbProps, + VisuallyHidden, + mergeProps, + useFocusRing, + useNumberFormatter, + useSlider, + useSliderThumb, +} from "react-aria"; +import * as styles from "./slider.css"; + +export interface SliderProps extends AriaSliderProps { + name: string; + fluidWidth?: boolean; + width?: BoxProps["width"]; + previewPercent?: number; + thumbTrackColor?: BoxProps["color"]; + previewTrackColor?: BoxProps["color"]; + renderLabel?: ({ + labelProps, + outputProps, + valuePercent, + valueLabel, + }: { + labelProps: React.LabelHTMLAttributes; + outputProps: React.OutputHTMLAttributes; + valuePercent: number; + valueLabel: string; + }) => React.ReactNode; + formatOptions?: Parameters[0]; +} + +function clampPreviewProgressPercent( + valuePercent: number, + previewPercent: number, +) { + const totalPercent = valuePercent + previewPercent; + + if (totalPercent > 1) { + return 1 - valuePercent; + } + + return totalPercent; +} + +export default function Slider(props: SliderProps) { + const trackRef = React.useRef(null); + const numberFormatter = useNumberFormatter(props.formatOptions); + + const state = useSliderState({ + ...props, + numberFormatter, + defaultValue: 0, + minValue: 0, + maxValue: 100, + }); + + const { groupProps, trackProps, labelProps, outputProps } = useSlider( + props, + state, + trackRef, + ); + + return ( + + {props.label && ( +
    + + {state.getThumbValueLabel(0)} +
    + )} + + {!props.label && + typeof props.renderLabel === "function" && + props.renderLabel({ + labelProps, + outputProps, + valuePercent: state.getThumbPercent(0), + valueLabel: state.getThumbValueLabel(0), + })} + +
    + + + {props.previewPercent != null && props.previewTrackColor && ( + + )} + + +
    +
    + ); +} + +export interface SliderThumbProps extends AriaSliderThumbProps { + state: SliderState; + trackRef: React.RefObject; + index: number; +} + +function Thumb(props: SliderThumbProps) { + const { state, trackRef, index, name } = props; + const { theme } = useTheme(); + + const inputRef = React.useRef(null); + const { thumbProps, inputProps, isDragging } = useSliderThumb( + { + index, + trackRef, + inputRef, + name, + }, + state, + ); + + const { focusProps, isFocusVisible } = useFocusRing(); + + return ( +
    + + + +
    + ); +} diff --git a/packages/react/src/ui/spinner/index.ts b/packages/react/src/ui/spinner/index.ts new file mode 100644 index 00000000..2369a461 --- /dev/null +++ b/packages/react/src/ui/spinner/index.ts @@ -0,0 +1 @@ +export { default } from './spinner' diff --git a/packages/react/src/ui/spinner/spinner.css.ts b/packages/react/src/ui/spinner/spinner.css.ts new file mode 100644 index 00000000..c11f6088 --- /dev/null +++ b/packages/react/src/ui/spinner/spinner.css.ts @@ -0,0 +1,10 @@ +import { style, keyframes } from "@vanilla-extract/css"; + +const rotate = keyframes({ + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, +}); + +export const loader = style({ + animation: `${rotate} 1s linear infinite`, +}); diff --git a/packages/react/src/ui/spinner/spinner.tsx b/packages/react/src/ui/spinner/spinner.tsx new file mode 100644 index 00000000..a7c2b16b --- /dev/null +++ b/packages/react/src/ui/spinner/spinner.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import clx from "clsx"; +import Icon from "../icon"; +import { loader } from "./spinner.css"; +import type { SpinnerProps } from "./spinner.types"; + +function Spinner(props: SpinnerProps) { + function combinedClassName() { + return clx(loader, props.className); + } + + return ( + + ); +} + +export default Spinner; diff --git a/packages/react/src/ui/spinner/spinner.types.tsx b/packages/react/src/ui/spinner/spinner.types.tsx new file mode 100644 index 00000000..ad37624c --- /dev/null +++ b/packages/react/src/ui/spinner/spinner.types.tsx @@ -0,0 +1,3 @@ +import { IconProps } from "../icon/icon.types"; + +export interface SpinnerProps extends Omit {} diff --git a/packages/react/src/ui/stack/index.ts b/packages/react/src/ui/stack/index.ts new file mode 100644 index 00000000..69077146 --- /dev/null +++ b/packages/react/src/ui/stack/index.ts @@ -0,0 +1 @@ +export { default } from "./stack"; diff --git a/packages/react/src/ui/stack/stack.tsx b/packages/react/src/ui/stack/stack.tsx new file mode 100644 index 00000000..c66e0b31 --- /dev/null +++ b/packages/react/src/ui/stack/stack.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import Box from "../box"; +import type { StackProps } from "./stack.types"; + +function Stack(props: StackProps) { + const { as = "div", direction = "horizontal", space = "$0" } = props; + return ( + + {props.children} + + ); +} + +export default Stack; diff --git a/packages/react/src/ui/stack/stack.types.tsx b/packages/react/src/ui/stack/stack.types.tsx new file mode 100644 index 00000000..78039e6a --- /dev/null +++ b/packages/react/src/ui/stack/stack.types.tsx @@ -0,0 +1,22 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; + +export interface StackProps extends Omit { + as?: any; + className?: string; + boxRef?: any; + children?: React.ReactNode; + forwardedRef?: any; + domAttributes?: any; + attributes?: Sprinkles; + direction?: "vertical" | "horizontal"; + space?: Sprinkles["gap"]; + flexWrap?: Sprinkles["flexWrap"]; + justify?: Sprinkles["justifyContent"]; + align?: Sprinkles["alignItems"]; + flex?: Sprinkles["flex"]; +} + +export const DEFAULT_VALUES = { + as: "div", +}; diff --git a/packages/react/src/ui/staking-asset-header/index.ts b/packages/react/src/ui/staking-asset-header/index.ts new file mode 100644 index 00000000..a4ddce57 --- /dev/null +++ b/packages/react/src/ui/staking-asset-header/index.ts @@ -0,0 +1 @@ +export { default } from "./staking-asset-header"; diff --git a/packages/react/src/ui/staking-asset-header/staking-asset-header.tsx b/packages/react/src/ui/staking-asset-header/staking-asset-header.tsx new file mode 100644 index 00000000..098fad1e --- /dev/null +++ b/packages/react/src/ui/staking-asset-header/staking-asset-header.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import { formatCurrency } from "../../helpers/number"; +import { StakingAssetHeaderProps } from "./staking-asset-header.types"; + +function StakingAssetHeader(props: StakingAssetHeaderProps) { + const { totalLabel = "Total", availableLabel = "Available" } = props; + return ( + + + + + + {totalLabel} + + + + {props.totalAmount} + + + {props.symbol} + + + + ≈ + {formatCurrency( + props.totalPrice, + "en-US", + props.priceformatOptions + )} + + + + + + {availableLabel} + + + + {props.available} + + + {props.symbol} + + + + ≈ + {formatCurrency( + props.availablePrice, + "en-US", + props.priceformatOptions + )} + + + + ); +} + +export default StakingAssetHeader; diff --git a/packages/react/src/ui/staking-asset-header/staking-asset-header.types.tsx b/packages/react/src/ui/staking-asset-header/staking-asset-header.types.tsx new file mode 100644 index 00000000..577ccdcd --- /dev/null +++ b/packages/react/src/ui/staking-asset-header/staking-asset-header.types.tsx @@ -0,0 +1,16 @@ +import type { BoxProps } from "../box/box.types"; +import type { NumberFormatOptions } from "../../models/components.model"; + +export interface StakingAssetHeaderProps extends BoxProps { + imgSrc: string; + symbol: string; + // ==== Numeric props + totalAmount: number; + totalPrice: number; + available: number; + availablePrice: number; + // ==== Labels + totalLabel?: string; + availableLabel?: string; + priceformatOptions?: NumberFormatOptions; +} diff --git a/packages/react/src/ui/staking-claim-header/index.ts b/packages/react/src/ui/staking-claim-header/index.ts new file mode 100644 index 00000000..a67bda0b --- /dev/null +++ b/packages/react/src/ui/staking-claim-header/index.ts @@ -0,0 +1 @@ +export { default } from "./staking-claim-header"; diff --git a/packages/react/src/ui/staking-claim-header/staking-claim-header.tsx b/packages/react/src/ui/staking-claim-header/staking-claim-header.tsx new file mode 100644 index 00000000..274a51c3 --- /dev/null +++ b/packages/react/src/ui/staking-claim-header/staking-claim-header.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Text from "../text"; +import Icon from "../icon"; +import Button from "../button"; +import type { StakingClaimHeaderProps } from "./staking-claim-header.types"; + +function StakingClaimHeader(props: StakingClaimHeaderProps) { + const { + stakedLabel = "Staked", + claimRewardsLabel = "Claimable Rewards", + claimLabel = "Claim", + } = props; + return ( + + + + + + {stakedLabel} + + + + {props.stakedAmount} + + + {props.symbol} + + + + + + + + {claimRewardsLabel} + + + + {props.rewardsAmount} + + + {props.symbol} + + + + + + ); +} + +export default StakingClaimHeader; diff --git a/packages/react/src/ui/staking-claim-header/staking-claim-header.types.tsx b/packages/react/src/ui/staking-claim-header/staking-claim-header.types.tsx new file mode 100644 index 00000000..94e51d6c --- /dev/null +++ b/packages/react/src/ui/staking-claim-header/staking-claim-header.types.tsx @@ -0,0 +1,15 @@ +import type { BoxProps } from "../box/box.types"; + +export interface StakingClaimHeaderProps extends BoxProps { + stakedAmount: number; + rewardsAmount: number; + symbol: string; + // ==== Labels + stakedLabel?: string; + claimRewardsLabel?: string; + claimLabel?: string; + // ==== button states + isLoading?: boolean; + isDisabled?: boolean; + onClaim?: (event?: any) => void; +} diff --git a/packages/react/src/ui/staking-delegate/index.ts b/packages/react/src/ui/staking-delegate/index.ts new file mode 100644 index 00000000..7ea7f917 --- /dev/null +++ b/packages/react/src/ui/staking-delegate/index.ts @@ -0,0 +1 @@ +export { default } from "./staking-delegate"; diff --git a/packages/react/src/ui/staking-delegate/staking-delegate-card.tsx b/packages/react/src/ui/staking-delegate/staking-delegate-card.tsx new file mode 100644 index 00000000..9a10e2f7 --- /dev/null +++ b/packages/react/src/ui/staking-delegate/staking-delegate-card.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; +import BigNumber from "bignumber.js"; +import Stack from "../stack"; +import Text from "../text"; +import Skeleton from "../skeleton"; +import type { StakingDelegateCardProps } from "./staking-delegate.types"; +import NumberField from "../number-field"; + +function StakingDelegateCard(props: StakingDelegateCardProps) { + return ( + + + {props.label} + + {!props.isLoading ? ( + + + {props.tokenAmount} + + + {props.tokenName} + + + ) : null} + {props.isLoading ? ( + + + + + ) : null} + + ); +} + +export default StakingDelegateCard; diff --git a/packages/react/src/ui/staking-delegate/staking-delegate-input.tsx b/packages/react/src/ui/staking-delegate/staking-delegate-input.tsx new file mode 100644 index 00000000..20aec754 --- /dev/null +++ b/packages/react/src/ui/staking-delegate/staking-delegate-input.tsx @@ -0,0 +1,176 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import BigNumber from "bignumber.js"; +import Box from "../box"; +import Avatar from "../avatar"; +import Text from "../text"; +import Skeleton from "../skeleton"; +import Spinner from "../spinner"; +import { baseButton } from "../button/button.css"; +import { breakpoints } from "../../styles/tokens"; +import { toNumber } from "../../helpers/number"; +import * as styles from "./staking-delegate.css"; +import type { StakingDelegateInputProps } from "./staking-delegate.types"; +import NumberField from "../number-field"; + +function StakingDelegateInput(props: StakingDelegateInputProps) { + const cleanupRef = useRef<() => void>(null); + const rootRef = useRef(null); + const resizeObserver = useRef(null); + const [isMounted, setIsMounted] = useState(() => false); + + const [width, setWidth] = useState(() => 0); + + function isValidNotionalValue() { + return ( + props.notionalValue && new BigNumber(props.notionalValue).isGreaterThan(0) + ); + } + + useEffect(() => { + setIsMounted(true); + resizeObserver.current = new ResizeObserver((entries) => { + const rootWidth = entries[0]?.borderBoxSize[0]?.inlineSize ?? 0; + setWidth(rootWidth); + }); + resizeObserver.current.observe(rootRef.current, { + box: "border-box", + }); + cleanupRef.current = () => { + if (rootRef.current instanceof Element) { + resizeObserver.current.unobserve(rootRef.current); + } + }; + }, []); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + + return ( + + + = breakpoints.mdMobile ? "lg" : "sm"} + src={props.inputToken.tokenIconUrl ?? ""} + /> + + props.onValueChange?.(value)} + onInput={(event) => + props.onValueInput?.((event.target as HTMLInputElement).value) + } + formatOptions={{ + minimumFractionDigits: + props.formatOptions?.minimumFractionDigits ?? 0, + maximumFractionDigits: + props.formatOptions?.maximumFractionDigits ?? 6, + }} + inputClassName={width >= breakpoints.mdMobile ? "" : styles.inputSm} + /> + + + + {props.inputToken.tokenName} + + {isValidNotionalValue() ? ( + + ≈ ${props.notionalValue} + + ) : null} + {!isValidNotionalValue() && props.isLoadingNotionalValue ? ( + + ) : null} + + + {props.partials && props.partials.length > 0 ? ( + + {props.partials?.map((item) => ( + { + if (item.isLoading) { + return; + } + item.onClick(); + }, + }} + className={baseButton} + > + + + + + {item.label} + + + ))} + + ) : null} + + ); +} + +export default StakingDelegateInput; diff --git a/packages/react/src/ui/staking-delegate/staking-delegate.css.ts b/packages/react/src/ui/staking-delegate/staking-delegate.css.ts new file mode 100644 index 00000000..481bd49e --- /dev/null +++ b/packages/react/src/ui/staking-delegate/staking-delegate.css.ts @@ -0,0 +1,5 @@ +import { style } from "@vanilla-extract/css"; + +export const inputSm = style({ + width: "50%", +}); diff --git a/packages/react/src/ui/staking-delegate/staking-delegate.tsx b/packages/react/src/ui/staking-delegate/staking-delegate.tsx new file mode 100644 index 00000000..a8ea2e79 --- /dev/null +++ b/packages/react/src/ui/staking-delegate/staking-delegate.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Avatar from "../avatar"; +import Text from "../text"; +import StakingDelegateInput from "./staking-delegate-input"; +import StakingDelegateCard from "./staking-delegate-card"; +import type { StakingDelegateProps } from "./staking-delegate.types"; +import NumberField from "../number-field"; + +function StakingDelegate(props: StakingDelegateProps) { + return ( + + + + + {!!props.header?.title ? ( + + {props.header?.title} + + ) : null} + {!!props.header?.subtitle ? ( + + {props.header?.subtitle} + + ) : null} + + + {props.headerExtra ? <>{props.headerExtra} : null} + {props.delegationItems && props.delegationItems.length > 0 ? ( + + {props.delegationItems?.map((item) => ( + + ))} + + ) : null} + {!!props.inputProps ? ( + + ) : null} + {!!props.footer ? <>{props.footer} : null} + + ); +} + +export default StakingDelegate; diff --git a/packages/react/src/ui/staking-delegate/staking-delegate.types.tsx b/packages/react/src/ui/staking-delegate/staking-delegate.types.tsx new file mode 100644 index 00000000..2a24d81c --- /dev/null +++ b/packages/react/src/ui/staking-delegate/staking-delegate.types.tsx @@ -0,0 +1,63 @@ +import type { + BaseComponentProps, + NumberFormatOptions, +} from "../../models/components.model"; +import type { BoxProps } from "../box/box.types"; + +export type DelegationItem = { + label: string; + tokenAmount: string; + tokenName: string; + isLoading?: boolean; +}; + +export type InputPartialChange = { + label: string; + isLoading?: boolean; + onClick: () => void; +}; + +export interface StakingDelegateProps extends BaseComponentProps { + header?: { + title?: string; + subtitle?: string; + avatarUrl?: string; + }; + headerExtra?: BaseComponentProps["children"]; + delegationItems?: DelegationItem[]; + footer?: BaseComponentProps["children"]; + inputProps?: StakingDelegateInputProps; + attributes?: Omit< + BoxProps, + "attributes" | "as" | "className" | "children" | "style" | "ref" + >; +} + +export interface StakingDelegateCardProps + extends BaseComponentProps, + DelegationItem { + attributes?: Omit< + BoxProps, + "attributes" | "as" | "className" | "children" | "style" | "ref" + >; +} + +export interface StakingDelegateInputProps extends BaseComponentProps { + value?: number; + notionalValue?: number; + inputToken?: { + tokenName: string; + tokenIconUrl: string; + }; + maxValue?: number; + minValue?: number; + partials?: InputPartialChange[]; + isLoadingNotionalValue?: boolean; + onValueChange?: (value: number) => void; + onValueInput?: (rawValue: string) => void; + formatOptions?: NumberFormatOptions; + attributes?: Omit< + BoxProps, + "attributes" | "as" | "className" | "children" | "style" | "ref" + >; +} diff --git a/packages/react/src/ui/star-text/index.ts b/packages/react/src/ui/star-text/index.ts new file mode 100644 index 00000000..7eb6db6c --- /dev/null +++ b/packages/react/src/ui/star-text/index.ts @@ -0,0 +1 @@ +export { default } from "./star-text"; diff --git a/packages/react/src/ui/star-text/star-text.tsx b/packages/react/src/ui/star-text/star-text.tsx new file mode 100644 index 00000000..3f13563e --- /dev/null +++ b/packages/react/src/ui/star-text/star-text.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import Box from "../box"; +import Stack from "../stack"; +import Icon from "../icon"; +import Text from "../text"; +import { formatNumeric } from "../../helpers/number"; +import type { StarTextProps } from "./star-text.types"; + +function StarText(props: StarTextProps) { + const { size = "md", tokenName = "STARS", showTokenIcon = true } = props; + return ( + props.onClick?.() }} + className={props.className} + > + + {!!props.label ? ( + + {props.label} + + ) : null} + {`${formatNumeric(props.value, 2)} ${tokenName}`} + {!props.iconSrc && showTokenIcon ? ( + + ) : null} + {typeof props.iconSrc === "string" && showTokenIcon ? ( + + ) : null} + + + ); +} + +export default StarText; diff --git a/packages/react/src/ui/star-text/star-text.types.tsx b/packages/react/src/ui/star-text/star-text.types.tsx new file mode 100644 index 00000000..a3c14839 --- /dev/null +++ b/packages/react/src/ui/star-text/star-text.types.tsx @@ -0,0 +1,13 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export interface StarTextProps extends BaseComponentProps { + size?: "md" | "lg"; + label?: string; + value: string | number; + tokenName?: string; + showTokenIcon?: boolean; + iconSrc?: string; + onClick?: (event?: any) => void; + attributes?: Sprinkles; +} diff --git a/packages/react/src/ui/swap-price/index.ts b/packages/react/src/ui/swap-price/index.ts new file mode 100644 index 00000000..ee336ec2 --- /dev/null +++ b/packages/react/src/ui/swap-price/index.ts @@ -0,0 +1 @@ +export { default } from "./swap-price"; diff --git a/packages/react/src/ui/swap-price/swap-price.css.ts b/packages/react/src/ui/swap-price/swap-price.css.ts new file mode 100644 index 00000000..c68b123f --- /dev/null +++ b/packages/react/src/ui/swap-price/swap-price.css.ts @@ -0,0 +1,32 @@ +import { style } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const swapPriceContainer = style([ + { + borderTop: `2px solid ${themeVars.colors.cardBg}`, + }, +]); + +export const img = style({ + width: themeVars.space[12], + height: themeVars.space[12], +}); + +export const absImg = style({ + position: "absolute", + top: "0", + right: "0", +}); + +export const routeDivider = style({ + height: "1px", + paddingLeft: themeVars.space[7], + paddingRight: themeVars.space[7], + flex: 1, + background: `repeating-linear-gradient(90deg, ${themeVars.colors.divider} 0 4px, #0000 0 12px)`, +}); + +export const priceContainer = style({ + overflow: "hidden", + maxHeight: "0", +}); diff --git a/packages/react/src/ui/swap-price/swap-price.tsx b/packages/react/src/ui/swap-price/swap-price.tsx new file mode 100644 index 00000000..648c2920 --- /dev/null +++ b/packages/react/src/ui/swap-price/swap-price.tsx @@ -0,0 +1,256 @@ +import * as React from "react"; +import { useState, useRef } from "react"; +import BigNumber from "bignumber.js"; +import clsx from "clsx"; +import anime from "animejs"; +import Stack from "../stack"; +import Box from "../box"; +import Text from "../text"; +import IconButton from "../icon-button"; +import { store } from "../../models/store"; +import * as styles from "./swap-price.css"; +import type { SwapPriceProps, SwapPriceDetailRoute } from "./swap-price.types"; +import type { AnimeInstance } from "animejs"; + +function SwapPrice(props: SwapPriceProps) { + const { + title = "Price", + priceImpactLabel = "Price Impact", + swapFeeLabel = "Swap Fee", + expectedOutputLabel = "Expected Output", + minimumReceivedLabel = "Minimum received after slippage", + routeLabel = "Route", + } = props; + const priceRef = useRef(null); + const animationRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(() => false); + function toggleExpand() { + const curStatus = !isExpanded; + setIsExpanded(curStatus); + if (curStatus) { + anime({ + targets: [priceRef.current], + maxHeight: "1000px", + easing: "easeInQuint", + duration: 250, + }); + } else { + anime({ + targets: [priceRef.current], + maxHeight: "0", + easing: "easeOutQuint", + duration: 250, + }); + } + } + function routesPath() { + let hasOsmo: boolean = + props?.fromItem?.symbol === "OSMO" || props?.toItem?.symbol === "OSMO"; + const osmoImgSrc = + "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmo.png"; + const osmo = "OSMO"; + let path = []; + if (hasOsmo) { + path = [ + { + swapFee: props?.swapFee?.percentage, + baseLogo: props?.fromItem?.imgSrc, + baseSymbol: props?.fromItem?.symbol, + quoteLogo: props?.toItem?.imgSrc, + quoteSymbol: props?.toItem?.symbol, + }, + ]; + } else { + path = [ + { + swapFee: props?.swapFee?.percentage, + baseLogo: props?.fromItem?.imgSrc, + baseSymbol: props?.fromItem?.symbol, + quoteLogo: osmoImgSrc, + quoteSymbol: osmo, + }, + { + swapFee: props.swapFee.percentage, + baseLogo: osmoImgSrc, + baseSymbol: osmo, + quoteLogo: props?.toItem?.imgSrc, + quoteSymbol: props?.toItem?.symbol, + }, + ]; + } + return path; + } + return ( + + + + {title} + + + {`1 ${props?.fromItem?.symbol} = ${new BigNumber( + props?.fromItem?.priceDisplayAmount + ) + .dividedBy(props?.toItem?.priceDisplayAmount) + .decimalPlaces(6) + .toString()} ${props?.toItem?.symbol}`} + {`~ $${props?.fromItem?.priceDisplayAmount}`} + + + toggleExpand()} + /> + + +
    + + + {priceImpactLabel} + + {props.priceImpact} + + + + + {swapFeeLabel} ({props?.swapFee?.percentage}) + + {`~ ${props?.swapFee?.value}`} + + + {expectedOutputLabel} + {`~ ${store + .getState() + .formatNumber({ value: props.toAmount })} ${ + props?.toItem?.symbol + }`} + + + {minimumReceivedLabel} + {`${ + props?.minimumReceived ?? 0 + } ${props?.toItem?.symbol}`} + + {props?.hasRoute ? ( + <> + + {routeLabel} + + + + {props?.fromItem?.symbol} + + + {routesPath()?.map((item, index) => ( + <> + + {item?.baseSymbol} + {item?.quoteSymbol} + + + {item?.swapFee} + + + + ))} + + {props?.toItem?.symbol} + + + + ) : null} + +
    +
    + ); +} +export default SwapPrice; diff --git a/packages/react/src/ui/swap-price/swap-price.types.tsx b/packages/react/src/ui/swap-price/swap-price.types.tsx new file mode 100644 index 00000000..3cddda24 --- /dev/null +++ b/packages/react/src/ui/swap-price/swap-price.types.tsx @@ -0,0 +1,54 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { BoxProps } from "../box/box.types"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; + +export interface SwapPriceType { + priceRate: string; + dollarValue: string; +} + +export type SwapDetailCoin = { + logoUrl: string | undefined; + symbol: string; +}; + +export type SwapPriceDetailRoute = { + poolId: string; + swapFee: string; + baseLogo: string | undefined; + baseSymbol: string; + quoteLogo: string | undefined; + quoteSymbol: string; +}; + +export type SwapPriceDetailRouteDetail = { + tokenIn: SwapDetailCoin; + routes: SwapPriceDetailRoute[]; + tokenOut: SwapDetailCoin; +}; + +export interface SwapPriceProps extends BaseComponentProps { + // ==== Labels + title?: string; + priceImpactLabel?: string; + swapFeeLabel?: string; + expectedOutputLabel?: string; + minimumReceivedLabel?: string; + routeLabel?: string; + // ==== + hasRoute?: boolean; + // price: SwapPriceType; + priceImpact: string; + swapFee: { + percentage: string; + value: string; + }; + minimumReceived?: number; + // internal props + fromItem: AvailableItem; + toItem: AvailableItem; + disabled?: boolean; + fromAmount: number; + toAmount: number; + attributes?: BoxProps; +} diff --git a/packages/react/src/ui/swap-token/index.ts b/packages/react/src/ui/swap-token/index.ts new file mode 100644 index 00000000..08cc4677 --- /dev/null +++ b/packages/react/src/ui/swap-token/index.ts @@ -0,0 +1 @@ +export { default } from "./swap-token"; diff --git a/packages/react/src/ui/swap-token/swap-token.css.ts b/packages/react/src/ui/swap-token/swap-token.css.ts new file mode 100644 index 00000000..929f0ab1 --- /dev/null +++ b/packages/react/src/ui/swap-token/swap-token.css.ts @@ -0,0 +1,68 @@ +import { style, styleVariants, createVar } from "@vanilla-extract/css"; +import { baseButton } from "../button/button.css"; +import { themeVars } from "../../styles/themes.css"; + +const swapBorderColorVar = createVar(); + +export const switchContainer = style({ + height: themeVars.space[7], + position: "relative", +}); + +const swapIconBase = style([ + { + display: "flex", + justifyContent: "center", + alignContent: "center", + position: "absolute", + borderRadius: "50%", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + border: `3px solid ${swapBorderColorVar} !important`, + minWidth: `${themeVars.space[14]}`, + width: `${themeVars.space[14]}`, + height: `${themeVars.space[14]}`, + }, +]); + +export const swapIcon = styleVariants({ + light: [ + style({ + vars: { + [swapBorderColorVar]: themeVars.colors.white, + }, + }), + baseButton, + swapIconBase, + ], + dark: [ + style({ + vars: { + [swapBorderColorVar]: themeVars.colors.gray700, + }, + }), + baseButton, + swapIconBase, + ], +}); + +export const settingContainer = style({ + flex: 1, + height: "100%", +}); + +export const percentLabelContainer = style({ + zIndex: 0, +}); + +export const percentContainer = style({ + position: "absolute", + top: "50%", + transform: "translate(0, -50%)", + right: "-400px", + width: "100%", + display: "flex", + justifyContent: "flex-end", + zIndex: 1, +}); diff --git a/packages/react/src/ui/swap-token/swap-token.tsx b/packages/react/src/ui/swap-token/swap-token.tsx new file mode 100644 index 00000000..11bd542e --- /dev/null +++ b/packages/react/src/ui/swap-token/swap-token.tsx @@ -0,0 +1,255 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import anime from "animejs"; +import Stack from "../stack"; +import Text from "../text"; +import IconButton from "../icon-button"; +import Box from "../box"; +import Button from "../button"; +import Icon from "../icon"; +import SwapPrice from "../swap-price"; +import { store } from "../../models/store"; +import TransferItem from "../transfer-item"; +import { IconProps } from "../icon/icon.types"; +import type { ThemeVariant } from "../../models/system.model"; +import * as styles from "./swap-token.css"; +import type { SwapTokenProps } from "./swap-token.types"; +import type { AvailableItem } from "../transfer-item/transfer-item.types"; + +function SwapToken(props: SwapTokenProps) { + const { + toleranceLimits = [1, 2.5, 3, 5], + slippageLabel = "Slippage tolerance", + swapDisabledLabel = "Insufficient balance", + swapLabel = "Swap", + } = props; + const swapIconRef = useRef(null); + const toteranceRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const rootRef = useRef(null); + const resizeObserver = useRef(null); + const [theme, setTheme] = useState(() => "light"); + const [swapIcon, setSwapIcon] = useState(() => "arrowDownLine"); + const [tolerance, setTolerance] = useState(() => 1); + const [isSetting, setIsSetting] = useState(() => false); + const [fromAmount, setFromAmount] = useState(() => 0); + const [toAmount, setToAmount] = useState(() => 0); + const [fromItem, setFromItem] = useState(() => null); + const [toItem, setToItem] = useState(() => null); + const [fromList, setFromList] = useState(() => []); + const [toList, setToList] = useState(() => []); + const [width, setWidth] = useState(() => 0); + function toggleIcon(deg, icon) { + anime({ targets: [swapIconRef.current], rotate: deg }); + setSwapIcon(icon); + } + function toggleToteranceStatus() { + let curSetting: boolean = !isSetting; + if (curSetting) { + anime({ + targets: [toteranceRef.current], + opacity: 1, + right: 0, + easing: "easeInQuint", + duration: 300, + }); + } else { + anime({ + targets: [toteranceRef.current], + opacity: 0, + right: -300, + easing: "easeOutQuint", + duration: 250, + }); + } + setIsSetting(curSetting); + } + function isSmallSize() { + return width < 326; + } + function setToterance(per) { + setTolerance(per); + toggleToteranceStatus(); + } + useEffect(() => { + setTheme(store.getState().theme); + resizeObserver.current = new ResizeObserver((entries) => { + const rootWidth = entries[0]?.borderBoxSize[0]?.inlineSize ?? 0; + setWidth(rootWidth); + }); + resizeObserver.current.observe(rootRef.current, { box: "border-box" }); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + if (rootRef.current instanceof Element) { + resizeObserver.current.unobserve(rootRef.current); + } + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + + props.from.onItemSelected(selectedItem) + } + onChange={(item, value) => props.from.onAmountChange(item, value)} + onInput={(item, value) => { + if (typeof props.from.onAmountInput === "function") { + props.from.onAmountInput(item, value); + } + }} + /> + + + + + + props.to.onItemSelected(selectedItem)} + onChange={(item, value) => props.to.onAmountChange(item, value)} + onInput={(item, value) => { + if (typeof props.to.onAmountInput === "function") { + props.to.onAmountInput(item, value); + } + }} + /> + + + {slippageLabel} + + + + + {tolerance}% + + toggleToteranceStatus()} + /> + +
    + + {toleranceLimits?.map((percent) => ( + + ))} + toggleToteranceStatus()} + /> + +
    +
    +
    + + + + +
    + ); +} + +export default SwapToken; diff --git a/packages/react/src/ui/swap-token/swap-token.types.tsx b/packages/react/src/ui/swap-token/swap-token.types.tsx new file mode 100644 index 00000000..c7f22da5 --- /dev/null +++ b/packages/react/src/ui/swap-token/swap-token.types.tsx @@ -0,0 +1,60 @@ +import type { + AvailableItem, + TransferItemProps, +} from "../transfer-item/transfer-item.types"; +import type { BaseComponentProps } from "../../models/components.model"; +import type { SwapPriceProps } from "../swap-price/swap-price.types"; +import type { ChainListItemProps } from "../chain-list-item/chain-list-item.types"; + +export interface SwapItemProps extends TransferItemProps {} + +export type SwapInfo = { + fromItem: AvailableItem; + toItem: AvailableItem; + fromAmount: number; + toAmount: number; +}; + +export type SwapItem = { + label?: string; + options: Array; + selected: AvailableItem; + amount: number; + onItemSelected: (selectedItem: AvailableItem) => void; + onAmountChange?: (selectedItem: AvailableItem, amount: number) => void; + onAmountInput?: (selectedItem: AvailableItem, rawValue: string) => void; +}; + +export type ComboboxOption = { + iconUrl?: ChainListItemProps["iconUrl"]; + name: ChainListItemProps["name"]; + tokenName: ChainListItemProps["tokenName"]; + amount?: ChainListItemProps["amount"]; + notionalValue?: ChainListItemProps["notionalValue"]; +}; + +export interface SwapTokenProps extends BaseComponentProps { + swapPrice: { + hasRoute: SwapPriceProps["hasRoute"]; + priceImpact: SwapPriceProps["priceImpact"]; + swapFee: SwapPriceProps["swapFee"]; + // Route preview props + routeDisabled?: boolean; + minimumReceived?: number; + }; + from: SwapItem; + to: SwapItem; + toleranceLimits?: Array; + filterFn?: ( + options: Array, + query: string + ) => Array; + onToggleDirection: () => void; + onSwap: (event?: any) => void; + onToleranceChange: (toterancePercent: number) => void; + swapDisabled?: boolean; + // Labels + swapLabel?: string; + swapDisabledLabel?: string; + slippageLabel?: string; +} diff --git a/packages/react/src/ui/table/index.ts b/packages/react/src/ui/table/index.ts new file mode 100644 index 00000000..6be713c3 --- /dev/null +++ b/packages/react/src/ui/table/index.ts @@ -0,0 +1 @@ +export { default } from "./table"; diff --git a/packages/react/src/ui/table/table-body.tsx b/packages/react/src/ui/table/table-body.tsx new file mode 100644 index 00000000..9c9f5e56 --- /dev/null +++ b/packages/react/src/ui/table/table-body.tsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import Box from "../box"; +import type { TableBodyProps } from "./table.types"; + +function TableBody(props: TableBodyProps) { + return ; +} + +export default TableBody; diff --git a/packages/react/src/ui/table/table-cell.tsx b/packages/react/src/ui/table/table-cell.tsx new file mode 100644 index 00000000..432f54e2 --- /dev/null +++ b/packages/react/src/ui/table/table-cell.tsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import Box from "../box"; +import type { TableCellProps } from "./table.types"; + +function TableCell(props: TableCellProps) { + return ; +} + +export default TableCell; diff --git a/packages/react/src/ui/table/table-column-header-cell.tsx b/packages/react/src/ui/table/table-column-header-cell.tsx new file mode 100644 index 00000000..2d8101b0 --- /dev/null +++ b/packages/react/src/ui/table/table-column-header-cell.tsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import Box from "../box"; +import type { TableColumnHeaderCellProps } from "./table.types"; + +function TableColumnHeaderCell(props: TableColumnHeaderCellProps) { + return ; +} + +export default TableColumnHeaderCell; diff --git a/packages/react/src/ui/table/table-head.tsx b/packages/react/src/ui/table/table-head.tsx new file mode 100644 index 00000000..31ca4f9d --- /dev/null +++ b/packages/react/src/ui/table/table-head.tsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import Box from "../box"; +import type { TableHeadProps } from "./table.types"; + +function TableHead(props: TableHeadProps) { + return ; +} + +export default TableHead; diff --git a/packages/react/src/ui/table/table-row-header-cell.tsx b/packages/react/src/ui/table/table-row-header-cell.tsx new file mode 100644 index 00000000..df65d08e --- /dev/null +++ b/packages/react/src/ui/table/table-row-header-cell.tsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import Box from "../box"; +import type { TableRowHeaderCellProps } from "./table.types"; + +function TableRowHeaderCell(props: TableRowHeaderCellProps) { + return ; +} + +export default TableRowHeaderCell; diff --git a/packages/react/src/ui/table/table-row.tsx b/packages/react/src/ui/table/table-row.tsx new file mode 100644 index 00000000..5b6eb399 --- /dev/null +++ b/packages/react/src/ui/table/table-row.tsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import Box from "../box"; +import type { TableRowProps } from "./table.types"; + +function TableRow(props: TableRowProps) { + return ; +} + +export default TableRow; diff --git a/packages/react/src/ui/table/table.css.ts b/packages/react/src/ui/table/table.css.ts new file mode 100644 index 00000000..1f35ec3b --- /dev/null +++ b/packages/react/src/ui/table/table.css.ts @@ -0,0 +1,7 @@ +import { style } from "@vanilla-extract/css"; + +export const table = style({ + textIndent: 0, + borderColor: "inherit", + borderCollapse: "collapse", +}); diff --git a/packages/react/src/ui/table/table.tsx b/packages/react/src/ui/table/table.tsx new file mode 100644 index 00000000..dfe155d5 --- /dev/null +++ b/packages/react/src/ui/table/table.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import clx from "clsx"; +import Box from "../box"; +import * as styles from "./table.css"; +import type { TableProps } from "./table.types"; + +function Table(props: TableProps) { + const { gridLayout = "auto" } = props; + return ( + + ); +} + +export default Table; diff --git a/packages/react/src/ui/table/table.types.tsx b/packages/react/src/ui/table/table.types.tsx new file mode 100644 index 00000000..19235598 --- /dev/null +++ b/packages/react/src/ui/table/table.types.tsx @@ -0,0 +1,17 @@ +import type { BoxProps } from "../box/box.types"; + +export interface TableProps extends BoxProps { + gridLayout?: "auto" | "fixed"; +} + +export interface TableHeadProps extends BoxProps {} + +export interface TableRowHeaderCellProps extends BoxProps {} + +export interface TableBodyProps extends BoxProps {} + +export interface TableRowProps extends BoxProps {} + +export interface TableCellProps extends BoxProps {} + +export interface TableColumnHeaderCellProps extends BoxProps {} diff --git a/packages/react/src/ui/tabs/index.ts b/packages/react/src/ui/tabs/index.ts new file mode 100644 index 00000000..6f1da78e --- /dev/null +++ b/packages/react/src/ui/tabs/index.ts @@ -0,0 +1 @@ +export { default } from "./tabs"; diff --git a/packages/react/src/ui/tabs/tabs.css.ts b/packages/react/src/ui/tabs/tabs.css.ts new file mode 100644 index 00000000..6941cffd --- /dev/null +++ b/packages/react/src/ui/tabs/tabs.css.ts @@ -0,0 +1,39 @@ +import { style, createVar } from "@vanilla-extract/css"; + +export const selectedWidth = createVar(); +export const selectedLeft = createVar(); + +export const tabsBase = style([ + { + listStyle: "none", + display: "flex", + borderRadius: "50px", + }, +]); + +export const tabsHorizontal = style([ + tabsBase, + { + flexDirection: "row", + }, +]); + +export const tabButton = style( + { + all: "unset", + cursor: "pointer", + width: "100%", + textAlign: "center", + borderRadius: "50px", + }, +); + +export const tabSelection = style({ + zIndex: -1, + height: '100%', + position: 'absolute', + left: 0, + borderRadius: '50px', + willChange: `transform, width`, + transition: `transform 150ms, width 100ms`, +}) diff --git a/packages/react/src/ui/tabs/tabs.tsx b/packages/react/src/ui/tabs/tabs.tsx new file mode 100644 index 00000000..b7a945e7 --- /dev/null +++ b/packages/react/src/ui/tabs/tabs.tsx @@ -0,0 +1,226 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import Box from "../box"; +import Text from "../text"; +import { store } from "../../models/store"; +import { + standardTransitionProperties, + scrollBar, + visuallyHidden, +} from "../shared/shared.css"; +import * as styles from "./tabs.css"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { ThemeVariant } from "../../models/system.model"; +import type { TabProps, TabsProps } from "./tabs.types"; + +function Tabs(props: TabsProps) { + const tabListRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const [isMounted, setIsMounted] = useState(() => false); + + const [theme, setTheme] = useState(() => "light"); + + const [active, setActive] = useState(() => 0); + + const [width, setWidth] = useState(() => 0); + + const [transform, setTransform] = useState(() => "translateX(0)"); + + function isControlled() { + return typeof props.activeTab !== "undefined"; + } + + function getActiveTabId() { + return isControlled() ? props.activeTab : active; + } + + function findActiveTabContent() { + const finalActiveTab = getActiveTabId(); + const panel: TabProps | null = props?.tabs + ? props?.tabs.find((_, index) => index === finalActiveTab) ?? null + : null; + return panel?.content ?? null; + } + + function getTabContentFor(id: number) { + const panel: TabProps | null = props?.tabs + ? props?.tabs.find((_, index) => index === id) ?? null + : null; + return panel?.content ?? null; + } + + function getBgColor() { + return theme === "light" ? "$gray200" : "$gray800"; + } + + function getTextColor(tabIndex: number) { + const finalActiveTab = getActiveTabId(); + if (tabIndex !== finalActiveTab) { + return "$textSecondary"; + } + return theme === "light" ? "$white" : "$gray900"; + } + + function setActiveStyles(activeTab: number) { + if (!tabListRef.current) return; + const nextTab = tabListRef.current.querySelector( + `[role="tab"][data-tab-key="tab-${activeTab}"]` + ) as HTMLElement; + setWidth(nextTab?.offsetWidth ?? 0); + setTransform(`translateX(${nextTab?.offsetLeft}px)`); + } + + useEffect(() => { + setTheme(store.getState().theme); + setIsMounted(true); + setTimeout(() => { + const finalActiveTab = getActiveTabId(); + setActiveStyles(props.defaultActiveTab ?? finalActiveTab); + }, 100); + const handleResize = () => { + const finalActiveTab = getActiveTabId(); + setActiveStyles(finalActiveTab); + }; + window.addEventListener("resize", handleResize, true); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + window.removeEventListener("resize", handleResize); + }); + }, []); + + useEffect(() => { + // Only apply this effect for controlled component + if (!isControlled()) return; + setActiveStyles(props.activeTab); + }, [props.activeTab]); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") { + cleanupRef.current(); + } + }; + }, []); + + return ( + + + + {props?.tabs?.map((tab, index) => ( + + { + if (!isControlled()) { + setActive(index); + } + setActiveStyles(index); + props.onActiveTabChange?.(index); + }, + }} + className={styles.tabButton} + > + + {tab.label} + + + + ))} + + + + {props.isLazy ? <>{findActiveTabContent()} : null} + {!props.isLazy ? ( + <> + {props.tabs?.map((_tabItem, index) => ( + + {getTabContentFor(index)} + + ))} + + ) : null} + + + + ); +} + +export default Tabs; diff --git a/packages/react/src/ui/tabs/tabs.types.tsx b/packages/react/src/ui/tabs/tabs.types.tsx new file mode 100644 index 00000000..8d50f411 --- /dev/null +++ b/packages/react/src/ui/tabs/tabs.types.tsx @@ -0,0 +1,17 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { BoxProps } from "../box/box.types"; + +export type TabProps = { + label: string; + content: BaseComponentProps["children"]; +}; + +export interface TabsProps extends BaseComponentProps { + defaultActiveTab?: number; + activeTab?: number; + // Whether or not to mount/unmount tab children on activeTab change, isLazy = true means unmount + isLazy?: boolean; + onActiveTabChange?: (tabId: number) => void; + tabs: TabProps[]; + attributes?: BoxProps; +} diff --git a/packages/react/src/ui/text-field-addon/index.ts b/packages/react/src/ui/text-field-addon/index.ts new file mode 100644 index 00000000..62536007 --- /dev/null +++ b/packages/react/src/ui/text-field-addon/index.ts @@ -0,0 +1 @@ +export { default } from "./text-field-addon"; diff --git a/packages/react/src/ui/text-field-addon/text-field-addon.css.ts b/packages/react/src/ui/text-field-addon/text-field-addon.css.ts new file mode 100644 index 00000000..814e2672 --- /dev/null +++ b/packages/react/src/ui/text-field-addon/text-field-addon.css.ts @@ -0,0 +1,66 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { inputBorderVar } from "../text-field/text-field.css"; +import { themeVars } from "../../styles/themes.css"; + +export const textFieldAddon = style({ + display: "flex", + color: "inherit", + fontSize: "inherit", + position: "absolute", + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", +}); + +export const textFieldAddonPositions = styleVariants({ + end: [ + style({ + right: 0, + top: 0, + bottom: 0, + }), + ], + start: [ + style({ + left: 0, + top: 0, + bottom: 0, + }), + ], +}); + +export const textFieldAddonSizes = styleVariants({ + sm: [ + style({ + paddingLeft: themeVars.space[6], + paddingRight: themeVars.space[6], + paddingTop: themeVars.space[4], + paddingBottom: themeVars.space[4], + }), + ], + md: [ + style({ + paddingLeft: themeVars.space[8], + paddingRight: themeVars.space[8], + paddingTop: themeVars.space[4], + paddingBottom: themeVars.space[4], + }), + ], +}); + +export const textFieldAddonDivider = styleVariants({ + end: [ + style({ + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderLeftColor: inputBorderVar, + }), + ], + start: [ + style({ + borderRightWidth: "1px", + borderRightStyle: "solid", + borderRightColor: inputBorderVar, + }), + ], +}); diff --git a/packages/react/src/ui/text-field-addon/text-field-addon.tsx b/packages/react/src/ui/text-field-addon/text-field-addon.tsx new file mode 100644 index 00000000..3a369a52 --- /dev/null +++ b/packages/react/src/ui/text-field-addon/text-field-addon.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import clx from "clsx"; +import { + textFieldAddon, + textFieldAddonSizes, + textFieldAddonPositions, + textFieldAddonDivider, +} from "./text-field-addon.css"; +import type { TextFieldAddonProps } from "./text-field-addon.types"; + +function TextFieldAddon(props: TextFieldAddonProps) { + const { divider = false, intent = "default", disabled = false } = props; + return ( +
    + {props.children} +
    + ); +} + +export default TextFieldAddon; diff --git a/packages/react/src/ui/text-field-addon/text-field-addon.types.tsx b/packages/react/src/ui/text-field-addon/text-field-addon.types.tsx new file mode 100644 index 00000000..10547ec3 --- /dev/null +++ b/packages/react/src/ui/text-field-addon/text-field-addon.types.tsx @@ -0,0 +1,10 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { TextFieldProps } from "../text-field/text-field.types"; + +export interface TextFieldAddonProps extends BaseComponentProps { + divider?: boolean; + position: "start" | "end"; + intent?: TextFieldProps["intent"]; + disabled?: TextFieldProps["disabled"]; + size?: TextFieldProps["size"]; +} diff --git a/packages/react/src/ui/text-field/index.ts b/packages/react/src/ui/text-field/index.ts new file mode 100644 index 00000000..84de9c17 --- /dev/null +++ b/packages/react/src/ui/text-field/index.ts @@ -0,0 +1 @@ +export { default } from "./text-field"; diff --git a/packages/react/src/ui/text-field/text-field.css.ts b/packages/react/src/ui/text-field/text-field.css.ts new file mode 100644 index 00000000..9f6e4584 --- /dev/null +++ b/packages/react/src/ui/text-field/text-field.css.ts @@ -0,0 +1,172 @@ +import { + createVar, + style, + styleVariants, + ComplexStyleRule, +} from "@vanilla-extract/css"; +import { baseTextStyles } from "../text/text.css"; +import { unstyledButton } from "../button/button.css"; +import { themeVars } from "../../styles/themes.css"; + +export const inputBorderVar = createVar(); +export const inputRingShadowVar = createVar(); +export const inputBgVar = createVar(); +export const inputTextVar = createVar(); + +export const rootInput = style({ + position: "relative", + display: "flex", + color: inputTextVar, + vars: { + [inputBorderVar]: themeVars.colors.inputBorder, + [inputBgVar]: themeVars.colors.inputBg, + [inputTextVar]: themeVars.colors.text, + }, +}); + +export const rootInputFocused = style({ + vars: { + [inputBorderVar]: themeVars.colors.text, + }, +}); + +export const focusStyleRule: ComplexStyleRule = { + vars: { + [inputBorderVar]: themeVars.colors.inputBorderFocus, + [inputRingShadowVar]: `${themeVars.colors.inputBg} 0px 0px 0px 0px, ${themeVars.colors.textPlaceholder} 0px 0px 0px 1px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px`, + }, + outline: `2px solid transparent`, + outlineOffset: "2px", + boxShadow: inputRingShadowVar, +}; + +export const hoverStyleRule: ComplexStyleRule = { + vars: { + [inputBorderVar]: themeVars.colors.text, + }, +}; + +export const inputBorderAndShadow = style({ + borderStyle: "solid", + borderWidth: "1px", + borderRadius: "6px", + borderColor: inputBorderVar, + vars: { + [inputBorderVar]: themeVars.colors.inputBorder, + }, + selectors: { + "&:hover": hoverStyleRule, + "&:focus-visible": focusStyleRule, + }, +}); + +const baseInputStyles = style([ + baseTextStyles, + inputBorderAndShadow, + style({ + flex: "1", + outline: "none", + position: "relative", + appearance: "none", + transitionProperty: + "background-color,border-color,color,fill,stroke,opacity,box-shadow,transform", + transitionDuration: "200ms", + backgroundColor: inputBgVar, + color: "inherit", + selectors: { + "&::-webkit-outer-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + "&::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: "0", + }, + }, + }), +]); + +export const inputStyles = styleVariants({ + light: [baseInputStyles], + dark: [ + baseInputStyles, + style({ + vars: { + [inputTextVar]: themeVars.colors.textSecondary, + }, + }), + ], +}); + +export const inputRootIntent = styleVariants({ + default: [], + error: [ + style({ + vars: { + [inputBorderVar]: themeVars.colors.inputDangerBorder, + [inputBgVar]: themeVars.colors.inputDangerBg, + }, + }), + ], + disabled: [ + style({ + vars: { + [inputBorderVar]: "none", + [inputBgVar]: themeVars.colors.inputDisabledBg, + }, + }), + ], +}); + +export const inputIntent = styleVariants({ + default: [], + error: [], + disabled: [ + style({ + color: themeVars.colors.inputDisabledText, + borderColor: inputBgVar, + }), + ], +}); + +export const inputSizes = styleVariants({ + sm: [ + style({ + height: themeVars.space[14], + paddingLeft: themeVars.space[6], + paddingRight: themeVars.space[6], + paddingTop: themeVars.space[4], + paddingBottom: themeVars.space[4], + }), + ], + md: [ + style({ + height: themeVars.space[15], + paddingLeft: themeVars.space[10], + paddingRight: themeVars.space[10], + paddingTop: themeVars.space[8], + paddingBottom: themeVars.space[8], + }), + ], + lg: [ + style({ + height: themeVars.space[17], + paddingLeft: themeVars.space[10], + paddingRight: themeVars.space[10], + paddingTop: themeVars.space[9], + paddingBottom: themeVars.space[9], + }), + ], +}); + +export const clearIcon = style({ + color: "inherit", + fontSize: themeVars.fontSize.lg, +}); + +export const clearButton = style([ + unstyledButton, + style({ + padding: 0, + }), +]); diff --git a/packages/react/src/ui/text-field/text-field.tsx b/packages/react/src/ui/text-field/text-field.tsx new file mode 100644 index 00000000..477c9991 --- /dev/null +++ b/packages/react/src/ui/text-field/text-field.tsx @@ -0,0 +1,125 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Stack from "../stack"; +import FieldLabel from "../field-label"; +import TextFieldAddon from "../text-field-addon"; +import Icon from "../icon"; +import { store } from "../../models/store"; +import { + inputStyles, + inputSizes, + inputIntent, + inputRootIntent, + clearIcon, + rootInput, + rootInputFocused, + clearButton, +} from "./text-field.css"; +import { validTypes, defaultInputModesForType } from "./text-field.types"; +import type { ThemeVariant } from "../../models/system.model"; +import type { TextFieldProps } from "./text-field.types"; + +function TextField(props: TextFieldProps) { + const { type = "text", size = "sm", intent = "default" } = props; + const cleanupRef = useRef<() => void>(null); + const [theme, setTheme] = useState(() => "light"); + const [isFocused, setIsFocused] = useState(() => false); + function isClearable() { + return ( + typeof props.onClear !== "undefined" && + !props.disabled && + typeof props.value === "string" && + props.value.length > 0 + ); + } + useEffect(() => { + setTheme(store.getState().theme); + cleanupRef.current = store.subscribe((newState) => { + setTheme(newState.theme); + }); + }, []); + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") cleanupRef.current(); + }; + }, []); + return ( + + {props.label ? ( + + ) : null} +
    + {props.startAddon ? <>{props.startAddon} : null} + { + props.onChange?.(e); + props.inputAttributes?.onChange?.(e); + }} + onFocus={(e) => { + setIsFocused(true); + props.onFocus?.(e); + props.inputAttributes?.onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + props.onBlur?.(e); + props.inputAttributes?.onBlur?.(e); + }} + placeholder={!props.disabled ? props.placeholder : undefined} + inputMode={props.inputMode || defaultInputModesForType[type]} + className={clx( + inputStyles[theme], + inputSizes[size], + props.disabled ? inputIntent.disabled : inputIntent[intent], + props.inputClassName + )} + /> + {isClearable() ? ( + + + + ) : null} + {props.endAddon ? <>{props.endAddon} : null} +
    +
    + ); +} + +export default TextField; diff --git a/packages/react/src/ui/text-field/text-field.types.tsx b/packages/react/src/ui/text-field/text-field.types.tsx new file mode 100644 index 00000000..74494037 --- /dev/null +++ b/packages/react/src/ui/text-field/text-field.types.tsx @@ -0,0 +1,103 @@ +import type { + Children, + BaseComponentProps, +} from "../../models/components.model"; +import type { FieldLabelProps } from "../field-label/field-label.types"; +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export type FieldLabelVariant = + | { + "aria-labelledby": string; + } + | { + "aria-label": string; + } + | { + label: FieldLabelProps["label"]; + }; + +export type FieldBaseProps = { + id: string; + value?: any; + labelId?: string; + name?: string | undefined; + disabled?: boolean; + readonly?: boolean; + autoComplete?: string | undefined; + description?: string | undefined; + message?: Children; + "aria-describedby"?: string | undefined; + data?: any; + autoFocus?: boolean; + prefix?: string; + required?: boolean; +}; + +export type PassthroughProps = + | "id" + | "name" + | "disabled" + | "autoComplete" + | "autoFocus"; + +export interface FieldRenderProps + extends Pick { + background: Sprinkles["backgroundColor"]; + borderRadius: Sprinkles["borderRadius"]; + width: Sprinkles["width"]; + paddingLeft: Sprinkles["paddingLeft"]; + paddingRight: Sprinkles["paddingRight"]; + "aria-describedby"?: string; + "aria-required"?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; + className: string; +} + +export type PrivateFieldProps = FieldBaseProps & FieldLabelVariant; + +export const validTypes = { + text: "text", + password: "password", + email: "email", + search: "search", + number: "number", + tel: "tel", + url: "url", +}; + +type InputMode = "text" | "email" | "search" | "numeric" | "tel" | "url"; + +export const defaultInputModesForType: Record< + keyof typeof validTypes, + InputMode +> = { + text: "text", + password: "text", + email: "email", + search: "search", + number: "numeric", + tel: "tel", + url: "url", +}; + +export interface TextFieldProps extends BaseComponentProps, FieldBaseProps { + value: string; + type?: keyof typeof validTypes; + inputMode?: InputMode; + intent?: "default" | "error"; + onChange?: (e: any) => void; + onBlur?: (e?: any) => void; + onFocus?: (e?: any) => void; + onClear?: (event?: any) => void; + size?: "sm" | "md"; + placeholder?: string | undefined; + label?: Children | undefined; + clearLabel?: string; + attributes?: any; + inputAttributes?: any; + startAddon?: Children | undefined; + endAddon?: Children | undefined; + inputContainer?: string; + inputClassName?: string; +} diff --git a/packages/react/src/ui/text/index.ts b/packages/react/src/ui/text/index.ts new file mode 100644 index 00000000..7dc7f964 --- /dev/null +++ b/packages/react/src/ui/text/index.ts @@ -0,0 +1 @@ +export { default } from "./text"; diff --git a/packages/react/src/ui/text/text.css.ts b/packages/react/src/ui/text/text.css.ts new file mode 100644 index 00000000..f7cd29b4 --- /dev/null +++ b/packages/react/src/ui/text/text.css.ts @@ -0,0 +1,64 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import type { RecipeVariants } from "@vanilla-extract/recipes"; +import { themeVars } from "../../styles/themes.css"; + +const variant = { + body: style({ + fontSize: themeVars.fontSize.sm, + color: themeVars.colors.text, + fontWeight: themeVars.fontWeight.normal, + lineHeight: themeVars.lineHeight.normal, + }), + heading: style({ + fontSize: themeVars.fontSize.md, + color: themeVars.colors.text, + fontWeight: themeVars.fontWeight.semibold, + lineHeight: themeVars.lineHeight.tall, + }), +}; + +export const baseTextStyles = style({ + fontFamily: themeVars.font.body, +}); + +export const textTransformStyle = styleVariants({ + ellipsis: [ + baseTextStyles, + style({ + textOverflow: `ellipsis`, + overflow: `hidden`, + whiteSpace: `nowrap`, + }), + ], + underline: [ + baseTextStyles, + style({ + textDecoration: `underline`, + }), + ], +}); + +export const variants = recipe({ + base: baseTextStyles, + variants: { + variant, + ellipsis: { + true: style({ + textOverflow: `ellipsis`, + overflow: `hidden`, + whiteSpace: `nowrap`, + }), + }, + underline: { + true: style({ + textDecoration: `underline`, + }), + }, + }, + defaultVariants: { + variant: "body", + }, +}); + +export type Variants = RecipeVariants; diff --git a/packages/react/src/ui/text/text.helper.ts b/packages/react/src/ui/text/text.helper.ts new file mode 100644 index 00000000..ae6ce80b --- /dev/null +++ b/packages/react/src/ui/text/text.helper.ts @@ -0,0 +1,51 @@ +import { Sprinkles } from "../../styles/rainbow-sprinkles.css"; + +export type Variant = "body" | "heading"; +export type TextTransform = "ellipsis" | "underline" | "none"; + +export function getVariantStyles( + variant: Variant, + customFontFamily?: Sprinkles["fontFamily"] +): Sprinkles { + if (variant === "body") { + return { + fontFamily: customFontFamily ?? "$body", + fontSize: "$sm", + fontWeight: "$normal", + lineHeight: "$normal", + }; + } + + if (variant === "heading") { + return { + fontFamily: customFontFamily ?? "$body", + fontSize: "$md", + fontWeight: "$semibold", + lineHeight: "$tall", + }; + } +} + +export function getTextTransformStyles({ + ellipsis, + underline, +}: { + ellipsis: boolean; + underline: boolean; +}): Sprinkles { + if (ellipsis) { + return { + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }; + } + + if (underline) { + return { + textDecoration: "underline", + }; + } + + return {}; +} diff --git a/packages/react/src/ui/text/text.tsx b/packages/react/src/ui/text/text.tsx new file mode 100644 index 00000000..58d4145e --- /dev/null +++ b/packages/react/src/ui/text/text.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import Box from "../box"; +import type { TextProps } from "./text.types"; +import { getTextTransformStyles, getVariantStyles } from "./text.helper"; + +function Text(props: TextProps) { + const { + as = "p", + fontSize = "$sm", + color = "$text", + variant = "body", + wordBreak = "break-word", + ellipsis = false, + underline = false, + } = props; + function spreadAttributes() { + return Object.assign( + { margin: "$0", as: as, className: props.className }, + props.attributes, + props.domAttributes, + getVariantStyles(variant ?? "body", props.fontFamily), + getTextTransformStyles({ ellipsis: ellipsis, underline: underline }), + { + color: color, + fontSize: fontSize, + fontWeight: props.fontWeight, + letterSpacing: props.letterSpacing, + lineHeight: props.lineHeight, + textAlign: props.textAlign, + textTransform: props.textTransform, + wordBreak: wordBreak, + } + ); + } + return {props.children}; +} + +export default Text; diff --git a/packages/react/src/ui/text/text.types.tsx b/packages/react/src/ui/text/text.types.tsx new file mode 100644 index 00000000..665c7237 --- /dev/null +++ b/packages/react/src/ui/text/text.types.tsx @@ -0,0 +1,33 @@ +import type { Sprinkles } from "../../styles/rainbow-sprinkles.css"; +import type { BaseComponentProps } from "../../models/components.model"; +import type { Variant } from "./text.helper"; + +export interface TextProps extends BaseComponentProps { + as?: + | "code" + | "div" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "label" + | "p" + | "span"; + color?: Sprinkles["color"]; + fontFamily?: Sprinkles["fontFamily"]; + fontSize?: Sprinkles["fontSize"]; + fontWeight?: Sprinkles["fontWeight"]; + letterSpacing?: Sprinkles["letterSpacing"]; + lineHeight?: Sprinkles["lineHeight"]; + textAlign?: Sprinkles["textAlign"]; + textTransform?: Sprinkles["textTransform"]; + whiteSpace?: Sprinkles["whiteSpace"]; + wordBreak?: Sprinkles["wordBreak"]; + variant?: Variant; + ellipsis?: boolean; + underline?: boolean; + attributes?: Sprinkles; + domAttributes?: any; +} diff --git a/packages/react/src/ui/theme-provider/index.ts b/packages/react/src/ui/theme-provider/index.ts new file mode 100644 index 00000000..b5534bc1 --- /dev/null +++ b/packages/react/src/ui/theme-provider/index.ts @@ -0,0 +1 @@ +export { default } from "./theme-provider"; diff --git a/packages/react/src/ui/theme-provider/theme-provider.tsx b/packages/react/src/ui/theme-provider/theme-provider.tsx new file mode 100644 index 00000000..d1e2fee3 --- /dev/null +++ b/packages/react/src/ui/theme-provider/theme-provider.tsx @@ -0,0 +1,209 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clsx from "clsx"; +import { isEqual } from "lodash"; +import { + mediaQueryColorScheme, + resolveThemeMode, + getAccent, + getAccentText, +} from "../../helpers/style"; +import { lightThemeClass, darkThemeClass } from "../../styles/themes.css"; +import { isSSR } from "../../helpers/platform"; +import { store } from "../../models/store"; +import { ThemeVariant } from "../../models/system.model"; +import { assignThemeVars } from "../../styles/override/override"; +import type { ThemeDef } from "../../styles/override/override.types"; +import type { ThemeProviderProps } from "./theme-provider.types"; + +function ThemeProvider(props: ThemeProviderProps) { + const cleanupRef = useRef<() => void>(null); + const [preferredMode, setPreferredMode] = useState(() => null); + + const [isMounted, setIsMounted] = useState(() => false); + + const [localCustomTheme, setLocalCustomTheme] = useState(() => null); + + const [localThemeDefs, setLocalThemeDefs] = useState(() => []); + + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [UIStore, setUIStore] = useState(() => store.getState()); + + function isControlled() { + return props.themeMode != null; + } + + function isReady() { + return preferredMode && isMounted; + } + + function lightQuery() { + if (isSSR()) return null; + return window?.matchMedia?.(mediaQueryColorScheme(`light`)); + } + + function darkQuery() { + if (isSSR()) return null; + return window?.matchMedia?.(mediaQueryColorScheme(`dark`)); + } + + function isDark() { + return !!darkQuery?.()?.matches; + } + + function isLight() { + return !!lightQuery?.()?.matches; + } + + function themeClass() { + return getNewThemeClass(store.getState()); + } + + function getNewThemeClass(uiStore: ReturnType) { + if (isControlled()) { + if (props.themeMode === "system") { + const finalThemeMode = isReady() ? preferredMode : props.themeMode; + return finalThemeMode === "dark" ? darkThemeClass : lightThemeClass; + } + return props.themeMode === "dark" ? darkThemeClass : lightThemeClass; + } + return uiStore.themeClass; + } + + useEffect(() => { + setIsMounted(true); + + // Resolve the theme mode + const resolvedThemeMode = resolveThemeMode(props.defaultTheme); + + // Set the initial theme based on the resolved mode + if (!isControlled()) { + store.getState().setThemeMode(resolvedThemeMode); + } + const darkListener = ({ matches }: MediaQueryListEvent) => { + if (matches) { + setPreferredMode("dark"); + } + }; + const lightListener = ({ matches }: MediaQueryListEvent) => { + if (matches) { + setPreferredMode("light"); + } + }; + const cleanupStore = store.subscribe((newState) => { + setUIStore(newState); + setInternalTheme(newState.theme); + }); + if (darkQuery() && lightQuery()) { + if ( + typeof darkQuery().addEventListener === "function" && + typeof lightQuery().addEventListener === "function" + ) { + darkQuery?.()?.addEventListener("change", darkListener); + lightQuery?.()?.addEventListener("change", lightListener); + } + } + cleanupRef.current = () => { + if (typeof darkQuery().removeEventListener === "function") { + darkQuery?.()?.removeEventListener("change", darkListener); + } + if (typeof lightQuery().removeEventListener === "function") { + lightQuery?.()?.removeEventListener("change", lightListener); + } + if (typeof cleanupStore === "function") { + cleanupStore(); + } + }; + }, []); + + useEffect(() => { + if (!isReady()) { + return; + } + const themeMode = store.getState().themeMode; + const setThemeModeFn = store.getState().setThemeMode; + if (themeMode === "system" || themeMode == null) { + return setThemeModeFn("system"); + } + }, [preferredMode, internalTheme, isReady(), UIStore]); + useEffect(() => { + const finalThemeDefs = props.themeDefs ?? []; + const isValidThemeDefs = + Array.isArray(props.themeDefs) && finalThemeDefs.length > 0; + if (!isValidThemeDefs) { + return; + } + + // Only set global custom themes if props.themeMode is not controlled + // controlled props.themeMode usage means that the user is managing nested theme + if (isControlled()) { + return; + } else { + if (!isEqual(store.getState().themeDefs, finalThemeDefs)) { + store.getState().setThemeDefs(finalThemeDefs); + } + } + }, [props.themeDefs, isControlled()]); + useEffect(() => { + if (!props.customTheme || !isMounted) { + return; + } + + // TODO: handle custom theme for controlled mode + store.getState().setCustomTheme(props.customTheme); + }, [props.customTheme, isMounted]); + useEffect(() => { + const overrideStyleManager = store.getState().overrideStyleManager; + if (!overrideStyleManager) { + return; + } + overrideStyleManager.update(props.overrides, null); + }, [props.overrides]); + useEffect(() => { + // Skip if accent is not provided + if (!props.accent) return; + const prevAccent = UIStore.themeAccent; + const currentColorMode = UIStore.theme; + if (prevAccent !== props.accent) { + UIStore.setThemeAccent(props.accent ?? "blue"); + assignThemeVars( + { + colors: { + // @ts-ignore + accent: getAccent(props.accent, currentColorMode ?? "light"), + // @ts-ignore + accentText: getAccentText(currentColorMode ?? "light"), + }, + }, + currentColorMode + ); + } + }, [props.accent]); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") { + cleanupRef.current(); + } + }; + }, []); + + return ( +
    + {props.children} +
    + ); +} + +export default ThemeProvider; diff --git a/packages/react/src/ui/theme-provider/theme-provider.types.tsx b/packages/react/src/ui/theme-provider/theme-provider.types.tsx new file mode 100644 index 00000000..2b6ed194 --- /dev/null +++ b/packages/react/src/ui/theme-provider/theme-provider.types.tsx @@ -0,0 +1,24 @@ +import type { BaseComponentProps } from "../../models/components.model"; +import type { UIState } from "../../models/store"; +import type { ThemeDef } from "../../styles/override/override.types"; +import type { ComponentOverrideMap } from "../../styles/override/override.types"; +import type { Accent } from "../../styles/tokens"; + +export const DEFAULT_VALUES = { + defaultTheme: "dark", + customTheme: null, + accent: "blue", +} as const; + +export interface ThemeProviderProps extends BaseComponentProps { + accent?: Accent; + // TODO: rename all ThemeVariant related public API to use colorMode + defaultTheme?: UIState["theme"]; + // Controlled prop themeMode, this will override the themeMode in the store + // and will not be persisted in localstorage + themeMode?: UIState["themeMode"]; + overrides?: ComponentOverrideMap; + children?: React.ReactNode; + themeDefs?: Array; + customTheme?: UIState["customTheme"]; +} diff --git a/packages/react/src/ui/timeline/index.ts b/packages/react/src/ui/timeline/index.ts new file mode 100644 index 00000000..2d206af0 --- /dev/null +++ b/packages/react/src/ui/timeline/index.ts @@ -0,0 +1 @@ +export { default } from "./timeline"; diff --git a/packages/react/src/ui/timeline/timeline.css.ts b/packages/react/src/ui/timeline/timeline.css.ts new file mode 100644 index 00000000..57b3702f --- /dev/null +++ b/packages/react/src/ui/timeline/timeline.css.ts @@ -0,0 +1,217 @@ +import { style, createContainer } from "@vanilla-extract/css"; +import { themeVars } from "../../styles/themes.css"; + +export const timelineContainer = createContainer(); + +const DOT_SIZE_PX = 14; + +export const timeline = style({ + containerType: "size", + containerName: timelineContainer, +}); + +export const eventItemsContainer = style({ + position: "relative", + selectors: { + '&[data-is-even="false"]': { + flexDirection: "row-reverse", + }, + }, + "@container": { + [`${timelineContainer} (max-width: 480px)`]: { + flexDirection: "column", + alignItems: "flex-start", + selectors: { + '&[data-is-even="false"]': { + flexDirection: "column", + alignItems: "flex-start", + }, + }, + }, + }, +}); + +export const eventItem = style({ + width: "50%", + maxWidth: "50%", + flex: "0 0 50%", + alignSelf: "flex-start", + "@container": { + [`${timelineContainer} (max-width: 480px)`]: { + width: "100%", + maxWidth: "100%", + flex: "0 0 100%", + paddingLeft: themeVars.space["12"], + }, + }, +}); + +export const eventItemSecondary = style({ + width: "50%", + maxWidth: "50%", + flex: "0 0 50%", + alignSelf: "flex-start", + selectors: { + '&[data-direction="left"]': { + justifyContent: "flex-end", + }, + '&[data-direction="right"]': { + justifyContent: "flex-start", + }, + }, + "@container": { + [`${timelineContainer} (max-width: 480px)`]: { + width: "100%", + maxWidth: "100%", + flex: "0 0 100%", + paddingLeft: themeVars.space["12"], + paddingTop: themeVars.space["6"], + selectors: { + '&[data-direction="left"]': { + justifyContent: "flex-start", + }, + '&[data-direction="right"]': { + justifyContent: "flex-start", + }, + }, + }, + }, +}); + +export const eventContent = style({ + position: "relative", + width: "100%", + selectors: { + "&:after": { + content: `""`, + position: "absolute", + borderRadius: "2px", + backgroundColor: themeVars.colors.cardBg, + transform: "rotate(45deg)", + width: themeVars.space["8"], + height: themeVars.space["8"], + top: themeVars.space["8"], + zIndex: 1, + }, + }, +}); + +export const eventContentLeft = style({ + marginLeft: themeVars.space["11"], + "@container": { + [`${timelineContainer} (max-width: 480px)`]: { + marginLeft: themeVars.space["11"], + marginRight: 0, + }, + }, +}); + +export const eventContentRight = style({ + marginRight: themeVars.space["11"], + "@container": { + [`${timelineContainer} (max-width: 480px)`]: { + marginLeft: themeVars.space["11"], + marginRight: 0, + }, + }, +}); + +export const eventContentArrowLeft = style({ + selectors: { + "&:after": { + left: "-9px", + borderTopLeftRadius: "0px", + borderBottomRightRadius: "0px", + borderRight: "none", + borderTop: "none", + borderLeft: `1px solid ${themeVars.colors.divider}`, + borderBottom: `1px solid ${themeVars.colors.divider}`, + }, + }, +}); + +export const eventContentArrowRight = style({ + "@container": { + [`${timelineContainer} (max-width: 480px)`]: { + selectors: { + // Collapse the arrow on mobile + "&:after": { + left: "-9px", + borderTopLeftRadius: "0px", + borderBottomRightRadius: "0px", + borderRight: "none", + borderTop: "none", + borderLeft: `1px solid ${themeVars.colors.divider}`, + borderBottom: `1px solid ${themeVars.colors.divider}`, + }, + }, + }, + }, + selectors: { + "&:after": { + right: "-9px", + borderTopLeftRadius: "0px", + borderBottomRightRadius: "0px", + borderRight: `1px solid ${themeVars.colors.divider}`, + borderTop: `1px solid ${themeVars.colors.divider}`, + borderLeft: "none", + borderBottom: "none", + }, + }, +}); + +export const rowSeparator = style({ + position: "absolute", + height: "100%", + width: "50px", +}); + +const barAndDotPosition = style({ + position: "absolute", + left: "50%", +}); + +export const eventCircle = style([ + { + transform: "translate(calc(-50% + 0.5px), -50%)", + backgroundColor: themeVars.colors.purple100, + borderRadius: "50%", + height: `${DOT_SIZE_PX}px`, + width: `${DOT_SIZE_PX}px`, + top: themeVars.space["10"], + zIndex: 2, + }, + barAndDotPosition, +]); + +export const eventCircleInner = style({ + borderRadius: "50%", + height: `${DOT_SIZE_PX / 2}px`, + width: `${DOT_SIZE_PX / 2}px`, + transform: "translate(50%, 50%)", + selectors: { + '&[data-theme="light"]': { + backgroundColor: themeVars.colors.white, + }, + '&[data-theme="dark"]': { + backgroundColor: themeVars.colors.black, + }, + }, +}); + +export const eventBar = style([ + { + backgroundColor: themeVars.colors.divider, + borderRadius: themeVars.radii.md, + height: "calc(100% + 70px)", + width: "1px", + top: themeVars.space["8"], + zIndex: 0, + selectors: { + '&[data-is-last="true"]': { + height: "0px", + }, + }, + }, + barAndDotPosition, +]); diff --git a/packages/react/src/ui/timeline/timeline.tsx b/packages/react/src/ui/timeline/timeline.tsx new file mode 100644 index 00000000..a71dd231 --- /dev/null +++ b/packages/react/src/ui/timeline/timeline.tsx @@ -0,0 +1,188 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import clx from "clsx"; +import Box from "../box"; +import Text from "../text"; +import * as styles from "./timeline.css"; +import { store, UIState } from "../../models/store"; +import type { TimelineProps } from "./timeline.types"; + +function Timeline(props: TimelineProps) { + const timelineRef = useRef(null); + const cleanupRef = useRef<() => void>(null); + const [internalTheme, setInternalTheme] = useState(() => "light"); + + const [timelineHeight, setTimelineHeight] = useState(() => 0); + + function updateTimelineHeight() { + if (timelineRef.current) { + const height = timelineRef.current.scrollHeight; + setTimelineHeight(height); + } + } + + useEffect(() => { + const handleResize = () => { + updateTimelineHeight(); + }; + window.addEventListener("resize", handleResize); + const cleanupStore = store.subscribe((newState, prevState) => { + setInternalTheme(newState.theme); + }); + updateTimelineHeight(); + cleanupRef.current = () => { + window.removeEventListener("resize", handleResize); + cleanupStore(); + }; + }, []); + + useEffect(() => { + updateTimelineHeight(); + }, [props.events]); + + useEffect(() => { + return () => { + if (typeof cleanupRef.current === "function") { + cleanupRef.current(); + } + }; + }, []); + + return ( + + {props.events?.map((event, index) => ( + + + + {!event.customContent ? ( + + + {event.timestamp} + + + {event.title} + + {event.description ? ( + + {event.description} + + ) : null} + + ) : null} + {event.customContent ?
    {event.customContent}
    : null} +
    +
    + + {!!event.secondaryContent ? ( + + {event.secondaryContent} + + ) : null} + +