diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 91a2eb8c..e805e6f1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,13 +11,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18] + node-version: [20] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3.0.0 - with: - version: 8.11.0 + - uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 @@ -35,13 +33,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18] + node-version: [20] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3.0.0 - with: - version: 8.11.0 + - uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 @@ -58,20 +54,18 @@ jobs: run: pnpm lint - name: Check types - run: pnpm lint:types + run: pnpm -F @ensdomains/thorin lint:types test: name: Test runs-on: ubuntu-latest strategy: matrix: - node-version: [18] + node-version: [20] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3.0.0 - with: - version: 8.11.0 + - uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3136cdeb..4028de4d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,15 +10,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18] + node-version: [20] steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.release.target_commitish }} - - uses: pnpm/action-setup@v3.0.0 - with: - version: 8.11.0 + - uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index b2892f4d..b344b5c0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,6 @@ yarn-error.log* docs/.next/ docs/out/ -docs/public/playroom/ -# Do not delete. This is a dummy file to ensure that cloudflare does not serve the original playroom index file. -!docs/public/playroom/index.html -docs/src/playroom/snippets components/dist/ components/coverage/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 1bf51931..b8f03d37 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -pnpm lint \ No newline at end of file +# pnpm lint // TODO: Make pnpm work with husky \ No newline at end of file diff --git a/.node-version b/.node-version index 6e9d5a1e..510c9216 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16.13.1 \ No newline at end of file +22.3.0 \ No newline at end of file diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index a58d2d2c..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -18.18.2 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 5087330a..6dc11230 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,4 @@ docs/.next/ -docs/public/playroom/ docs/out components/dist/ diff --git a/.stylelintrc.json b/.stylelintrc.json deleted file mode 100644 index c8135b9f..00000000 --- a/.stylelintrc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": [ - "stylelint-config-recommended", - "stylelint-config-styled-components", - "stylelint-config-prettier" - ], - "overrides": [ - { - "files": ["**/*.tsx"], - "customSyntax": "@stylelint/postcss-css-in-js" - } - ], - "rules": { - "property-no-unknown": [ - true, - { - "ignoreProperties": ["flex-gap", "webkit-appearance"] - } - ], - "no-duplicate-selectors": null, - "function-no-unknown": null - } -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7cd1eef2..ed0c7171 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,7 +2,6 @@ "recommendations": [ "dbaeumer.vscode-eslint", "jounqin.vscode-mdx", - "styled-components.vscode-styled-components", "stylelint.vscode-stylelint" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 54e29c2e..c8831c20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,25 +1,14 @@ { "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.fixAll.stylelint": "never", + "source.fixAll.stylelint": "explicit", "source.organizeImports": "never" }, - "editor.formatOnSave": true, - "eslint.options": { - "extensions": [".js", ".json", ".mdx", ".ts", ".tsx"] - }, - "eslint.validate": [ - "javascript", - "json", - "mdx", - "typescript", - "typescriptreact" - ], - "stylelint.configFile": ".stylelintrc.json", - "stylelint.validate": ["css", "typescriptreact"], - "eslint.packageManager": "yarn", - "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "svg.preview.background": "custom" + "editor.formatOnSave": false, + "eslint.useFlatConfig": true, + "stylelint.configFile": "stylelint.config.mjs", + "svg.preview.background": "custom", + "prettier.enable": false, + "stylelint.configBasedir": ".", + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/README.md b/README.md index 5371740b..b47cedc5 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,16 @@ -# ENS Design System · [![NPM version](https://img.shields.io/npm/v/thorin.svg?style=for-the-badge&labelColor=161c22)](https://www.npmjs.com/package/@ensdomains/thorin) [![License](https://img.shields.io/npm/l/thorin.svg?style=for-the-badge&labelColor=161c22)](/LICENSE) +# ENS Design System -A design system for ENS built with React and styled-components. +[![NPM version](https://img.shields.io/npm/v/thorin.svg?style=for-the-badge&labelColor=161c22)](https://www.npmjs.com/package/@ensdomains/thorin) [![License](https://img.shields.io/npm/l/thorin.svg?style=for-the-badge&labelColor=161c22)](/LICENSE) + +A design system for ENS built with React and vanilla-extract. **NOTE: This project is in alpha stage. It is in active development and is subject to change.** ## Install -To install this package using npm: - -```bash -npm install @ensdomains/thorin styled-components react-transition-state@1.1.5 - -``` - -To install this package using yarn: ```bash -yarn add @ensdomains/thorin styled-components react-transition-state@1.1.5 -``` - -## Set Up - -In your App component, wrap the root of your app in a [`ThemeProvider`](https://styled-components.com/docs/advanced) module from [styled-components](https://styled-components.com). Import `ThorinGlobalStyles` and declare it as a child of `ThemeProvider` to set global styles. Set the theme by passing a theme object to `ThemeProvider`. - -```tsx -import { ThemeProvider } from 'styled-components' -import { ThorinGlobalStyles, lightTheme } from '@ensdomains/thorin' - -const App = () => { - return ( - - - {children} - - ) -} -``` - -### Dark Theme - -To use the dark theme, import darkTheme and pass it to the ThemeProvider - -```tsx -import { ThemeProvider } from 'styled-components' -import { ThorinGlobalStyles, lightTheme } from '@ensdomains/thorin' - -const App = () => { - return ( - - - {children} - - ) -} -``` - -## Use Components - -A list of components with examples are available on the [project website](https://thorin.ens.domains). - -A simple example to get you started: - -```tsx -import { Input, SearchSVG } from '@ensdomains/thorin' - -const SearchInput = () => { - return ( - } - /> - ) -} +pnpm i react react-dom @ensdomains/thorin ``` ## Documentation @@ -88,21 +26,21 @@ The documentation is divided into two sections. ```bash gh clone repo @ensdomains/thorin -pnpm install +pnpm i pnpm dev ``` Before development, it is recommended that you read the following: - [Development Guide](https://thorin.ens.domains/guides/development) - Information and tips to help you when working on this project including: - - [Component Groups](https://thorin.ens.domains/guides/development#component-groups) - How the components are organized. - - [Adding Components](https://thorin.ens.domains/guides/development#adding-components) - A list of files that need to be added or modified for each component. - - [Style Guidlines](https://thorin.ens.domains/guides/development#style-guidelines) - Rules and tips to follow to keep the project code consistent and maintainable. - - [Common Issues](https://thorin.ens.domains/guides/development#common-issues) - A list of known issues and how to resolve them. +- [Component Groups](https://thorin.ens.domains/guides/development#component-groups) - How the components are organized. +- [Adding Components](https://thorin.ens.domains/guides/development#adding-components) - A list of files that need to be added or modified for each component. +- [Style Guidlines](https://thorin.ens.domains/guides/development#style-guidelines) - Rules and tips to follow to keep the project code consistent and maintainable. +- [Common Issues](https://thorin.ens.domains/guides/development#common-issues) - A list of known issues and how to resolve them. ## Contributing -Contribute to this project by sending a pull request to [this repository](https://github.com/ensdomains/thorin). +Contribute to this project by sending a pull request. ## Sources diff --git a/components/.eslintignore b/components/.eslintignore deleted file mode 100644 index 007ce46f..00000000 --- a/components/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -dist -coverage -src/components/icons/generated - -node_modules -pnpm-lock.yaml -pnpm-workspace.yaml \ No newline at end of file diff --git a/components/.eslintrc.js b/components/.eslintrc.js deleted file mode 100644 index c9a47813..00000000 --- a/components/.eslintrc.js +++ /dev/null @@ -1,125 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 8, - sourceType: 'module', - ecmaFeatures: { - impliedStrict: true, - experimentalObjectRestSpread: true, - }, - allowImportExportEverywhere: true, - }, - plugins: ['@typescript-eslint', 'import', 'react', 'jest'], - extends: [ - 'eslint:recommended', - 'plugin:eslint-comments/recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:import/typescript', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:prettier/recommended', - 'prettier', - ], - rules: { - // `@typescript-eslint` - // https://github.com/typescript-eslint/typescript-eslint - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': [ - 2, - { - argsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/no-var-requires': 'off', - // `eslint-plugin-import` - // https://github.com/benmosher/eslint-plugin-import - 'import/order': [ - 'error', - { - groups: ['external', 'internal'], - 'newlines-between': 'always-and-inside-groups', - }, - ], - 'sort-imports': [ - 'warn', - { - ignoreCase: false, - ignoreDeclarationSort: true, - ignoreMemberSort: false, - }, - ], - // `eslint-plugin-react` - // https://github.com/yannickcr/eslint-plugin-react - 'react/display-name': 'off', - 'react/jsx-boolean-value': ['warn', 'never'], - 'react/jsx-curly-brace-presence': [ - 'error', - { props: 'never', children: 'ignore' }, - ], - 'react/jsx-sort-props': [ - 'error', - { - callbacksLast: true, - }, - ], - 'react/jsx-wrap-multilines': 'error', - 'react/no-array-index-key': 'error', - 'react/no-multi-comp': 'off', - 'react/prop-types': 'off', - 'react/self-closing-comp': 'warn', - }, - overrides: [ - { - files: ['*.mdx'], - extends: ['plugin:mdx/recommended'], - rules: { - 'import/no-anonymous-default-export': 'off', - 'react/display-name': 'off', - 'react/jsx-no-undef': 'off', - 'no-undef': 'off', - }, - settings: { - 'mdx/code-blocks': true, - }, - }, - { - files: '**/*.{md,mdx}/**', - extends: 'plugin:mdx/code-blocks', - rules: { - '@typescript-eslint/no-unused-vars': 'off', - 'prettier/prettier': 'off', - 'import/no-unresolved': 'off', - 'react/react-in-jsx-scope': 'off', - 'react/jsx-no-undef': 'off', - }, - }, - ], - settings: { - 'import/parsers': { - '@typescript-eslint/parser': ['.ts', '.tsx', '.d.ts'], - }, - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - // "project": "**/tsconfig.json" - project: [ - __dirname + '/components/tsconfig.json', - __dirname + '/docs/tsconfig.json', - ], - }, - }, - react: { - version: 'detect', - }, - }, - env: { - es6: true, - browser: true, - node: true, - jest: true, - }, -} diff --git a/components/.gitignore b/components/.gitignore new file mode 100644 index 00000000..75e854d8 --- /dev/null +++ b/components/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/components/jest.config.ts b/components/jest.config.ts deleted file mode 100644 index 65ac0baa..00000000 --- a/components/jest.config.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Config } from '@jest/types' -import { pathsToModuleNameMapper } from 'ts-jest' - -import { compilerOptions } from './tsconfig.json' - -const config: Config.InitialOptions = { - collectCoverageFrom: [ - 'src/**/*.{ts,tsx}', - '!**/index.ts', - '!**/*.snippets.tsx', - '!**/*.css.ts', - '!**/icons/**', - '!**/tokens/**', - ], - moduleNameMapper: { - '\\.svg$': '/__mocks__/svg.js', - ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }), - }, - preset: 'ts-jest', - roots: [''], - testEnvironment: 'jsdom', - testRegex: '.*\\.test\\.(ts|tsx)$', - watchPlugins: [ - 'jest-watch-typeahead/filename', - 'jest-watch-typeahead/testname', - ], - transform: { - '\\.tsx?': [ - 'ts-jest', - { - babelConfig: { - plugins: ['babel-plugin-styled-components'], - }, - }, - ], - }, -} - -export default config diff --git a/components/knip.json b/components/knip.json new file mode 100644 index 00000000..e796956c --- /dev/null +++ b/components/knip.json @@ -0,0 +1,3 @@ +{ + "project": ["src/**"] +} \ No newline at end of file diff --git a/components/package.json b/components/package.json index 6635a4cb..1abbeeb8 100644 --- a/components/package.json +++ b/components/package.json @@ -1,20 +1,18 @@ { "name": "@ensdomains/thorin", - "version": "0.6.50", + "version": "1.0.0-beta.16", "description": "A web3 native design system", - "main": "./dist/index.cjs.js", - "module": "./dist/index.es.js", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/types/index.d.ts", "exports": { ".": { - "import": "./dist/index.es.js", - "require": "./dist/index.cjs.js", - "types": "./dist/types/index.d.ts" + "types": "./dist/types/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" }, - "./styles": { - "import": "./dist/style.css", - "require": "./dist/style.css" - } + "./dist/*.css": "./dist/*.css" }, "sideEffects": [ "src/atoms/**/*", @@ -22,72 +20,51 @@ "*.css.ts" ], "files": [ - "dist/**" + "dist" ], + "engines": { + "node": ">=20" + }, "repository": "ensdomains/thorin", "license": "MIT", "scripts": { "build": "vite build", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "clean": "rimraf dist src/components/icons/generated/", + "build:watch": "vite build --watch --mode development", "lint:types": "tsc --noEmit", "prepack": "pnpm build", - "test": "jest", - "ver": "pnpm npm version" + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "ver": "npm version" }, "dependencies": { - "@types/jest": "^29.5.12", - "clsx": "^1.1.1", - "focus-visible": "^5.2.0", - "jest-babel": "^1.0.1", - "lodash": "^4.17.21", - "ts-pattern": "^4.3.0" + "@vanilla-extract/sprinkles": "^1.6.3", + "clsx": "^2.1.1", + "react-transition-state": "^2.1.3", + "ts-pattern": "^5.5.0" }, "devDependencies": { - "@babel/core": "^7.20.5", - "@honkhonk/vite-plugin-svgr": "^1.1.0", - "@jest/types": "^27.2.5", - "@stylelint/postcss-css-in-js": "^0.38.0", - "@svgr/babel-plugin-remove-jsx-attribute": "^6.0.0", - "@svgr/rollup": "^6.2.1", - "@testing-library/dom": "^10.1.0", - "@testing-library/jest-dom": "^6.4.5", - "@testing-library/react": "^15.0.7", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", - "@types/glob": "^7.2.0", - "@types/lodash": "^4.14.176", - "@types/node": "^16.11.6", - "@types/react": "^18.3.2", - "@types/react-dom": "^18.3.0", - "@types/rimraf": "^3.0.2", - "@types/styled-components": "^5", - "@types/testing-library__jest-dom": "^5.14.1", - "babel-plugin-styled-components": "^2.0.6", - "deepmerge": "^4.2.2", - "esbuild-darwin-arm64": "^0.14.27", - "glob": "^7.2.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jest-styled-components": "^7.0.8", - "jest-watch-typeahead": "^1.0.0", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vanilla-extract/css": "^1.16.0", + "@vanilla-extract/dynamic": "^2.1.2", + "@vanilla-extract/recipes": "^0.5.5", + "@vanilla-extract/vite-plugin": "^4.0.18", + "@vitest/coverage-v8": "^2.1.8", + "jsdom": "^25.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "rimraf": "^3.0.2", - "ts-jest": "^29.1.3", - "ts-node": "^10.9.2", - "typescript": "4.9.4", - "vite": "^3.2.5", - "vite-plugin-babel-macros": "^1.0.6", - "vite-plugin-dts": "1.7.1", - "vite-plugin-stylelint": "^2.2.3", - "vite-plugin-svgr": "^1.1.0", - "vite-tsconfig-paths": "^4.0.1" + "vite": "^5.4.11", + "vite-plugin-dts": "^4.3.0", + "vite-tsconfig-paths": "^5.1.3", + "vitest": "^2.1.8" }, "peerDependencies": { "react": "^18", - "react-dom": "^18", - "react-transition-state": "^2.1.1", - "styled-components": "^5.3.6" + "react-dom": "^18" } } diff --git a/components/src/components/atoms/Avatar/Avatar.test.tsx b/components/src/components/atoms/Avatar/Avatar.test.tsx index 799da70e..77192980 100644 --- a/components/src/components/atoms/Avatar/Avatar.test.tsx +++ b/components/src/components/atoms/Avatar/Avatar.test.tsx @@ -1,11 +1,7 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen, waitFor } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Avatar } from './Avatar' describe('', () => { @@ -13,13 +9,23 @@ describe('', () => { it('renders', async () => { render( - - - , + , + ) + await waitFor(() => { + expect(screen.getByRole('img')).toBeInTheDocument() + + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://images.mirror-media.xyz/publication-images/H-zIoEYWk4SpFkljJiwB9.png') + }) + }) + it('should render placeholder if no src is provided', async () => { + render( + , ) - await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument()) + await waitFor(() => { + expect(screen.getByAltText('Avatar')).toBeInTheDocument() + }) }) }) diff --git a/components/src/components/atoms/Avatar/Avatar.tsx b/components/src/components/atoms/Avatar/Avatar.tsx index 58cf3257..4b025db4 100644 --- a/components/src/components/atoms/Avatar/Avatar.tsx +++ b/components/src/components/atoms/Avatar/Avatar.tsx @@ -1,5 +1,11 @@ import * as React from 'react' -import styled, { css } from 'styled-components' + +import { iconClass, img, placeholder } from './styles.css' + +import { Box, type BoxProps } from '../Box/Box' +import { avatar, overlay } from './styles.css' +import { CheckSVG } from '../../../index' +import clsx from 'clsx' type NativeImgAttributes = React.ImgHTMLAttributes @@ -7,101 +13,52 @@ type Shape = 'circle' | 'square' interface Container { $shape: Shape - $noBorder?: boolean + $size: BoxProps['wh'] } -const Container = styled.div( - ({ theme, $shape, $noBorder }) => css` - ${() => { - switch ($shape) { - case 'circle': - return css` - border-radius: ${theme.radii.full}; - &:after { - border-radius: ${theme.radii.full}; - } - ` - case 'square': - return css` - border-radius: ${theme.radii['2xLarge']} - &:after { - border-radius: ${theme.radii['2xLarge']} - } - ` - default: - return css`` - } - }} - - ${!$noBorder && - css` - &::after { - box-shadow: ${theme.shadows['-px']} ${theme.colors.backgroundSecondary}; - content: ''; - inset: 0; - position: absolute; - } - `} - - background-color: ${theme.colors.backgroundSecondary}; - - width: 100%; - padding-bottom: 100%; - - > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } - - overflow: hidden; - position: relative; - `, +const Container = ({ $shape, $size, ...props }: BoxProps & Container) => ( + ) -const Placeholder = styled.div<{ $url?: string; $disabled: boolean }>( - ({ theme, $url, $disabled }) => css` - background: ${$url || theme.colors.gradients.blue}; - - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - - ${$disabled && - css` - filter: grayscale(1); - `} - `, -) - -const Img = styled.img<{ $shown: boolean; $disabled: boolean }>( - ({ $shown, $disabled }) => css` - height: 100%; - width: 100%; - object-fit: cover; - display: none; - - ${$shown && - css` - display: block; - `} - - ${$disabled && - css` - filter: grayscale(1); - `} - `, -) +type PlaceholderProps = { + $disabled: boolean + $url?: string +} +const Placeholder = ({ + $disabled, + $url, + ...props +}: PlaceholderProps & BoxProps) => { + return ( + + ) +} -export type Props = { +export type AvatarProps = { /** Accessibility text. */ label: string - /** If true, removes the border around the avatar. */ - noBorder?: boolean /** Uses tokens space settings to set the size */ src?: NativeImgAttributes['src'] /** The shape of the avatar. */ @@ -111,20 +68,27 @@ export type Props = { /** If true sets the component into disabled format. */ disabled?: boolean /** An element that overlays the avatar */ - overlay?: React.ReactNode + checked?: boolean + /** An svg to overlay over the avatar */ + icon?: React.FC + /** The deconding attribute of an img element */ + decoding?: NativeImgAttributes['decoding'] + /** A custom sizing for the avatar */ + size?: BoxProps['wh'] } & Omit -export const Avatar = ({ +export const Avatar: React.FC = ({ label, - noBorder = false, shape = 'circle', src, placeholder, decoding = 'async', disabled = false, - overlay, + icon, + checked, + size, ...props -}: Props) => { +}) => { const ref = React.useRef(null) const [showImage, setShowImage] = React.useState(!!src) @@ -153,9 +117,24 @@ export const Avatar = ({ }, [ref, hideImg, showImg]) const isImageVisible = showImage && !!src + + const Overlay = React.useMemo(() => { + if (!checked) return null + return ( + + + + ) + }, [checked, disabled, icon]) + return ( - - {overlay} + {!isImageVisible && ( )} - {label} setShowImage(false)} onLoad={() => setShowImage(true)} /> + {Overlay} ) } diff --git a/components/src/components/atoms/Avatar/index.ts b/components/src/components/atoms/Avatar/index.ts deleted file mode 100644 index d59bf3b1..00000000 --- a/components/src/components/atoms/Avatar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Avatar } from './Avatar' -export type { Props } from './Avatar' diff --git a/components/src/components/atoms/Avatar/styles.css.ts b/components/src/components/atoms/Avatar/styles.css.ts new file mode 100644 index 00000000..13104ed6 --- /dev/null +++ b/components/src/components/atoms/Avatar/styles.css.ts @@ -0,0 +1,63 @@ +import { globalStyle, style, styleVariants } from '@vanilla-extract/css' +import { commonVars } from '@/src/css/theme.css' +import { recipe } from '@vanilla-extract/recipes' + +export const avatar = style({}) + +export const img = recipe({ + base: { + display: 'none', + position: 'absolute', + height: commonVars.space.full, + width: commonVars.space.full, + objectFit: 'cover', + }, + variants: { + loaded: { + true: { + display: 'block', + }, + }, + disabled: { + true: { + filter: 'grayscale(1)', + }, + }, + }, +}) + +export const overlay = recipe({ + variants: { + checked: { + true: { + background: `rgba(56, 137, 255, 0.75)`, + }, + false: { + background: `rgba(0,0,0, 0.25)`, + }, + }, + disabled: { + true: { + background: 'transparent', + }, + }, + }, +}) + +export const iconClass = style({ + width: '40%', +}) + +export const placeholder = styleVariants({ + disabled: { + filter: 'grayscale(1)', + }, +}) + +globalStyle(`${avatar} > * `, { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', +}) diff --git a/components/src/components/atoms/BackdropSurface/BackdropSurface.tsx b/components/src/components/atoms/BackdropSurface/BackdropSurface.tsx index c3248bc4..84e27f44 100644 --- a/components/src/components/atoms/BackdropSurface/BackdropSurface.tsx +++ b/components/src/components/atoms/BackdropSurface/BackdropSurface.tsx @@ -1,36 +1,34 @@ -import type { TransitionState } from 'react-transition-state' +import * as React from 'react' -import styled, { css } from 'styled-components' +import type { TransitionState } from 'react-transition-state' -export const BackdropSurface = styled.div<{ - $state: TransitionState['status'] - $empty: boolean -}>( - ({ theme, $state, $empty }) => css` - width: 100vw; - height: 100vh; - position: fixed; - overflow: hidden; - z-index: 999; - top: 0; - left: 0; - transition: ${theme.transitionDuration['300']} all - ${theme.transitionTimingFunction.popIn}; +import { backdropSurface } from './styles.css' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' - ${!$empty && $state === 'entered' - ? css` - background-color: rgba(0, 0, 0, ${theme.opacity.overlayFallback}); +export type BackdropSurfaceProps = { $state: TransitionState['status'], $empty: boolean } & BoxProps - @supports (-webkit-backdrop-filter: none) or (backdrop-filter: none) { - backdrop-filter: blur(16px); - background-color: rgba(0, 0, 0, ${theme.opacity.overlay}); - } - ` - : css` - background-color: rgba(0, 0, 0, 0); - @supports (-webkit-backdrop-filter: none) or (backdrop-filter: none) { - backdrop-filter: blur(0px); - } - `} - `, +export const BackdropSurface = React.forwardRef( + ({ $empty, $state, ...props }, ref) => ( + + ), ) + +BackdropSurface.displayName = 'BackdropSurface' diff --git a/components/src/components/atoms/BackdropSurface/index.ts b/components/src/components/atoms/BackdropSurface/index.ts deleted file mode 100644 index dc2becb6..00000000 --- a/components/src/components/atoms/BackdropSurface/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BackdropSurface } from './BackdropSurface' diff --git a/components/src/components/atoms/BackdropSurface/styles.css.ts b/components/src/components/atoms/BackdropSurface/styles.css.ts new file mode 100644 index 00000000..93a690b2 --- /dev/null +++ b/components/src/components/atoms/BackdropSurface/styles.css.ts @@ -0,0 +1,25 @@ +import { recipe } from '@vanilla-extract/recipes' + +export const backdropSurface = recipe({ + variants: { + entered: { + true: { + 'backgroundColor': 'rgba(0, 0, 0, 0.5)', + '@supports': { + '(-webkit-backdrop-filter: none) or (backdrop-filter: none)': { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + backdropFilter: 'blur(16px)', + }, + }, + }, + false: { + 'backgroundColor': 'rgba(0, 0, 0, 0)', + '@supports': { + '(-webkit-backdrop-filter: none) or (backdrop-filter: none)': { + backdropFilter: 'blur(0px)', + }, + }, + }, + }, + }, +}) diff --git a/components/src/components/atoms/Banner/Banner.test.tsx b/components/src/components/atoms/Banner/Banner.test.tsx index e2ec831a..0cefaef0 100644 --- a/components/src/components/atoms/Banner/Banner.test.tsx +++ b/components/src/components/atoms/Banner/Banner.test.tsx @@ -1,11 +1,7 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Banner } from './Banner' describe('', () => { @@ -13,11 +9,9 @@ describe('', () => { it('renders', () => { render( - - - Message - - , + + Message + , ) expect(screen.getByText('Title')).toBeInTheDocument() expect(screen.getByText('Message')).toBeInTheDocument() diff --git a/components/src/components/atoms/Banner/Banner.tsx b/components/src/components/atoms/Banner/Banner.tsx index 0d9a9906..bab183c1 100644 --- a/components/src/components/atoms/Banner/Banner.tsx +++ b/components/src/components/atoms/Banner/Banner.tsx @@ -1,15 +1,16 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { mq } from '@/src/utils/responsiveHelpers' +import type { WithAlert } from './utils/getValueForAlert' +import { getValueForAlert } from './utils/getValueForAlert' +import { Typography } from '../Typography/Typography' -import { WithAlert } from '../../../types' - -import { Typography } from '../Typography' - -import { AlertSVG, CrossSVG, EthSVG, UpRightArrowSVG } from '../..' - -type NativeDivProps = React.HTMLAttributes +import type { IconProps } from '@/src/icons' +import { AlertSVG, CrossSVG, EthSVG, UpRightArrowSVG } from '@/src/icons' +import type { AsProp, BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import * as styles from './styles.css' +import { clsx } from 'clsx' +import { assignInlineVars } from '@vanilla-extract/dynamic' type IconTypes = 'filledCircle' | 'normal' | 'none' @@ -18,13 +19,13 @@ type BaseProps = { title?: string as?: 'a' onDismiss?: () => void - actionIcon?: React.ReactNode - icon?: React.ReactNode + actionIcon?: React.FC + icon?: React.FC iconType?: IconTypes -} & NativeDivProps +} & BoxProps type WithIcon = { - icon?: React.ReactNode + icon?: React.FC iconType?: Omit } @@ -50,184 +51,128 @@ type WithoutAnchor = { onDismiss?: () => void } -type NonNullableAlert = NonNullable +type NonNullableAlert = NonNullable -const Container = styled.div<{ +type ContainerProps = BoxProps & { $alert: NonNullableAlert $hasAction: boolean -}>( - ({ theme, $alert, $hasAction }) => css` - position: relative; - background: ${theme.colors.backgroundPrimary}; - border: 1px solid ${theme.colors.border}; - border-radius: ${theme.radii['2xLarge']}; - padding: ${theme.space[4]}; - display: flex; - align-items: stretch; - gap: ${theme.space[4]}; - width: ${theme.space.full}; - transition: all 150ms ease-in-out; - - ${mq.sm.min( - css` - padding: ${theme.space['6']}; - gap: ${theme.space[6]}; - align-items: center; - `, - )} - - ${$hasAction && - css` - padding-right: ${theme.space[8]}; - &:hover { - transform: translateY(-1px); - background: ${theme.colors.greySurface}; - ${$alert === 'error' && - css` - background: ${theme.colors.redLight}; - `} - ${$alert === 'warning' && - css` - background: ${theme.colors.yellowLight}; - `} - } - `} - - ${$alert === 'error' && - css` - background: ${theme.colors.redSurface}; - border: 1px solid ${theme.colors.redPrimary}; - `} - - ${$alert === 'warning' && - css` - background: ${theme.colors.yellowSurface}; - border: 1px solid ${theme.colors.yellowPrimary}; - `}; - `, +} +const ContainerBox = React.forwardRef( + ({ $alert, $hasAction, className, style, ...props }, ref) => ( + + ), ) -const Content = styled.div( - ({ theme }) => css` - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - gap: ${theme.space[1]}; - `, +const IconBox = ({ + $alert, + ...props +}: BoxProps & { $alert: NonNullableAlert }) => ( + ) -const IconContainer = styled.div<{ - $alert: NonNullableAlert - $type: Omit -}>( - ({ theme, $alert, $type }) => css` - width: ${theme.space[8]}; - height: ${theme.space[8]}; - flex: 0 0 ${theme.space[8]}; - - svg { - display: block; - width: 100%; - height: 100%; - } - - ${mq.sm.min(css` - width: ${theme.space[10]}; - height: ${theme.space[10]}; - flex: 0 0 ${theme.space[10]}; - `)} - - ${$type === 'filledCircle' && - css` - color: ${theme.colors.backgroundPrimary}; - border-radius: ${theme.radii.full}; - - svg { - transform: scale(0.5); - } - - ${$alert === 'info' && - css` - background: ${theme.colors.text}; - `} - `} - - ${$alert === 'error' && - css` - background: ${theme.colors.redPrimary}; - `} - - ${$alert === 'warning' && - css` - background: ${theme.colors.yellowPrimary}; - `} - `, +const SVGBox = ({ + $alert, + as, +}: { as: AsProp, $alert: NonNullableAlert }) => ( + ) -const ActionButtonContainer = styled.button( - ({ theme }) => css` - position: absolute; - top: 0; - right: 0; - padding: ${theme.space[2]}; - `, +const ActionButtonBox = (props: BoxProps) => ( + ) -const ActionButtonIconWrapper = styled.div<{ - $alert: NonNullableAlert - $hasAction?: boolean -}>( - ({ theme, $alert, $hasAction }) => css` - width: ${theme.space[5]}; - height: ${theme.space[5]}; - border-radius: ${theme.radii.full}; - background: ${theme.colors.accentSurface}; - color: ${theme.colors.accentPrimary}; - transition: all 150ms ease-in-out; - - display: flex; - align-items: center; - justify-content: center; - - svg { - display: block; - width: ${theme.space[3]}; - height: ${theme.space[3]}; +const ActionButtonIconBox = ({ + $alert, + $hasAction, + className, + style, + ...props +}: BoxProps & { $alert: NonNullableAlert, $hasAction: boolean }) => ( + +) - ${$alert === 'error' && - css` - background: ${theme.colors.backgroundPrimary}; - color: ${theme.colors.redPrimary}; - `} - - ${$alert === 'warning' && - css` - background: ${theme.colors.backgroundPrimary}; - color: ${theme.colors.yellowPrimary}; - `} - - ${$hasAction && - css` - cursor: pointer; - &:hover { - transform: translateY(-1px); - background: ${theme.colors.accentLight}; - color: ${theme.colors.accentDim}; - ${$alert === 'error' && - css` - background: ${theme.colors.redLight}; - color: ${theme.colors.redDim}; - `} - ${$alert === 'warning' && - css` - background: ${theme.colors.yellowLight}; - color: ${theme.colors.yellowDim}; - `} - } - `} - `, +const ActionButtonSVGBox = (props: BoxProps) => ( + ) const ActionButton = ({ @@ -235,34 +180,38 @@ const ActionButton = ({ icon, hasHref, onDismiss, -}: Pick & { hasHref: boolean } & WithIcon) => { - if (onDismiss) +}: Pick & { hasHref: boolean } & WithIcon) => { + if (onDismiss) { + const Icon = (icon || CrossSVG) return ( - onDismiss()}> - - {icon || } - - + onDismiss()}> + + + + ) - if (hasHref || icon) + } + if (hasHref || icon) { + const Icon = (icon || UpRightArrowSVG) return ( - - - {icon || } - - + + + + + ) + } return null } -export type Props = BaseProps & +export type BannerProps = BaseProps & (WithAnchor | WithoutAnchor) & (WithIcon | WithoutIcon) & WithAlert const defaultIconType = ( alert: NonNullableAlert, - icon: React.ReactNode | undefined, + icon?: React.FC, ): IconTypes => { if (alert !== 'info') return 'filledCircle' if (icon) return 'normal' @@ -271,57 +220,56 @@ const defaultIconType = ( export const Banner = React.forwardRef< HTMLDivElement, - React.PropsWithChildren + React.PropsWithChildren >( ( - { - title, - alert = 'info', - icon, - iconType, - as: asProp, - children, - onDismiss, - ...props - }, + { title, alert = 'info', icon, iconType, as: asProp, children, onDismiss, ...props }, ref, ) => { - const Icon = - icon || - (alert && ['error', 'warning'].includes(alert) ? ( - - ) : ( - - )) + const Icon + = icon + || (alert && ['error', 'warning'].includes(alert) + ? ( + AlertSVG + ) + : ( + EthSVG + )) const hasHref = !!props.href const hasAction = hasHref || !!props.onClick const _iconType = iconType || defaultIconType(alert, icon) return ( - {_iconType !== 'none' && ( - - {Icon} - + + + )} - + {title && {title}} {children} - + - + ) }, ) diff --git a/components/src/components/atoms/Banner/index.ts b/components/src/components/atoms/Banner/index.ts deleted file mode 100644 index fff2e0ad..00000000 --- a/components/src/components/atoms/Banner/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Banner } from './Banner' -export type { Props as BannerProps } from './Banner' diff --git a/components/src/components/atoms/Banner/styles.css.ts b/components/src/components/atoms/Banner/styles.css.ts new file mode 100644 index 00000000..fedb9c13 --- /dev/null +++ b/components/src/components/atoms/Banner/styles.css.ts @@ -0,0 +1,31 @@ +import { translateY } from '@/src/css/utils/common' +import { createVar, style } from '@vanilla-extract/css' + +export const svg = style({ + width: '100%', + height: '100%', +}) + +export const containerBoxHasAction = createVar() + +export const containerBox = style({ + 'transform': translateY(0), + ':hover': { + transform: containerBoxHasAction, + }, +}) + +export const svgBoxTransform = createVar() + +export const svgBox = style({ + transform: svgBoxTransform, +}) + +export const actionButtonIconBoxHasAction = createVar() + +export const actionButtonIconBox = style({ + 'transform': translateY(0), + ':hover': { + transform: actionButtonIconBoxHasAction, + }, +}) diff --git a/components/src/components/atoms/Banner/utils/getValueForAlert.ts b/components/src/components/atoms/Banner/utils/getValueForAlert.ts new file mode 100644 index 00000000..ab3f1949 --- /dev/null +++ b/components/src/components/atoms/Banner/utils/getValueForAlert.ts @@ -0,0 +1,61 @@ +import type { Color } from '@/src/tokens/color' +import type { Alert } from '@/src/types' + +type Properties = { + background: Color + border: Color + hover: Color + icon: Color + svg: Color + actionIcon: Color + actionSvg: Color + actionIconHover: Color + actionSvgHover: Color +} + +type Property = keyof Properties + +export type WithAlert = { alert?: Alert } + +const alertMap: { [key in Alert]: Properties } = { + error: { + background: 'redSurface', + border: 'redPrimary', + hover: 'redLight', + icon: 'redPrimary', + svg: 'textAccent', + actionIcon: 'backgroundPrimary', + actionSvg: 'redPrimary', + actionIconHover: 'redLight', + actionSvgHover: 'redDim', + }, + warning: { + background: 'yellowSurface', + hover: 'yellowLight', + border: 'yellowPrimary', + icon: 'yellowPrimary', + svg: 'textAccent', + actionIcon: 'backgroundPrimary', + actionSvg: 'yellowPrimary', + actionIconHover: 'yellowLight', + actionSvgHover: 'yellowDim', + }, + info: { + background: 'backgroundPrimary', + hover: 'greySurface', + border: 'border', + icon: 'transparent' as Color, + svg: 'text', + actionIcon: 'accentSurface', + actionSvg: 'accentPrimary', + actionIconHover: 'accentLight', + actionSvgHover: 'accentDim', + }, +} as const + +export const getValueForAlert = ( + alert: Alert, + property: Property, +): Properties[T] => { + return alertMap[alert][property] || alertMap.info[property] +} diff --git a/components/src/components/atoms/Box/Box.tsx b/components/src/components/atoms/Box/Box.tsx new file mode 100644 index 00000000..06590fcf --- /dev/null +++ b/components/src/components/atoms/Box/Box.tsx @@ -0,0 +1,51 @@ +import type { + AllHTMLAttributes, ComponentClass, + FunctionComponent } from 'react' +import React, { + forwardRef, +} from 'react' +import { clsx } from 'clsx' + +import { + type Sprinkles, + sprinkles, +} from '../../../css/sprinkles.css' + +type HTMLProperties = Omit< + AllHTMLAttributes, + 'as' | 'width' | 'height' | 'color' | 'translate' | 'transform' +> + +export type AsProp = React.ElementType | FunctionComponent | ComponentClass + +export type BoxProps = Sprinkles & + HTMLProperties & { as?: AsProp } + +export const Box = forwardRef( + ( + { as = 'div', className, children, ...props }, + ref, + ) => { + const atomProps: Record = {} + const nativeProps: Record = {} + + for (const key in props) { + if (sprinkles.properties.has(key as keyof Omit)) { + atomProps[key] = props[key as keyof typeof props] + } + else { + nativeProps[key] = props[key as keyof typeof props] + } + } + + const atomicCss = sprinkles(atomProps) + + return React.createElement(as, { + className: clsx(atomicCss, className), + ...nativeProps, + ref, + } as React.RefAttributes, + children, + ) + }, +) diff --git a/components/src/components/atoms/Button/Button.css.ts b/components/src/components/atoms/Button/Button.css.ts new file mode 100644 index 00000000..58d30702 --- /dev/null +++ b/components/src/components/atoms/Button/Button.css.ts @@ -0,0 +1,24 @@ +import { translateY } from '@/src/css/utils/common' +import { createVar, style } from '@vanilla-extract/css' + +export const counterIconBoxTransform = createVar() + +export const counterIconBox = style({ + transform: counterIconBoxTransform, +}) + +export const hasShadow = createVar() + +export const buttonBox = style({ + 'boxShadow': hasShadow, + 'transform': translateY(0), + ':hover': { + transform: translateY(-1), + }, + ':active': { + transform: translateY(-1), + }, + ':disabled': { + transform: translateY(0), + }, +}) diff --git a/components/src/components/atoms/Button/Button.test.tsx b/components/src/components/atoms/Button/Button.test.tsx index 3ecc9298..1b89bb1f 100644 --- a/components/src/components/atoms/Button/Button.test.tsx +++ b/components/src/components/atoms/Button/Button.test.tsx @@ -1,22 +1,27 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Button } from './Button' describe(' - , - ) + render() expect(screen.getByText(/connect/i)).toBeInTheDocument() }) + + it('should render prefix if specified', () => { + render() + expect(screen.getByText(/👋/i)).toBeInTheDocument() + }) + it('should render suffix if specified', () => { + render() + expect(screen.getByText(/👋/i)).toBeInTheDocument() + }) + it('should show a spinner if loading', () => { + render() + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) }) diff --git a/components/src/components/atoms/Button/Button.tsx b/components/src/components/atoms/Button/Button.tsx index b3c172d3..9c26063d 100644 --- a/components/src/components/atoms/Button/Button.tsx +++ b/components/src/components/atoms/Button/Button.tsx @@ -1,15 +1,21 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { Space } from '@/src/tokens' +import { P, match } from 'ts-pattern' -import { - WithColorStyle, - getColorStyle, -} from '@/src/types/withColorOrColorStyle' +import { scale } from '@/src/css/utils/common' -import { ReactNodeNoStrings } from '../../../types' -import { Spinner } from '../Spinner' +import { removeNullishProps } from '@/src/utils/removeNullishProps' + +import { getValueForSize } from './utils/getValueForSize' + +import type { ReactNodeNoStrings } from '../../../types' +import { Spinner } from '../Spinner/Spinner' +import type { AsProp, BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import * as styles from './Button.css' +import clsx from 'clsx' +import { assignInlineVars } from '@vanilla-extract/dynamic' +import type { ColorStyles, Hue } from '@/src/tokens' export type Size = 'small' | 'medium' | 'flexible' @@ -17,29 +23,27 @@ type NativeButtonProps = React.ButtonHTMLAttributes type NativeAnchorProps = React.AllHTMLAttributes type BaseProps = { - /** An alternative element type to render the component as.*/ + /** An alternative element type to render the component as. */ as?: 'a' children: NativeButtonProps['children'] /** If true, prevents user interaction with button. */ disabled?: NativeButtonProps['disabled'] /** Insert a ReactNode before the children */ - prefix?: ReactNodeNoStrings + prefix?: AsProp /** Shows loading spinner inside button */ loading?: boolean /** Constrains button to specific shape */ - shape?: 'square' | 'rounded' | 'circle' + shape?: 'rectangle' | 'square' | 'rounded' | 'circle' /** Sets dimensions and layout */ size?: Size /** Adds ReactNode after children */ - suffix?: ReactNodeNoStrings + suffix?: AsProp /** The zIndex attribute for button element. */ zIndex?: string /** If true, sets the style to indicate "on" state. Useful for toggles switches. */ pressed?: boolean /** If true, adds a box-shadow */ shadow?: boolean - /** A space value for the width of the button */ - width?: Space /** If true, makes inner div full width */ fullWidthContent?: boolean /** When set, shows a count indicator on the button */ @@ -48,7 +52,9 @@ type BaseProps = { onClick?: NativeButtonProps['onClick'] /** Show indicator that button has extra info via tooltip. */ shouldShowTooltipIndicator?: boolean -} & Omit + color?: Hue + colorStyle?: ColorStyles +} & Omit type WithAnchor = { /** The href attribute for the anchor element. */ @@ -65,215 +71,176 @@ type WithoutAnchor = { target?: never } -interface ButtonElement { +type ButtonBoxProps = { $pressed: boolean $shadow: boolean - $outlined: boolean $shape?: BaseProps['shape'] $size?: BaseProps['size'] $type?: BaseProps['type'] - $center: boolean | undefined - $colorStyle: WithColorStyle['colorStyle'] + $colorStyle: ColorStyles + $color?: Hue $hasCounter?: boolean - $width: BaseProps['width'] } - -const ButtonElement = styled.button( - ({ - theme, - $pressed, - $shadow, - $size, - $colorStyle = 'accentPrimary', - $shape, - $hasCounter, - $width, - }) => css` - position: relative; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: ${theme.space['2']}; - - transition-property: all; - transition-duration: ${theme.transitionDuration['150']}; - transition-timing-function: ${theme.transitionTimingFunction['inOut']}; - width: 100%; - border-radius: ${theme.radii.large}; - font-weight: ${theme.fontWeights.bold}; - border-width: ${theme.borderWidths.px}; - border-style: ${theme.borderStyles.solid}; - - background: ${getColorStyle($colorStyle, 'background')}; - color: ${getColorStyle($colorStyle, 'text')}; - border-color: ${getColorStyle($colorStyle, 'border')}; - - /* solves sticky problem */ - @media (hover: hover) { - &:hover { - transform: translateY(-1px); - background: ${getColorStyle($colorStyle, 'hover')}; - } - &:active { - transform: translateY(0px); - } - } - @media (hover: none) { - &:active { - transform: translateY(-1px); - background: ${getColorStyle($colorStyle, 'hover')}; - } - } - - &:disabled { - cursor: not-allowed; - background: ${getColorStyle('disabled', 'background')}; - transform: none; - color: ${getColorStyle('disabled', 'text')}; - border-color: transparent; - } - - ${$pressed && - css` - background: ${getColorStyle($colorStyle, 'hover')}; - `}; - - ${$shadow && - css` - box-shadow: ${theme.shadows['0.25']} ${theme.colors.grey}; - `}; - - ${$size === 'small' && - css` - font-size: ${theme.fontSizes.small}; - line-height: ${theme.lineHeights.small}; - height: ${theme.space['10']}; - padding: 0 ${theme.space['3.5']}; - svg { - display: block; - width: ${theme.space['3']}; - height: ${theme.space['3']}; - color: ${getColorStyle($colorStyle, 'text')}; - } - `} - - ${$size === 'medium' && - css` - font-size: ${theme.fontSizes.body}; - line-height: ${theme.lineHeights.body}; - height: ${theme.space['12']}; - padding: 0 ${theme.space['4']}; - svg { - display: block; - width: ${theme.space['4']}; - height: ${theme.space['4']}; - color: ${getColorStyle($colorStyle, 'text')}; +const ButtonBox = React.forwardRef< + HTMLButtonElement, + BoxProps & ButtonBoxProps +>( + ( + { + $pressed, + $shadow, + $shape = 'rectangle', + $size = 'medium', + $colorStyle = 'accentPrimary', + $hasCounter, + $color, + as, + className, + style, + ...props + }, + ref, + ) => ( + + ), ) -const ContentContainer = styled.div<{ $fullWidth?: boolean }>( - ({ $fullWidth }) => css` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - ${$fullWidth && - css` - width: 100%; - `} - `, +const SVGBox = ({ + $size, + ...props +}: BoxProps & { + $size: 'small' | 'medium' | 'flexible' +}) => + +const ContentBox = ({ + $fullWidth, + ...props +}: BoxProps & { $fullWidth?: boolean }) => ( + ) -const CounterWrapper = styled.div( - ({ theme }) => css` - position: absolute; - top: 0; - right: 0; - height: 100%; - padding-right: ${theme.space[3]}; - - display: flex; - align-items: center; - justify-content: flex-end; - pointer-events: none; - `, +const CounterBox = (props: BoxProps) => ( + ) -const Counter = styled.div<{ $visible: boolean }>( - ({ theme, $visible }) => css` - display: flex; - padding: 0 ${theme.space[1]}; - justify-content: center; - align-items: center; - border: 2px solid white; - border-radius: ${theme.radii.full}; - font-size: ${theme.space[3]}; - min-width: ${theme.space[6]}; - height: ${theme.space[6]}; - box-sizing: border-box; - transform: scale(1); - opacity: 1; - transition: all 0.3s ease-in-out; - - ${!$visible && - css` - transform: scale(0.3); - opacity: 0; - `} - `, +const CounterIconBox = ({ + $visible, + $colorStyle, + className, + style, + ...props +}: BoxProps & { + $visible: boolean + $colorStyle: ColorStyles +}) => ( + ) -const TooltipIndicator = styled.div` - display: flex; - align-items: center; - justify-content: center; - background: #e9b911; - border-radius: 50%; - width: 24px; - height: 24px; - position: absolute; - right: -10px; - top: -10px; - color: white; -` +const TooltipIndicatorBox = (props: BoxProps) => ( + +) -export type Props = BaseProps & (WithoutAnchor | WithAnchor) & WithColorStyle +export type ButtonProps = BaseProps & (WithoutAnchor | WithAnchor) -export const Button = React.forwardRef( +export const Button = React.forwardRef( ( { children, @@ -293,76 +260,84 @@ export const Button = React.forwardRef( onClick, pressed = false, shadow = false, - width, fullWidthContent, count, + color, shouldShowTooltipIndicator, as: asProp, ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const labelContent = ( - - {children} - + {children} ) let childContent: ReactNodeNoStrings if (shape === 'circle' || shape === 'square') { - childContent = loading ? : labelContent - } else { - const hasPrefix = !!prefix - const hasNoPrefixNoSuffix = !hasPrefix && !suffix - const hasSuffixNoPrefix = !hasPrefix && !!suffix - - let prefixOrLoading = prefix - if (loading && hasPrefix) prefixOrLoading = - else if (loading && hasNoPrefixNoSuffix) prefixOrLoading = - - let suffixOrLoading = suffix - if (loading && hasSuffixNoPrefix) suffixOrLoading = + childContent = loading ? : labelContent + } + else { + const prefixOrLoading = match([loading, !!prefix, !!suffix]) + .with([true, true, P._], () => ) + .with([true, false, false], () => ) + .with([P._, true, P._], () => + , + ) + .otherwise(() => null) + + const suffixOrLoading = match([loading, !!prefix, !!suffix]) + .with([true, false, true], () => ) + .with([P._, P._, true], () => ( + + ), + ) + .otherwise(() => null) childContent = ( <> - {!!prefixOrLoading && prefixOrLoading} + {prefixOrLoading} {labelContent} - {!!suffixOrLoading && suffixOrLoading} + {suffixOrLoading} ) } return ( - {shouldShowTooltipIndicator && ( - ? + + ? + )} {childContent} - - {count} - - + + + {count} + + + ) }, ) diff --git a/components/src/components/atoms/Button/index.ts b/components/src/components/atoms/Button/index.ts deleted file mode 100644 index 8de2f518..00000000 --- a/components/src/components/atoms/Button/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Button } from './Button' -export type { Props as ButtonProps } from './Button' diff --git a/components/src/components/atoms/Button/utils.ts b/components/src/components/atoms/Button/utils.ts deleted file mode 100644 index c8ee657a..00000000 --- a/components/src/components/atoms/Button/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { css } from 'styled-components' - -import { DefaultTheme } from '@/src/types' - -export interface GetCenterProps { - center: boolean | undefined - size: 'small' | 'medium' | 'extraSmall' | undefined - side: 'left' | 'right' - theme: DefaultTheme -} - -export const getCenterProps = ({ center, size, side, theme }: GetCenterProps) => - center && - css` - position: absolute; - ${side}: ${size === 'medium' ? theme.space['4'] : theme.space['5']}; - ` diff --git a/components/src/components/atoms/Button/utils/getValidatedColor.ts b/components/src/components/atoms/Button/utils/getValidatedColor.ts new file mode 100644 index 00000000..e48d7269 --- /dev/null +++ b/components/src/components/atoms/Button/utils/getValidatedColor.ts @@ -0,0 +1,13 @@ +import type { Hue } from '@/src/tokens/color' +import { validatePrimaryColor } from '@/src/tokens/color' + +export const getValidatedColor = ( + color?: Hue, + fallback = 'textPrimary', +): string => { + if (!color) return fallback + const matches = color.match('^(.*?)(Primary|Secondary)?$') + const baseColor = matches?.[1] || 'accent' + const validatedColor = validatePrimaryColor(baseColor, 'accent') + return `$${validatedColor}Primary` +} diff --git a/components/src/components/atoms/Button/utils/getValueForSize.ts b/components/src/components/atoms/Button/utils/getValueForSize.ts new file mode 100644 index 00000000..cb8b6fe8 --- /dev/null +++ b/components/src/components/atoms/Button/utils/getValueForSize.ts @@ -0,0 +1,44 @@ +import type { FontSize, LineHeight } from '@/src/tokens/typography' +import type { Size } from '../Button' +import type { Space } from '@/src/tokens' + +type Properties = { + fontSize: FontSize + lineHeight: LineHeight + height: Space + px: Space + svgSize: Space +} + +type Property = keyof Properties + +const sizeMap: { [key in Size]: Properties } = { + small: { + fontSize: 'small', + lineHeight: 'small', + height: '10', + px: '3.5', + svgSize: '3', + }, + medium: { + fontSize: 'body', + lineHeight: 'body', + height: '12', + px: '4', + svgSize: '4', + }, + flexible: { + fontSize: 'body', + lineHeight: 'body', + height: 'initial', + px: '4', + svgSize: '4', + }, +} + +export const getValueForSize = ( + size: Size, + property: T, +): Properties[T] => { + return sizeMap[size]?.[property] || sizeMap.medium[property] +} diff --git a/components/src/components/atoms/Card/Card.test.tsx b/components/src/components/atoms/Card/Card.test.tsx index 603a7b21..ea61fa32 100644 --- a/components/src/components/atoms/Card/Card.test.tsx +++ b/components/src/components/atoms/Card/Card.test.tsx @@ -1,22 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Card } from './Card' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - foo bar baz - , - ) + render(foo bar baz) expect(screen.getByText(/foo/i)).toBeInTheDocument() }) }) diff --git a/components/src/components/atoms/Card/Card.tsx b/components/src/components/atoms/Card/Card.tsx index 875a4fe7..3b356cb0 100644 --- a/components/src/components/atoms/Card/Card.tsx +++ b/components/src/components/atoms/Card/Card.tsx @@ -1,58 +1,48 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { mq } from '@/src/utils/responsiveHelpers' +import { removeNullishProps } from '@/src/utils/removeNullishProps' -import { Typography } from '../Typography' +import { Typography } from '../Typography/Typography' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' -export type Props = { +export type CardProps = { title?: string -} & NativeDivProps - -const Container = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: column; - gap: ${theme.space['4']}; - - padding: ${theme.space['4']}; - border-radius: ${theme.radii['2xLarge']}; - background-color: ${theme.colors.backgroundPrimary}; - border: 1px solid ${theme.colors.border}; - - ${mq.sm.min( - css` - padding: ${theme.space['6']}; - `, - )} - `, +} & BoxProps + +const ContainerBox = (props: BoxProps) => ( + ) -const Divider = styled.div( - ({ theme }) => css` - width: calc(100% + 2 * ${theme.space['4']}); - height: 1px; - background: ${theme.colors.border}; - margin: 0 -${theme.space['4']}; - ${mq.sm.min( - css` - margin: 0 -${theme.space['6']}; - width: calc(100% + 2 * ${theme.space['6']}); - `, - )} - `, +export const CardDivider = (props: BoxProps) => ( + ) -type NativeDivProps = React.HTMLAttributes - -export const Card = ({ title, children, ...props }: Props) => { +export const Card: React.FC = ({ title, children, ...props }) => { return ( - + {title && {title}} {children} - + ) } Card.displayName = 'Card' -Card.Divider = Divider diff --git a/components/src/components/atoms/Card/index.ts b/components/src/components/atoms/Card/index.ts deleted file mode 100644 index 423e583d..00000000 --- a/components/src/components/atoms/Card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Card } from './Card' diff --git a/components/src/components/atoms/DynamicPopover/DynamicPopover.tsx b/components/src/components/atoms/DynamicPopover/DynamicPopover.tsx index 77afcfff..59d3b1c3 100644 --- a/components/src/components/atoms/DynamicPopover/DynamicPopover.tsx +++ b/components/src/components/atoms/DynamicPopover/DynamicPopover.tsx @@ -1,20 +1,24 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { TransitionState, useTransition } from 'react-transition-state' - -import { debounce } from 'lodash' - -import { mq } from '@/src/utils/responsiveHelpers' - -import { Portal } from '../Portal' +import type { TransitionState } from 'react-transition-state' +import { useTransition } from 'react-transition-state' + +import { Portal } from '../Portal/Portal' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import { getValueForTransitionState } from './utils/getValueForTransitionState' +import * as styles from './style.css' +import { debounce } from '../../../utils/debounce' +import { useBreakPoints } from '@/src/hooks/useBreakpoints' +import type { TransitionDuration } from '@/src/tokens' +import { assignInlineVars } from '@vanilla-extract/dynamic' export type DynamicPopoverSide = 'top' | 'right' | 'bottom' | 'left' export type DynamicPopoverAlignment = 'start' | 'center' | 'end' export type PopoverProps = React.PropsWithChildren<{ - placement: DynamicPopoverSide - mobilePlacement: DynamicPopoverSide + placement?: DynamicPopoverSide + mobilePlacement?: DynamicPopoverSide state?: TransitionState['status'] }> @@ -23,14 +27,15 @@ export type DynamicPopoverAnimationFunc = ( verticalClearance: number, side: DynamicPopoverSide, mobileSide: DynamicPopoverSide, -) => { translate: string; mobileTranslate: string } + isDesktop: boolean, +) => string export type DynamicPopoverButtonProps = { pressed?: boolean onClick?: React.MouseEventHandler } -export interface DynamicPopoverProps { +export type DynamicPopoverProps = { /** A react node that has includes the styling and content of the popover */ popover: React.ReactElement /** The side and alignment of the popover in relation to the target */ @@ -43,18 +48,18 @@ export interface DynamicPopoverProps { anchorRef: React.RefObject /** Function that will be called when the DynamicPopover is shown */ onShowCallback?: () => void - /** Width of the DynamicPopover*/ + /** Width of the DynamicPopover */ width?: number | string - /** Width of the DynamicPopover on mobile*/ + /** Width of the DynamicPopover on mobile */ mobileWidth?: number | string - /** Dynamic popover will switch sides if there is not enough room*/ + /** Dynamic popover will switch sides if there is not enough room */ useIdealPlacement?: boolean /** Add to the default gap between the popover and its target */ additionalGap?: number /** Aligns the popover */ align?: DynamicPopoverAlignment /** The duration of the transition */ - transitionDuration?: number + transitionDuration?: TransitionDuration /** If this is not undefined, popover becomes externally controlled */ isOpen?: boolean /** Hides the overflow of the content */ @@ -73,20 +78,20 @@ const computeIdealSide = ( ): DynamicPopoverSide => { const top = referenceRect.top - floatingRect.height - padding - offset const left = referenceRect.left - floatingRect.width - padding - offset - const right = - window.innerWidth - - referenceRect.left - - referenceRect.width - - floatingRect.width - - padding - - offset - const bottom = - window.innerHeight - - referenceRect.top - - referenceRect.height - - floatingRect.height - - padding - - offset + const right + = window.innerWidth + - referenceRect.left + - referenceRect.width + - floatingRect.width + - padding + - offset + const bottom + = window.innerHeight + - referenceRect.top + - referenceRect.height + - floatingRect.height + - padding + - offset if (side === 'top' && top < 0 && bottom > top) return 'bottom' if (side === 'right' && right < 0 && left > right) return 'left' @@ -103,13 +108,14 @@ const defaultAnimationFunc: DynamicPopoverAnimationFunc = ( verticalClearance: number, side: string, mobileSide: string, + isDesktop: boolean, ) => { let translate = '' if (side === 'top') translate = `translate(0, -${verticalClearance}px)` else if (side === 'right') translate = `translate(${horizontalClearance}px, 0)` else if (side === 'bottom') translate = `translate(0, ${verticalClearance}px)` - else translate = `translate(-${horizontalClearance}px, 0);` + else translate = `translate(-${horizontalClearance}px, 0)` let mobileTranslate = '' if (mobileSide === 'top') @@ -118,150 +124,108 @@ const defaultAnimationFunc: DynamicPopoverAnimationFunc = ( mobileTranslate = `translate(${horizontalClearance}px, 0)` else if (mobileSide === 'bottom') mobileTranslate = `translate(0, ${verticalClearance}px)` - else mobileTranslate = `translate(-${horizontalClearance}px, 0);` + else mobileTranslate = `translate(-${horizontalClearance}px, 0)` - return { translate, mobileTranslate } + return isDesktop ? translate : mobileTranslate } const checkRectContainsPoint = ( rect?: DOMRect, - point?: { x: number; y: number }, + point?: { x: number, y: number }, ) => { if (!rect || !point) return false return ( - point.x >= rect.x && - point.x <= rect.x + rect.width && - point.y >= rect.y && - point.y <= rect.y + rect.height + point.x >= rect.x + && point.x <= rect.x + rect.width + && point.y >= rect.y + && point.y <= rect.y + rect.height ) } -const makeWidth = (width: number | string) => - typeof width === 'number' ? `${width}px` : width - -const PopoverContainer = styled.div<{ - $state: TransitionState['status'] +type PopoverBoxProps = { + $state: TransitionState $translate: string - $mobileTranslate: string $width: number | string $mobileWidth: number | string $x: number $y: number - $isControlled: boolean - $transitionDuration: number + // $isControlled: boolean + $transitionDuration: TransitionDuration $hideOverflow: boolean | undefined -}>( - ({ - $state, - $translate, - $mobileTranslate, - $width, - $mobileWidth, - $x, - $y, - $isControlled, - $transitionDuration, - $hideOverflow, - }) => [ - css` - /* stylelint-disable */ - -webkit-backface-visibility: hidden; - -moz-backface-visibility: hidden; - -webkit-transform: translate3d(0, 0, 0); - -moz-transform: translate3d(0, 0, 0); - /* stylelint-enable */ - - /* Default state is unmounted */ - display: block; - box-sizing: border-box; - visibility: hidden; - position: absolute; - z-index: 99999; - width: ${makeWidth($mobileWidth)}; - transform: translate3d(0, 0, 0) ${$mobileTranslate}; - transition: none; - opacity: 0; - pointer-events: none; - top: 0; - left: 0; - - ${$hideOverflow && - css` - overflow: hidden; - `} - - ${$state === 'preEnter' && - css` - display: block; - visibility: visible; - top: ${$y}px; - left: ${$x}px; - `} - - ${$state === 'entering' && - css` - display: block; - visibility: visible; - opacity: 1; - transition: opacity ${$transitionDuration}ms ease-in-out; - top: ${$y}px; - left: ${$x}px; - `} - - ${$state === 'entered' && - css` - display: block; - visibility: visible; - opacity: 1; - transition: opacity ${$transitionDuration}ms ease-in-out; - top: ${$y}px; - left: ${$x}px; - pointer-events: initial; - - ${$isControlled && - css` - pointer-events: auto; - `} - `} - - ${$state === 'exiting' && - css` - display: block; - visibility: visible; - opacity: 0; - transition: all ${$transitionDuration}ms ease-in-out; - top: ${$y}px; - left: ${$x}px; - `} - `, - mq.sm.min(css` - width: ${makeWidth($width)}; - transform: translate3d(0, 0, 0) ${$translate}; - `), - ], +} + +const PopoverBox = React.forwardRef( + ( + { + $state, + $translate, + $width, + $mobileWidth, + $x, + $y, + // $isControlled, + $transitionDuration, + $hideOverflow, + ...props + }, + ref, + ) => ( + + ), ) -export const DynamicPopover = ({ +export const DynamicPopover: React.FC = ({ popover, placement = 'top', mobilePlacement = 'top', animationFn: _animationFn, anchorRef, onShowCallback, - width = 250, - mobileWidth = 150, + width = 60, + mobileWidth = 36, useIdealPlacement = false, additionalGap = 0, - transitionDuration = 350, + transitionDuration = 300, isOpen, align = 'center', hideOverflow, -}: DynamicPopoverProps) => { +}) => { const popoverContainerRef = React.useRef() const isControlled = isOpen !== undefined - const [{ status: state }, toggle] = useTransition({ + const [state, toggle] = useTransition({ preEnter: true, exit: true, mountOnEnter: true, @@ -306,15 +270,18 @@ export const DynamicPopover = ({ if (align === 'start') { popoverWidth = 0 anchorWidth = 0 - } else if (align === 'end') { + } + else if (align === 'end') { popoverWidth = popoverRect.width anchorWidth = anchorRect.width } - } else { + } + else { if (align === 'start') { popoverHeight = 0 anchorHeight = 0 - } else if (align === 'end') { + } + else if (align === 'end') { popoverHeight = popoverRect.height anchorHeight = anchorRect.height } @@ -368,20 +335,23 @@ export const DynamicPopover = ({ verticalClearance: number, side: DynamicPopoverSide, mobileSide: DynamicPopoverSide, + isDesktop: boolean, ) => - _animationFn(horizontalClearance, verticalClearance, side, mobileSide) + _animationFn(horizontalClearance, verticalClearance, side, mobileSide, isDesktop) } return ( horizontalClearance: number, verticalClearance: number, side: DynamicPopoverSide, mobileSide: DynamicPopoverSide, + isDesktop: boolean, ) => defaultAnimationFunc( horizontalClearance, verticalClearance, side, mobileSide, + isDesktop, ) }, [_animationFn]) @@ -468,6 +438,7 @@ export const DynamicPopover = ({ return () => { if (originalIsControlled) toggle(!originalIsOpen) } + return () => toggle(false) }, [isControlled, isOpen]) const _placement = useIdealPlacement @@ -477,11 +448,14 @@ export const DynamicPopover = ({ ? positionState.idealMobilePlacement : mobilePlacement - const { translate, mobileTranslate } = animationFn( + const breakpoints = useBreakPoints() + + const translate = animationFn( positionState.horizontalClearance, positionState.verticalClearance, _placement, _mobilePlacement, + breakpoints.sm, ) const renderCallback = React.useCallback(() => { @@ -489,14 +463,11 @@ export const DynamicPopover = ({ onShowCallback?.() }, [setPosition, onShowCallback]) - if (state === 'unmounted') return null - return ( - + ) } diff --git a/components/src/components/atoms/DynamicPopover/index.ts b/components/src/components/atoms/DynamicPopover/index.ts deleted file mode 100644 index 789bd898..00000000 --- a/components/src/components/atoms/DynamicPopover/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { DynamicPopover } from './DynamicPopover' -export type { - DynamicPopoverSide, - DynamicPopoverAlignment, - DynamicPopoverProps, - PopoverProps, -} from './DynamicPopover' diff --git a/components/src/components/atoms/DynamicPopover/style.css.ts b/components/src/components/atoms/DynamicPopover/style.css.ts new file mode 100644 index 00000000..49bb209e --- /dev/null +++ b/components/src/components/atoms/DynamicPopover/style.css.ts @@ -0,0 +1,21 @@ +import { responsiveConditions } from '@/src/css/sprinkles.css' +import type { ComplexStyleRule } from '@vanilla-extract/css' +import { createVar, style } from '@vanilla-extract/css' + +export const popoverBoxWidth = createVar() + +export const mobileWidth = createVar() + +export const container = style({ + 'WebkitBackfaceVisibility': 'hidden', + 'MozBackfaceVisibility': 'hidden', + 'backfaceVisibility': 'hidden', + '@media': { + [responsiveConditions.sm['@media']]: { + width: popoverBoxWidth, + }, + [responsiveConditions.xs['@media']]: { + width: mobileWidth, + }, + }, +} as ComplexStyleRule) diff --git a/components/src/components/atoms/DynamicPopover/utils/getValueForTransitionState.ts b/components/src/components/atoms/DynamicPopover/utils/getValueForTransitionState.ts new file mode 100644 index 00000000..1ed36218 --- /dev/null +++ b/components/src/components/atoms/DynamicPopover/utils/getValueForTransitionState.ts @@ -0,0 +1,84 @@ +import type { TransitionState } from 'react-transition-state' + +const transitionStateValues: { + [key in TransitionState['status']]: { + display: string + visibility: string + opacity: number + transitionProperty: string + pointerEvents: string + topFunc: (x: number) => string + leftFunc: (y: number) => string + } +} = { + unmounted: { + display: 'block', + visibility: 'hidden', + opacity: 0, + transitionProperty: 'none', + pointerEvents: 'none', + topFunc: (x: number) => `${x}px`, + leftFunc: (y: number) => `${y}px`, + }, + preEnter: { + display: 'block', + visibility: 'visible', + opacity: 0, + transitionProperty: 'none', + pointerEvents: 'none', + topFunc: (x: number) => `${x}px`, + leftFunc: (y: number) => `${y}px`, + }, + entering: { + display: 'block', + visibility: 'visible', + opacity: 1, + transitionProperty: 'all', + pointerEvents: 'auto', + topFunc: (x: number) => `${x}px`, + leftFunc: (y: number) => `${y}px`, + }, + entered: { + display: 'block', + visibility: 'visible', + opacity: 1, + transitionProperty: 'all', + topFunc: (x: number) => `${x}px`, + leftFunc: (y: number) => `${y}px`, + pointerEvents: 'auto', + }, + preExit: { + display: 'block', + visibility: 'visible', + opacity: 0, + transitionProperty: 'all', + topFunc: (x: number) => `${x}px`, + leftFunc: (y: number) => `${y}px`, + pointerEvents: 'none', + }, + exiting: { + display: 'block', + visibility: 'visible', + opacity: 0, + transitionProperty: 'all', + topFunc: (x: number) => `${x}px`, + leftFunc: (y: number) => `${y}px`, + pointerEvents: 'none', + }, + exited: { + display: 'block', + visibility: 'hidden', + opacity: 0, + transitionProperty: 'none', + topFunc: () => `0px`, + leftFunc: () => `0px`, + pointerEvents: 'none', + }, +} + +type Property = keyof (typeof transitionStateValues)['unmounted'] + +export const getValueForTransitionState = ( + state: TransitionState['status'], + property: Property, +): any => transitionStateValues[state][property] diff --git a/components/src/components/atoms/Field/Field.test.tsx b/components/src/components/atoms/Field/Field.test.tsx index cfb2517a..c4b57a0d 100644 --- a/components/src/components/atoms/Field/Field.test.tsx +++ b/components/src/components/atoms/Field/Field.test.tsx @@ -1,11 +1,7 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Field } from './Field' describe('', () => { @@ -13,11 +9,9 @@ describe('', () => { it('renders', () => { render( - - -
- - , + +
+ , ) expect(screen.getByText(/foo/i)).toBeInTheDocument() }) diff --git a/components/src/components/atoms/Field/Field.tsx b/components/src/components/atoms/Field/Field.tsx index 34b0c33c..98b4cf8a 100644 --- a/components/src/components/atoms/Field/Field.tsx +++ b/components/src/components/atoms/Field/Field.tsx @@ -1,13 +1,15 @@ import * as React from 'react' -import styled, { css } from 'styled-components' +import { P, match } from 'ts-pattern' -import { Space } from '@/src/tokens' +import type { Space } from '@/src/tokens' -import { ReactNodeNoStrings } from '../../../types' +import type { ReactNodeNoStrings } from '../../../types' import { useFieldIds } from '../../../hooks' -import { VisuallyHidden } from '../VisuallyHidden' +import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden' import { Typography } from '../Typography/Typography' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' export type State = ReturnType | undefined const Context = React.createContext(undefined) @@ -38,75 +40,77 @@ export type FieldBaseProps = { readOnly?: boolean } -type Props = FieldBaseProps & { +export type FieldProps = FieldBaseProps & { children: React.ReactElement | ((context: State) => ReactNodeNoStrings) /** The id attribute of the label element */ id?: NativeFormProps['id'] disabled?: boolean } & Omit -const Label = styled.label<{ +const RequiredBox = () => ( + + * + +) + +const LabelBox = ({ + $disabled = false, + $readOnly = false, + $required, + children, + ...props +}: BoxProps & { $disabled?: boolean $readOnly?: boolean $required?: boolean -}>( - ({ theme, $disabled, $readOnly, $required }) => css` - display: flex; - flex-basis: auto; - flex-grow: 2; - flex-shrink: 1; - overflow: hidden; - position: relative; - cursor: pointer; - - ${$readOnly && - css` - cursor: default; - pointer-events: none; - `} - - ${$disabled && - css` - cursor: not-allowed; - pointer-events: none; - `} - - ${$required && - css` - ::after { - content: ' *'; - white-space: pre; - color: ${theme.colors.red}; - } - `} - `, +}) => ( + 'not-allowed' as const) + .with([false, true], () => 'default' as const) + .with([false, false], () => 'pointer' as const) + .exhaustive()} + display="flex" + flexBasis="auto" + flexGrow={2} + flexShrink={1} + overflow="hidden" + position="relative" + {...props} + > + {children} + {$required && } + ) -const InnerLabel = styled(Typography)( - () => css` - width: 100%; - `, +const InnerLabelBox = (props: React.ComponentProps) => ( + ) -const SecondaryLabel = styled(Typography)( - () => css` - flex-basis: auto; - flex-grow: 0; - flex-shrink: 2; - text-align: right; - overflow: hidden; - position: relative; - `, +const SecondaryLabelBox = (props: React.ComponentProps) => ( + ) -const LabelContentContainer = styled.div<{ $inline?: boolean }>( - ({ theme, $inline }) => css` - display: flex; - align-items: center; - padding: 0 ${$inline ? '0' : theme.space['2']}; - overflow: hidden; - gap: ${theme.space['2']}; - `, +const LabelContentContainerBox = ({ + $inline, + ...props +}: BoxProps & { $inline?: boolean }) => ( + ) const LabelContent = ({ @@ -129,42 +133,58 @@ const LabelContent = ({ readOnly?: boolean }) => { const content = ( - - + {required && ( + <> + + required + + )} + + {labelSecondary && ( - + {labelSecondary} - + )} - + ) if (hideLabel) return {content} return content } -const Description = styled(Typography)<{ $inline?: boolean }>( - ({ theme, $inline }) => css` - padding: 0 ${$inline ? '0' : theme.space['2']}; - width: 100%; - overflow: hidden; - `, +const DescriptionBox = ({ + $inline, + ...props +}: React.ComponentProps & { $inline: boolean }) => ( + ) -const Error = styled(Typography)<{ $inline?: boolean }>( - ({ theme, $inline }) => ` - padding: 0 ${$inline ? '0' : theme.space[2]}; -`, +const ErrorBox = ({ + $inline, + ...props +}: React.ComponentProps & { $inline: boolean }) => ( + ) + const DecorativeContent = ({ ids, error, @@ -173,17 +193,17 @@ const DecorativeContent = ({ inline, disabled, }: { - error: Props['error'] - description: Props['description'] - hideLabel: Props['hideLabel'] - inline: Props['inline'] + error: FieldProps['error'] + description: FieldProps['description'] + hideLabel: FieldProps['hideLabel'] + inline: FieldProps['inline'] ids: any disabled?: boolean }) => { if (hideLabel) return null if (error) return ( - {error} - + ) if (description) return ( - {description} - + ) return null } @@ -215,35 +235,40 @@ interface ContainerProps { $reverse?: boolean } -const Container = styled.div( - ({ theme, $inline, $width, $reverse }) => css` - position: relative; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: 'normal'; - gap: ${theme.space['2']}; - width: ${theme.space[$width]}; - - ${$inline && - css` - flex-direction: ${$reverse ? 'row-reverse' : 'row'}; - align-items: 'flex-start'; - `} - `, +const ContainerBox = ({ + $width, + $inline, + $reverse, + ...props +}: BoxProps & ContainerProps) => ( + 'row-reverse' as const) + .with([true, false], () => 'row' as const) + .with([false, P._], () => 'column' as const) + .exhaustive()} + gap="2" + justifyContent="flex-start" + position="relative" + width={$width} + {...props} + /> ) -const ContainerInner = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: column; - gap: ${theme.space[1]}; - flex: 1; - overflow: hidden; - `, +const ContainerInnerBox = (props: BoxProps) => ( + ) -export const Field = ({ +export const Field: React.FC = ({ children, description, error, @@ -258,7 +283,7 @@ export const Field = ({ reverse = false, disabled, ...props -}: Props) => { +}) => { const ids = useFieldIds({ id, description: description !== undefined, @@ -270,7 +295,7 @@ export const Field = ({ if (typeof children === 'function') content = ( - {(context) => children(context)} + {context => children(context)} ) else if (children) content = React.cloneElement(children, ids.content) @@ -300,21 +325,21 @@ export const Field = ({ if (inline) return ( - +
{content}
- + {labelContent} {decorativeContent} - -
+ + ) return ( - + {labelContent} {content} {decorativeContent} - + ) } diff --git a/components/src/components/atoms/Field/index.ts b/components/src/components/atoms/Field/index.ts deleted file mode 100644 index c0fcd9fb..00000000 --- a/components/src/components/atoms/Field/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Field } from './Field' -export type { FieldBaseProps, State } from './Field' diff --git a/components/src/components/atoms/FileInput/FileInput.test.tsx b/components/src/components/atoms/FileInput/FileInput.test.tsx index 8d73f5ac..127683a5 100644 --- a/components/src/components/atoms/FileInput/FileInput.test.tsx +++ b/components/src/components/atoms/FileInput/FileInput.test.tsx @@ -10,21 +10,19 @@ describe('', () => { it('renders', () => { render( - {(context) => - context.name ?
{context.name}
:
Upload file
- } + {context => + context.name ?
{context.name}
:
Upload file
}
, ) expect(screen.getByText(/upload/i)).toBeInTheDocument() }) it('should pass a ref down', async () => { - const ref = { current: null } as React.RefObject + const ref = { current: null } as React.RefObject render( - {(context) => - context.name ?
{context.name}
:
Upload file
- } + {context => + context.name ?
{context.name}
:
Upload file
}
, ) await waitFor(() => { diff --git a/components/src/components/atoms/FileInput/FileInput.tsx b/components/src/components/atoms/FileInput/FileInput.tsx index ac7650a0..ed1a1e16 100644 --- a/components/src/components/atoms/FileInput/FileInput.tsx +++ b/components/src/components/atoms/FileInput/FileInput.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import { ReactNodeNoStrings } from '../../../types' +import type { ReactNodeNoStrings } from '../../../types' import { useFieldIds } from '../../../hooks' -import { VisuallyHidden } from '../VisuallyHidden' +import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden' import { validateAccept } from './utils' type Context = { @@ -19,7 +19,7 @@ const initialState: Context = {} type NativeInputProps = React.InputHTMLAttributes -export type Props = { +export type FileInputProps = { /** The accept attribute of input element */ accept?: NativeInputProps['accept'] /** The autoFocus attribute of input element */ @@ -27,7 +27,7 @@ export type Props = { /** A function that receives a context object and return a react element. The context object is made of the following properties droppable, focused, file, name, previewUrl, type and reset. */ children: (context: Context) => ReactNodeNoStrings /** Preloads the file input file to submit. */ - defaultValue?: { name?: string; type: string; url: string } + defaultValue?: { name?: string, type: string, url: string } /** The disabled attribute of input element. */ disabled?: NativeInputProps['disabled'] /** Error text or react element */ @@ -57,7 +57,7 @@ export type Props = { 'onReset' | 'onChange' | 'onError' | 'defaultValue' | 'children' | 'type' > -export const FileInput = React.forwardRef( +export const FileInput = React.forwardRef( ( { accept, @@ -77,8 +77,8 @@ export const FileInput = React.forwardRef( onFocus, onReset, ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const defaultRef = React.useRef(null) const inputRef = (ref as React.RefObject) || defaultRef @@ -95,7 +95,7 @@ export const FileInput = React.forwardRef( // Disallow file larger than max if (maxSize && file.size > maxSize * 1_000_000) { event?.preventDefault() - onError && + if (onError) onError( `File is ${(file.size / 1_000_000).toFixed( 2, @@ -103,13 +103,13 @@ export const FileInput = React.forwardRef( ) return } - setState((x) => ({ + setState(x => ({ ...x, file, name: file.name, type: file.type, })) - onChange && onChange(file) + if (onChange) onChange(file) }, [maxSize, onChange, onError], ) @@ -126,7 +126,7 @@ export const FileInput = React.forwardRef( const handleDragOver = React.useCallback( (event: React.DragEvent) => { event.preventDefault() - setState((x) => ({ ...x, droppable: true })) + setState(x => ({ ...x, droppable: true })) }, [], ) @@ -134,7 +134,7 @@ export const FileInput = React.forwardRef( const handleDragLeave = React.useCallback( (event: React.DragEvent) => { event.preventDefault() - setState((x) => ({ ...x, droppable: false })) + setState(x => ({ ...x, droppable: false })) }, [], ) @@ -142,14 +142,15 @@ export const FileInput = React.forwardRef( const handleDrop = React.useCallback( (event: React.DragEvent) => { event.preventDefault() - setState((x) => ({ ...x, droppable: false })) + setState(x => ({ ...x, droppable: false })) let file: File | null if (event.dataTransfer.items) { const files = event.dataTransfer.items if (files?.[0].kind !== 'file') return file = files[0].getAsFile() if (!file) return - } else { + } + else { const files = event.dataTransfer.files if (!files?.length) return file = files[0] @@ -162,35 +163,33 @@ export const FileInput = React.forwardRef( const handleFocus = React.useCallback( (event: React.FocusEvent) => { - setState((x) => ({ ...x, focused: true })) - onFocus && onFocus(event) + setState(x => ({ ...x, focused: true })) + if (onFocus) onFocus(event) }, [onFocus], ) const handleBlur = React.useCallback( (event: React.FocusEvent) => { - setState((x) => ({ ...x, focused: false })) - onBlur && onBlur(event) + setState(x => ({ ...x, focused: false })) + if (onBlur) onBlur(event) }, [onBlur], ) - /* eslint-disable react-hooks/exhaustive-deps */ const reset = React.useCallback( (event: React.MouseEvent) => { event.preventDefault() setState(initialState) if (inputRef.current) inputRef.current.value = '' - onReset && onReset() + if (onReset) onReset() }, // No need to add defaultValue [inputRef, onReset], ) - /* eslint-enable react-hooks/exhaustive-deps */ // Display preview for default value - /* eslint-disable react-hooks/exhaustive-deps */ + React.useEffect(() => { if (!defaultValue) return setState({ @@ -199,13 +198,12 @@ export const FileInput = React.forwardRef( type: defaultValue.type, }) }, []) - /* eslint-enable react-hooks/exhaustive-deps */ // Create URL for displaying media preview React.useEffect(() => { if (!state.file) return const previewUrl = URL.createObjectURL(state.file) - setState((x) => ({ ...x, previewUrl })) + setState(x => ({ ...x, previewUrl })) return () => URL.revokeObjectURL(previewUrl) }, [state.file]) diff --git a/components/src/components/atoms/FileInput/index.ts b/components/src/components/atoms/FileInput/index.ts deleted file mode 100644 index e9735a49..00000000 --- a/components/src/components/atoms/FileInput/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { FileInput } from './FileInput' -export type { Props as FileInputProps } from './FileInput' diff --git a/components/src/components/atoms/FileInput/utils.ts b/components/src/components/atoms/FileInput/utils.ts index e20f799d..0ea570a7 100644 --- a/components/src/components/atoms/FileInput/utils.ts +++ b/components/src/components/atoms/FileInput/utils.ts @@ -1,6 +1,6 @@ -import { Props } from './FileInput' +import type { FileInputProps } from './FileInput' -export const validateAccept = (fileType: string, accept: Props['accept']) => { +export const validateAccept = (fileType: string, accept: FileInputProps['accept']) => { const allowedTypes = accept?.split(', ') if (!allowedTypes) return true const mime = getMimeType(fileType) diff --git a/components/src/components/atoms/Heading/Heading.css.ts b/components/src/components/atoms/Heading/Heading.css.ts new file mode 100644 index 00000000..c4f73dbe --- /dev/null +++ b/components/src/components/atoms/Heading/Heading.css.ts @@ -0,0 +1,19 @@ +import { sprinkles } from '@/src/css/sprinkles.css' +import { recipe } from '@vanilla-extract/recipes' + +export const heading = recipe({ + variants: { + level: { + ['1']: sprinkles({ + fontSize: 'headingTwo', + fontWeight: 'extraBold', + lineHeight: 'headingTwo', + }), + ['2']: sprinkles({ + fontSize: 'extraLarge', + fontWeight: 'bold', + lineHeight: 'extraLarge', + }), + }, + }, +}) diff --git a/components/src/components/atoms/Heading/Heading.test.tsx b/components/src/components/atoms/Heading/Heading.test.tsx index 186ecf7d..e30ec1a1 100644 --- a/components/src/components/atoms/Heading/Heading.test.tsx +++ b/components/src/components/atoms/Heading/Heading.test.tsx @@ -1,22 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Heading } from './Heading' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - foo bar baz - , - ) + render(foo bar baz) expect(screen.getByText(/foo/i)).toBeInTheDocument() }) }) diff --git a/components/src/components/atoms/Heading/Heading.tsx b/components/src/components/atoms/Heading/Heading.tsx index 99ad5a71..41a5ad7e 100644 --- a/components/src/components/atoms/Heading/Heading.tsx +++ b/components/src/components/atoms/Heading/Heading.tsx @@ -1,88 +1,41 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { mq } from '@/src/utils/responsiveHelpers' -import { WithColor, getColor } from '@/src/types/withColorOrColorStyle' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import clsx from 'clsx' +import * as styles from './Heading.css' +import type { Color } from '@/src/tokens/color' interface HeadingContainerProps { - $textAlign?: React.CSSProperties['textAlign'] - $textTransform: React.CSSProperties['textTransform'] + textAlign?: React.CSSProperties['textAlign'] + textTransform: React.CSSProperties['textTransform'] $level: '1' | '2' - $responsive?: boolean - $color: NonNullable + $color: NonNullable } -const HeadingContainer = styled.div( - ({ theme, $textAlign, $textTransform, $level, $responsive, $color }) => css` - ${$textAlign - ? css` - text-align: ${$textAlign}; - ` - : ``} - ${$textTransform - ? css` - text-transform: ${$textTransform}; - ` - : ``} - - ${() => { - switch ($level) { - case '1': - return css` - font-size: ${theme.fontSizes.headingOne}; - font-weight: ${theme.fontWeights.extraBold}; - line-height: ${theme.lineHeights.headingOne}; - ` - case '2': - return css` - font-size: ${theme.fontSizes.headingTwo}; - font-weight: ${theme.fontWeights.bold}; - line-height: ${theme.lineHeights.headingTwo}; - ` - default: - return `` - } - }} - - ${() => { - if ($responsive) { - switch ($level) { - case '1': - return css` - font-size: ${theme.fontSizes.headingTwo}; - line-height: ${theme.lineHeights.headingTwo}; - ${mq.lg.min(css` - font-size: ${theme.fontSizes.headingOne}; - line-height: ${theme.lineHeights.headingOne}; - `)} - ` - case '2': - return css` - font-size: ${theme.fontSizes.extraLarge}; - line-height: ${theme.lineHeights.extraLarge}; - ${mq.sm.min(css` - font-size: ${theme.fontSizes.headingTwo}; - line-height: ${theme.lineHeights.headingTwo}; - `)} - ` - default: - return `` - } - } - }} - - ${$color && - css` - color: ${getColor($color)}; - `} - - font-family: ${theme.fonts['sans']}; - `, +const ContainerBox = React.forwardRef< + HTMLElement, + BoxProps & HeadingContainerProps +>( + ( + { textAlign, textTransform, $level, $color, className, ...props }, + ref, + ) => ( + + ), ) type NativeDivAttributes = React.HTMLAttributes -type Props = { +export type HeadingProps = { /** CSS property of textAlign */ align?: React.CSSProperties['textAlign'] /** JSX element to render. */ @@ -92,13 +45,12 @@ type Props = { id?: NativeDivAttributes['id'] /** CSS property of text-transform */ transform?: React.CSSProperties['textTransform'] - /** */ - responsive?: boolean level?: '1' | '2' -} & WithColor & - Omit + color?: Color +} & +Omit -export const Heading = React.forwardRef( +export const Heading = React.forwardRef( ( { align, @@ -106,26 +58,24 @@ export const Heading = React.forwardRef( as = 'h1', id, level = '2', - responsive, transform, color = 'text', ...props - }: Props, - ref: React.ForwardedRef, + }, + ref, ) => ( - {children} - + ), ) diff --git a/components/src/components/atoms/Heading/index.ts b/components/src/components/atoms/Heading/index.ts deleted file mode 100644 index d2739823..00000000 --- a/components/src/components/atoms/Heading/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Heading } from './Heading' diff --git a/components/src/components/atoms/Portal/Portal.tsx b/components/src/components/atoms/Portal/Portal.tsx index 775faf6a..054eba23 100644 --- a/components/src/components/atoms/Portal/Portal.tsx +++ b/components/src/components/atoms/Portal/Portal.tsx @@ -1,7 +1,7 @@ import * as React from 'react' -import * as ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' -type Props = { +export type PortalProps = { /** The classname attribute of the container element */ className?: string /** The element tag of the container element */ @@ -11,12 +11,12 @@ type Props = { renderCallback?: () => void } -export const Portal: React.FC = ({ +export const Portal: React.FC = ({ children, className, el = 'div', renderCallback, -}: Props) => { +}) => { const [container] = React.useState(document.createElement(el)) if (className) container.classList.add(className) @@ -27,10 +27,9 @@ export const Portal: React.FC = ({ return () => { document.body.removeChild(container) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [renderCallback]) - return ReactDOM.createPortal(children, container) + return createPortal(children, container) } Portal.displayName = 'Portal' diff --git a/components/src/components/atoms/Portal/index.ts b/components/src/components/atoms/Portal/index.ts deleted file mode 100644 index 65d9c797..00000000 --- a/components/src/components/atoms/Portal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Portal } from './Portal' diff --git a/components/src/components/atoms/RecordItem/RecordItem.test.tsx b/components/src/components/atoms/RecordItem/RecordItem.test.tsx index 1a3ef183..19e64ee8 100644 --- a/components/src/components/atoms/RecordItem/RecordItem.test.tsx +++ b/components/src/components/atoms/RecordItem/RecordItem.test.tsx @@ -1,18 +1,15 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, mockFunction, render, screen } from '@/test' import { useCopied } from '@/src/hooks/useCopied' import { RecordItem } from './RecordItem' -import { FlameSVG } from '../..' -import { lightTheme } from '../../../tokens/index' +import { FlameSVG } from '@/src/icons' -jest.mock('@/src/hooks/useCopied') +vi.mock('@/src/hooks/useCopied') -const mockCopied = jest.fn() +const mockCopied = vi.fn() const mockUseCopied = mockFunction(useCopied) mockUseCopied.mockReturnValue({ copy: mockCopied, copied: false }) @@ -21,17 +18,14 @@ describe('', () => { it('renders', () => { render( - - } - keyLabel="Title" - keySublabel="Subtitle" - value="Real value" - > - Display value - - , - , + + Display value + , ) expect(screen.getByText('Display value')).toBeInTheDocument() expect(screen.getByText('Title')).toBeInTheDocument() @@ -41,17 +35,14 @@ describe('', () => { it('should copy value to clipboard if clicked', () => { render( - - } - keyLabel="Title" - keySublabel="Subtitle" - value="Real value" - > - Display value - - , - , + + Display value + , ) screen.getByText('Display value').click() @@ -60,39 +51,33 @@ describe('', () => { it('should render anchor if as is a', () => { render( - - } - keyLabel="Title" - keySublabel="Subtitle" - value="Real value" - > - Display value - - , - , + + Display value + , ) expect(screen.getByTestId('record-item').nodeName).toBe('A') }) it('should have link as href if as is a', () => { render( - - } - keyLabel="Title" - keySublabel="Subtitle" - link="https://ens.domains" - value="Real value" - > - Display value - - , - , + + Display value + , ) expect(screen.getByTestId('record-item')).toHaveAttribute( 'href', @@ -102,20 +87,17 @@ describe('', () => { it('should passthrough custom target prop if as is a', () => { render( - - } - keyLabel="Title" - keySublabel="Subtitle" - target="_parent" - value="Real value" - > - Display value - - , - , + + Display value + , ) expect(screen.getByTestId('record-item')).toHaveAttribute( 'target', @@ -125,19 +107,16 @@ describe('', () => { it('should render button if as is button', () => { render( - - } - keyLabel="Title" - keySublabel="Subtitle" - value="Real value" - > - Display value - - , - , + + Display value + , ) expect(screen.getByTestId('record-item').nodeName).toBe('BUTTON') }) diff --git a/components/src/components/atoms/RecordItem/RecordItem.tsx b/components/src/components/atoms/RecordItem/RecordItem.tsx index a71d07c7..aeaacee2 100644 --- a/components/src/components/atoms/RecordItem/RecordItem.tsx +++ b/components/src/components/atoms/RecordItem/RecordItem.tsx @@ -1,13 +1,19 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { ReactNode } from 'react' +import type { ReactNode } from 'react' -import { CheckSVG, CopySVG, UpArrowSVG } from '@/src' +import { match } from 'ts-pattern' -import { Neverable } from '@/src/types' +import { CheckSVG, CopySVG, UpArrowSVG } from '@/src/icons' + +import type { Neverable } from '@/src/types' import { Typography } from '../Typography/Typography' import { useCopied } from '../../../hooks/useCopied' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import * as styles from './styles.css' +import { assignInlineVars } from '@vanilla-extract/dynamic' +import { clsx } from 'clsx' type Size = 'small' | 'large' @@ -15,7 +21,7 @@ type BaseProps = { value: string size?: Size inline?: boolean - icon?: ReactNode + icon?: React.ComponentProps['as'] keyLabel?: string | ReactNode keySublabel?: string | ReactNode children: string @@ -43,130 +49,117 @@ type AsAnchorProps = { as: 'a' link?: string } & Neverable & - NativeAnchorProps +NativeAnchorProps type AsButtonProps = { as?: 'button' link?: never } & Neverable & - NativeButtonProps +NativeButtonProps -export type Props = BaseProps & +export type RecordItemProps = BaseProps & NativeElementProps & (AsAnchorProps | AsButtonProps) -const Container = styled.button<{ - $inline: boolean -}>( - ({ theme, $inline }) => css` - display: flex; - align-items: flex-start; - - gap: ${theme.space[2]}; - padding: ${theme.space['2.5']} ${theme.space[3]}; - width: 100%; - height: fit-content; - background: ${theme.colors.greySurface}; - border: 1px solid ${theme.colors.border}; - border-radius: ${theme.radii.large}; - transition: all 150ms ease-in-out; - cursor: pointer; - - ${$inline && - css` - width: fit-content; - height: ${theme.space['10']}; - `} - - &:hover { - transform: translateY(-1px); - background: ${theme.colors.greyLight}; - } - `, +const ContainerBox = ({ + $inline, + className, + ...props +}: BoxProps & { $inline: boolean }) => ( + ) -const PrefixContainer = styled.div<{ $size: Size; $inline: boolean }>( - ({ theme, $inline, $size }) => css` - display: flex; - gap: ${theme.space[2]}; - align-items: flex-start; - width: ${$size === 'large' ? theme.space['30'] : theme.space['22.5']}; - flex: 0 0 ${$size === 'large' ? theme.space['30'] : theme.space['22.5']}; - - ${$inline && - css` - width: fit-content; - flex: initial; - `} - `, +const PrefixBox = ({ + $inline, + $size, + ...props +}: BoxProps & { $inline: boolean, $size: Size }) => ( + 'initial' as const) + .otherwise(() => ($size === 'large' ? '30' : '22.5'))} + flexGrow={0} + flexShrink={0} + gap="2" + width={match($inline) + .with(true, () => 'fit' as const) + .otherwise(() => ($size === 'large' ? '30' : '22.5'))} + {...props} + /> ) -const PrefixLabelsContainer = styled.div<{ $inline: boolean }>( - ({ theme, $inline }) => css` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0; - overflow: hidden; - - ${$inline && - css` - flex-direction: row; - gap: ${theme.space[2]}; - align-items: center; - `} - `, +const PrefixLabelsContainerBox = ({ + $inline, + ...props +}: BoxProps & { $inline: boolean }) => ( + ) -const PrefixLabel = styled(Typography)<{ - $inline: boolean -}>( - () => css` - text-align: left; - width: 100%; - `, -) +// const PrefixLabelBox = (props: BoxProps) => ( +// +// ) -const PrefixIcon = styled.div( - ({ theme }) => css` - svg { - display: block; - width: ${theme.space['5']}; - height: ${theme.space['5']}; - } - `, +const PrefixSVGBox = (props: BoxProps) => ( + ) -const Label = styled(Typography)<{ $inline: boolean }>( - ({ $inline }) => css` - flex: 1; - text-align: left; - word-break: break-all; - - ${$inline && - css` - word-break: initial; - `} - `, -) +// const LabelBox = ({ $inline, ...props }: BoxProps & { $inline: boolean }) => ( +// +// ) -const TrailingIcon = styled.svg<{ $rotate?: boolean }>( - ({ theme, $rotate }) => css` - display: block; - margin-top: ${theme.space['1']}; - width: ${theme.space['3']}; - height: ${theme.space['3']}; - color: ${theme.colors.greyPrimary}; - ${$rotate && - css` - transform: rotate(45deg); - `} - `, +const TrailingSVGBox = ({ + $rotate, + className, + style, + ...props +}: BoxProps & { $rotate?: boolean }) => ( + ) export const RecordItem = React.forwardRef< HTMLAnchorElement | HTMLButtonElement, - Props + RecordItemProps >( ( { @@ -182,89 +175,93 @@ export const RecordItem = React.forwardRef< children, ...props }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars ref, ) => { const { copy, copied } = useCopied() - const generatedProps = - asProp === 'a' + const generatedProps + = asProp === 'a' ? ({ href: link, rel: 'nofollow noreferrer', target: '_blank', ...props, - } as NativeElementProps & NativeAnchorProps) + } as Omit) : ({ onClick: () => { copy(value) }, ...props, - } as NativeElementProps & NativeButtonProps) + } as Omit) const hasPrefix = !!icon || !!keyLabel const hasLabels = !!keyLabel || !!keySublabel - const KeyLabel = - typeof keyLabel === 'string' ? ( - - {keyLabel} - - ) : ( - keyLabel - ) + const KeyLabel + = typeof keyLabel === 'string' + ? ( + + {keyLabel} + + ) + : ( + keyLabel + ) - const KeySublabel = - typeof keySublabel === 'string' ? ( - - {keySublabel} - - ) : ( - keySublabel - ) + const KeySublabel + = typeof keySublabel === 'string' + ? ( + + {keySublabel} + + ) + : ( + keySublabel + ) const PostfixProps = postfixIcon ? { as: postfixIcon } : link - ? { $rotate: true, as: UpArrowSVG } - : copied - ? { as: CheckSVG } - : { as: CopySVG } + ? { $rotate: true, as: UpArrowSVG } + : copied + ? { as: CheckSVG } + : { as: CopySVG } return ( - } - > + {hasPrefix && ( - - {icon && {icon}} + + {icon && } {hasLabels && ( - + {KeyLabel} {KeySublabel} - + )} - + )} - - - + + + ) }, ) diff --git a/components/src/components/atoms/RecordItem/index.ts b/components/src/components/atoms/RecordItem/index.ts deleted file mode 100644 index 97460b9b..00000000 --- a/components/src/components/atoms/RecordItem/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { RecordItem } from './RecordItem' -export type { Props as RecordItemProps } from './RecordItem' diff --git a/components/src/components/atoms/RecordItem/styles.css.ts b/components/src/components/atoms/RecordItem/styles.css.ts new file mode 100644 index 00000000..5e6744de --- /dev/null +++ b/components/src/components/atoms/RecordItem/styles.css.ts @@ -0,0 +1,14 @@ +import { createVar, style } from '@vanilla-extract/css' + +export const trailingSvgBoxTransform = createVar() + +export const trailingSVGBox = style({ + transform: trailingSvgBoxTransform, +}) + +export const containerBox = style({ + 'transform': 'translateY(0)', + ':hover': { + transform: 'translateY(-1px)', + }, +}) diff --git a/components/src/components/atoms/ScrollBox/ScrollBox.test.tsx b/components/src/components/atoms/ScrollBox/ScrollBox.test.tsx index f04e88e2..51955052 100644 --- a/components/src/components/atoms/ScrollBox/ScrollBox.test.tsx +++ b/components/src/components/atoms/ScrollBox/ScrollBox.test.tsx @@ -1,16 +1,17 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - -import { cleanup, makeMockIntersectionObserver, render, screen } from '@/test' -import 'jest-styled-components' - -import { lightTheme } from '@/src/tokens' +import { + cleanup, + getPropertyValue, + makeMockIntersectionObserver, + render, + screen, +} from '@/test' import { ScrollBox } from './ScrollBox' const Component = ({ onReachedTop }: { onReachedTop?: () => void }) => ( - + <> void }) => (
- + ) -const mockIntersectionObserverCls = jest.fn() -const mockObserve = jest.fn() -const mockDisconnect = jest.fn() +const mockIntersectionObserverCls = vi.fn() +const mockObserve = vi.fn() +const mockDisconnect = vi.fn() const mockIntersectionObserver = makeMockIntersectionObserver( mockIntersectionObserverCls, @@ -43,11 +44,13 @@ const mockIntersectionObserver = makeMockIntersectionObserver( mockDisconnect, ) -const expectLine = (e: 'top' | 'bottom', visible: boolean) => - expect(screen.getByTestId(`scrollbox-${e}-line`)).toHaveAttribute( - `data-${e}-line`, - visible ? 'true' : 'false', +const expectLine = (e: 'top' | 'bottom', visible: boolean) => { + const test = getPropertyValue( + screen.getByTestId(`scrollbox-${e}-divider`), + 'visibility', ) + expect(test).toEqual(visible ? 'visible' : 'hidden') +} describe('', () => { afterEach(cleanup) @@ -80,8 +83,8 @@ describe('', () => { expectLine('bottom', false) }) it('should show most recent intersection if multiple updates', () => { - let cb: (entries: any) => void - mockIntersectionObserverCls.mockImplementation((callback: any) => { + let cb: (entries: Pick[]) => void + mockIntersectionObserverCls.mockImplementation((callback: typeof cb) => { cb = callback return { observe: mockObserve, @@ -94,12 +97,12 @@ describe('', () => { els.push(el) if (els.length === 2) { cb([ - ...els.map((el) => ({ + ...els.map(el => ({ isIntersecting: false, target: el, time: 100, })), - ...els.map((el) => ({ + ...els.map(el => ({ isIntersecting: true, target: el, time: 1000, @@ -115,7 +118,7 @@ describe('', () => { }) it('should fire callback on intersection', () => { mockIntersectionObserver(true, false) - const onReachedTop = jest.fn() + const onReachedTop = vi.fn() render() expect(onReachedTop).toHaveBeenCalled() }) diff --git a/components/src/components/atoms/ScrollBox/ScrollBox.tsx b/components/src/components/atoms/ScrollBox/ScrollBox.tsx index 58196b8c..edfb4c4f 100644 --- a/components/src/components/atoms/ScrollBox/ScrollBox.tsx +++ b/components/src/components/atoms/ScrollBox/ScrollBox.tsx @@ -1,118 +1,57 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ import * as React from 'react' -import styled, { css } from 'styled-components' -import { Space } from '../../../tokens/index' +import { commonVars } from '@/src/css/theme.css' -const Container = styled.div( - ({ theme }) => css` - position: relative; - border: solid ${theme.space.px} transparent; - width: 100%; - height: 100%; - border-left-width: 0; - border-right-width: 0; - `, -) +import type { Space } from '@/src/tokens' -const StyledScrollBox = styled.div<{ $horizontalPadding?: Space }>( - ({ theme, $horizontalPadding }) => css` - overflow: auto; - position: relative; - width: 100%; - height: 100%; +import * as styles from './styles.css' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' - ${$horizontalPadding && - css` - padding: 0 ${theme.space[$horizontalPadding]}; - `} +const ScrollBoxBox = React.forwardRef>((props, ref) => ( + +)) - @property --scrollbar { - syntax: ''; - inherits: true; - initial-value: ${theme.colors.greyLight}; - } - - /* stylelint-disable custom-property-no-missing-var-function */ - transition: --scrollbar 0.15s ease-in-out, - height 0.15s ${theme.transitionTimingFunction.popIn}, - --top-line-color 0.15s ease-in-out, --bottom-line-color 0.15s ease-in-out; - /* stylelint-enable custom-property-no-missing-var-function */ - - ::-webkit-scrollbar { - width: ${theme.space['3.5']}; - transition: box-shadow 0.15s ease-in-out; - } - - ::-webkit-scrollbar, - ::-webkit-scrollbar-track, - ::-webkit-scrollbar-track-piece { - background-color: transparent; - } - - ::-webkit-scrollbar-button { - display: none; - } - - ::-webkit-scrollbar-thumb { - transition: box-shadow 0.15s ease-in-out; - box-shadow: inset 0 0 ${theme.space['3']} ${theme.space['3']} - var(--scrollbar); - border: solid ${theme.space['1']} transparent; - border-radius: ${theme.space['3']}; - background-color: transparent; - } - - &:hover { - --scrollbar: ${theme.colors.greyBright}; - } - `, -) - -const IntersectElement = styled.div( - () => css` - display: block; - height: 0px; - `, -) - -const Divider = styled.div<{ $horizontalPadding?: Space }>( - ({ theme, $horizontalPadding }) => css` - position: absolute; - left: 0; - height: 1px; - width: ${theme.space.full}; - background: transparent; - transition: background-color 0.15s ease-in-out; - - ${$horizontalPadding && - css` - left: ${theme.space[$horizontalPadding]}; - width: calc(100% - 2 * ${theme.space[$horizontalPadding]}); - `} - - &[data-top-line] { - top: -${theme.space.px}; - } - - &[data-top-line='true'] { - background: ${theme.colors.border}; - } - - &[data-bottom-line] { - bottom: -${theme.space.px}; - } - - &[data-bottom-line='true'] { - background: ${theme.colors.border}; - } - `, -) +const DividerBox = ({ + show, + position, + horizontalPadding, +}: { + show: boolean + position: 'top' | 'bottom' + horizontalPadding?: Space +}) => { + return ( + + ) +} -type Props = { +export type ScrollBoxProps = { /** If true, the dividers will be hidden */ - hideDividers?: boolean | { top?: boolean; bottom?: boolean } + hideDividers?: boolean | { top?: boolean, bottom?: boolean } /** If true, the dividers will always be shown */ - alwaysShowDividers?: boolean | { top?: boolean; bottom?: boolean } + alwaysShowDividers?: boolean | { top?: boolean, bottom?: boolean } /** The number of pixels below the top of the content where events such as showing/hiding dividers and onReachedTop will be executed */ topTriggerPx?: number /** The number of pixels above the bottom of the content where events such as showing/hiding dividers and onReachedTop will be executed */ @@ -121,9 +60,9 @@ type Props = { onReachedTop?: () => void /** A callback function that is fired when the content reaches bottomTriggerPx */ onReachedBottom?: () => void - /** The amount of horizontal padding to apply to the scrollbox. This will decrease the content area as well as the width of the overflow indicator dividers*/ + /** The amount of horizontal padding to apply to the scrollbox. This will decrease the content area as well as the width of the overflow indicator dividers */ horizontalPadding?: Space -} & React.HTMLAttributes +} & BoxProps export const ScrollBox = ({ hideDividers = false, @@ -135,21 +74,21 @@ export const ScrollBox = ({ horizontalPadding, children, ...props -}: Props) => { +}: ScrollBoxProps) => { const ref = React.useRef(null) const topRef = React.useRef(null) const bottomRef = React.useRef(null) - const hideTop = - typeof hideDividers === 'boolean' ? hideDividers : !!hideDividers?.top - const hideBottom = - typeof hideDividers === 'boolean' ? hideDividers : !!hideDividers?.bottom - const alwaysShowTop = - typeof alwaysShowDividers === 'boolean' + const hideTop + = typeof hideDividers === 'boolean' ? hideDividers : !!hideDividers?.top + const hideBottom + = typeof hideDividers === 'boolean' ? hideDividers : !!hideDividers?.bottom + const alwaysShowTop + = typeof alwaysShowDividers === 'boolean' ? alwaysShowDividers : !!alwaysShowDividers?.top - const alwaysShowBottom = - typeof alwaysShowDividers === 'boolean' + const alwaysShowBottom + = typeof alwaysShowDividers === 'boolean' ? alwaysShowDividers : !!alwaysShowDividers?.bottom @@ -166,21 +105,21 @@ export const ScrollBox = ({ const intersectingBottom: [boolean, number] = [false, -1] for (let i = 0; i < entries.length; i += 1) { const entry = entries[i] - const iref = - entry.target === topRef.current ? intersectingTop : intersectingBottom + const iref + = entry.target === topRef.current ? intersectingTop : intersectingBottom if (entry.time > iref[1]) { iref[0] = entry.isIntersecting iref[1] = entry.time } } - intersectingTop[1] !== -1 && - !hideTop && - !alwaysShowTop && - setShowTop(!intersectingTop[0]) - intersectingBottom[1] !== -1 && - !hideBottom && - !alwaysShowBottom && - setShowBottom(!intersectingBottom[0]) + intersectingTop[1] !== -1 + && !hideTop + && !alwaysShowTop + && setShowTop(!intersectingTop[0]) + intersectingBottom[1] !== -1 + && !hideBottom + && !alwaysShowBottom + && setShowBottom(!intersectingBottom[0]) intersectingTop[0] && funcRef.current.onReachedTop?.() intersectingBottom[0] && funcRef.current.onReachedBottom?.() } @@ -200,9 +139,8 @@ export const ScrollBox = ({ observer.observe(bottomEl) } return () => { - observer.disconnect() + observer?.disconnect() } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [bottomTriggerPx, topTriggerPx]) React.useEffect(() => { @@ -210,25 +148,55 @@ export const ScrollBox = ({ }, [onReachedTop, onReachedBottom]) return ( - - - + + + {children} - - - + - - + ) } diff --git a/components/src/components/atoms/ScrollBox/index.ts b/components/src/components/atoms/ScrollBox/index.ts deleted file mode 100644 index d17aa10c..00000000 --- a/components/src/components/atoms/ScrollBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ScrollBox } from './ScrollBox' diff --git a/components/src/components/atoms/ScrollBox/styles.css.ts b/components/src/components/atoms/ScrollBox/styles.css.ts new file mode 100644 index 00000000..9ebb5bf9 --- /dev/null +++ b/components/src/components/atoms/ScrollBox/styles.css.ts @@ -0,0 +1,38 @@ +import { style } from '@vanilla-extract/css' + +import { commonVars, cssVars, modeVars } from '@/src/css/theme.css' + +export const scrollBox = style({ + overflow: 'auto', + position: 'relative', + width: commonVars.space.full, + height: commonVars.space.full, + borderColor: modeVars.color.greyLight, + transition: `${modeVars.color.greyLight} 0.15s ease-in-out, height 0.15s ${cssVars.transitionTimingFunction.popIn}, var(--top-line-color) 0.15s ease-in-out, var(--bottom-line-color) 0.15s ease-in-out`, + selectors: { + '&::-webkit-scrollbar': { + width: commonVars.space['3.5'], + transition: 'box-shadow 0.15s ease-in-out', + backgroundColor: 'transparent', + }, + '&::-webkit-scrollbar-track': { + backgroundColor: 'transparent', + }, + '&::-webkit-scrollbar-track-piece': { + backgroundColor: 'transparent', + }, + '&::-webkit-scrollbar-button': { + display: 'none', + }, + '&::-webkit-scrollbar-thumb': { + transition: 'box-shadow 0.15s ease-in-out', + boxShadow: `inset 0 0 ${commonVars.space['3']} ${commonVars.space['3']} ${modeVars.color.greyLight}`, + border: `solid ${commonVars.space['1']} transparent`, + borderRadius: commonVars.space['3'], + backgroundColor: 'transparent', + }, + '&:hover': { + color: modeVars.color.greyBright, + }, + }, +}) diff --git a/components/src/components/atoms/Skeleton/Skeleton.test.tsx b/components/src/components/atoms/Skeleton/Skeleton.test.tsx index 852897f7..191145de 100644 --- a/components/src/components/atoms/Skeleton/Skeleton.test.tsx +++ b/components/src/components/atoms/Skeleton/Skeleton.test.tsx @@ -1,22 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Skeleton } from './Skeleton' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - foo bar baz - , - ) + render(foo bar baz) expect(screen.getByText(/foo/i)).toBeInTheDocument() }) }) diff --git a/components/src/components/atoms/Skeleton/Skeleton.tsx b/components/src/components/atoms/Skeleton/Skeleton.tsx index 10e05bf1..ac8462f7 100644 --- a/components/src/components/atoms/Skeleton/Skeleton.tsx +++ b/components/src/components/atoms/Skeleton/Skeleton.tsx @@ -1,68 +1,32 @@ import * as React from 'react' -import styled, { css, keyframes } from 'styled-components' -import { Context } from '../../molecules/SkeletonGroup' +import classNames from 'clsx' -interface ContainerProps { - $active?: boolean -} - -const shine = keyframes` - to { - background-position-x: -200%; - } -` - -const Container = styled.div( - ({ theme, $active }) => css` - ${$active && - css` - background: ${theme.colors.greyLight} - linear-gradient( - 110deg, - ${theme.colors.greyLight} 8%, - ${theme.colors.greySurface} 18%, - ${theme.colors.greyLight} 33% - ); - background-size: 200% 100%; - animation: 1.5s ${shine} infinite linear; - border-radius: ${theme.radii.medium}; - width: ${theme.space.fit}; - `} - `, -) - -const ContainerInner = styled.span<{ $active?: boolean }>( - ({ $active }) => css` - display: block; - ${$active - ? css` - visibility: hidden; - * { - visibility: hidden !important; - } - ` - : ``} - `, -) +import * as styles from './styles.css' -type NativeDivProps = React.HTMLAttributes +import { SkeletonGroupContext } from '../../molecules/SkeletonGroup/SkeletonGroup' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' -type Props = { - /** An alternative element type to render the component as.*/ +export type SkeletonProps = { + /** An alternative element type to render the component as. */ as?: 'span' /** If true, hides the content and shows the skeleton style. */ loading?: boolean -} & NativeDivProps +} & BoxProps -export const Skeleton = ({ as, children, loading, ...props }: Props) => { - const groupLoading = React.useContext(Context) +export const Skeleton: React.FC = ({ as, children, loading, ...props }) => { + const groupLoading = React.useContext(SkeletonGroupContext) const active = loading ?? groupLoading return ( - - {children} - + + {children} + ) } diff --git a/components/src/components/atoms/Skeleton/index.ts b/components/src/components/atoms/Skeleton/index.ts deleted file mode 100644 index e42e3ebe..00000000 --- a/components/src/components/atoms/Skeleton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Skeleton } from './Skeleton' diff --git a/components/src/components/atoms/Skeleton/styles.css.ts b/components/src/components/atoms/Skeleton/styles.css.ts new file mode 100644 index 00000000..292c9d79 --- /dev/null +++ b/components/src/components/atoms/Skeleton/styles.css.ts @@ -0,0 +1,26 @@ +import { keyframes, style } from '@vanilla-extract/css' + +import { commonVars, modeVars } from '@/src/css/theme.css' + +const shine = keyframes({ + to: { + backgroundPositionX: '-200%', + }, +}) + +export const animations = style({ + backgroundColor: modeVars.color.greyLight, + backgroundImage: `linear-gradient( + 90deg, + ${modeVars.color.greyLight} 0px, + ${modeVars.color.greySurface} 18%, + ${modeVars.color.greyLight} 33% + )`, + backgroundSize: '200% 100%', + animationName: shine, + animationDuration: '1.5s', + animationTimingFunction: 'linear', + animationIterationCount: 'infinite', + borderRadius: commonVars.radii.medium, + width: 'fit-content', +}) diff --git a/components/src/components/atoms/Spinner/Spinner.test.tsx b/components/src/components/atoms/Spinner/Spinner.test.tsx index db78c30c..6e327ab6 100644 --- a/components/src/components/atoms/Spinner/Spinner.test.tsx +++ b/components/src/components/atoms/Spinner/Spinner.test.tsx @@ -1,21 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Spinner } from './Spinner' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - - , - ) + render() expect(screen.getByText(/loading/i)).toBeInTheDocument() }) }) diff --git a/components/src/components/atoms/Spinner/Spinner.tsx b/components/src/components/atoms/Spinner/Spinner.tsx index aebb4017..f995be9d 100644 --- a/components/src/components/atoms/Spinner/Spinner.tsx +++ b/components/src/components/atoms/Spinner/Spinner.tsx @@ -1,98 +1,79 @@ import * as React from 'react' -import styled, { css, keyframes } from 'styled-components' -import { Colors } from '@/src/tokens' +import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden' +import * as styles from './styles.css' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import { clsx } from 'clsx' +import type { Color } from '@/src/tokens/color' -import { VisuallyHidden } from '../VisuallyHidden' - -type NativeDivProps = React.HTMLAttributes type Size = 'small' | 'medium' | 'large' -type Props = { + +export type SpinnerProps = { /** Hidden text used for accessibilty. */ accessibilityLabel?: string /** A tokens 'mode' color value */ - color?: Colors + color?: Color size?: Size -} & Omit - -const rotate = keyframes` - 100% { - transform: rotate(1turn); - } -` - -const Container = styled.div<{ $size: Size; $color?: Colors }>( - ({ theme, $color, $size }) => css` - animation: ${rotate} 1.1s linear infinite; - - ${$color && - css` - color: ${theme.colors[$color]}; - `} +} & Omit - ${() => { - switch ($size) { - case 'small': - return css` - height: ${theme.space['4']}; - width: ${theme.space['4']}; - stroke-width: ${theme.space['1']}; - ` - case 'medium': - return css` - height: ${theme.space['6']}; - stroke-width: ${theme.space['1.25']}; - width: ${theme.space['6']}; - ` - case 'large': - return css` - height: ${theme.space['16']}; - stroke-width: ${theme.space['1']}; - width: ${theme.space['16']}; - ` - default: - return `` - } - }} +const ContainerBox = React.forwardRef< + HTMLElement, + BoxProps & { $size: Size, $color?: Color } +>(({ $size, $color, className, ...props }, ref) => ( + +)) - svg { - display: block; - stroke: currentColor; - height: 100%; - width: 100%; - } - `, +const SVG = () => ( + + + + ) -export const Spinner = React.forwardRef( +export const Spinner = React.forwardRef( ( - { accessibilityLabel, size = 'small', color = 'text', ...props }: Props, - ref: React.Ref, + { accessibilityLabel, size = 'small', color, ...props }, + ref, ) => { return ( - + {accessibilityLabel && ( {accessibilityLabel} )} - - - - - + + ) }, ) diff --git a/components/src/components/atoms/Spinner/index.ts b/components/src/components/atoms/Spinner/index.ts deleted file mode 100644 index fbf16c1f..00000000 --- a/components/src/components/atoms/Spinner/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Spinner } from './Spinner' diff --git a/components/src/components/atoms/Spinner/styles.css.ts b/components/src/components/atoms/Spinner/styles.css.ts new file mode 100644 index 00000000..3f6a9cd3 --- /dev/null +++ b/components/src/components/atoms/Spinner/styles.css.ts @@ -0,0 +1,35 @@ +import { sprinkles } from '@/src/css/sprinkles.css' +import { keyframes, style } from '@vanilla-extract/css' +import { recipe } from '@vanilla-extract/recipes' + +const rotate = keyframes({ + '100%': { + transform: 'rotate(1turn)', + }, +}) + +export const animation = style({ + animationName: rotate, + animationDuration: '1.1s', + animationTimingFunction: 'linear', + animationIterationCount: 'infinite', +}) + +export const variants = recipe({ + variants: { + size: { + small: sprinkles({ + wh: '4', + strokeWidth: '1', + }), + medium: sprinkles({ + wh: '6', + strokeWidth: '1.25', + }), + large: sprinkles({ + wh: '16', + strokeWidth: '1', + }), + }, + }, +}) diff --git a/components/src/components/atoms/Tag/Tag.test.tsx b/components/src/components/atoms/Tag/Tag.test.tsx index c48d2379..390ae310 100644 --- a/components/src/components/atoms/Tag/Tag.test.tsx +++ b/components/src/components/atoms/Tag/Tag.test.tsx @@ -1,22 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Tag } from './Tag' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - 10 ETH - , - ) + render(10 ETH) expect(screen.getByText(/eth/i)).toBeInTheDocument() }) }) diff --git a/components/src/components/atoms/Tag/Tag.tsx b/components/src/components/atoms/Tag/Tag.tsx index 18221e97..ba724977 100644 --- a/components/src/components/atoms/Tag/Tag.tsx +++ b/components/src/components/atoms/Tag/Tag.tsx @@ -1,83 +1,76 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { - WithColorStyle, - getColorStyle, -} from '@/src/types/withColorOrColorStyle' +import { translateY } from '@/src/css/utils/common' -interface ContainerProps { - $hover?: boolean - $size: 'small' | 'medium' - $colorStyle: NonNullable -} - -const Container = styled.div( - ({ theme, $hover, $size, $colorStyle }) => css` - align-items: center; - display: flex; - border-radius: ${theme.radii['full']}; - font-size: ${theme.fontSizes.small}; - line-height: ${theme.lineHeights.small}; - font-weight: ${theme.fontWeights.bold}; - width: ${theme.space['max']}; - padding: ${theme.space['0.5']} ${theme.space['2']}; - background: ${getColorStyle($colorStyle, 'background')}; - color: ${getColorStyle($colorStyle, 'text')}; - border: 1px solid ${getColorStyle($colorStyle, 'border')}; - cursor: default; - - ${$size === 'small' && - css` - font-size: ${theme.fontSizes.extraSmall}; - line-height: ${theme.lineHeights.extraSmall}; - `} +import { removeNullishProps } from '@/src/utils/removeNullishProps' - ${$hover && - css` - transition-duration: ${theme.transitionDuration['150']}; - transition-property: color, border-color, background-color; - transition-timing-function: ${theme.transitionTimingFunction['inOut']}; +import * as styles from './styles.css' +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' +import clsx from 'clsx' +import type { Colors, ColorStyles } from '@/src/tokens' +import { getColorStyleParts } from '@/src/utils/getColorStyleParts' +import { match } from 'ts-pattern' +import { assignInlineVars } from '@vanilla-extract/dynamic' - &:hover, - &:active { - transform: translateY(-1px); - background-color: ${getColorStyle($colorStyle, 'hover')}; - } - `} - `, -) - -type NativeDivProps = React.HTMLAttributes - -export type Props = { +export type TagProps = { /** Element type of container */ as?: 'div' | 'span' /** If true, changes colors on hover */ hover?: boolean /** Size of element */ size?: 'small' | 'medium' -} & NativeDivProps & - WithColorStyle + colorStyle?: ColorStyles +} & Omit -export const Tag = ({ +export const Tag: React.FC = ({ as = 'div', children, hover, size = 'small', colorStyle = 'accentSecondary', + className, + style, ...props -}: Props) => { +}) => { + const [baseColor, baseTheme] = getColorStyleParts(colorStyle) return ( - `${baseColor}Primary` as Colors) + .otherwise(() => `${baseColor}Surface` as Colors), + hover: match(baseTheme) + .with('Primary', (): Colors => hover ? `${baseColor}Bright` : `${baseColor}Primary`) + .otherwise((): Colors => hover ? `${baseColor}Light` : `${baseColor}Surface`), + active: match(baseTheme) + .with('Primary', (): Colors => `${baseColor}Bright`) + .otherwise((): Colors => `${baseColor}Light`), + }} + borderRadius="full" + color={match(baseTheme).with('Primary', (): Colors => 'textAccent').otherwise((): Colors => `${baseColor}Primary`)} + display="flex" + fontSize={size === 'small' ? 'extraSmall' : 'small'} + fontWeight="bold" + lineHeight={size === 'small' ? 'extraSmall' : 'small'} + px="2" + py="0.5" + + transitionDuration={150} + transitionTimingFunction="inOut" + width="max" + {...removeNullishProps(props)} + style={{ + ...style, ...assignInlineVars({ + [styles.tagHover]: translateY(hover ? -1 : 0), + }), + }} + className={clsx(className, styles.tag)} > {children} - + ) } diff --git a/components/src/components/atoms/Tag/index.ts b/components/src/components/atoms/Tag/index.ts deleted file mode 100644 index e74ecce6..00000000 --- a/components/src/components/atoms/Tag/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Tag } from './Tag' -export type { Props as TagProps } from './Tag' diff --git a/components/src/components/atoms/Tag/styles.css.ts b/components/src/components/atoms/Tag/styles.css.ts new file mode 100644 index 00000000..0d605d13 --- /dev/null +++ b/components/src/components/atoms/Tag/styles.css.ts @@ -0,0 +1,15 @@ +import { translateY } from '@/src/css/utils/common' +import { createVar, style } from '@vanilla-extract/css' + +export const tagHover = createVar() + +export const tag = style({ + 'transitionProperty': 'color, border-color, background-color, transform', + 'transform': translateY(0), + ':hover': { + transform: tagHover, + }, + ':active': { + transform: translateY(-1), + }, +}) diff --git a/components/src/components/atoms/ThemeProvider/ThemeProvider.tsx b/components/src/components/atoms/ThemeProvider/ThemeProvider.tsx new file mode 100644 index 00000000..62238d3b --- /dev/null +++ b/components/src/components/atoms/ThemeProvider/ThemeProvider.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' + +type Mode = 'dark' | 'light' + +type ThemeContextValue = { + mode: Mode + setMode: (mode: Mode) => void +} + +const ThemeContext = React.createContext(null) + +type Props = { + defaultMode?: Mode + onThemeChange?: (mode: Mode) => void +} + +export const ThemeProvider: React.FC> = ({ + defaultMode = 'light', + onThemeChange, + children, +}) => { + const [mode, setMode] = React.useState(defaultMode) + + const value = React.useMemo(() => ({ mode, setMode }), [mode]) + + React.useEffect(() => { + if (onThemeChange) onThemeChange(mode) + }, [mode]) + + return {children} +} + +export const useTheme = () => { + const context = React.useContext(ThemeContext) + if (context === null) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} diff --git a/components/src/components/atoms/Typography/Typography.test.tsx b/components/src/components/atoms/Typography/Typography.test.tsx index f5ff236f..1834b6e5 100644 --- a/components/src/components/atoms/Typography/Typography.test.tsx +++ b/components/src/components/atoms/Typography/Typography.test.tsx @@ -1,22 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Typography } from './Typography' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - foo bar baz - , - ) + render(foo bar baz) expect(screen.getByText(/foo/i)).toBeInTheDocument() }) }) diff --git a/components/src/components/atoms/Typography/Typography.tsx b/components/src/components/atoms/Typography/Typography.tsx index b6a0b1c1..bde6314a 100644 --- a/components/src/components/atoms/Typography/Typography.tsx +++ b/components/src/components/atoms/Typography/Typography.tsx @@ -1,62 +1,50 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { Font, FontSize, FontWeight } from '@/src/tokens/typography' +import type { Font, FontSize, FontWeight } from '@/src/tokens/typography' -import { - WithTypography, - getFontSize, - getFontWeight, - getLineHeight, -} from '@/src/types/withTypography' -import { WithColor, getColor } from '@/src/types/withColorOrColorStyle' +import { removeNullishProps } from '@/src/utils/removeNullishProps' + +import { Box, type BoxProps } from '../Box/Box' +import type { FontVariant } from './utils/variant.css' +import { fontVariant } from './utils/variant.css' +import clsx from 'clsx' +import type { Color } from '@/src/tokens/color' type ContainerProps = { $ellipsis?: boolean - $fontVariant: WithTypography['fontVariant'] + $fontVariant: FontVariant $size?: FontSize - $color: NonNullable + $color?: Color $weight?: FontWeight $font: Font } -const Container = styled.div( - ({ theme, $ellipsis, $fontVariant = 'body', $color, $font, $weight }) => css` - font-family: ${theme.fonts.sans}; - line-height: ${theme.lineHeights.body}; - color: ${getColor($color)}; - - ${$ellipsis && - css` - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - `} - - ${$fontVariant && - css` - font-size: ${getFontSize($fontVariant)}; - font-weight: ${getFontWeight($fontVariant)}; - line-height: ${getLineHeight($fontVariant)}; - `} - - ${$font === 'mono' && - css` - font-family: ${theme.fonts.mono}; - `} - - ${$weight && - css` - font-weight: ${theme.fontWeights[$weight]}; - `}; - `, +const ContainerBox = React.forwardRef( + ( + { $ellipsis, $fontVariant = 'body', $color, $font, $weight, as, $size, className, ...props }, + ref, + ) => ( + + ), ) type NativeDivProps = React.HTMLAttributes -type Props = { +export type TypographyProps = { /** element type of container */ - asProp?: + as?: | 'code' | 'div' | 'h1' @@ -71,46 +59,45 @@ type Props = { | 'i' /** If true, will truncate text with an elipsis on overflow. If false, text will break on the next word. */ ellipsis?: boolean - /** The classname attribute of contianer. */ - className?: NativeDivProps['className'] /** The tokens.fontWeight value */ /** A font value that overrides the existing font property */ font?: Font /** A weight value that overrides existing weight property */ weight?: FontWeight -} & Omit & - WithTypography & - WithColor + color?: Color + fontVariant?: FontVariant +} & Omit & +Omit & { fontVariant?: FontVariant } -export const Typography = React.forwardRef( +export const Typography = React.forwardRef( ( { - asProp, + as, children, ellipsis, - className, fontVariant = 'body', font = 'sans', - color = 'text', + color = 'textPrimary', weight, + textTransform, ...props }, ref, ) => { return ( - {children} - + ) }, ) diff --git a/components/src/components/atoms/Typography/index.ts b/components/src/components/atoms/Typography/index.ts deleted file mode 100644 index 3ec44f36..00000000 --- a/components/src/components/atoms/Typography/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Typography } from './Typography' diff --git a/components/src/components/atoms/Typography/utils/variant.css.ts b/components/src/components/atoms/Typography/utils/variant.css.ts new file mode 100644 index 00000000..2b3606d7 --- /dev/null +++ b/components/src/components/atoms/Typography/utils/variant.css.ts @@ -0,0 +1,93 @@ +import { sprinkles } from '@/src/css/sprinkles.css' +import { recipe } from '@vanilla-extract/recipes' + +const variants = { + label: sprinkles({ + fontSize: 'extraSmall', + lineHeight: 'extraSmall', + fontWeight: 'normal', + }), + labelHeading: sprinkles({ + fontSize: 'small', + lineHeight: 'small', + fontWeight: 'normal', + }), + headingOne: sprinkles({ + fontSize: 'headingOne', + lineHeight: 'headingOne', + fontWeight: 'extraBold', + }), + headingTwo: sprinkles({ + fontSize: 'headingTwo', + lineHeight: 'headingTwo', + fontWeight: 'bold', + }), + headingThree: sprinkles({ + fontSize: 'headingThree', + lineHeight: 'headingThree', + fontWeight: 'bold', + }), + headingFour: sprinkles({ + fontSize: 'headingFour', + lineHeight: 'headingFour', + fontWeight: 'bold', + }), + extraLargeBold: sprinkles({ + fontSize: 'extraLarge', + lineHeight: 'extraLarge', + fontWeight: 'bold', + }), + extraLarge: sprinkles({ + fontSize: 'extraLarge', + lineHeight: 'extraLarge', + fontWeight: 'normal', + }), + largeBold: sprinkles({ + fontSize: 'large', + lineHeight: 'large', + fontWeight: 'bold', + }), + large: sprinkles({ + fontSize: 'large', + lineHeight: 'large', + fontWeight: 'normal', + }), + bodyBold: sprinkles({ + fontSize: 'body', + lineHeight: 'body', + fontWeight: 'bold', + }), + body: sprinkles({ + fontSize: 'body', + lineHeight: 'body', + fontWeight: 'normal', + }), + smallBold: sprinkles({ + fontSize: 'small', + lineHeight: 'small', + fontWeight: 'bold', + }), + small: sprinkles({ + fontSize: 'small', + lineHeight: 'small', + fontWeight: 'normal', + }), + extraSmallBold: sprinkles({ + fontSize: 'extraSmall', + lineHeight: 'extraSmall', + fontWeight: 'bold', + }), + extraSmall: sprinkles({ + fontSize: 'extraSmall', + lineHeight: 'extraSmall', + fontWeight: 'normal', + }), +} + +export const fontVariant = recipe({ + variants: { + fontVariant: variants, + }, +}) + +export type FontVariant = keyof typeof variants diff --git a/components/src/components/atoms/VisuallyHidden/VisuallyHidden.tsx b/components/src/components/atoms/VisuallyHidden/VisuallyHidden.tsx index c8742b93..80f6cd3d 100644 --- a/components/src/components/atoms/VisuallyHidden/VisuallyHidden.tsx +++ b/components/src/components/atoms/VisuallyHidden/VisuallyHidden.tsx @@ -1,14 +1,21 @@ -import styled, { css } from 'styled-components' +import * as React from 'react' -export const VisuallyHidden = styled.div( - () => css` - border-width: 0; - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - `, +import type { BoxProps } from '../Box/Box' +import { Box } from '../Box/Box' + +export const VisuallyHidden: React.FC = props => ( + ) + +VisuallyHidden.displayName = 'VisuallyHidden' diff --git a/components/src/components/atoms/VisuallyHidden/index.ts b/components/src/components/atoms/VisuallyHidden/index.ts deleted file mode 100644 index a6be36e9..00000000 --- a/components/src/components/atoms/VisuallyHidden/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { VisuallyHidden } from './VisuallyHidden' diff --git a/components/src/components/atoms/index.ts b/components/src/components/atoms/index.ts index 1f264d06..c1c66a45 100644 --- a/components/src/components/atoms/index.ts +++ b/components/src/components/atoms/index.ts @@ -1,17 +1,20 @@ -export { Avatar } from './Avatar' -export { BackdropSurface } from './BackdropSurface' -export { Banner } from './Banner' -export { Button } from './Button' -export { Card } from './Card' -export { DynamicPopover } from './DynamicPopover' -export { Field } from './Field' -export { FileInput } from './FileInput' -export { Heading } from './Heading' -export { Portal } from './Portal' -export { RecordItem } from './RecordItem' -export { ScrollBox } from './ScrollBox' -export { Skeleton } from './Skeleton' -export { Spinner } from './Spinner' -export { Tag } from './Tag' -export { Typography } from './Typography' -export { VisuallyHidden } from './VisuallyHidden' +export { Avatar, type AvatarProps } from './Avatar/Avatar' +export { BackdropSurface, type BackdropSurfaceProps } from './BackdropSurface/BackdropSurface' +export { Banner, type BannerProps } from './Banner/Banner' +export { Button, type ButtonProps } from './Button/Button' +export { Card, CardDivider, type CardProps } from './Card/Card' +export * from './DynamicPopover/DynamicPopover' +export { Field, type FieldProps } from './Field/Field' +export { FileInput } from './FileInput/FileInput' +export { Heading, type HeadingProps } from './Heading/Heading' +export { Portal, type PortalProps } from './Portal/Portal' +export { RecordItem, type RecordItemProps } from './RecordItem/RecordItem' +export { ScrollBox, type ScrollBoxProps } from './ScrollBox/ScrollBox' +export { Skeleton, type SkeletonProps } from './Skeleton/Skeleton' +export { Spinner, type SpinnerProps } from './Spinner/Spinner' +export { Tag, type TagProps } from './Tag/Tag' +export { Typography, type TypographyProps } from './Typography/Typography' +export type { FontVariant } from './Typography/utils/variant.css' +export { VisuallyHidden } from './VisuallyHidden/VisuallyHidden' +export { Box, type BoxProps, type AsProp } from './Box/Box' +export { ThemeProvider, useTheme } from './ThemeProvider/ThemeProvider' diff --git a/components/src/components/index.ts b/components/src/components/index.ts deleted file mode 100644 index 83ac0355..00000000 --- a/components/src/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './atoms' -export * from './molecules' -export * from './organisms' -export * from '../icons' diff --git a/components/src/components/molecules/Backdrop/Backdrop.test.tsx b/components/src/components/molecules/Backdrop/Backdrop.test.tsx index 21e4f255..855062e2 100644 --- a/components/src/components/molecules/Backdrop/Backdrop.test.tsx +++ b/components/src/components/molecules/Backdrop/Backdrop.test.tsx @@ -1,14 +1,10 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen, waitFor } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Backdrop } from './Backdrop' -window.scroll = jest.fn() +window.scroll = vi.fn() const Element = ({ open = true, @@ -19,11 +15,9 @@ const Element = ({ className?: string testId?: number }) => ( - - - {() =>
test
} -
-
+ + {() =>
test
} +
) describe('', () => { diff --git a/components/src/components/molecules/Backdrop/Backdrop.tsx b/components/src/components/molecules/Backdrop/Backdrop.tsx index 5446c1aa..d37916af 100644 --- a/components/src/components/molecules/Backdrop/Backdrop.tsx +++ b/components/src/components/molecules/Backdrop/Backdrop.tsx @@ -1,15 +1,14 @@ import * as React from 'react' -import { TransitionState, useTransition } from 'react-transition-state' +import type { TransitionState } from 'react-transition-state' +import { useTransition } from 'react-transition-state' -import { Portal } from '../..' +import { Portal } from '../../atoms/Portal/Portal' -import { BackdropSurface } from '../../atoms/BackdropSurface' +import { BackdropSurface } from '../../atoms/BackdropSurface/BackdropSurface' -type Props = { +export type BackdropProps = { /** A function that renders the children nodes */ - children: (renderProps: { - state: TransitionState['status'] - }) => React.ReactNode + children: (renderProps: { state: TransitionState }) => React.ReactNode /** An element that provides backdrop styling. Defaults to BackdropSurface component. */ surface?: React.ElementType /** A event fired when the background is clicked. */ @@ -23,7 +22,7 @@ type Props = { renderCallback?: () => void } -export const Backdrop = ({ +export const Backdrop: React.FC = ({ children, surface, onDismiss, @@ -31,8 +30,8 @@ export const Backdrop = ({ className = 'modal', open, renderCallback, -}: Props) => { - const [{ status: state }, toggle] = useTransition({ +}) => { + const [state, toggle] = useTransition({ timeout: { enter: 50, exit: 300, @@ -40,6 +39,7 @@ export const Backdrop = ({ mountOnEnter: true, unmountOnExit: true, }) + const boxRef = React.useRef(null) const Background = surface || BackdropSurface @@ -57,48 +57,50 @@ export const Backdrop = ({ style.top = t } - const toggleValue = open || false - toggle(toggleValue) - if (typeof window !== 'undefined' && !noBackground && open) { - if (currBackdrops() === 0) { - setStyles( - `${document.body.clientWidth}px`, - 'fixed', - `-${window.scrollY}px`, - ) - } - modifyBackdrops(1) - return () => { - const top = parseFloat(style.top || '0') * -1 - if (currBackdrops() === 1) { - setStyles('', '', '') - window.scroll({ - top, - }) + toggle(open || false) + if (typeof window !== 'undefined' && !noBackground) { + if (open) { + if (currBackdrops() === 0) { + setStyles( + `${document.body.clientWidth}px`, + 'fixed', + `-${window.scrollY}px`, + ) + } + modifyBackdrops(1) + return () => { + toggle(false) + const top = parseFloat(style.top || '0') * -1 + if (currBackdrops() === 1) { + setStyles('', '', '') + window.scroll({ + top, + }) + } + modifyBackdrops(-1) } - modifyBackdrops(-1) - toggle(!toggleValue) } } return () => { - toggle(!toggleValue) + toggle(false) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, noBackground]) - return state !== 'unmounted' ? ( - - {onDismiss && ( - - )} - {children({ state })} - - ) : null + return state.status !== 'unmounted' + ? ( + + {onDismiss && ( + + )} + {children({ state })} + + ) + : null } Backdrop.displayName = 'Backdrop' diff --git a/components/src/components/molecules/Backdrop/index.ts b/components/src/components/molecules/Backdrop/index.ts deleted file mode 100644 index d548ec07..00000000 --- a/components/src/components/molecules/Backdrop/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Backdrop } from './Backdrop' diff --git a/components/src/components/molecules/Checkbox/Checkbox.test.tsx b/components/src/components/molecules/Checkbox/Checkbox.test.tsx index 9f6de433..f2336f07 100644 --- a/components/src/components/molecules/Checkbox/Checkbox.test.tsx +++ b/components/src/components/molecules/Checkbox/Checkbox.test.tsx @@ -1,45 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import * as React from 'react' -import { RefObject, useState } from 'react' - -import { ThemeProvider } from 'styled-components' +import type { RefObject } from 'react' +import { useState } from 'react' import { cleanup, render, screen, userEvent, waitFor } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Checkbox } from './Checkbox' -const CheckboxWithState = (props: any) => { +const CheckboxWithState = React.forwardRef((props: any, ref) => { const [checked, setChecked] = useState(false) return ( - -
- hello there - {checked ?
checked
:
unchecked
} - { - setChecked(e.target.checked) - }} - {...props} - /> -
-
+
+ hello there + {checked ?
checked
:
unchecked
} + { + setChecked(e.target.checked) + }} + /> +
) -} +}) describe('', () => { afterEach(cleanup) it('renders', async () => { - render( - - - , - ) + render() expect(screen.getByRole('checkbox')).toBeInTheDocument() }) @@ -76,17 +68,11 @@ describe('', () => { await waitFor(() => { expect(screen.queryByText('unchecked')).toBeInTheDocument() }) - expect( - userEvent.click(screen.getByText('checkbox-label')), - ).rejects.toThrow() - await waitFor(() => { - expect(screen.queryByText('unchecked')).toBeInTheDocument() - }) }) it('should pass a ref down', async () => { const ref = { current: null } as RefObject - render() + render() await waitFor(() => { expect(ref.current).toBeInstanceOf(HTMLInputElement) }) diff --git a/components/src/components/molecules/Checkbox/Checkbox.tsx b/components/src/components/molecules/Checkbox/Checkbox.tsx index 12771804..a9cd6602 100644 --- a/components/src/components/molecules/Checkbox/Checkbox.tsx +++ b/components/src/components/molecules/Checkbox/Checkbox.tsx @@ -1,18 +1,19 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { - WithColorStyle, - getColorStyle, -} from '@/src/types/withColorOrColorStyle' +import * as styles from './styles.css' -import { Field } from '../..' -import { FieldBaseProps } from '../../atoms/Field' +import { Field } from '../../atoms/Field/Field' +import type { FieldBaseProps } from '../../atoms/Field/Field' import { getTestId } from '../../../utils/utils' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { getColorStyleParts } from '@/src/utils/getColorStyleParts' +import type { Colors, ColorStyles, Hue } from '@/src/tokens' +import { match } from 'ts-pattern' type NativeInputProps = React.InputHTMLAttributes -type Props = { +export type CheckboxProps = { /** Label content */ label: React.ReactNode /** The name attribute of input element. */ @@ -41,82 +42,79 @@ type Props = { background?: 'white' | 'grey' /** Set the input to readonly mode */ readOnly?: NativeInputProps['readOnly'] + colorStyle?: ColorStyles } & Omit & - Omit< - NativeInputProps, - | 'size' - | 'color' - | 'type' - | 'children' - | 'value' - | 'defaultValue' - | 'type' - | 'aria-invalid' - > & - WithColorStyle -interface InputProps { - $colorStyle: Props['colorStyle'] -} - -const Input = styled.input( - ({ theme, $colorStyle = 'accentPrimary' }) => css` - font: inherit; - display: grid; - position: relative; - place-content: center; - transition: transform 150ms ease-in-out, filter 150ms ease-in-out; - cursor: pointer; - - width: ${theme.space['5']}; - height: ${theme.space['5']}; - border-radius: ${theme.radii.small}; - background-color: ${theme.colors.border}; - - &:checked { - background: ${getColorStyle($colorStyle, 'background')}; - } - - &::before { - content: ''; - background: ${theme.colors.border}; - mask-image: ${`url('data:image/svg+xml; utf8, ')`}; - mask-repeat: no-repeat; - width: ${theme.space['3']}; - height: ${theme.space['3']}; - transition: all 90ms ease-in-out; - } - - &:hover { - transform: translateY(-1px); - } - - &:hover::before, - &:checked::before { - background: ${getColorStyle($colorStyle, 'text')}; - } - - &:disabled { - cursor: not-allowed; - } - - &:disabled::before, - &:disabled:hover::before { - background: ${theme.colors.border}; - } - - &:disabled:checked, - &:disabled:checked:hover { - background: ${theme.colors.border}; - } - - &:disabled:checked::before, - &:disabled:checked:hover::before { - background: ${theme.colors.greyPrimary}; - } - `, +Omit< + NativeInputProps, + | 'size' + | 'color' + | 'type' + | 'children' + | 'value' + | 'defaultValue' + | 'type' + | 'aria-invalid' + | 'height' + | 'width' +> + +const SVG = (props: React.SVGProps) => + +const InputBox = React.forwardRef( + ({ baseColor, baseTheme, disabled, checked, ...props }, ref) => ( + + `${baseColor}Surface` as Colors) + .otherwise(() => `${baseColor}Primary` as Colors), + }} + borderRadius="small" + checked={checked} + className={styles.checkbox} + cursor={{ base: 'pointer', disabled: 'not-allowed' }} + disabled={disabled} + display="grid" + fontFamily="inherit" + placeContent="center" + position="relative" + ref={ref} + transitionProperty="background-color" + transitionDuration={150} + transitionTimingFunction="ease-in-out" + type="checkbox" + wh="full" + {...props} + /> + `${baseColor}Primary` as Colors) + .otherwise(() => `textAccent` as Colors)} + className={styles.icon} + left="1" + width="3" + height="3" + pointerEvents="none" + position="absolute" + top="1" + transitionProperty="stroke" + transitionDuration={150} + transitionTimingFunction="ease-in-out" + wh="full" + /> + + ), ) -export const Checkbox = React.forwardRef( +export const Checkbox = React.forwardRef( ( { description, @@ -138,11 +136,12 @@ export const Checkbox = React.forwardRef( onFocus, colorStyle = 'accentPrimary', ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const defaultRef = React.useRef(null) const inputRef = (ref as React.RefObject) || defaultRef + const [baseColor, baseTheme] = getColorStyleParts(colorStyle) return ( - ) diff --git a/components/src/components/molecules/Checkbox/index.ts b/components/src/components/molecules/Checkbox/index.ts deleted file mode 100644 index ff4dd220..00000000 --- a/components/src/components/molecules/Checkbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Checkbox } from './Checkbox' diff --git a/components/src/components/molecules/Checkbox/styles.css.ts b/components/src/components/molecules/Checkbox/styles.css.ts new file mode 100644 index 00000000..8b754b2a --- /dev/null +++ b/components/src/components/molecules/Checkbox/styles.css.ts @@ -0,0 +1,28 @@ +import { globalStyle, style } from '@vanilla-extract/css' + +import { modeVars } from '@/src/css/theme.css' +import { translateY } from '@/src/css/utils/common' + +export const checkbox = style({}) + +export const icon = style({}) + +globalStyle(`${checkbox}:disabled:checked ~ ${icon}`, { + stroke: `${modeVars.color.greyPrimary} !important`, +}) + +globalStyle(`${checkbox}:disabled:not(:checked) ~ ${icon}`, { + stroke: `${modeVars.color.border} !important`, +}) + +globalStyle(`${checkbox}:not(:checked):not(:hover) ~ ${icon}`, { + stroke: `${modeVars.color.border} !important`, +}) + +export const inputBox = style({ + 'transform': translateY(0), + 'transition': 'transform 150ms ease-in-out', + ':hover': { + transform: translateY(-1), + }, +}) diff --git a/components/src/components/molecules/CheckboxRow/CheckboxRow.test.tsx b/components/src/components/molecules/CheckboxRow/CheckboxRow.test.tsx index f42e66ad..0f7c0975 100644 --- a/components/src/components/molecules/CheckboxRow/CheckboxRow.test.tsx +++ b/components/src/components/molecules/CheckboxRow/CheckboxRow.test.tsx @@ -1,22 +1,14 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { CheckboxRow } from './CheckboxRow' describe('', () => { afterEach(cleanup) it('should render', () => { - render( - - - , - ) + render() expect(screen.getByText('label')).toBeInTheDocument() }) }) diff --git a/components/src/components/molecules/CheckboxRow/CheckboxRow.tsx b/components/src/components/molecules/CheckboxRow/CheckboxRow.tsx index 07d2859e..3e3de6ea 100644 --- a/components/src/components/molecules/CheckboxRow/CheckboxRow.tsx +++ b/components/src/components/molecules/CheckboxRow/CheckboxRow.tsx @@ -1,137 +1,134 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { Hue } from '@/src/tokens' +import { translateY } from '@/src/css/utils/common' +import { match, P } from 'ts-pattern' -import { CheckSVG, Typography } from '../..' +import * as styles from './styles.css' import { useId } from '../../../hooks/useId' - -export type Props = { +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { CheckSVG } from '@/src/icons' +import { Typography } from '../../atoms' +import type { Colors, ColorStyles, Hue } from '@/src/tokens' +import { getColorStyleParts } from '@/src/utils/getColorStyleParts' +import { clsx } from 'clsx' +import { assignInlineVars } from '@vanilla-extract/dynamic' + +export type CheckboxRowProps = { label: string subLabel?: string - color?: Hue -} & React.InputHTMLAttributes - -const Container = styled.div<{ - $color: Hue -}>( - ({ theme, $color }) => css` - position: relative; - width: 100%; - - input ~ label:hover { - transform: translateY(-1px); - } - - input ~ label:hover div#circle { - background: ${theme.colors.border}; - } - - input:checked ~ label { - background: ${theme.colors[`${$color}Surface`]}; - border-color: transparent; - } - - input:disabled ~ label { - cursor: not-allowed; - } - - input:checked ~ label div#circle { - background: ${theme.colors[`${$color}Primary`]}; - border-color: transparent; - } - - input:disabled ~ label div#circle, - input:disabled ~ label:hover div#circle { - background: ${theme.colors.greySurface}; - } - - input:checked ~ label:hover div#circle { - background: ${theme.colors[`${$color}Bright`]}; - } - - input:disabled ~ label, - input:disabled ~ label:hover { - background: ${theme.colors.greySurface}; - transform: initial; - } - - input:disabled ~ label div#circle svg, - input:disabled ~ label:hover div#circle svg { - color: ${theme.colors.greySurface}; - } - - input:disabled:checked ~ label div#circle, - input:disabled:checked ~ label:hover div#circle { - background: ${theme.colors.border}; - } - - input:disabled:checked ~ label div#circle svg, - input:disabled:checked ~ label:hover div#circle svg { - color: ${theme.colors.greyPrimary}; - } - `, + colorStyle?: ColorStyles +} & Omit, 'height' | 'width' | 'color'> + +type BaseTheme = 'Primary' | 'Secondary' + +const ContainerBox = ({ disabled, className, style, ...props }: BoxProps) => ( + ) -const RootInput = styled.input( - () => css` - position: absolute; - width: 1px; - height: 1px; - `, +const Input = React.forwardRef((props, ref) => ( + +)) + +const Label = ({ + baseColor, + baseTheme, + ...props +}: BoxProps & { baseColor?: Hue, baseTheme: BaseTheme }) => ( + `${baseColor}Surface` as Colors) + .otherwise(() => `${baseColor}Surface` as Colors)} + borderColor="transparent" + borderRadius="large" + borderStyle="solid" + borderWidth="1x" + cursor="pointer" + display="flex" + gap="4" + padding="4" + transitionProperty="all" + transitionDuration={300} + transitionTimingFunction="ease-in-out" + wh="full" + /> ) -const Label = styled.label( - ({ theme }) => css` - display: flex; - align-items: center; - gap: ${theme.space['4']}; - - width: 100%; - height: 100%; - padding: ${theme.space['4']}; - - border-radius: ${theme.space['2']}; - border: 1px solid ${theme.colors.border}; - - cursor: pointer; - - transition: all 0.3s ease-in-out; - `, +const CircleFrame = (props: BoxProps) => ( + ) -const Circle = styled.div( - ({ theme }) => css` - display: flex; - align-items: center; - justify-content: center; - - flex: 0 0 ${theme.space['7']}; - width: ${theme.space['7']}; - height: ${theme.space['7']}; - border-radius: ${theme.radii.full}; - border: 1px solid ${theme.colors.border}; - - transition: all 0.3s ease-in-out; - - svg { - display: block; - color: ${theme.colors.backgroundPrimary}; - width: ${theme.space['4']}; - height: ${theme.space['4']}; - } - `, +const SVG = (props: BoxProps) => ( + ) -const Content = styled.div( - () => css` - display: flex; - flex-direction: column; - `, +const Circle = ({ + $hover, + baseColor, + baseTheme, + ...props +}: BoxProps & { $hover: boolean, baseColor: Hue, baseTheme: BaseTheme }) => ( + `${baseColor}Light` as Colors) + .otherwise(() => ($hover ? `${baseColor}Bright` : `${baseColor}Primary`) as Colors)} + borderColor="transparent" + borderRadius="full" + borderStyle="solid" + borderWidth="1x" + // color={getValueForColorStyle($colorStyle, 'svg')} + color={match(baseTheme) + .with(P.string.includes('Secondary'), () => `${baseColor}Dim` as Colors) + .otherwise(() => 'textAccent')} + display="flex" + justifyContent="center" + position="absolute" + transitionProperty="all" + transitionDuration={300} + transitionTimingFunction="ease-in-out" + wh="full" + > + + ) -export const CheckboxRow = React.forwardRef( - ({ label, subLabel, name, color = 'blue', disabled, ...props }, ref) => { +export const CheckboxRow = React.forwardRef( + ({ label, subLabel, name, colorStyle = 'blue', disabled, ...props }, ref) => { const defaultRef = React.useRef(null) const inputRef = ref || defaultRef @@ -139,21 +136,42 @@ export const CheckboxRow = React.forwardRef( const textColor = disabled ? 'grey' : 'text' + const [baseColor, baseTheme] = getColorStyleParts(colorStyle) + return ( - - + -
)} - +
- + ) }, ) diff --git a/components/src/components/molecules/CheckboxRow/index.ts b/components/src/components/molecules/CheckboxRow/index.ts deleted file mode 100644 index 198cd27a..00000000 --- a/components/src/components/molecules/CheckboxRow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CheckboxRow } from './CheckboxRow' -export type { Props as CheckboxRowProps } from './CheckboxRow' diff --git a/components/src/components/molecules/CheckboxRow/styles.css.ts b/components/src/components/molecules/CheckboxRow/styles.css.ts new file mode 100644 index 00000000..25de80b2 --- /dev/null +++ b/components/src/components/molecules/CheckboxRow/styles.css.ts @@ -0,0 +1,66 @@ +import { createVar, globalStyle, style } from '@vanilla-extract/css' + +import { modeVars } from '@/src/css/theme.css' +import { translateY } from '@/src/css/utils/common' + +export const input = style({}) + +export const label = style({}) + +export const circle = style({}) + +export const circleHover = style({}) + +globalStyle(`${input}:not(:disabled) ~ ${label}:hover ${circle}`, { + opacity: 0, +}) + +globalStyle(`${input}:not(:checked) ~ ${label}`, { + borderColor: modeVars.color.border, + backgroundColor: modeVars.color.backgroundPrimary, +}) + +globalStyle(`${input}:not(:checked) ~ ${label} ${circle}`, { + backgroundColor: modeVars.color.backgroundPrimary, + color: modeVars.color.backgroundPrimary, + borderColor: modeVars.color.border, +}) + +globalStyle(`${input}:not(:checked) ~ ${label} ${circleHover}`, { + backgroundColor: modeVars.color.border, + color: modeVars.color.textAccent, + borderColor: modeVars.color.border, +}) + +globalStyle(`${input}:disabled:not(:checked) ~ ${label}`, { + borderColor: modeVars.color.border, + backgroundColor: modeVars.color.greySurface, + color: modeVars.color.greyPrimary, +}) + +globalStyle(`${input}:disabled:not(:checked) ~ ${label} ${circle}`, { + backgroundColor: modeVars.color.greySurface, + color: modeVars.color.greySurface, + borderColor: modeVars.color.border, +}) + +globalStyle(`${input}:disabled:checked ~ ${label}`, { + borderColor: 'transparent', + backgroundColor: modeVars.color.greySurface, + color: modeVars.color.greyPrimary, +}) + +globalStyle(`${input}:disabled:checked ~ ${label} ${circle}`, { + backgroundColor: modeVars.color.border, + color: modeVars.color.greyPrimary, + borderColor: 'transparent', +}) + +export const isContainerBoxDisabled = createVar() + +export const containerBox = style({ + 'transform': translateY(0), + ':hover': { + transform: isContainerBoxDisabled, + }, +}) diff --git a/components/src/components/molecules/CountdownCircle/CountdownCircle.test.tsx b/components/src/components/molecules/CountdownCircle/CountdownCircle.test.tsx index b3b985aa..876a9cb1 100644 --- a/components/src/components/molecules/CountdownCircle/CountdownCircle.test.tsx +++ b/components/src/components/molecules/CountdownCircle/CountdownCircle.test.tsx @@ -1,76 +1,54 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { act, cleanup, render, screen } from '@/test' -import { lightTheme } from '@/src/tokens' - import { CountdownCircle } from './CountdownCircle' const advanceTime = (ms: number) => { act(() => { - jest.advanceTimersByTime(ms) + vi.advanceTimersByTime(ms) }) } describe('', () => { beforeAll(() => { - jest.useFakeTimers() + vi.useFakeTimers() }) afterAll(() => { - jest.useRealTimers() + vi.useRealTimers() }) afterEach(cleanup) it('renders', () => { - render( - - - , - ) + render() advanceTime(10000) expect(screen.getByTestId('countdown-circle')).toBeInTheDocument() }) it('should countdown starting from supplied value', () => { - render( - - - , - ) + render() advanceTime(1000) expect(screen.queryByText('9')).toBeInTheDocument() }) it('should not countdown if disabled', () => { - render( - - - , - ) + render() advanceTime(1000) expect(screen.queryByText('10')).toBeInTheDocument() }) it('should call callback on 0', () => { - const mockCallback = jest.fn() - render( - - - , - ) + const mockCallback = vi.fn() + render() advanceTime(1000) expect(mockCallback).toHaveBeenCalled() }) it('should use startTimestamp if provided', () => { render( - - - , + , ) advanceTime(5000) expect(screen.queryByTestId('countdown-complete-check')).toBeInTheDocument() diff --git a/components/src/components/molecules/CountdownCircle/CountdownCircle.tsx b/components/src/components/molecules/CountdownCircle/CountdownCircle.tsx index a5db05b0..112da703 100644 --- a/components/src/components/molecules/CountdownCircle/CountdownCircle.tsx +++ b/components/src/components/molecules/CountdownCircle/CountdownCircle.tsx @@ -1,145 +1,114 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { Colors } from '@/src/tokens' +import { P, match } from 'ts-pattern' import { CheckSVG } from '@/src/icons' -import { VisuallyHidden } from '../..' import { getTestId } from '../../../utils/utils' - -const CountDownContainer = styled.div( - () => css` - position: relative; - `, -) - -interface NumberBox { - $disabled?: boolean - $size: 'small' | 'large' -} - -const NumberBox = styled.div( - ({ theme, $disabled, $size }) => css` - position: absolute; - display: flex; - align-items: center; - justify-content: center; - font-weight: ${theme.fontWeights.extraBold}; - - color: ${theme.colors.accent}; - - ${$disabled && - css` - color: ${theme.colors.greyLight}; - `} - - #countdown-complete-check { - stroke-width: ${theme.borderWidths['1.5']}; - overflow: visible; - display: block; - } - - ${() => { - switch ($size) { - case 'small': - return css` - height: ${theme.space['16']}; - width: ${theme.space['16']}; - ` - case 'large': - return css` - font-size: ${theme.fontSizes.extraLarge}; - line-height: ${theme.lineHeights.extraLarge}; - margin-top: -${theme.space['0.5']}; - height: ${theme.space['24']}; - width: ${theme.space['24']}; - ` - default: - return `` - } - }} - `, +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { VisuallyHidden } from '../../atoms' +import type { Color } from '@/src/tokens/color' +import clsx from 'clsx' +import * as styles from './styles.css' + +const NumberBox = ({ + $size, + $color, + disabled, + className, + ...props +}: BoxProps & { $size: 'small' | 'large', $color: Color }) => ( + ) -interface ContainerProps { - $disabled?: boolean - $size: 'small' | 'large' - $color: Colors +const ContainerBox = React.forwardRef< + HTMLElement, + BoxProps & { + $size: 'small' | 'large' + $color: Color + disabled?: boolean + } +>(({ $size, $color, disabled, ...props }, ref) => ( + +)) + +const Circle = ({ + $progress, + disabled, + ...props +}: BoxProps & { + $progress?: number +}) => { + const showProgress = typeof $progress === 'number' && !disabled + const strokeDashArray = showProgress + ? `${48 * ($progress ?? 1)}, 56` + : '100, 100' + const opacity = showProgress || disabled ? '1' : '0.25' + return ( + ( + typeof x === 'number' && x <= 0)], + () => '0', + ) + .otherwise(() => '4')} + /> + )} + className={styles.circle} + /> + ) } -const Container = styled.div( - ({ theme, $disabled, $size, $color }) => css` - stroke: ${theme.colors.accent}; - - color: ${theme.colors[$color]}; - - ${$disabled && - css` - color: ${theme.colors.greyLight}; - `} - - ${() => { - switch ($size) { - case 'small': - return css` - height: ${theme.space['16']}; - width: ${theme.space['16']}; - stroke-width: ${theme.space['1']}; - ` - case 'large': - return css` - height: ${theme.space['24']}; - width: ${theme.space['24']}; - stroke-width: ${theme.space['1']}; - ` - default: - return `` - } - }} - `, -) - -interface CircleProps { - $finished: boolean -} - -const Circle = styled.circle( - ({ $finished }) => css` - transition: all 1s linear, stroke-width 0.2s ease-in-out 1s; - - ${$finished && - css` - stroke-width: 0; - `} - `, -) - -type NativeDivProps = React.HTMLAttributes - -type Props = { +export type CountdownCircleProps = { accessibilityLabel?: string - color?: Colors + color?: Color startTimestamp?: number countdownSeconds: number disabled?: boolean callback?: () => void size?: 'small' | 'large' -} & Omit +} & Omit -export const CountdownCircle = React.forwardRef( +export const CountdownCircle = React.forwardRef( ( { accessibilityLabel, - color = 'textSecondary', + color = 'accent', size = 'small', countdownSeconds, startTimestamp, disabled, callback, ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const _startTimestamp = React.useMemo( () => Math.ceil((startTimestamp || Date.now()) / 1000), @@ -163,7 +132,7 @@ export const CountdownCircle = React.forwardRef( const currentSeconds = calculateCurrentCount() if (currentSeconds === 0) { clearInterval(countInterval) - callback && callback() + if (callback) callback() } setCurrentCount(currentSeconds) }, 1000) @@ -172,49 +141,41 @@ export const CountdownCircle = React.forwardRef( }, [calculateCurrentCount, callback, countdownSeconds, disabled]) return ( - - - {disabled && countdownSeconds} - {!disabled && - (currentCount > 0 ? ( - currentCount - ) : ( - + {match([!!disabled, !!currentCount]) + .with([true, P._], () => countdownSeconds) + .with([false, true], () => currentCount) + .with([false, false], () => ( + - ))} + )) + .exhaustive()} - + {accessibilityLabel && ( {accessibilityLabel} )} - + - - + + ) }, ) diff --git a/components/src/components/molecules/CountdownCircle/index.ts b/components/src/components/molecules/CountdownCircle/index.ts deleted file mode 100644 index 64942601..00000000 --- a/components/src/components/molecules/CountdownCircle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CountdownCircle } from './CountdownCircle' diff --git a/components/src/components/molecules/CountdownCircle/styles.css.ts b/components/src/components/molecules/CountdownCircle/styles.css.ts new file mode 100644 index 00000000..177a85a0 --- /dev/null +++ b/components/src/components/molecules/CountdownCircle/styles.css.ts @@ -0,0 +1,26 @@ +import { sprinkles } from '@/src/css/sprinkles.css' +import { style } from '@vanilla-extract/css' +import { recipe } from '@vanilla-extract/recipes' + +export const variants = recipe({ + variants: { + size: { + small: sprinkles({ + fontSize: 'body', + lineHeight: 'body', + marginTop: '0', + wh: '16', + }), + large: sprinkles({ + fontSize: 'extraLarge', + lineHeight: 'extraLarge', + marginTop: '-0.5', + wh: '24', + }), + }, + }, +}) + +export const circle = style({ + transition: 'all 1s linear, stroke-width 0.2s ease-in-out 1s', +}) diff --git a/components/src/components/molecules/CurrencyToggle/CurrencyToggle.test.tsx b/components/src/components/molecules/CurrencyToggle/CurrencyToggle.test.tsx index 6cf5e961..df453120 100644 --- a/components/src/components/molecules/CurrencyToggle/CurrencyToggle.test.tsx +++ b/components/src/components/molecules/CurrencyToggle/CurrencyToggle.test.tsx @@ -1,21 +1,13 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render } from '@/test' -import { lightTheme } from '@/src/tokens' - import { CurrencyToggle } from './CurrencyToggle' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - - , - ) + render() }) }) diff --git a/components/src/components/molecules/CurrencyToggle/CurrencyToggle.tsx b/components/src/components/molecules/CurrencyToggle/CurrencyToggle.tsx index 9c0afcc1..25f15692 100644 --- a/components/src/components/molecules/CurrencyToggle/CurrencyToggle.tsx +++ b/components/src/components/molecules/CurrencyToggle/CurrencyToggle.tsx @@ -1,169 +1,105 @@ import * as React from 'react' -import styled, { css } from 'styled-components' - -import type { Space } from '@/src/tokens' import { useId } from '../../../hooks/useId' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { getValuesForKnob } from './utils/getValuesForKnob' +import * as styles from './styles.css' +import { getValueForCheckbox } from './utils/getValueForCheckBox' +import type { Color } from '@/src/tokens/color' -type Size = 'extraSmall' | 'small' | 'medium' +export type Size = 'extraSmall' | 'small' | 'medium' -export type Props = { +export type CurrencyToggleProps = { size?: Size fiat?: string -} & Omit, 'size'> - -const CONTAINER_SIZES: { - [key in Size]: { - width: Space - height: Space - } -} = { - extraSmall: { - width: '22.5', - height: '7', - }, - small: { - width: '26', - height: '10', - }, - medium: { - width: '32', - height: '12', - }, -} - -const KNOB_SIZES: { - [key in Size]: { - width: Space - height: Space - translateX: Space - } -} = { - extraSmall: { - width: '10', - height: '5.5', - translateX: '5', - }, - small: { - width: '12', - height: '8', - translateX: '6', - }, - medium: { - width: '15', - height: '10', - translateX: '7.5', - }, -} - -const Container = styled.div<{ $size: Size }>( - ({ theme, $size }) => css` - position: relative; - width: fit-content; - - label { - position: absolute; - left: 50%; - top: 50%; - width: ${theme.space[KNOB_SIZES[$size].width]}; - height: ${theme.space[KNOB_SIZES[$size].height]}; - font-size: ${theme.fontSizes.small}; - font-weight: ${$size === 'extraSmall' - ? theme.fontWeights.normal - : theme.fontWeights.bold}; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.1s linear; - cursor: pointer; - } - - label#eth { - color: ${theme.colors.textAccent}; - transform: translate(-50%, -50%) - translateX(-${theme.space[KNOB_SIZES[$size].translateX]}); - } - - label#fiat { - color: ${theme.colors.greyPrimary}; - transform: translate(-50%, -50%) - translateX(${theme.space[KNOB_SIZES[$size].translateX]}); - } - - input[type='checkbox']:checked ~ label#eth { - color: ${theme.colors.greyPrimary}; - } - - input[type='checkbox']:checked ~ label#fiat { - color: ${theme.colors.textAccent}; - } - - input[type='checkbox']:disabled ~ label#eth { - color: ${theme.colors.backgroundPrimary}; - } - - input[type='checkbox']:disabled ~ label#fiat { - color: ${theme.colors.greyPrimary}; - } - - input[type='checkbox']:disabled:checked ~ label#fiat { - color: ${theme.colors.backgroundPrimary}; - } - - input[type='checkbox']:disabled:checked ~ label#eth { - color: ${theme.colors.greyPrimary}; - } - - input[type='checkbox']:disabled ~ label { - cursor: not-allowed; - } - `, + color?: Color +} & Omit, 'size' | 'height' | 'width'> + +const Container = (props: BoxProps) => ( + ) -const InputComponent = styled.input<{ $size?: Size }>( - ({ theme, $size = 'medium' }) => css` - position: relative; - background-color: ${theme.colors.greySurface}; - height: ${theme.space[CONTAINER_SIZES[$size].height]}; - width: ${theme.space[CONTAINER_SIZES[$size].width]}; - border-radius: ${$size === 'extraSmall' - ? theme.radii.full - : theme.radii.large}; - - display: flex; - align-items: center; - justify-content: center; - - &::after { - content: ''; - display: block; - position: absolute; - background-color: ${theme.colors.bluePrimary}; - width: ${theme.space[KNOB_SIZES[$size].width]}; - height: ${theme.space[KNOB_SIZES[$size].height]}; - border-radius: ${$size === 'extraSmall' - ? theme.radii.full - : theme.space['1.5']}; - transform: translateX(-${theme.space[KNOB_SIZES[$size].translateX]}); - transition: transform 0.3s ease-in-out, background-color 0.1s ease-in-out; - } +const Label = ({ + $size, + ...props +}: BoxProps & { $size: Size }) => ( + +) - &:checked::after { - transform: translateX(${theme.space[KNOB_SIZES[$size].translateX]}); - } +const Checkbox = React.forwardRef( + ({ $size, ...props }, ref) => ( + + ), +) - &:disabled::after { - background-color: ${theme.colors.greyPrimary}; - } - `, +const Slider = ({ + $size, + $color, + ...props +}: { $size: Size, $color: Color }) => ( + ) -export const CurrencyToggle = React.forwardRef( - ({ size = 'medium', disabled, fiat = 'usd', ...props }, ref) => { +export const CurrencyToggle = React.forwardRef( + ( + { size = 'medium', color = 'accent', disabled, fiat = 'usd', ...props }, + ref, + ) => { const id = useId() return ( - - + ( {...props} $size={size} /> - ) }, diff --git a/components/src/components/molecules/CurrencyToggle/index.ts b/components/src/components/molecules/CurrencyToggle/index.ts deleted file mode 100644 index 0a0b0e0b..00000000 --- a/components/src/components/molecules/CurrencyToggle/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CurrencyToggle } from './CurrencyToggle' -export type { Props as CurrencyToggleProps } from './CurrencyToggle' diff --git a/components/src/components/molecules/CurrencyToggle/styles.css.ts b/components/src/components/molecules/CurrencyToggle/styles.css.ts new file mode 100644 index 00000000..e1a11e60 --- /dev/null +++ b/components/src/components/molecules/CurrencyToggle/styles.css.ts @@ -0,0 +1,38 @@ +import { globalStyle, style } from '@vanilla-extract/css' + +import { modeVars } from '@/src/css/theme.css' + +export const labelEth = style({ + transform: 'translate(-100%, -50%)', +}) + +export const labelFiat = style({ + transform: 'translate(0%, -50%)', +}) + +export const checkbox = style({}) + +export const slider = style({ + transition: 'transform 0.3s ease-in-out, background-color 0.1s ease-in-out', + transform: 'translate(-50%, -50%)', +}) + +globalStyle(`${checkbox}:not(:checked) ~ ${slider}`, { + transform: 'translate(-100%, -50%)', +}) + +globalStyle(`${checkbox}:checked ~ ${slider}`, { + transform: 'translate(0%, -50%)', +}) + +globalStyle(`${checkbox}:disabled ~ ${slider}`, { + backgroundColor: modeVars.color.greyPrimary, +}) + +globalStyle(`${checkbox}:checked ~ ${labelEth}`, { + color: modeVars.color.greyPrimary, +}) + +globalStyle(`${checkbox}:not(:checked) ~ ${labelFiat}`, { + color: modeVars.color.greyPrimary, +}) diff --git a/components/src/components/molecules/CurrencyToggle/utils/constants.ts b/components/src/components/molecules/CurrencyToggle/utils/constants.ts new file mode 100644 index 00000000..55a9d0c6 --- /dev/null +++ b/components/src/components/molecules/CurrencyToggle/utils/constants.ts @@ -0,0 +1,46 @@ +import type { Space } from '@/src/tokens' +import type { Size } from '../CurrencyToggle' + +export const CONTAINER_SIZES: { + [key in Size]: { + width: string + height: string + } +} = { + extraSmall: { + width: '22.5', + height: '7', + }, + small: { + width: '26', + height: '10', + }, + medium: { + width: '32', + height: '12', + }, +} + +export const KNOB_SIZES: { + [key in Size]: { + width: Space + height: Space + translateX: Space + } +} = { + extraSmall: { + width: '10', + height: '5.5', + translateX: '5', + }, + small: { + width: '12', + height: '8', + translateX: '6', + }, + medium: { + width: '15', + height: '10', + translateX: '7.5', + }, +} as const diff --git a/components/src/components/molecules/CurrencyToggle/utils/getValueForCheckBox.ts b/components/src/components/molecules/CurrencyToggle/utils/getValueForCheckBox.ts new file mode 100644 index 00000000..3e7705bb --- /dev/null +++ b/components/src/components/molecules/CurrencyToggle/utils/getValueForCheckBox.ts @@ -0,0 +1,33 @@ +import type { Sprinkles } from '@/src/css/sprinkles.css' +import type { Size } from '../CurrencyToggle' +import type { Space } from '@/src/tokens' + +type Properties = { + width: Space + height: Space + borderRadius: Sprinkles['borderRadius'] +} + +type Property = keyof Properties + +const checkBoxValues: { [key in Size]: Properties } = { + extraSmall: { + width: '22.5', + height: '7', + borderRadius: 'full', + }, + small: { + width: '26', + height: '10', + borderRadius: 'large', + }, + medium: { + width: '32', + height: '12', + borderRadius: 'large', + }, +} + +export const getValueForCheckbox = (size: Size, property: T): Properties[T] => { + return (checkBoxValues[size][property] || checkBoxValues.extraSmall[property]) as Properties[T] +} diff --git a/components/src/components/molecules/CurrencyToggle/utils/getValuesForKnob.ts b/components/src/components/molecules/CurrencyToggle/utils/getValuesForKnob.ts new file mode 100644 index 00000000..d92ab128 --- /dev/null +++ b/components/src/components/molecules/CurrencyToggle/utils/getValuesForKnob.ts @@ -0,0 +1,13 @@ +import type { Size } from '../CurrencyToggle' +import { KNOB_SIZES } from './constants' +import type { Space } from '@/src/tokens' + +type Property = 'width' | 'height' | 'translateX' + +export const getValuesForKnob = (size: Size, property: Property): Space => { + const value + = KNOB_SIZES[size]?.[property] || (KNOB_SIZES.small[property]) + if (property === 'translateX') + return value + return value +} diff --git a/components/src/components/molecules/Dropdown/ActionSheet.tsx b/components/src/components/molecules/Dropdown/ActionSheet.tsx index 679e1824..be6ff02d 100644 --- a/components/src/components/molecules/Dropdown/ActionSheet.tsx +++ b/components/src/components/molecules/Dropdown/ActionSheet.tsx @@ -1,59 +1,59 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { Colors, breakpoints } from '@/src/tokens' +import { breakpoints } from '@/src/tokens' + +import { actionSheeItem } from './styles.css' -import { Button, Modal, Typography } from '../..' import type { DropdownItem, DropdownItemObject } from './Dropdown' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { Typography, Button } from '../../atoms' +import { Modal } from '../Modal/Modal' -const ActionSheetContent = styled.div( - ({ theme }) => css` - width: 100%; - flex-direction: column; - padding: ${theme.space['2.5']}; - gap: ${theme.space['2.5']}; - display: flex; - `, +const ActionSheetContent = React.forwardRef( + (props, ref) => ( + + ), ) -const ActionSheetOptions = styled.div( - ({ theme }) => css` - border-radius: ${theme.radii['large']}; - width: ${theme.space['full']}; - text-align: center; - display: flex; - flex-direction: column; - gap: ${theme.space.px}; - `, +const ActionSheetOptions = (props: BoxProps) => ( + ) -const ActionSheetItem = styled.div( - ({ theme }) => css` - display: flex; - justify-content: center; - align-items: center; - gap: ${theme.space['2']}; - width: 100%; - padding: 20px; - position: relative; - background: ${theme.colors.backgroundPrimary}; - - &:first-child { - border-top-left-radius: ${theme.radii['large']}; - border-top-right-radius: ${theme.radii['large']}; - } - &:last-child { - border-bottom-left-radius: ${theme.radii['large']}; - border-bottom-right-radius: ${theme.radii['large']}; - } - `, +const ActionSheetItem = (props: BoxProps) => ( + ) -const ActionSheetLinkItem = styled.a<{ $color?: Colors }>( - ({ theme, $color = 'text' }) => css` - color: ${theme.colors[$color]}; - font-weight: ${theme.fontWeights.normal}; - `, +const ActionSheetLinkItem = (props: BoxProps) => ( + ) type Props = { @@ -63,7 +63,7 @@ type Props = { setIsOpen: React.Dispatch> DropdownChild: React.FC<{ setIsOpen: (isOpen: boolean) => void - item: React.ReactElement> + item: React.ReactElement> }> cancelLabel?: string } @@ -86,8 +86,8 @@ export const ActionSheet = React.forwardRef( return DropdownChild({ item, setIsOpen }) } - const { icon, label, onClick, value, href, color } = - item as DropdownItemObject + const { icon, label, onClick, value, href, color } + = item as DropdownItemObject return ( ( setIsOpen(false) }} > - {icon} - {href ? ( - - {label} - - ) : ( - {label} - )} + + {href + ? ( + + {label} + + ) + : ( + {label} + )} ) })} diff --git a/components/src/components/molecules/Dropdown/Dropdown.test.tsx b/components/src/components/molecules/Dropdown/Dropdown.test.tsx index 0c0f41d0..60c0d3a4 100644 --- a/components/src/components/molecules/Dropdown/Dropdown.test.tsx +++ b/components/src/components/molecules/Dropdown/Dropdown.test.tsx @@ -1,11 +1,8 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - -import { PointerEventsCheckLevel } from '@testing-library/user-event' - import { cleanup, + getPropertyValue, makeMockIntersectionObserver, render, screen, @@ -13,34 +10,34 @@ import { waitFor, } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Dropdown } from './Dropdown' +import { PointerEventsCheckLevel } from '@testing-library/user-event' + +const getVisibilityValue = () => + getPropertyValue(screen.getByTestId('popoverContainer'), 'visibility') const DropdownHelper = ({ mockCallback, children, ...props }: any) => { return ( - -
-
outside
- null, color: 'red' }, - ], - ...props, - }} - > - {children} - -
-
+
+
outside
+ null, color: 'red' }, + ], + ...props, + }} + > + {children} + +
) } -const mockIntersectionObserverCls = jest.fn() -const mockObserve = jest.fn() -const mockDisconnect = jest.fn() +const mockIntersectionObserverCls = vi.fn() +const mockObserve = vi.fn() +const mockDisconnect = vi.fn() const mockIntersectionObserver = makeMockIntersectionObserver( mockIntersectionObserverCls, @@ -65,7 +62,7 @@ describe('', () => { }) it('should call dropdown item callback when clicked', async () => { - const mockCallback = jest.fn() + const mockCallback = vi.fn() render() await userEvent.click(screen.getByText('Menu')) await waitFor(() => expect(screen.getByText('Dashboard')).toBeVisible()) @@ -79,26 +76,33 @@ describe('', () => { it('should close if clicking outside of dropdown', async () => { render() + + expect(getVisibilityValue()).toEqual('hidden') + await userEvent.click(screen.getByText('Menu')) await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeVisible() + expect(getVisibilityValue()).toBe('visible') }) - await userEvent.click(screen.getByText('outside')) + await userEvent.click(screen.getByText('outside')) await waitFor(() => { - expect(screen.queryByText('Dashboard')).not.toBeVisible() + expect(getVisibilityValue()).toBe('hidden') }) }) it('should close dropdown if button is clicked when open', async () => { render() - await await userEvent.click(screen.getByText('Menu')) + expect(getVisibilityValue()).toEqual('hidden') + + await userEvent.click(screen.getByText('Menu')) await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeVisible() + expect(getVisibilityValue()).toBe('visible') }) - await await userEvent.click(screen.getByText('Menu')) - expect(screen.queryByText('Dashboard')).not.toBeVisible() + await userEvent.click(screen.getByText('Menu')) + await waitFor(() => { + expect(getVisibilityValue()).toBe('hidden') + }) }) it('should render custom element when passed in', () => { @@ -116,21 +120,24 @@ describe('', () => { , ) - await await userEvent.click(screen.getByText('custom')) + expect(getVisibilityValue()).toEqual('hidden') + + await userEvent.click(screen.getByText('custom')) await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeVisible() + expect(getVisibilityValue()).toBe('visible') }) - await await userEvent.click(screen.getByText('custom')) - expect(screen.queryByText('Dashboard')).not.toBeVisible() + + await userEvent.click(screen.getByText('custom')) + await waitFor(() => expect(getVisibilityValue()).toBe('hidden')) }) it('should not error if no dropdown items are passed in', () => { render( - - {/*eslint-disable-next-line @typescript-eslint/ban-ts-comment*/} - {/*@ts-ignore*/} + <> + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} - , + , ) expect(screen.getByTestId('dropdown-btn')).toBeInTheDocument() }) diff --git a/components/src/components/molecules/Dropdown/Dropdown.tsx b/components/src/components/molecules/Dropdown/Dropdown.tsx index bdff8828..76ad1458 100644 --- a/components/src/components/molecules/Dropdown/Dropdown.tsx +++ b/components/src/components/molecules/Dropdown/Dropdown.tsx @@ -1,15 +1,27 @@ import * as React from 'react' -import styled, { DefaultTheme, css, useTheme } from 'styled-components' import { P, match } from 'ts-pattern' -import { debounce } from 'lodash' -import { TransitionState } from 'react-transition-state' +import type { TransitionState } from 'react-transition-state' -import { Button, ButtonProps } from '@/src/components/atoms/Button' -import { Colors, breakpoints } from '@/src/tokens' +import type { ButtonProps } from '@/src/components/atoms/Button/Button' +import { Button } from '@/src/components/atoms/Button/Button' +import type { Colors } from '@/src/tokens' +import { breakpoints } from '@/src/tokens' + +import { modeVars } from '@/src/css/theme.css' -import { DownChevronSVG, DynamicPopover, ScrollBox } from '../..' import { ActionSheet } from './ActionSheet' +import type { AsProp, BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import type { PopoverProps } from '../../atoms/DynamicPopover/DynamicPopover' +import { DynamicPopover } from '../../atoms/DynamicPopover/DynamicPopover' +import { debounce } from '@/src/utils/debounce' +import { DownChevronSVG } from '@/src/icons' +import { ScrollBox } from '../../atoms/ScrollBox/ScrollBox' +import * as styles from './styles.css' +import { clsx } from 'clsx' +import { assignInlineVars } from '@vanilla-extract/dynamic' +import type { Color } from '@/src/tokens/color' type Align = 'left' | 'right' type LabelAlign = 'flex-start' | 'flex-end' | 'center' @@ -19,9 +31,9 @@ export type DropdownItemObject = { label: string onClick?: (value?: string) => void wrapper?: (children: React.ReactNode, key: React.Key) => JSX.Element - icon?: React.ReactNode + icon?: AsProp value?: string - color?: Colors + color?: Color disabled?: boolean showIndicator?: boolean | Colors href?: string @@ -31,7 +43,7 @@ export type DropdownItem = | DropdownItemObject | React.ReactElement> -type Props = { +export type DropdownProps = { /** An optional custom dropdown button */ children?: React.ReactNode /** The props passed to the button for the dropdown */ @@ -78,18 +90,18 @@ type PropsWithoutIsOpen = { setIsOpen?: never } -type NativeDivProps = React.HTMLAttributes +type NativeDivProps = Omit, 'height' | 'width' | 'color'> type DropdownMenuProps = { items: DropdownItem[] setIsOpen: (isOpen: boolean) => void - width?: number | string shortThrow: boolean labelAlign?: LabelAlign direction: Direction state?: TransitionState['status'] height?: string | number -} & NativeDivProps +} & NativeDivProps & +PopoverProps type DropdownMenuContainerProps = { $shortThrow: boolean @@ -97,154 +109,131 @@ type DropdownMenuContainerProps = { $state?: TransitionState['status'] } -type DropdownMenuInnerProps = { - $labelAlign?: LabelAlign -} - -const DropdownMenuContainer = styled.div( - ({ theme, $shortThrow, $direction, $state }) => css` - padding: ${theme.space['1.5']}; - width: 100%; - - ${$direction === 'up' && - css` - bottom: 100%; - `} - - z-index: 0; - opacity: 0; - - ${$state === 'entered' && - css` - z-index: 1; - `} - - background-color: ${theme.colors.background}; - border-radius: ${theme.radii['2xLarge']}; - - border: 1px solid ${theme.colors.border}; - transition: all 0.35s cubic-bezier(1, 0, 0.22, 1.6); - - ${$direction === 'down' && - css` - margin-top: ${theme.space['1.5']}; - `} - ${$direction === 'up' && - css` - margin-bottom: ${theme.space['1.5']}; - `} - transform: translateY( - calc(${$direction === 'down' ? '-1' : '1'} * ${theme.space['12']}) - ); - - ${$shortThrow && - css` - transform: translateY( - calc(${$direction === 'down' ? '-1' : '1'} * ${theme.space['2.5']}) - ); - `} - - ${($state === 'entering' || $state === 'entered') && - css` - transform: translateY(0); - opacity: 1; - `} - `, -) - -const dropdownInnerStyles = ({ - theme, - $labelAlign, -}: DropdownMenuInnerProps & { theme: DefaultTheme }) => css` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: ${theme.space['1']}; - width: 100%; - - ${$labelAlign && - css` - & > * { - justify-content: ${$labelAlign}; - } - `} -` - -const DropdownMenuInner = - styled.div(dropdownInnerStyles) - -const StyledScrollBox = styled(ScrollBox)( - dropdownInnerStyles, - ({ theme }) => css` - padding-right: ${theme.space['1']}; - `, -) +const DropdownMenuBox = React.forwardRef< + HTMLElement, + BoxProps & DropdownMenuContainerProps +>(({ $shortThrow, $direction, $state, ...props }, ref) => { + const transformVariant = match([$state, $direction, $shortThrow]) + .with([P.union('entering', 'entered'), P._, P._], () => 'enteringOrEntered' as const) + .with( + [P._, 'up', true], + () => 'upShort' as const, + ) + .with( + [P._, 'down', true], + () => 'downShort' as const, + ) + .with( + [P._, 'up', false], + () => 'upLong' as const, + ) + .with( + [P._, 'down', false], + () => 'downLong' as const, + ) + .exhaustive() + return ( + `translateY(0)`) + // .with( + // [P._, 'up', true], + // () => `translateY(calc(${commonVars.space['2.5']}))`, + // ) + // .with( + // [P._, 'down', true], + // () => `translateY(calc(-1 * ${commonVars.space['2.5']}))`, + // ) + // .with( + // [P._, 'up', false], + // () => `translateY(calc(${commonVars.space['12']}))`, + // ) + // .with( + // [P._, 'down', false], + // () => `translateY(calc(-1 * ${commonVars.space['12']}))`, + // ) + // .exhaustive()} + // transition="all .35s cubic-bezier(1, 0, 0.22, 1.6)" + width="full" + zIndex={1} + /> + ) +}) interface MenuButtonProps { - $color?: Colors + $color?: Color + $icon?: AsProp $showIndicator?: boolean | Colors } -const MenuButton = styled.button( - ({ theme, $color, disabled, $showIndicator }) => css` - align-items: center; - cursor: pointer; - display: flex; - gap: ${theme.space['2']}; - width: ${theme.space['full']}; - height: ${theme.space['12']}; - padding: ${theme.space['3']}; - border-radius: ${theme.radii.large}; - font-weight: ${theme.fontWeights.normal}; - transition-duration: 0.15s; - transition-property: color, transform, filter; - transition-timing-function: ease-in-out; - - &:active { - transform: translateY(0px); - filter: brightness(1); - } - - color: ${theme.colors[$color || 'textPrimary']}; - - svg { - min-width: ${theme.space['4']}; - width: ${theme.space['4']}; - height: ${theme.space['4']}; - color: ${theme.colors[$color || 'text']}; - } - ${disabled && - css` - color: ${theme.colors.textTertiary}; - cursor: not-allowed; - `} - - justify-content: flex-start; - - &:hover { - background: ${theme.colors.greySurface}; - } - - ${$showIndicator && - css` - position: relative; - padding-right: ${theme.space['6']}; - &::after { - position: absolute; - content: ''; - top: 50%; - right: ${theme.space['3']}; - transform: translateY(-50%); - width: ${theme.space['2']}; - height: ${theme.space['2']}; - border-radius: ${theme.radii.full}; - background: ${theme.colors[ - typeof $showIndicator === 'boolean' ? 'accent' : $showIndicator - ]}; +const MenuButton = React.forwardRef( + ({ $color, $icon, $showIndicator, disabled, children, className, ...props }, ref) => ( + + {$icon + ? ( + + ) + : null} + {children} + {$showIndicator && ( + + )} + + ), ) const DropdownChild: React.FC<{ @@ -275,10 +264,13 @@ const DropdownMenu = React.forwardRef( items, setIsOpen, shortThrow, - labelAlign, direction, state, height, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + placement: _placement, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mobilePlacement: _mobilePlacement, ...props }, ref, @@ -300,21 +292,16 @@ const DropdownMenu = React.forwardRef( } = item as DropdownItemObject const props = { - $hasColor: !!color, $color: color, $showIndicator: showIndicator, + $icon: icon, disabled, onClick: () => { onClick?.(value) setIsOpen(false) }, as: href ? 'a' : 'button', - children: ( - <> - {icon} - {label} - - ), + children: label, href, } as const @@ -327,9 +314,9 @@ const DropdownMenu = React.forwardRef( const menuProps = React.useMemo( () => ({ - $shortThrow: shortThrow, - $direction: direction, - $state: state, + '$shortThrow': shortThrow, + '$direction': direction, + '$state': state, ...props, 'data-testid': 'dropdown-menu', ref, @@ -339,49 +326,60 @@ const DropdownMenu = React.forwardRef( if (height) { return ( - - + + {Content} - - +
+ ) } return ( - - + + {Content} - - + + ) }, ) -const Chevron = styled((props) => )<{ - $open?: boolean - $direction: Direction -}>( - ({ theme, $open, $direction }) => css` - margin-left: ${theme.space['1']}; - width: ${theme.space['3']}; - margin-right: ${theme.space['0.5']}; - transition-duration: ${theme.transitionDuration['200']}; - transition-property: all; - transition-timing-function: ${theme.transitionTimingFunction['inOut']}; - transform: rotate(${$direction === 'down' ? '0deg' : '180deg'}); - display: flex; - - & > svg { - fill: currentColor; - } - fill: currentColor; - - ${$open && - css` - transform: rotate(${$direction === 'down' ? '180deg' : '0deg'}); - `} - `, -) +const rotation = (direction: Direction, open: boolean) => + match([direction, open]) + .with(['down', false], () => 'rotate(0deg)') + .with(['down', true], () => 'rotate(180deg)') + .with(['up', false], () => 'rotate(180deg)') + .with(['up', true], () => 'rotate(0deg)') + .exhaustive() + +const Chevron = ({ + $open, + $direction, + enabled, +}: { $open?: boolean, $direction: Direction, enabled: boolean } & BoxProps) => { + if (!enabled) return null + return ( + + ) +} interface DropdownButtonProps { children?: React.ReactNode @@ -408,52 +406,53 @@ const DropdownButton: React.FC = ({ buttonProps, indicatorColor, }): React.ReactElement => { - const { colors } = useTheme() const hasIndicator = React.useMemo( - () => items.some((item) => 'showIndicator' in item && item.showIndicator), + () => items.some(item => 'showIndicator' in item && item.showIndicator), [items], ) const buttonPropsWithIndicator = React.useMemo( () => ({ ...buttonProps, 'data-indicator': hasIndicator && !isOpen, - style: { + 'style': { ...buttonProps?.style, - '--indicator-color': indicatorColor - ? colors[indicatorColor] - : colors.accent, + '--indicator-color': + modeVars.color[`$${indicatorColor}` as keyof typeof modeVars.color] + || modeVars.color.accent, }, - className: `${buttonProps?.className} indicator-container`, + 'className': `${buttonProps?.className} indicator-container`, }), - [buttonProps, hasIndicator, indicatorColor, colors, isOpen], + [buttonProps, hasIndicator, indicatorColor, isOpen], ) return ( <> - {children ? ( - React.Children.map(children, (child) => { - if (!React.isValidElement(child)) return null - return React.cloneElement(child as any, { - ...buttonPropsWithIndicator, - zindex: '10', - pressed: isOpen ? 'true' : undefined, - onClick: () => setIsOpen((prev) => !prev), - ref: buttonRef, - }) - }) - ) : ( - - )} + {children + ? ( + React.Children.map(children, (child) => { + if (!React.isValidElement(child)) return null + return React.cloneElement(child as any, { + ...buttonPropsWithIndicator, + zindex: '10', + pressed: isOpen ? 'true' : undefined, + onClick: () => setIsOpen(prev => !prev), + ref: buttonRef, + }) + }) + ) + : ( + + )} ) } @@ -485,9 +484,9 @@ const useClickOutside = ( React.useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( - !dropdownRef.current?.contains(e.target as Node) && - !buttonRef.current?.contains(e.target as Node) && - !actionSheetRef.current?.contains(e.target as Node) + !dropdownRef.current?.contains(e.target as Node) + && !buttonRef.current?.contains(e.target as Node) + && !actionSheetRef.current?.contains(e.target as Node) ) { setIsOpen(false) } @@ -495,7 +494,8 @@ const useClickOutside = ( if (isOpen) { document.addEventListener('mousedown', handleClickOutside) - } else { + } + else { document.removeEventListener('mousedown', handleClickOutside) } return () => { @@ -504,14 +504,15 @@ const useClickOutside = ( }, [dropdownRef, isOpen, setIsOpen, buttonRef, actionSheetRef]) } -export const Dropdown = ({ +export const Dropdown: React.FC = ({ children, buttonProps, + // eslint-disable-next-line @eslint-react/no-unstable-default-props items = [], chevron = true, align = 'left', - menuLabelAlign, - width = 150, + // menuLabelAlign, + width = 36, mobileWidth = width, shortThrow = false, keepMenuOnTop = false, @@ -523,7 +524,7 @@ export const Dropdown = ({ responsive = true, cancelLabel = 'Cancel', ...props -}: Props & (PropsWithIsOpen | PropsWithoutIsOpen)) => { +}) => { const dropdownRef = React.useRef(null) const buttonRef = React.useRef(null) const actionSheetRef = React.useRef(null) @@ -554,7 +555,7 @@ export const Dropdown = ({ { responsive: false, screenSize: P._ }, { responsive: true, - screenSize: P.when((screenSize) => screenSize >= breakpoints.sm), + screenSize: P.when(screenSize => screenSize >= breakpoints.sm), }, () => ( - } + )} width={width} /> ), @@ -584,7 +585,7 @@ export const Dropdown = ({ .with( { responsive: true, - screenSize: P.when((screenSize) => screenSize < breakpoints.sm), + screenSize: P.when(screenSize => screenSize < breakpoints.sm), }, () => ( ', () => { @@ -13,11 +9,9 @@ describe('
', () => { it('renders', () => { render( - -
-
-
-
, +
+
+
, ) expect(screen.getByText(/token/i)).toBeInTheDocument() }) diff --git a/components/src/components/molecules/FieldSet/FieldSet.tsx b/components/src/components/molecules/FieldSet/FieldSet.tsx index 0e7561e6..3377efcf 100644 --- a/components/src/components/molecules/FieldSet/FieldSet.tsx +++ b/components/src/components/molecules/FieldSet/FieldSet.tsx @@ -1,55 +1,48 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { ReactNodeNoStrings } from '../../../types' -import { Heading, Tag } from '../..' -import { TagProps } from '../../atoms/Tag' +import type { ReactNodeNoStrings } from '../../../types' +import type { TagProps } from '../../atoms/Tag/Tag' +import { Tag } from '../../atoms/Tag/Tag' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { Heading } from '../../atoms' -const Container = styled.fieldset( - ({ theme }) => css` - display: flex; - flex-direction: column; - gap: ${theme.space['4']}; - `, +const Container = (props: BoxProps) => ( + ) -const ContainerInner = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: column; - gap: ${theme.space['1']}; - padding: 0 ${theme.space['4']}; - `, +const ContainerInner = ({ children }: React.PropsWithChildren) => ( + {children} ) -const Row = styled.div( - ({ theme }) => css` - display: flex; - align-items: center; - flex-direction: row; - gap: ${theme.space['3']}; - `, +const Row = ({ children }: React.PropsWithChildren) => ( + + {children} + ) -const Description = styled.div( - ({ theme }) => css` - color: ${theme.colors.textSecondary}; - font-size: ${theme.fontSizes.body}; - line-height: ${theme.lineHeights.body}; - `, +const Description = ({ children }: React.PropsWithChildren) => ( + {children} ) -const ChildrenContainer = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: column; - gap: ${theme.space['4']}; - `, +const ChildrenContainer = ({ children }: React.PropsWithChildren) => ( + {children} ) type NativeFieldSetProps = React.FieldsetHTMLAttributes -export type Props = { +export type FieldSetProps = { children: ReactNodeNoStrings /** Description content */ description?: string | React.ReactNode @@ -68,12 +61,12 @@ export type Props = { | 'pending' | 'complete' | { - name: string - tone: TagProps['color'] - } -} & Omit + name: string + tone: TagProps['color'] + } +} & Omit -export const FieldSet = ({ +export const FieldSet: React.FC = ({ children, description, disabled, @@ -82,41 +75,41 @@ export const FieldSet = ({ name, status, ...props -}: Props) => { +}) => { let statusText: string | undefined - let statusTone: TagProps['color'] + let statusTone: TagProps['colorStyle'] switch (status) { case 'complete': { statusText = 'Complete' - statusTone = 'green' + statusTone = 'greenSecondary' break } case 'required': case 'pending': { statusText = status === 'pending' ? 'Pending' : 'Required' - statusTone = 'accent' + statusTone = 'accentSecondary' break } case 'optional': { statusText = 'Optional' - statusTone = 'grey' + statusTone = 'greySecondary' break } } if (typeof status === 'object') { statusText = status.name - statusTone = status.tone + statusTone = status.tone as TagProps['colorStyle'] } return ( - + {legend} {statusTone && statusText && ( - {statusText} + {statusText} )} diff --git a/components/src/components/molecules/FieldSet/index.ts b/components/src/components/molecules/FieldSet/index.ts deleted file mode 100644 index e06a99eb..00000000 --- a/components/src/components/molecules/FieldSet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FieldSet } from './FieldSet' diff --git a/components/src/components/molecules/Helper/Helper.test.tsx b/components/src/components/molecules/Helper/Helper.test.tsx index c71d42bf..dd0e644e 100644 --- a/components/src/components/molecules/Helper/Helper.test.tsx +++ b/components/src/components/molecules/Helper/Helper.test.tsx @@ -1,21 +1,13 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render } from '@/test' -import { lightTheme } from '@/src' - import { Helper } from './Helper' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - Test helper - , - ) + render(Test helper) }) }) diff --git a/components/src/components/molecules/Helper/Helper.tsx b/components/src/components/molecules/Helper/Helper.tsx index 53c453dd..0f3bc316 100644 --- a/components/src/components/molecules/Helper/Helper.tsx +++ b/components/src/components/molecules/Helper/Helper.tsx @@ -1,89 +1,62 @@ import * as React from 'react' -import styled, { css } from 'styled-components' import { AlertSVG, InfoCircleSVG } from '@/src/icons' -type NativeDivProps = React.HTMLAttributes +import type { Alert } from '@/src/types' + +import type { AsProp, BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' + +import * as styles from './styles.css' +import clsx from 'clsx' -type HelperType = 'info' | 'warning' | 'error' type Alignment = 'horizontal' | 'vertical' -export type Props = NativeDivProps & { - type?: HelperType +export type HelperProps = BoxProps & { + alert?: Alert alignment?: Alignment - children: React.ReactNode } -const Container = styled.div<{ $type: HelperType; $alignment: Alignment }>( - ({ theme, $type, $alignment }) => css` - width: ${theme.space.full}; - padding: ${theme.space['6']} ${theme.space['4']}; - - display: flex; - align-items: center; - flex-direction: column; - justify-content: center; - gap: ${theme.space['2']}; - border-radius: ${theme.radii.large}; - - text-align: center; - overflow-x: auto; - - ${$alignment === 'horizontal' && - css` - flex-direction: row; - justify-content: flex-start; - gap: ${theme.space['4']}; - padding: ${theme.space['4']}; - text-align: left; - `} - - background-color: ${theme.colors.blueSurface}; - border: ${theme.borderWidths.px} solid ${theme.colors.blue}; - - ${$type === 'warning' && - css` - background-color: ${theme.colors.yellowSurface}; - border-color: ${theme.colors.yellow}; - `} - - ${$type === 'error' && - css` - background-color: ${theme.colors.redSurface}; - border-color: ${theme.colors.red}; - `} - `, +const Container = ({ + $alert, + $alignment, + className, + ...props +}: BoxProps & { $alert: Alert, $alignment: Alignment }) => ( + ) -const IconElement = styled.div<{ $type: HelperType }>( - ({ theme, $type }) => css` - width: ${theme.space['6']}; - height: ${theme.space['6']}; - - color: ${theme.colors.blue}; - - ${$type === 'warning' && - css` - color: ${theme.colors.yellow}; - `} - ${$type === 'error' && - css` - color: ${theme.colors.red}; - `} - `, +const IconElement = ({ $alert, as }: { $alert: Alert, as: AsProp }) => ( + ) -export const Helper = ({ - type = 'info', +export const Helper: React.FC = ({ + alert = 'info', alignment = 'vertical', children, ...props -}: Props) => { - const Icon = type === 'info' ? InfoCircleSVG : AlertSVG +}) => { + const Icon = alert === 'info' ? InfoCircleSVG : AlertSVG return ( - - + + {children} ) diff --git a/components/src/components/molecules/Helper/index.ts b/components/src/components/molecules/Helper/index.ts deleted file mode 100644 index b07be275..00000000 --- a/components/src/components/molecules/Helper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Helper } from './Helper' diff --git a/components/src/components/molecules/Helper/styles.css.ts b/components/src/components/molecules/Helper/styles.css.ts new file mode 100644 index 00000000..e5a8d31a --- /dev/null +++ b/components/src/components/molecules/Helper/styles.css.ts @@ -0,0 +1,26 @@ +import { sprinkles } from '@/src/css/sprinkles.css' +import { recipe } from '@vanilla-extract/recipes' + +export const variants = recipe({ + variants: { + alert: { + error: sprinkles({ + backgroundColor: 'redSurface', + borderColor: 'redPrimary', + }), + warning: sprinkles({ + backgroundColor: 'yellowSurface', + borderColor: 'yellowPrimary', + }), + info: sprinkles({ + backgroundColor: 'blueSurface', + borderColor: 'bluePrimary', + }), + }, + svgAlert: { + error: sprinkles({ color: 'redPrimary' }), + warning: sprinkles({ color: 'yellowPrimary' }), + info: sprinkles({ color: 'bluePrimary' }), + }, + }, +}) diff --git a/components/src/components/molecules/Input/Input.test.tsx b/components/src/components/molecules/Input/Input.test.tsx index 105b56ca..9b569c82 100644 --- a/components/src/components/molecules/Input/Input.test.tsx +++ b/components/src/components/molecules/Input/Input.test.tsx @@ -1,31 +1,19 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, fireEvent, render, screen, userEvent, waitFor } from '@/test' -import { lightTheme } from '@/src/tokens' - import { Input } from './Input' describe('', () => { afterEach(cleanup) it('renders', () => { - render( - - - , - ) + render() expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('receives user input', async () => { - render( - - - , - ) + render() await userEvent.type(screen.getByRole('textbox'), 'Satoshi Nakamoto') expect(screen.getByRole('textbox')).toHaveValue('Satoshi Nakamoto') @@ -33,24 +21,18 @@ describe('', () => { describe('[type=text]', () => { it('maxLength', async () => { - render( - - - , - ) + render() - const element = screen.getByLabelText('Short Name') + const element = screen.getByLabelText(/short/i) await userEvent.type(element, 'Satoshi Nakamoto') expect(element).toHaveValue('Satoshi') }) }) it('should pass a ref down', async () => { - const ref = { current: null } as React.RefObject + const ref = { current: null } as React.RefObject render( - - - , + , ) await waitFor(() => { expect(ref.current).toBeInstanceOf(HTMLInputElement) @@ -58,20 +40,18 @@ describe('', () => { }) it('should fire onChange if clear button is pressed', async () => { - const ref = { current: null } as React.RefObject - const handleOnChange = jest.fn() + const ref = { current: null } as React.RefObject + const handleOnChange = vi.fn() render( - - - , + , ) await userEvent.type(screen.getByRole('textbox'), 'Satoshi Nakamoto') fireEvent.click(screen.getByTestId('input-action-button')) diff --git a/components/src/components/molecules/Input/Input.tsx b/components/src/components/molecules/Input/Input.tsx index 4fdaec2e..fcb0dc5d 100644 --- a/components/src/components/molecules/Input/Input.tsx +++ b/components/src/components/molecules/Input/Input.tsx @@ -1,17 +1,20 @@ import * as React from 'react' -import styled, { FlattenInterpolation, css } from 'styled-components' +import type { StatusDot } from '@/src/css/recipes/statusDot.css' +import { statusDot } from '@/src/css/recipes/statusDot.css' +import { statusBorder } from '@/src/css/recipes/statusBorder.css' import { setNativeValue } from '@/src/utils/setNativeValue' -import { CrossCircleSVG, Field } from '../..' -import { FieldBaseProps } from '../../atoms/Field' -import { Radii, Space } from '../../../tokens/index' -import { DefaultTheme } from '../../../types/index' -import { - FontVariant, - getFontSize, - getLineHeight, -} from '../../../types/withTypography' +import * as styles from './styles.css' + +import type { FieldBaseProps } from '../../atoms/Field/Field' +import { Field } from '../../atoms/Field/Field' +import type { Space } from '../../../tokens/index' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { getValueForSize } from './utils/getValueForSize' +import { CrossCircleSVG } from '@/src/icons' +import clsx from 'clsx' type NativeInputProps = React.InputHTMLAttributes @@ -80,20 +83,22 @@ type BaseProps = Omit & { /** Sets the height of the input element. */ size?: 'small' | 'medium' | 'large' | 'extraLarge' /** Set of styles */ - parentStyles?: FlattenInterpolation + // parentStyles?: BoxProps } & Omit< - NativeInputProps, - | 'size' - | 'prefix' - | 'children' - | 'value' - | 'defaultValue' - | 'type' - | 'aria-invalid' - | 'onInput' - | 'onKeyDown' - | 'onWheel' - > + NativeInputProps, + | 'size' + | 'prefix' + | 'children' + | 'value' + | 'defaultValue' + | 'type' + | 'aria-invalid' + | 'onInput' + | 'onKeyDown' + | 'onWheel' + | 'height' + | 'color' +> type WithTypeEmail = { type?: 'email' @@ -108,376 +113,207 @@ type WithTypeDateTimeLocal = { type?: 'datetime-local' } -type Size = NonNullable +export type Size = NonNullable -const SPACES: { - [key in Size]: { - outerPadding: Space - gap: Space - icon: Space - iconPadding: Space - height: Space - } -} = { - small: { - outerPadding: '3.5', - gap: '2', - icon: '3', - iconPadding: '8.5', - height: '10', - }, - medium: { - outerPadding: '4', - gap: '2', - icon: '4', - iconPadding: '10', - height: '12', - }, - large: { - outerPadding: '4', - gap: '2', - icon: '5', - iconPadding: '11', - height: '16', - }, - extraLarge: { - outerPadding: '6', - gap: '2', - icon: '6', - iconPadding: '14', - height: '20', - }, -} - -const getSpaceValue = ( - theme: DefaultTheme, - size: keyof typeof SPACES, - key: keyof typeof SPACES['small'], -): string => { - return theme.space[SPACES[size][key]] -} - -const getIconPadding = ( - theme: DefaultTheme, - size: keyof typeof SPACES, - iconWidth?: Space, - negative?: boolean, -) => { - if (iconWidth) - return negative - ? `calc(-${theme.space[SPACES[size].outerPadding]} - ${ - theme.space[iconWidth] - } - ${theme.space[SPACES[size].gap]})` - : `calc(${theme.space[SPACES[size].outerPadding]} + ${ - theme.space[iconWidth] - } + ${theme.space[SPACES[size].gap]})` - return negative - ? `-${theme.space[SPACES[size].iconPadding]}` - : theme.space[SPACES[size].iconPadding] -} - -const RADII: { - [key in Size]: Radii -} = { - small: 'large', - medium: 'large', - large: '2.5xLarge', - extraLarge: '2.5xLarge', -} - -const getRadiusValue = (theme: DefaultTheme, size: keyof typeof RADII) => { - return theme.radii[RADII[size]] -} - -const TYPOGRAPHIES: { - [key in Size]: FontVariant -} = { - small: 'small', - medium: 'body', - large: 'large', - extraLarge: 'headingThree', -} - -const getTypographyValue = (size: keyof typeof TYPOGRAPHIES) => { - return TYPOGRAPHIES[size] -} - -const Container = styled.div<{ +type ContainerProps = { $size: Size $disabled?: boolean $hasError?: boolean - $suffix: boolean + $suffix?: boolean $validated?: boolean $showDot?: boolean - $userStyles?: FlattenInterpolation -}>( - ({ theme, $size, $hasError, $userStyles, $validated, $showDot }) => css` - position: relative; - height: ${getSpaceValue(theme, $size, 'height')}; - display: flex; - transition-duration: ${theme.transitionDuration['150']}; - transition-property: color, border-color, background-color; - transition-timing-function: ${theme.transitionTimingFunction['inOut']}; - - :after { - content: ''; - position: absolute; - width: ${theme.space['4']}; - height: ${theme.space['4']}; - border: 2px solid ${theme.colors.backgroundPrimary}; - box-sizing: border-box; - border-radius: 50%; - right: -${theme.space['1.5']}; - top: -${theme.space['1.5']}; - transition: all 0.3s ease-out; - transform: scale(0.3); - opacity: 0; - } - - ${$showDot && - $validated && - css` - :after { - background: ${theme.colors.greenPrimary}; - transform: scale(1); - opacity: 1; - } - `} - - ${$showDot && - !$hasError && - css` - &:focus-within:after { - background: ${theme.colors.bluePrimary}; - transform: scale(1); - opacity: 1; - } - `} - - ${$hasError && - $showDot && - css` - :after { - background: ${theme.colors.redPrimary}; - transform: scale(1); - opacity: 1; - } - `} - - ${$userStyles} - `, + $userStyles?: BoxProps +} +const Container = ({ + $size, + className, + ...props +}: BoxProps & ContainerProps & StatusDot) => ( + ) -const Label = styled.label<{ $size: Size }>( - ({ theme, $size }) => css` - display: flex; - align-items: center; - gap: ${theme.space[2]}; - - height: ${theme.space.full}; - color: ${theme.colors.greyPrimary}; - background: ${theme.colors.greySurface}; - font-size: ${getFontSize(getTypographyValue($size))}; - line-height: ${getLineHeight(getTypographyValue($size))}; - font-weight: ${theme.fontWeights.normal}; - padding: 0 ${getSpaceValue(theme, $size, 'outerPadding')}; - - svg { - display: block; - color: ${theme.colors.greyPrimary}; - } - `, +const Label = ({ + $size, + $disabled, + className, + ...props +}: BoxProps & { $disabled?: boolean, $size: Size }) => ( + ) -const Prefix = styled(Label)( - () => css` - order: -2; - `, -) +type IconBoxProps = { + $icon: React.ReactElement + $iconWidth?: Space + $size: Size +} -const IconWrapper = styled.div<{ $size: Size; $iconWidth?: Space }>( - ({ theme, $size, $iconWidth }) => css` - order: -1; - padding-left: ${getSpaceValue(theme, $size, 'outerPadding')}; - flex: 0 0 ${getIconPadding(theme, $size, $iconWidth)}; - margin-right: ${getIconPadding(theme, $size, $iconWidth, true)}; - display: flex; - align-items: center; - justify-content: flex-start; - pointer-events: none; - svg { - display: block; - width: ${$iconWidth - ? theme.space[$iconWidth] - : getSpaceValue(theme, $size, 'icon')}; - height: ${$iconWidth - ? theme.space[$iconWidth] - : getSpaceValue(theme, $size, 'icon')}; - color: ${theme.colors.greyPrimary}; - } - z-index: 1; - `, +const Icon = ({ + $icon, + $iconWidth, + $size, + className, + ...props +}: BoxProps & IconBoxProps) => ( + + {$icon} + ) -const ActionButton = styled.button<{ $size: Size }>( - ({ theme, $size }) => css` - padding-right: ${getSpaceValue(theme, $size, 'outerPadding')}; - margin-left: -${getSpaceValue(theme, $size, 'iconPadding')}; - flex: 0 0 ${getSpaceValue(theme, $size, 'iconPadding')}; - display: flex; - justify-content: flex-end; - align-items: center; - transition: all 0.1s ease-in-out; - transform: scale(1); - opacity: 1; - cursor: pointer; - - svg { - display: block; - width: ${getSpaceValue(theme, $size, 'icon')}; - height: ${getSpaceValue(theme, $size, 'icon')}; - color: ${theme.colors.greyPrimary}; - transition: all 150ms ease-in-out; - } - - &:hover svg { - color: ${theme.colors.greyBright}; - transform: translateY(-1px); - } - `, -) +const ActionButton = ({ + $icon, + $size, + ...props +}: BoxProps & { $size: Size, $icon?: React.ReactNode }) => { + const Icon: React.FC = typeof $icon === 'function' + ? $icon + : CrossCircleSVG + + return ( + + + + ) +} -const InputComponent = styled.input<{ +type InputComponentProps = { $size: Size $hasAction: boolean $hasIcon: boolean $hasError: boolean $iconWidth?: Space -}>( - ({ theme, $size, $hasIcon, $hasAction, $hasError, $iconWidth }) => css` - background-color: transparent; - position: relative; - width: ${theme.space['full']}; - height: ${theme.space['full']}; - font-weight: ${theme.fontWeights.normal}; - text-overflow: ellipsis; - color: ${theme.colors.textPrimary}; - padding: 0 ${getSpaceValue(theme, $size, 'outerPadding')}; - font-size: ${getFontSize(getTypographyValue($size))}; - line-height: ${getLineHeight(getTypographyValue($size))}; - - ${$hasIcon && - css` - padding-left: ${getIconPadding(theme, $size, $iconWidth)}; - `} - - ${$hasAction && - css` - padding-right: ${getSpaceValue(theme, $size, 'iconPadding')}; - `} - - &::placeholder { - color: ${theme.colors.greyPrimary}; - font-weight: ${$size === 'large' || $size === 'extraLarge' - ? theme.fontWeights.bold - : theme.fontWeights.normal}; - } - - &:read-only { - cursor: default; - } - - &:disabled { - background: ${theme.colors.greyLight}; - cursor: not-allowed; - color: ${theme.colors.greyPrimary}; - } +} - ${$hasError && - css` - color: ${theme.colors.redPrimary}; - `} - `, +const InputComponent = React.forwardRef( + ( + { + $size, + $hasIcon, + $hasAction, + $hasError, + // $iconWidth, + ...props + }, + ref, + ) => ( + + ), ) -const InnerContainer = styled.div<{ +type InnerContainerProps = { $size: Size - $hasError: boolean + $hasError?: boolean $disabled: boolean - $readOnly: boolean - $alwaysShowAction: boolean -}>( - ({ theme, $size, $hasError, $disabled, $readOnly, $alwaysShowAction }) => css` - position: relative; - background-color: ${theme.colors.backgroundPrimary}; - border-radius: ${getRadiusValue(theme, $size)}; - border-width: ${theme.space.px}; - border-color: ${theme.colors.border}; - color: ${theme.colors.textPrimary}; - overflow: hidden; - width: 100%; - height: 100%; - display: flex; - transition-duration: ${theme.transitionDuration['150']}; - transition-property: color, border-color, background-color; - transition-timing-function: ${theme.transitionTimingFunction['inOut']}; - - ${$disabled && - css` - border-color: ${theme.colors.border}; - background-color: ${theme.colors.greyLight}; - `} - - ${$hasError && - css` - border-color: ${theme.colors.redPrimary}; - cursor: default; - `} - - ${!$hasError && - !$readOnly && - css` - &:focus-within { - border-color: ${theme.colors.accentBright}; - } - `} - - input ~ label { - cursor: text; - } - - input:read-only ~ label, - input:read-only ~ button { - cursor: default; - } - - input:disabled ~ label, - input:disabled ~ button { - background: ${theme.colors.greyLight}; - cursor: not-allowed; - } - - input:disabled ~ button, - input:read-only ~ button { - opacity: 0; - transform: scale(0.8); - pointer-events: none; - } + $readOnly?: boolean + $alwaysShowAction?: boolean +} - ${!$alwaysShowAction && - css` - input:placeholder-shown ~ button { - opacity: 0; - transform: scale(0.8); - pointer-events: none; - } - `} - `, +const InnerContainer = ({ + $size, + $disabled, + ...props +}: BoxProps & InnerContainerProps) => ( + ) -type Props = BaseProps & (WithTypeEmail | WithTypeText | WithTypeDateTimeLocal) +export type InputProps = BaseProps & (WithTypeEmail | WithTypeText | WithTypeDateTimeLocal) -export const Input = React.forwardRef( +export const Input = React.forwardRef( ( { autoFocus, @@ -518,10 +354,9 @@ export const Input = React.forwardRef( onFocus, onClickAction, size = 'medium', - parentStyles, ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const defaultRef = React.useRef(null) const inputRef = (ref as React.RefObject) || defaultRef @@ -566,25 +401,28 @@ export const Input = React.forwardRef( required={required} width={width} > - {(ids) => ( + {ids => ( {prefix && ( - + )} - {icon && ( - - {icon} - + {icon && React.isValidElement(icon) && ( + )} {hasAction && ( e.preventDefault()} - > - {actionIcon ? actionIcon : } - + onMouseDown={e => e.preventDefault()} + /> )} {suffix && ( ) } + +PageButtons.displayName = 'PageButtons' diff --git a/components/src/components/molecules/PageButtons/index.tsx b/components/src/components/molecules/PageButtons/index.tsx deleted file mode 100644 index fd9d40f6..00000000 --- a/components/src/components/molecules/PageButtons/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { PageButtons } from './PageButtons' diff --git a/components/src/components/molecules/PageButtons/utils/getValueForSize.ts b/components/src/components/molecules/PageButtons/utils/getValueForSize.ts new file mode 100644 index 00000000..d1a7114d --- /dev/null +++ b/components/src/components/molecules/PageButtons/utils/getValueForSize.ts @@ -0,0 +1,38 @@ +import type { FontSize, LineHeight } from '@/src/tokens/typography' +import type { Size } from '../PageButtons' +import type { Sprinkles } from '@/src/css/sprinkles.css' +import type { Space } from '@/src/tokens' + +type Properties = { + fontSize: FontSize + lineHeight: LineHeight + borderRadius: Sprinkles['borderRadius'] + minWidth: Space + height: Space +} + +type Property = keyof Properties + +const sizeMap: { [key in Size]: Properties } = { + small: { + fontSize: 'small', + lineHeight: 'small', + borderRadius: 'large', + minWidth: '9', + height: '9', + }, + medium: { + fontSize: 'body', + lineHeight: 'body', + borderRadius: 'extraLarge', + minWidth: '10', + height: '10', + }, +} + +export const getValueForSize = ( + size: Size, + property: T, +): Properties[T] => { + return sizeMap[size]?.[property] || sizeMap.medium[property] +} diff --git a/components/src/components/molecules/Profile/Profile.test.tsx b/components/src/components/molecules/Profile/Profile.test.tsx index b93c610f..138ecd82 100644 --- a/components/src/components/molecules/Profile/Profile.test.tsx +++ b/components/src/components/molecules/Profile/Profile.test.tsx @@ -1,10 +1,13 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - -import { cleanup, render, screen, userEvent, waitFor } from '@/test' - -import { lightTheme } from '@/src/tokens' +import { + cleanup, + getPropertyValue, + render, + screen, + userEvent, + waitFor, +} from '@/test' import { Profile } from './Profile' @@ -14,45 +17,31 @@ describe('', () => { afterEach(cleanup) it('renders', async () => { - render( - - - , - ) + render() expect(screen.getByTestId('profile')).toBeInTheDocument() }) it('should add middle ellipsis to address', () => { - render( - - - , - ) + render() const addressDisplay = screen.getByTestId('profile-title') expect(addressDisplay.innerHTML).toEqual('0x155...ccb92') }) it('should display only ensName if provided', () => { - render( - - - , - ) + render() const addressDisplay = screen.getByTestId('profile-title') expect(addressDisplay.innerHTML).toEqual('nick.eth') }) it('should display dropdown if items are provided', async () => { render( - - null, color: 'red' }, - ]} - ensName="nick.eth" - /> - , + null, color: 'red' }, + ]} + ensName="nick.eth" + />, ) await userEvent.click(screen.getByText('nick.eth')) await waitFor(() => { @@ -61,11 +50,11 @@ describe('', () => { }) it('should hide text if size is small', () => { - render( - - - , + render() + const display = getPropertyValue( + screen.getByTestId('profile-inner-container'), + 'display', ) - expect(screen.queryByText('nick.eth')).not.toBeVisible() + expect(display).toEqual('none') }) }) diff --git a/components/src/components/molecules/Profile/Profile.tsx b/components/src/components/molecules/Profile/Profile.tsx index d8ce1882..0c15f028 100644 --- a/components/src/components/molecules/Profile/Profile.tsx +++ b/components/src/components/molecules/Profile/Profile.tsx @@ -1,18 +1,26 @@ import * as React from 'react' -import styled, { css, useTheme } from 'styled-components' -import { Colors, Tokens } from '@/src/tokens' +import type { Colors } from '@/src/tokens' + +import { brightness, translateY } from '@/src/css/utils/common' + +import { removeNullishProps } from '@/src/utils/removeNullishProps' import { getTestId, shortenAddress } from '../../../utils/utils' -import { Typography } from '../..' -import { Avatar, Props as AvatarProps } from '../../atoms/Avatar' -import { Dropdown, DropdownItem } from '../Dropdown/Dropdown' +import type { AvatarProps } from '../../atoms/Avatar/Avatar' +import { Avatar } from '../../atoms/Avatar/Avatar' +import type { DropdownItem } from '../Dropdown/Dropdown' +import { Dropdown } from '../Dropdown/Dropdown' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import { Typography } from '../../atoms/Typography/Typography' +import * as styles from './styles.css' +import { clsx } from 'clsx' +import { assignInlineVars } from '@vanilla-extract/dynamic' type Size = 'small' | 'medium' | 'large' -type NativeDivProps = React.HTMLAttributes - type BaseProps = { /** The url of the avatar icon, or the avatar props to passthrough */ avatar?: AvatarProps['src'] | Omit @@ -28,7 +36,7 @@ type BaseProps = { size?: Size /** The colour of the indicator */ indicatorColor?: Colors -} & Omit +} & Omit interface ContainerProps { $size: Size @@ -36,97 +44,62 @@ interface ContainerProps { $open: boolean } -const calculateWidth = (space: Tokens['space'], size: Size) => { - if (size === 'small') return space['10'] - if (size === 'medium') return space['45'] - return space['80'] +const calculateWidth = (size: Size) => { + if (size === 'small') return '10' + if (size === 'medium') return '45' + return '80' } -const Container = styled.div( - ({ theme, $size, $hasDropdown, $open }) => css` - align-items: center; - display: flex; - flex-direction: row; - justify-content: flex-start; - gap: ${theme.space['2']}; - border-radius: ${theme.radii['full']}; - transition-duration: ${theme.transitionDuration['150']}; - transition-property: color, border-color, background-color, transform, - filter; - transition-timing-function: ${theme.transitionTimingFunction['inOut']}; - position: relative; - z-index: 10; - padding: ${theme.space['1']}; - background-color: ${theme.colors.backgroundPrimary}; - width: fit-content; - - ${$hasDropdown && - css` - cursor: pointer; - &:hover { - transform: translateY(-1px); - filter: brightness(1.05); - } - `} - - ${$open && - css` - background-color: ${theme.colors.border}; - `} - - width: ${calculateWidth(theme.space, $size)}; - - ${$size === 'small' && - css` - height: ${theme.space['10']}; - padding: 0; - border: none; - `} - - ${$size === 'medium' && - css` - height: ${theme.space['12']}; - padding-right: ${theme.space['4']}; - `} - - ${$size === 'large' && - css` - width: fit-content; - height: ${theme.space['14']}; - max-width: ${theme.space['80']}; - padding-right: ${theme.space['5']}; - `} - `, -) - -const AvatarContainer = styled.div<{ $size?: 'small' | 'medium' | 'large' }>( - ({ theme, $size }) => css` - width: ${theme.space['10']}; - flex: 0 0 ${theme.space['10']}; - ${$size === 'large' && - css` - width: ${theme.space['12']}; - flex: 0 0 ${theme.space['12']}; - `} - `, +const Container = React.forwardRef( + ({ $size, $hasDropdown, $open, className, style, ...props }, ref) => ( + + ), ) -const ProfileInnerContainer = styled.div<{ - $size?: 'small' | 'medium' | 'large' -}>( - ({ theme, $size }) => css` - display: ${$size === 'small' ? 'none' : 'block'}; - min-width: ${theme.space['none']}; - `, +const AvatarContainer = ({ $size, ...props }: BoxProps & { $size: Size }) => ( + ) -const ReducedLineText = styled(Typography)( - () => css` - line-height: initial; - `, -) +const ProfileInnerContainer = ({ + $size, + ...props +}: BoxProps & { $size: Size }) => { + return ( + + ) +} -const ProfileInner = ({ size, avatar, address, ensName }: Props) => ( +const ProfileInner = ({ size = 'medium', avatar, address, ensName }: ProfileProps) => ( <> ( /> - - {ensName || - shortenAddress( - address, - size === 'large' ? 30 : 10, - size === 'large' ? 10 : 5, - size === 'large' ? 10 : 5, - )} - + {ensName + || shortenAddress( + address, + size === 'large' ? 30 : 10, + size === 'large' ? 10 : 5, + size === 'large' ? 10 : 5, + )} + ) -type Props = BaseProps +export type ProfileProps = BaseProps -export const Profile = ({ +export const Profile: React.FC = ({ size = 'medium', avatar, dropdownItems, @@ -165,14 +138,13 @@ export const Profile = ({ alignDropdown = 'left', indicatorColor, ...props -}: Props) => { - const { space } = useTheme() +}) => { const [isOpen, setIsOpen] = React.useState(false) if (dropdownItems) { return ( setIsOpen(!isOpen)} + {...removeNullishProps(props)} > @@ -197,12 +169,10 @@ export const Profile = ({ return ( diff --git a/components/src/components/molecules/Profile/index.ts b/components/src/components/molecules/Profile/index.ts deleted file mode 100644 index cc5dcb25..00000000 --- a/components/src/components/molecules/Profile/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Profile } from './Profile' diff --git a/components/src/components/molecules/Profile/styles.css.ts b/components/src/components/molecules/Profile/styles.css.ts new file mode 100644 index 00000000..43143fff --- /dev/null +++ b/components/src/components/molecules/Profile/styles.css.ts @@ -0,0 +1,44 @@ +import { sprinkles } from '@/src/css/sprinkles.css' +import { brightness, translateY } from '@/src/css/utils/common' +import { createVar } from '@vanilla-extract/css' +import { recipe } from '@vanilla-extract/recipes' + +export const hasDropdownBrightness = createVar() +export const hasDropdownTransform = createVar() + +export const variants = recipe({ + base: { + 'transitionProperty': 'color, border-color, background-color, transform, filter', + 'filter': brightness(1), + 'transform': translateY(0), + ':hover': { + filter: hasDropdownBrightness, + transform: hasDropdownTransform, + }, + }, + variants: { + size: { + small: sprinkles({ + height: '10', + padding: '0', + width: '10', + maxWidth: '0', + paddingRight: '0', + }), + medium: sprinkles({ + height: '12', + padding: '1', + width: '45', + maxWidth: '45', + paddingRight: '4', + }), + large: sprinkles({ + height: '14', + padding: '1', + width: 'fit', + maxWidth: '80', + paddingRight: '5', + }), + }, + }, +}) diff --git a/components/src/components/molecules/RadioButton/Radio.test.tsx b/components/src/components/molecules/RadioButton/Radio.test.tsx index 7d7ff1ff..54002f62 100644 --- a/components/src/components/molecules/RadioButton/Radio.test.tsx +++ b/components/src/components/molecules/RadioButton/Radio.test.tsx @@ -1,44 +1,35 @@ import * as React from 'react' import { useState } from 'react' -import { ThemeProvider } from 'styled-components' - import { cleanup, render, screen, userEvent, waitFor } from '@/test' -import { lightTheme } from '@/src/tokens' - +import type { RadioButtonProps } from './RadioButton' import { RadioButton } from './RadioButton' -const RadioWithState = (props: any) => { +const RadioWithState = React.forwardRef>((props, ref) => { const [checked, setChecked] = useState(false) return ( - -
- hello there - {checked ?
checked
:
unchecked
} - { - setChecked(e.target.checked) - }} - {...props} - /> -
-
+
+ hello there + {checked ?
checked
:
unchecked
} + { + setChecked(e.target.checked) + }} + /> +
) -} +}) describe('', () => { afterEach(cleanup) it('renders', async () => { - render( - - - , - ) + render() expect(screen.getByRole('radio')).toBeInTheDocument() }) @@ -71,22 +62,18 @@ describe('', () => { await waitFor(() => { expect(screen.queryByText('unchecked')).toBeInTheDocument() }) - expect(userEvent.click(screen.getByText('radio-label'))).rejects.toThrow() - await waitFor(() => { - expect(screen.queryByText('unchecked')).toBeInTheDocument() - }) }) it('should pass a ref down', async () => { - const ref = { current: null } as React.RefObject - render() + const ref = { current: null } as React.RefObject + render() await waitFor(() => { expect(ref.current).toBeInstanceOf(HTMLInputElement) }) }) it('should display the label on the right of the form element if labelRight is true', async () => { - render() + render() expect(screen.getByText('radio-label')).toBeInTheDocument() }) }) diff --git a/components/src/components/molecules/RadioButton/RadioButton.tsx b/components/src/components/molecules/RadioButton/RadioButton.tsx index b0c13c6c..26644359 100644 --- a/components/src/components/molecules/RadioButton/RadioButton.tsx +++ b/components/src/components/molecules/RadioButton/RadioButton.tsx @@ -1,18 +1,17 @@ import * as React from 'react' -import styled, { css } from 'styled-components' -import { - WithColorStyle, - getColorStyle, -} from '@/src/types/withColorOrColorStyle' +import * as styles from './styles.css' -import { Field } from '../..' -import { FieldBaseProps } from '../../atoms/Field' +import type { FieldBaseProps } from '../../atoms/Field/Field' +import { Field } from '../../atoms/Field/Field' import { getTestId } from '../../../utils/utils' - +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' +import type { Color } from '@/src/tokens/color' +import clsx from 'clsx' type NativeInputProps = React.InputHTMLAttributes -type Props = { +export type RadioButtonProps = { /** A string or component that represents the input item. */ label: React.ReactNode /** The name attribute for input elements. */ @@ -36,74 +35,65 @@ type Props = { /** The handler for blur events. */ onBlur?: NativeInputProps['onBlur'] } & Omit & - Omit< - NativeInputProps, - 'children' | 'value' | 'defaultValue' | 'aria-invalid' | 'type' | 'role' - > & - WithColorStyle - -const Input = styled.input<{ - $colorStyle: NonNullable -}>( - ({ theme, $colorStyle }) => css` - cursor: pointer; - font: inherit; - border-radius: 50%; - display: grid; - place-content: center; - transition: transform 150ms ease-in-out; - width: ${theme.space['5']}; - flex: 0 0 ${theme.space['5']}; - height: ${theme.space['5']}; - background-color: ${theme.colors.border}; - - &::before { - content: ''; - width: ${theme.space['3']}; - height: ${theme.space['3']}; - border-radius: 50%; - transition: all 150ms ease-in-out; - background: ${theme.colors.border}; - background-size: 100% 100%; - background-position: center; - } - - &:checked::before { - background: ${getColorStyle($colorStyle, 'background')}; - } - - &:disabled { - cursor: not-allowed; - } - - &:hover::before { - background: ${theme.colors.greyBright}; - } - - &:disabled::before { - background: ${theme.colors.border}; - } - - &:checked:hover::before { - background: ${getColorStyle($colorStyle, 'hover')}; - } - - &:disabled:checked::before, - &:disabled:checked:hover::before { - background: ${theme.colors.greyPrimary}; - } - - &:hover { - transform: translateY(-1px); - } +Omit< + NativeInputProps, + | 'children' + | 'value' + | 'defaultValue' + | 'aria-invalid' + | 'type' + | 'role' + | 'color' + | 'height' + | 'width' +> & { color?: Color } + +const Mark = ({ $color, disabled, className, ...props }: BoxProps & { $color: Color }) => ( + +) - &:disabled:hover { - transform: initial; - } - `, +const Input = React.forwardRef( + ({ $color, className, ...props }, ref) => ( + + + + + ), ) -export const RadioButton = React.forwardRef( +export const RadioButton = React.forwardRef( ( { description, @@ -120,13 +110,13 @@ export const RadioButton = React.forwardRef( value, checked, width, - colorStyle = 'accentPrimary', + color = 'accent', onBlur, onChange, onFocus, ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const defaultRef = React.useRef(null) const inputRef = (ref as React.RefObject) || defaultRef @@ -147,17 +137,15 @@ export const RadioButton = React.forwardRef( }} > , + props: Omit, ) => { const [state, setState] = useState('30') return ( - -
+ { + setState(e.target.value) + }, + }} > - { - setState(e.target.value) - }, - }} - > - - - - -
-
+ + + + +
) } @@ -73,7 +68,7 @@ describe('', () => { }) it('should fire onBlur when losing focus ', async () => { - const mockCallback = jest.fn() + const mockCallback = vi.fn() render( <>
outside
@@ -92,17 +87,15 @@ describe('', () => { }) it('should fire onChange when checked value does not match value', () => { - const mockCallback = jest.fn((e: any) => { + const mockCallback = vi.fn>((e) => { return e.target.value }) render( - - - - - - - , + + + + + , ) expect(mockCallback.mock.calls.length).toBe(1) expect(mockCallback.mock.results[0].value).toBe('30') @@ -112,26 +105,24 @@ describe('', () => { const PlainJaneRadios = () => { const [state, setState] = useState('HTML') return ( - -
- setState(e.target.value)}> - - - - - - - -
-
+
+ setState(e.target.value)}> + + + + + + + +
) } diff --git a/components/src/components/molecules/RadioButtonGroup/RadioButtonGroup.tsx b/components/src/components/molecules/RadioButtonGroup/RadioButtonGroup.tsx index 87d418ad..f0741b2f 100644 --- a/components/src/components/molecules/RadioButtonGroup/RadioButtonGroup.tsx +++ b/components/src/components/molecules/RadioButtonGroup/RadioButtonGroup.tsx @@ -1,25 +1,30 @@ import * as React from 'react' -import styled, { css } from 'styled-components' - -import { RadioButton } from '@/src/components' +import type { RadioButton, RadioButtonProps } from '@/src/components/molecules' import { getTestId } from '../../../utils/utils' import { createSyntheticEvent } from '../../../utils/createSyntheticEvent' +import type { BoxProps } from '../../atoms/Box/Box' +import { Box } from '../../atoms/Box/Box' -const Container = styled.div<{ $inline?: boolean }>( - ({ theme, $inline }) => css` - display: flex; - flex-direction: ${$inline ? 'row' : 'column'}; - gap: ${theme.space['2']}; - justify-content: flex-start; - flex-wrap: ${$inline ? 'wrap' : 'nowrap'}; - `, -) +const Container = React.forwardRef< + HTMLElement, + BoxProps & { $inline?: boolean } +>(({ $inline, ...props }, ref) => ( + +)) -type NativeDivProps = React.HTMLAttributes type NativeInputProps = React.InputHTMLAttributes -export type Props = { + +export type RadioButtonGroupProps = { /** Display the radio buttons in a row */ inline?: boolean /** The children of the component that conform to the basic input attributes */ @@ -32,9 +37,9 @@ export type Props = { onChange?: NativeInputProps['onChange'] /** The handler for the blur event. */ onBlur?: NativeInputProps['onBlur'] -} & Omit +} & Omit -export const RadioButtonGroup = React.forwardRef( +export const RadioButtonGroup = React.forwardRef( ( { value: _value, @@ -43,8 +48,8 @@ export const RadioButtonGroup = React.forwardRef( onChange, onBlur, ...props - }: Props, - ref: React.Ref, + }, + ref, ) => { const defaultRef = React.useRef(null) const rootRef = (ref as React.RefObject) || defaultRef @@ -54,7 +59,6 @@ export const RadioButtonGroup = React.forwardRef( const [value, setValue] = React.useState(_value) React.useEffect(() => { if (_value && _value != value) setValue(_value) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [_value]) const handleChange = (e: React.ChangeEvent) => { @@ -91,14 +95,14 @@ export const RadioButtonGroup = React.forwardRef( return ( - {React.Children.map(children, (child: any) => { + {React.Children.map(children as unknown as React.ReactElement[], (child) => { if (child.props.checked && !didSetDefault) { setDidSetDefault(true) if (value !== child.props.value) { @@ -110,7 +114,9 @@ export const RadioButtonGroup = React.forwardRef( const isChecked = child.props.value === value + // eslint-disable-next-line @eslint-react/no-clone-element return React.cloneElement(child, { + // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/issues/40888 ref: isChecked ? checkedRef : undefined, checked: isChecked, onChange: handleChange, diff --git a/components/src/components/molecules/RadioButtonGroup/index.ts b/components/src/components/molecules/RadioButtonGroup/index.ts deleted file mode 100644 index 88974a3f..00000000 --- a/components/src/components/molecules/RadioButtonGroup/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { RadioButtonGroup } from './RadioButtonGroup' -export type { Props as RadioButtonGroupProps } from './RadioButtonGroup' diff --git a/components/src/components/molecules/Select/Select.test.tsx b/components/src/components/molecules/Select/Select.test.tsx index 44fb0a15..e9491b1a 100644 --- a/components/src/components/molecules/Select/Select.test.tsx +++ b/components/src/components/molecules/Select/Select.test.tsx @@ -1,46 +1,43 @@ import * as React from 'react' -import { ThemeProvider } from 'styled-components' - -import { cleanup, render, screen, userEvent, waitFor } from '@/test' - -import { lightTheme } from '@/src/tokens' +import { + cleanup, + render, + screen, + userEvent, + waitFor, +} from '@/test' import { Select } from './Select' -import { Input } from '../Input' describe(' - , + - , + ', () => { }) it('should call onChange when selection made', async () => { - const mockCallback = jest.fn((e: any) => [ + const mockCallback = vi.fn<((e: React.ChangeEvent) => void)>(e => [ e.target.value, e.currentTarget.value, ]) render( - - , ) await userEvent.click(screen.getByTestId('select-container')) await userEvent.click(screen.getByTestId('select-option-1')) @@ -74,31 +69,27 @@ describe(' - , + - , + ', () => { }) it('should not allow disabled option to be selected', async () => { - const mockCallback = jest.fn() + const mockCallback = vi.fn() render( - + ', () => { ]} onChange={mockCallback} /> - , - ) - await userEvent.click(screen.getByTestId('select-container')) - await userEvent.click(screen.getByText('Two')) - expect(screen.getAllByText('Two').length).toEqual(1) - }) - - it('should close dropdown when clicking outside of element', async () => { - const mockCallback = jest.fn() - render( - -
-
outside
- -
-
, +
+
outside
+ ', () => { it('should update selection using arrows', async () => { render( - - , ) await userEvent.click(screen.getByTestId('select-container')) @@ -222,23 +205,21 @@ describe(' -
- , +
+
outside
+ ', () => { }) it('should show create options only if it is unique ', async () => { - const mockCallback = jest.fn() + const mockCallback = vi.fn() render( - -
-
outside
- +
, ) await userEvent.click(screen.getByTestId('select-container')) @@ -301,23 +280,21 @@ describe(' -
- , +
+
outside
+ ', () => { }) it('should call on create if create option is selected with arrows and enter is pressed', async () => { - const mockCallback = jest.fn() + const mockCallback = vi.fn() render( - -
-
outside
- - , +
, ) await waitFor(() => { - expect(ref.current).toBeInstanceOf(HTMLInputElement) + expect(screen.getByText('select')).toBeVisible() }) - }) - - it('should show dropdown menu when clicked and embeded in Input', async () => { - render( - - - } - prefixAs="div" - /> - , - ) - await userEvent.click(screen.getByTestId('select-container')) + await userEvent.type(screen.getByTestId('select-input'), 'onsies') + await userEvent.type(screen.getByTestId('select-input'), '{arrowdown}') + await userEvent.type(screen.getByTestId('select-input'), '{enter}') + await waitFor(() => { - expect(screen.getByText('One')).toBeVisible() - expect(screen.getByText('Two')).toBeVisible() + expect(mockCallback).toBeCalledWith('onsies') }) }) - it('should have focus on input when clicked and embeded in Input as autocomplete Select', async () => { + it('should pass a ref down', async () => { + const ref = { current: null } as React.RefObject render( - - - } - prefixAs="div" - /> - , +