From 1b4ed3d9f53de3ccf5f7c2710d1feab7a0828929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=B3vis=20Neto?= Date: Wed, 25 Sep 2024 11:07:37 +0200 Subject: [PATCH 1/2] chore: move external components from safe-react-components repo to the tx-builder/components folder --- .../src/components/Accordion/index.tsx | 87 ++++++++ apps/tx-builder/src/components/Button.tsx | 204 +++++++++++++++++ apps/tx-builder/src/components/Card/index.tsx | 36 +++ apps/tx-builder/src/components/Divider.tsx | 29 +++ apps/tx-builder/src/components/Dot/index.tsx | 24 ++ .../tx-builder/src/components/ETHHashInfo.tsx | 149 +++++++++++++ .../src/components/EllipsisMenu/index.tsx | 102 +++++++++ .../src/components/GenericModal.tsx | 121 ++++++++++ apps/tx-builder/src/components/Link/index.tsx | 21 ++ .../src/components/Loader/index.tsx | 34 +++ apps/tx-builder/src/components/Switch.tsx | 46 ++++ apps/tx-builder/src/components/Text.tsx | 66 ++++++ apps/tx-builder/src/components/Title.tsx | 72 ++++++ apps/tx-builder/src/components/Tooltip.tsx | 110 +++++++++ .../components/buttons/ButtonLink/index.tsx | 52 +++++ .../CopyToClipboardBtn/copyTextToClipboard.ts | 24 ++ .../buttons/CopyToClipboardBtn/index.tsx | 78 +++++++ .../buttons/ExplorerButton/index.tsx | 59 +++++ .../components/buttons/Identicon/index.tsx | 32 +++ .../components/forms/fields/AddressInput.tsx | 208 ++++++++++++++++++ .../forms/fields/TextFieldInput.tsx | 57 +++++ .../src/components/forms/fields/styles.ts | 118 ++++++++++ apps/tx-builder/src/hooks/useDebounce.ts | 14 ++ apps/tx-builder/src/hooks/useThrottle.ts | 26 +++ apps/tx-builder/src/utils/address.ts | 59 +++++ apps/tx-builder/src/utils/strings.ts | 25 +++ 26 files changed, 1853 insertions(+) create mode 100644 apps/tx-builder/src/components/Accordion/index.tsx create mode 100644 apps/tx-builder/src/components/Button.tsx create mode 100644 apps/tx-builder/src/components/Card/index.tsx create mode 100644 apps/tx-builder/src/components/Divider.tsx create mode 100644 apps/tx-builder/src/components/Dot/index.tsx create mode 100644 apps/tx-builder/src/components/ETHHashInfo.tsx create mode 100644 apps/tx-builder/src/components/EllipsisMenu/index.tsx create mode 100644 apps/tx-builder/src/components/GenericModal.tsx create mode 100644 apps/tx-builder/src/components/Link/index.tsx create mode 100644 apps/tx-builder/src/components/Loader/index.tsx create mode 100644 apps/tx-builder/src/components/Switch.tsx create mode 100644 apps/tx-builder/src/components/Text.tsx create mode 100644 apps/tx-builder/src/components/Title.tsx create mode 100644 apps/tx-builder/src/components/Tooltip.tsx create mode 100644 apps/tx-builder/src/components/buttons/ButtonLink/index.tsx create mode 100644 apps/tx-builder/src/components/buttons/CopyToClipboardBtn/copyTextToClipboard.ts create mode 100644 apps/tx-builder/src/components/buttons/CopyToClipboardBtn/index.tsx create mode 100644 apps/tx-builder/src/components/buttons/ExplorerButton/index.tsx create mode 100644 apps/tx-builder/src/components/buttons/Identicon/index.tsx create mode 100644 apps/tx-builder/src/components/forms/fields/AddressInput.tsx create mode 100644 apps/tx-builder/src/components/forms/fields/TextFieldInput.tsx create mode 100644 apps/tx-builder/src/components/forms/fields/styles.ts create mode 100644 apps/tx-builder/src/hooks/useDebounce.ts create mode 100644 apps/tx-builder/src/hooks/useThrottle.ts create mode 100644 apps/tx-builder/src/utils/address.ts create mode 100644 apps/tx-builder/src/utils/strings.ts diff --git a/apps/tx-builder/src/components/Accordion/index.tsx b/apps/tx-builder/src/components/Accordion/index.tsx new file mode 100644 index 00000000..36fc1431 --- /dev/null +++ b/apps/tx-builder/src/components/Accordion/index.tsx @@ -0,0 +1,87 @@ +import { ReactElement } from 'react' +import AccordionMUI, { AccordionProps as AccordionMUIProps } from '@material-ui/core/Accordion' +import AccordionSummaryMUI, { + AccordionSummaryProps as AccordionSummaryMUIProps, +} from '@material-ui/core/AccordionSummary' +import styled from 'styled-components' +import FixedIcon from '../FixedIcon' + +type AccordionProps = AccordionMUIProps & { + compact?: boolean +} + +type StyledAccordionProps = AccordionMUIProps & { + $compact?: AccordionProps['compact'] +} + +const StyledAccordion = styled(AccordionMUI)` + &.MuiAccordion-root { + border-radius: ${({ $compact }) => ($compact ? '8px' : '0')}; + border: ${({ $compact, theme }) => ($compact ? '2px solid ' + theme.palette.divider : 'none')}; + border-bottom: 2px solid ${({ theme }) => theme.palette.divider}; + margin-bottom: ${({ $compact }) => ($compact ? '16px' : '0')}; + overflow: hidden; + + &:before { + height: 0; + } + + &:first-child { + border-top: 2px solid ${({ theme }) => theme.palette.divider}; + } + + &.Mui-expanded { + margin: ${({ $compact }) => ($compact ? '0 0 16px 0' : '0')}; + } + + .MuiAccordionDetails-root { + padding: 16px; + } + } +` + +const StyledAccordionSummary = styled(AccordionSummaryMUI)` + &.MuiAccordionSummary-root { + &.Mui-expanded { + min-height: 48px; + border-bottom: 2px solid ${({ theme }) => theme.palette.divider}; + background-color: ${({ theme }) => theme.palette.background.default}; + } + + &:hover { + background-color: ${({ theme }) => theme.palette.background.default}; + } + + .MuiAccordionSummary-content { + &.Mui-expanded { + margin: 0; + } + } + .MuiIconButton-root { + font-size: 0; + padding: 16px; + } + } +` + +export const Accordion = ({ compact, children, ...props }: AccordionProps): ReactElement => { + return ( + + {children} + + ) +} + +export const AccordionSummary = ({ + children, + ...props +}: AccordionSummaryMUIProps): ReactElement => { + return ( + } {...props}> + {children} + + ) +} + +export { default as AccordionActions } from '@material-ui/core/AccordionActions' +export { default as AccordionDetails } from '@material-ui/core/AccordionDetails' diff --git a/apps/tx-builder/src/components/Button.tsx b/apps/tx-builder/src/components/Button.tsx new file mode 100644 index 00000000..739dd810 --- /dev/null +++ b/apps/tx-builder/src/components/Button.tsx @@ -0,0 +1,204 @@ +import React, { ReactElement, ReactNode, HTMLAttributes } from 'react' +import ButtonMUI, { ButtonProps as ButtonMUIProps } from '@material-ui/core/Button' +import { alpha } from '@material-ui/core/styles' + +import styled, { css, DefaultTheme, FlattenInterpolation, ThemeProps } from 'styled-components' +import { Icon, IconProps, IconTypes } from './Icon' + +type Colors = 'primary' | 'secondary' | 'error' +type Variations = 'bordered' | 'contained' | 'outlined' + +type CustomButtonMuiProps = Omit & { + to?: string + component?: ReactNode +} +type LocalProps = { + children?: ReactNode + color?: Colors + variant?: Variations + iconType?: IconProps['type'] + iconSize?: IconProps['size'] +} + +type Props = LocalProps & CustomButtonMuiProps & HTMLAttributes + +const StyledIcon = styled(Icon)` + margin-right: 5px; +` + +const customStyles: { + [key in Colors]: { + [key in Variations]: FlattenInterpolation> + } +} = { + primary: { + contained: css` + color: ${({ theme }) => theme.palette.common.white}; + background-color: ${({ theme }) => theme.palette.primary.main}; + box-shadow: 1px 2px 10px ${alpha('#28363D', 0.18)}; + + &:hover { + color: ${({ theme }) => theme.palette.common.white}; + background-color: ${({ theme }) => theme.palette.primary.dark}; + } + `, + outlined: css` + color: ${({ theme }) => theme.palette.primary.main}; + background-color: transparent; + path.icon-color { + fill: ${({ theme }) => theme.palette.primary.main}; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.primary.main}; + } + + &:hover { + color: ${({ theme }) => theme.palette.primary.dark}; + } + `, + bordered: css` + color: ${({ theme }) => theme.palette.primary.main}; + background-color: transparent; + border: 2px solid ${({ theme }) => theme.palette.primary.main}; + path.icon-color { + fill: ${({ theme }) => theme.palette.primary.main}; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.primary.main}; + } + `, + }, + secondary: { + contained: css` + color: ${({ theme }) => theme.palette.primary}; + background-color: ${({ theme }) => theme.palette.secondary.main}; + box-shadow: 1px 2px 10px ${alpha('#28363D', 0.18)}; + + path.icon-color { + color: ${({ theme }) => theme.palette.common.primary}; + } + + &:hover { + path.icon-color { + color: ${({ theme }) => theme.palette.common.primary}; + } + + background-color: ${({ theme }) => theme.palette.secondary.dark}; + } + `, + outlined: css` + color: ${({ theme }) => theme.palette.secondary.main}; + background-color: transparent; + path.icon-color { + fill: ${({ theme }) => theme.palette.secondary.main}; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.secondary.main}; + } + `, + bordered: css` + color: ${({ theme }) => theme.palette.secondary.main}; + background-color: transparent; + border: 2px solid ${({ theme }) => theme.palette.secondary.main}; + path.icon-color { + fill: ${({ theme }) => theme.palette.secondary.main}; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.secondary.main}; + } + `, + }, + error: { + contained: css` + color: ${({ theme }) => theme.palette.common.white}; + background-color: ${({ theme }) => theme.palette.error.main}; + box-shadow: 1px 2px 10px ${alpha('#28363D', 0.18)}; + + &:hover { + background-color: ${({ theme }) => theme.palette.error.dark}; + } + `, + outlined: css` + color: ${({ theme }) => theme.palette.error.main}; + background-color: transparent; + path.icon-color { + fill: ${({ theme }) => theme.palette.error.main}; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.error.main}; + } + `, + bordered: css` + color: ${({ theme }) => theme.palette.error.main}; + background-color: transparent; + border: 2px solid ${({ theme }) => theme.palette.error.main}; + path.icon-color { + fill: ${({ theme }) => theme.palette.error.main}; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.error.main}; + } + `, + }, +} + +const StyledButton = styled(ButtonMUI)<{ $localProps: LocalProps }>` + && { + font-weight: 700; + padding: 8px 1.4rem; + min-width: 120px; + + &.MuiButton-root { + text-transform: none; + border-radius: 8px; + letter-spacing: 0; + } + + &.Mui-disabled { + color: ${({ theme }) => theme.palette.common.white}; + } + + path.icon-color { + fill: ${({ theme }) => theme.palette.common.white}; + } + + &:disabled { + opacity: 0.5; + } + + ${({ $localProps }) => { + if ($localProps.color !== undefined && $localProps.variant !== undefined) { + return customStyles[$localProps.color][$localProps.variant] + } + }} + } +` + +const Button = ({ + children, + color = 'primary', + variant = 'contained', + iconType, + iconSize, + // We need destructuring all LocalProps, remaining props are for CustomButtonMuiProps + ...buttonMuiProps +}: Props): ReactElement => { + return ( + + {iconType && iconSize && } + {children} + + ) +} + +export default Button diff --git a/apps/tx-builder/src/components/Card/index.tsx b/apps/tx-builder/src/components/Card/index.tsx new file mode 100644 index 00000000..83f161ce --- /dev/null +++ b/apps/tx-builder/src/components/Card/index.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import styled from 'styled-components' +import { alpha } from '@material-ui/core/styles' + +const StyledCard = styled.div` + box-shadow: 1px 2px 10px 0 ${alpha('#28363D', 0.18)}; + border-radius: 8px; + padding: 24px; + background-color: ${({ theme }) => theme.palette.common.white}; + position: relative; +` + +const Disabled = styled.div` + opacity: 0.5; + position: absolute; + height: 100%; + width: 100%; + background-color: ${({ theme }) => theme.palette.common.white}; + z-index: 1; + top: 0; + left: 0; +` + +type Props = { + className?: string + disabled?: boolean +} & React.HTMLAttributes + +const Card: React.FC = ({ className, children, disabled, ...rest }): React.ReactElement => ( + + {disabled && } + {children} + +) + +export default Card diff --git a/apps/tx-builder/src/components/Divider.tsx b/apps/tx-builder/src/components/Divider.tsx new file mode 100644 index 00000000..93c28df3 --- /dev/null +++ b/apps/tx-builder/src/components/Divider.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import styled from 'styled-components' + +type Props = { + className?: string + orientation?: 'vertical' | 'horizontal' +} + +const HorizontalDivider = styled.div` + margin: 16px -1.6rem; + border-top: solid 1px #dcdee0; + width: calc(100% + 3.2rem); +` + +const VerticalDivider = styled.div` + border-right: 1px solid ${({ theme }) => theme.legacy.colors.separator}; + margin: 0 5px; + height: 100%; +` + +const Divider = ({ className, orientation }: Props): React.ReactElement => { + return orientation === 'vertical' ? ( + + ) : ( + + ) +} + +export default Divider diff --git a/apps/tx-builder/src/components/Dot/index.tsx b/apps/tx-builder/src/components/Dot/index.tsx new file mode 100644 index 00000000..df53a564 --- /dev/null +++ b/apps/tx-builder/src/components/Dot/index.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import styled from 'styled-components' +import { type Theme } from '@material-ui/core/styles' + +type Props = { + className?: string + color: keyof Theme['palette'] +} + +const StyledDot = styled.div` + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + height: 36px; + width: 36px; + background-color: ${({ theme, color }) => theme.palette[color].main}; +` + +const Dot: React.FC = ({ children, ...rest }): React.ReactElement => ( + {children} +) + +export default Dot diff --git a/apps/tx-builder/src/components/ETHHashInfo.tsx b/apps/tx-builder/src/components/ETHHashInfo.tsx new file mode 100644 index 00000000..e03611de --- /dev/null +++ b/apps/tx-builder/src/components/ETHHashInfo.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { BreakpointDefaults } from '@material-ui/core/styles/createBreakpoints' +import { textShortener } from '../utils/strings' +import { EllipsisMenuItem } from '@gnosis.pm/safe-react-components' +import Text from './Text' +import { Theme } from '@material-ui/core' +import ExplorerButton from './buttons/ExplorerButton' +import Identicon, { identiconSizes } from './buttons/Identicon' +import CopyToClipboardBtn from './buttons/CopyToClipboardBtn' +import EllipsisMenu from './EllipsisMenu' + +export type ExplorerInfo = () => { url: string; alt: string } + +const StyledContainer = styled.div` + display: flex; + align-items: center; +` + +const AvatarContainer = styled.div` + display: flex; + margin-right: 8px; +` + +const InfoContainer = styled.div` + display: flex; + align-items: flex-start; + justify-content: center; + flex-direction: column; +` + +const AddressContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; +` + +const StyledImg = styled.img<{ size: keyof typeof identiconSizes }>` + height: ${({ size }) => identiconSizes[size]}; + width: ${({ size }) => identiconSizes[size]}; +` + +type Props = { + className?: string + hash: string + showHash?: boolean + shortenHash?: number + name?: string + strongName?: boolean + textColor?: keyof Theme['palette'] + textSize?: keyof BreakpointDefaults + showAvatar?: boolean + customAvatar?: string + customAvatarFallback?: string + avatarSize?: keyof BreakpointDefaults + showCopyBtn?: boolean + menuItems?: EllipsisMenuItem[] + explorerUrl?: ExplorerInfo +} + +type ShortNameProps = + | { + shouldShowShortName: boolean + shouldCopyShortName?: boolean + shortName: string + } + | { + shouldShowShortName?: boolean + shouldCopyShortName: boolean + shortName: string + } + | { + shouldShowShortName?: never + shouldCopyShortName?: never + shortName?: string + } + +type EthHashInfoProps = Props & ShortNameProps + +const EthHashInfo = ({ + hash, + showHash = true, + name, + className, + shortenHash, + showAvatar, + customAvatar, + customAvatarFallback, + avatarSize = 'md', + showCopyBtn, + menuItems, + explorerUrl, + shortName, + shouldShowShortName, + shouldCopyShortName, +}: EthHashInfoProps): React.ReactElement => { + const [fallbackToIdenticon, setFallbackToIdenticon] = useState(false) + const [fallbackSrc, setFallabckSrc] = useState(undefined) + + const setAppImageFallback = (): void => { + if (customAvatarFallback && !fallbackToIdenticon) { + setFallabckSrc(customAvatarFallback) + } else { + setFallbackToIdenticon(true) + } + } + + return ( + + {showAvatar && ( + + {!fallbackToIdenticon && customAvatar ? ( + + ) : ( + + )} + + )} + + + {name && {name}} + + {showHash && ( + + {shouldShowShortName && ( + + {shortName}: + + )} + {shortenHash ? textShortener(hash, shortenHash + 2, shortenHash) : hash} + + )} + {showCopyBtn && ( + + )} + {explorerUrl && } + {menuItems && } + + + + ) +} + +export default EthHashInfo diff --git a/apps/tx-builder/src/components/EllipsisMenu/index.tsx b/apps/tx-builder/src/components/EllipsisMenu/index.tsx new file mode 100644 index 00000000..ff563b51 --- /dev/null +++ b/apps/tx-builder/src/components/EllipsisMenu/index.tsx @@ -0,0 +1,102 @@ +import { ClickAwayListener } from '@material-ui/core' +import Menu from '@material-ui/core/Menu' +import MenuItem from '@material-ui/core/MenuItem' +import React from 'react' +import styled from 'styled-components' +import FixedIcon from '../FixedIcon' + +const StyledMenu = styled(Menu)` + && { + .MuiMenu-paper { + box-shadow: 0 0 4px rgba(0, 0, 0, 0.1); + } + + .MuiMenu-list { + div:not(:first-child) { + border-top: 1px solid ${({ theme }) => theme.palette.divider}; + } + } + } +` + +const MenuWrapper = styled.div` + display: flex; +` + +const MenuItemWrapper = styled.div` + :focus { + outline-color: ${({ theme }) => theme.palette.divider}; + } +` + +const IconWrapper = styled.button` + background: none; + border: none; + cursor: pointer; + margin: 0; + border-radius: 50%; + transition: background-color 0.2s ease-in-out; + outline-color: transparent; + height: 24px; + width: 24px; + + span { + display: flex; + } + + :hover { + background-color: ${({ theme }) => theme.palette.divider}; + } +` + +export type EllipsisMenuItem = { + label: string + disabled?: boolean + onClick: () => void +} + +type Props = { + menuItems: EllipsisMenuItem[] +} + +const EllipsisMenu = ({ menuItems }: Props): React.ReactElement => { + const [anchorEl, setAnchorEl] = React.useState(null) + + const handleClick = (event: React.MouseEvent): void => + setAnchorEl(event.currentTarget) + + const closeMenuHandler = () => { + setAnchorEl(null) + } + + const onMenuItemClick = (item: EllipsisMenuItem) => { + item.onClick() + closeMenuHandler() + } + + return ( + + + + + + + {menuItems.map(item => ( + + onMenuItemClick(item)}> + {item.label} + + + ))} + + + + ) +} + +export default EllipsisMenu diff --git a/apps/tx-builder/src/components/GenericModal.tsx b/apps/tx-builder/src/components/GenericModal.tsx new file mode 100644 index 00000000..d0f15d52 --- /dev/null +++ b/apps/tx-builder/src/components/GenericModal.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import Modal from '@material-ui/core/Modal' +import { makeStyles } from '@material-ui/core/styles' +import { alpha } from '@material-ui/core/styles' +import styled from 'styled-components' +import Media from 'react-media' +import { defaultTheme } from '../theme/safeTheme' +import { Typography } from '@material-ui/core' +import { Icon } from './Icon' + +const StyledButton = styled.button` + background: none; + border: none; + padding: 5px; + width: 26px; + height: 26px; + + span { + margin-right: 0; + } + + :focus { + outline: none; + } + + :hover { + background: ${({ theme }) => theme.palette.divider}; + border-radius: 16px; + } +` + +const TitleSection = styled.div` + display: flex; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 2px solid ${({ theme }) => theme.palette.divider}; +` + +const BodySection = styled.div<{ + withoutBodyPadding?: boolean + smallHeight: boolean +}>` + max-height: ${({ smallHeight }) => (smallHeight ? '280px' : '460px')}; + overflow-y: auto; + padding: ${({ withoutBodyPadding }) => (withoutBodyPadding ? '0' : '16px 24px')}; +` + +const FooterSection = styled.div` + border-top: 2px solid ${({ theme }) => theme.palette.divider}; + padding: 16px 24px; +` + +export type GenericModalProps = { + title: string | React.ReactNode + body: React.ReactNode + withoutBodyPadding?: boolean + footer?: React.ReactNode + onClose: () => void +} + +const useStyles = makeStyles({ + modal: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflowY: 'scroll', + background: alpha('#E8E7E6', 0.75), + }, + + paper: { + position: (props: { smallHeight: boolean }) => (props.smallHeight ? 'relative' : 'absolute'), + top: (props: { smallHeight: boolean }) => (props.smallHeight ? 'unset' : '121px'), + minWidth: '500px', + width: (props: { smallHeight: boolean }) => (props.smallHeight ? '500px' : 'inherit'), + backgroundColor: defaultTheme.palette.common.white, + borderRadius: '8px', + boxShadow: `0 0 0.75 0 #28363D`, + + '&:focus': { + outline: 'none', + }, + }, +}) + +const GenericModalComponent = ({ + body, + footer, + onClose, + title, + withoutBodyPadding, + smallHeight, +}: GenericModalProps & { smallHeight: boolean }) => { + const classes = useStyles({ smallHeight }) + + return ( + +
+ + {title} + + + + + + + {body} + + + {footer && {footer}} +
+
+ ) +} + +const GenericModal = (props: GenericModalProps): React.ReactElement => ( + + {matches => } + +) + +export default GenericModal diff --git a/apps/tx-builder/src/components/Link/index.tsx b/apps/tx-builder/src/components/Link/index.tsx new file mode 100644 index 00000000..0e13278d --- /dev/null +++ b/apps/tx-builder/src/components/Link/index.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import styled from 'styled-components' +import { type Theme } from '@material-ui/core/styles' + +export interface Props extends React.AnchorHTMLAttributes { + color?: keyof Theme['palette'] | 'white' +} + +const StyledLink = styled.a` + text-decoration: none; + cursor: pointer; + color: ${({ theme, color = 'primary' }) => + color === 'white' ? theme.palette.common.white : theme.palette[color].dark}; + font-family: ${({ theme }) => theme.typography.fontFamily}; +` + +const Link: React.FC = ({ children, ...rest }): React.ReactElement => { + return {children} +} + +export default Link diff --git a/apps/tx-builder/src/components/Loader/index.tsx b/apps/tx-builder/src/components/Loader/index.tsx new file mode 100644 index 00000000..c0e6d75b --- /dev/null +++ b/apps/tx-builder/src/components/Loader/index.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import styled from 'styled-components' +import CircularProgress from '@material-ui/core/CircularProgress' +import { type Theme } from '@material-ui/core/styles' + +const loaderSizes = { + xxs: '10px', + xs: '16px', + sm: '30px', + md: '50px', + lg: '70px', +} + +type Props = { + size: keyof typeof loaderSizes + color?: keyof Theme['palette'] + className?: string +} + +const StyledCircularProgress = styled( + ({ size, className }: Props): React.ReactElement => ( + + ), +)` + &.MuiCircularProgress-colorPrimary { + color: ${({ theme, color = 'primary' }) => theme.palette[color].main}; + } +` + +const Loader = ({ className, size, color }: Props): React.ReactElement => ( + +) + +export default Loader diff --git a/apps/tx-builder/src/components/Switch.tsx b/apps/tx-builder/src/components/Switch.tsx new file mode 100644 index 00000000..bb027a50 --- /dev/null +++ b/apps/tx-builder/src/components/Switch.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import SwitchMui from '@material-ui/core/Switch' +import styled from 'styled-components' +import { alpha } from '@material-ui/core/styles' + +const StyledSwitch = styled(({ ...rest }) => )` + && { + .MuiSwitch-thumb { + background: ${({ theme, checked }) => + checked ? theme.palette.secondary.main : theme.palette.common.white}; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.2), + 0 0 1px rgba(0, 0, 0, 0.5); + } + + .MuiSwitch-track { + background: ${({ theme }) => theme.palette.common.black}; + } + + .MuiIconButton-label, + .MuiSwitch-colorSecondary.Mui-checked { + color: ${({ checked, theme }) => (checked ? theme.palette.secondary.dark : '#B2B5B2')}; + } + + .MuiSwitch-colorSecondary.Mui-checked:hover { + background-color: ${({ theme }) => alpha(theme.palette.secondary.dark, 0.08)}; + } + + .Mui-checked + .MuiSwitch-track { + background-color: ${({ theme }) => theme.palette.secondary.dark}; + } + } +` + +type Props = { + checked: boolean + onChange: (checked: boolean) => void +} + +const Switch = ({ checked, onChange }: Props): React.ReactElement => { + const onSwitchChange = (_event: any, checked: boolean) => onChange(checked) + + return +} + +export default Switch diff --git a/apps/tx-builder/src/components/Text.tsx b/apps/tx-builder/src/components/Text.tsx new file mode 100644 index 00000000..ea4cb567 --- /dev/null +++ b/apps/tx-builder/src/components/Text.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import Tooltip from '@material-ui/core/Tooltip' +import { withStyles, alpha } from '@material-ui/core/styles' +import { type Theme } from '@material-ui/core/styles' + +import { Typography, TypographyProps } from '@material-ui/core' +import styled from 'styled-components' + +type Props = { + children: React.ReactNode + tooltip?: string + color?: keyof Theme['palette'] | 'white' + className?: string + component?: 'span' | 'p' + strong?: boolean + center?: boolean +} + +const StyledTooltip = withStyles(theme => ({ + tooltip: { + backgroundColor: theme.palette.common.white, + color: theme.palette.text.primary, + boxShadow: `0px 0px 10px ${alpha('#28363D', 0.2)}`, + }, + arrow: { + color: theme.palette.common.white, + boxShadow: 'transparent', + }, +}))(Tooltip) + +const StyledTypography = styled(Typography)<{ $color?: keyof Theme['palette'] | 'white' } & Props>` + color: ${({ $color, theme }) => + $color + ? $color === 'white' + ? theme.palette.common.white + : theme.palette[$color].main + : theme.palette.primary.main}; + + ${({ center }) => center && 'text-align: center;'} + + ${({ strong }) => strong && `font-weight: bold;`} +` + +const Text = ({ + children, + component = 'p', + tooltip, + color, + ...rest +}: Props & Omit): React.ReactElement => { + const TextElement = ( + + {children} + + ) + + return tooltip === undefined ? ( + TextElement + ) : ( + + {TextElement} + + ) +} + +export default Text diff --git a/apps/tx-builder/src/components/Title.tsx b/apps/tx-builder/src/components/Title.tsx new file mode 100644 index 00000000..7600e6c2 --- /dev/null +++ b/apps/tx-builder/src/components/Title.tsx @@ -0,0 +1,72 @@ +import { BreakpointDefaults } from '@material-ui/core/styles/createBreakpoints' +import React from 'react' +import styled from 'styled-components' + +type Props = { + children: string | React.ReactNode + size: keyof BreakpointDefaults + withoutMargin?: boolean + strong?: boolean +} + +const StyledH1 = styled.h1<{ withoutMargin?: boolean; strong?: boolean }>` + font-family: ${({ theme }) => theme.legacy.fonts.fontFamily}; + font-size: ${({ theme }) => theme.legacy.title.size.xl.fontSize}; + line-height: ${({ theme }) => theme.legacy.title.size.xl.lineHeight}; + font-weight: ${({ strong }) => (strong ? 'bold' : 'normal')}; + margin: ${({ withoutMargin }) => (withoutMargin ? 0 : '30px')} 0; +` + +const StyledH2 = styled.h2<{ withoutMargin?: boolean; strong?: boolean }>` + font-family: ${({ theme }) => theme.legacy.fonts.fontFamily}; + font-size: ${({ theme }) => theme.legacy.title.size.lg.fontSize}; + line-height: ${({ theme }) => theme.legacy.title.size.lg.lineHeight}; + font-weight: ${({ strong }) => (strong ? 'bold' : 'normal')}; + margin: ${({ withoutMargin }) => (withoutMargin ? 0 : '28px')} 0; +` + +const StyledH3 = styled.h3<{ withoutMargin?: boolean; strong?: boolean }>` + font-family: ${({ theme }) => theme.legacy.fonts.fontFamily}; + font-size: ${({ theme }) => theme.legacy.title.size.md.fontSize}; + line-height: ${({ theme }) => theme.legacy.title.size.md.lineHeight}; + font-weight: ${({ strong }) => (strong ? 'bold' : 'normal')}; + margin: ${({ withoutMargin }) => (withoutMargin ? 0 : '26px')} 0; +` + +const StyledH4 = styled.h4<{ withoutMargin?: boolean; strong?: boolean }>` + font-family: ${({ theme }) => theme.legacy.fonts.fontFamily}; + font-size: ${({ theme }) => theme.legacy.title.size.sm.fontSize}; + line-height: ${({ theme }) => theme.legacy.title.size.sm.lineHeight}; + font-weight: ${({ strong }) => (strong ? 'bold' : 'normal')}; + margin: ${({ withoutMargin }) => (withoutMargin ? 0 : '22px')} 0; +` + +const StyledH5 = styled.h5<{ withoutMargin?: boolean; strong?: boolean }>` + font-family: ${({ theme }) => theme.legacy.fonts.fontFamily}; + font-size: ${({ theme }) => theme.legacy.title.size.xs.fontSize}; + line-height: ${({ theme }) => theme.legacy.title.size.xs.lineHeight}; + font-weight: ${({ strong }) => (strong ? 'bold' : 'normal')}; + margin: ${({ withoutMargin }) => (withoutMargin ? 0 : '18px')} 0; +` + +const Title = ({ children, size, ...rest }: Props) => { + switch (size) { + case 'xl': { + return {children} + } + case 'lg': { + return {children} + } + case 'md': { + return {children} + } + case 'sm': { + return {children} + } + case 'xs': { + return {children} + } + } +} + +export default Title diff --git a/apps/tx-builder/src/components/Tooltip.tsx b/apps/tx-builder/src/components/Tooltip.tsx new file mode 100644 index 00000000..62cc86cd --- /dev/null +++ b/apps/tx-builder/src/components/Tooltip.tsx @@ -0,0 +1,110 @@ +import { ReactElement } from 'react' +import MUITooltip, { TooltipProps as TooltipPropsMui } from '@material-ui/core/Tooltip' +import { withStyles, alpha, type Theme } from '@material-ui/core/styles' +import { BreakpointDefaults } from '@material-ui/core/styles/createBreakpoints' +import { PaletteColor } from '@material-ui/core/styles/createPalette' + +type TooltipProps = { + size?: keyof BreakpointDefaults + backgroundColor?: keyof Theme['palette'] + textColor?: keyof Theme['palette'] + padding?: string + border?: string +} + +const getPaddingBySize = (size: keyof BreakpointDefaults): string => { + switch (size) { + case 'lg': + return '8px 16px' + default: + return '4px 8px' + } +} + +const getBorderBySize = (size: keyof BreakpointDefaults): string => { + switch (size) { + case 'lg': + return 'none' + default: + return `1px solid #B2B5B2` + } +} + +const getFontInfoBySize = ( + size: keyof BreakpointDefaults, +): { + fontSize: string + lineHeight: string +} => { + switch (size) { + case 'lg': + return { + fontSize: '14px', + lineHeight: '20px', + } + default: + return { + fontSize: '12px', + lineHeight: '16px', + } + } +} + +const customTooltip = ({ backgroundColor, textColor, size = 'md' }: TooltipProps) => + withStyles(theme => ({ + popper: { + zIndex: 2001, + }, + tooltip: { + backgroundColor: + backgroundColor && theme.palette[backgroundColor] + ? (theme.palette[backgroundColor] as PaletteColor).main + : '#E8E7E6', + boxShadow: `1px 2px 10px ${alpha('#28363D', 0.18)}`, + border: getBorderBySize(size), + color: textColor + ? (theme.palette[textColor] as PaletteColor).main + : backgroundColor === 'primary' + ? theme.palette.common.white + : theme.palette.text.primary, + borderRadius: '4px', + fontFamily: theme.typography.fontFamily, + padding: getPaddingBySize(size), + fontSize: getFontInfoBySize(size).fontSize, + lineHeight: getFontInfoBySize(size).lineHeight, + }, + arrow: { + color: backgroundColor ? (theme.palette[backgroundColor] as PaletteColor).main : '#E8E7E6', + border: 'none', + + '&::before': { + boxShadow: `1px 2px 10px ${alpha('#28363D', 0.18)}`, + }, + }, + }))(MUITooltip) + +type Props = { + title: string + children: ReactElement +} & TooltipProps + +export const Tooltip = ({ + title, + backgroundColor, + textColor, + children, + size, + ...rest +}: Props & TooltipPropsMui): ReactElement => { + const StyledTooltip = customTooltip({ + backgroundColor, + textColor, + size, + }) + + return ( + + {children} + + ) +} diff --git a/apps/tx-builder/src/components/buttons/ButtonLink/index.tsx b/apps/tx-builder/src/components/buttons/ButtonLink/index.tsx new file mode 100644 index 00000000..00137908 --- /dev/null +++ b/apps/tx-builder/src/components/buttons/ButtonLink/index.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import styled from 'styled-components' +import { Icon, IconProps, IconType } from '../../Icon' +import Text from '../../Text' +import { TypographyProps } from '@material-ui/core' +import { type Theme } from '@material-ui/core/styles' + +export interface Props extends React.ComponentPropsWithoutRef<'button'> { + iconType?: keyof IconType + iconSize?: IconProps['size'] + textSize?: TypographyProps['variant'] + color: keyof Theme['palette'] + children?: React.ReactNode +} + +const StyledButtonLink = styled.button` + background: transparent; + border: none; + text-decoration: none; + cursor: pointer; + color: ${({ theme, color }) => theme.palette[color].main}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + display: flex; + align-items: center; + + :focus { + outline: none; + } +` + +const StyledText = styled(Text)` + margin: 0 4px; +` + +const ButtonLink = ({ + iconType, + iconSize = 'md', + children, + textSize = 'body1', + ...rest +}: Props): React.ReactElement => { + return ( + + {iconType && } + + {children} + + + ) +} + +export default ButtonLink diff --git a/apps/tx-builder/src/components/buttons/CopyToClipboardBtn/copyTextToClipboard.ts b/apps/tx-builder/src/components/buttons/CopyToClipboardBtn/copyTextToClipboard.ts new file mode 100644 index 00000000..a5939018 --- /dev/null +++ b/apps/tx-builder/src/components/buttons/CopyToClipboardBtn/copyTextToClipboard.ts @@ -0,0 +1,24 @@ +const copyTextToClipboard = (text: string): void => { + const listener = (e: ClipboardEvent): void => { + e.preventDefault() + if (e.clipboardData) { + e.clipboardData.setData('text/plain', text) + } + } + + const range = document.createRange() + + const documentSelection = document.getSelection() + if (!documentSelection) { + return + } + + range.selectNodeContents(document.body) + documentSelection.addRange(range) + document.addEventListener('copy', listener) + document.execCommand('copy') + document.removeEventListener('copy', listener) + documentSelection.removeAllRanges() +} + +export default copyTextToClipboard diff --git a/apps/tx-builder/src/components/buttons/CopyToClipboardBtn/index.tsx b/apps/tx-builder/src/components/buttons/CopyToClipboardBtn/index.tsx new file mode 100644 index 00000000..9b0da766 --- /dev/null +++ b/apps/tx-builder/src/components/buttons/CopyToClipboardBtn/index.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import copyTextToClipboard from './copyTextToClipboard' +import { Icon } from '../../Icon' + +const StyledButton = styled.button` + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + border-radius: 50%; + transition: background-color 0.2s ease-in-out; + outline-color: transparent; + height: 24px; + width: 24px; + display: flex; + justify-content: center; + align-items: center; + :hover { + background-color: ${({ theme }) => theme.palette.divider}; + } +` + +type Props = { + textToCopy: string + className?: string + iconType?: Parameters[0]['type'] + tooltip?: string + tooltipAfterCopy?: string +} + +const CopyToClipboardBtn = ({ + className, + textToCopy, + iconType = 'copy', + tooltip = 'Copy to clipboard', +}: Props): React.ReactElement => { + const [clicked, setClicked] = useState(false) + + const copy = () => { + copyTextToClipboard(textToCopy) + setClicked(true) + } + + const onButtonClick = (event: React.MouseEvent): void => { + event.stopPropagation() + copy() + } + + const onKeyDown = (event: React.KeyboardEvent): void => { + // prevents event from bubbling when `Enter` is pressed + if (event.keyCode === 13) { + event.stopPropagation() + } + copy() + } + + const onButtonBlur = (): void => { + setTimeout((): void => setClicked(false), 300) + } + + return ( + + + + ) +} + +export default CopyToClipboardBtn diff --git a/apps/tx-builder/src/components/buttons/ExplorerButton/index.tsx b/apps/tx-builder/src/components/buttons/ExplorerButton/index.tsx new file mode 100644 index 00000000..f05c8183 --- /dev/null +++ b/apps/tx-builder/src/components/buttons/ExplorerButton/index.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import styled from 'styled-components' +import { Icon } from '../../Icon' +import { ExplorerInfo } from '../../ETHHashInfo' + +const StyledLink = styled.a` + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + border-radius: 50%; + transition: background-color 0.2s ease-in-out; + outline-color: transparent; + height: 24px; + width: 24px; + display: flex; + justify-content: center; + align-items: center; + :hover { + background-color: #f0efee; + } +` + +type Props = { + className?: string + explorerUrl: ExplorerInfo +} + +const ExplorerButton = ({ className, explorerUrl }: Props): React.ReactElement => { + const { url, alt } = explorerUrl() + const onClick = (event: React.MouseEvent): void => { + event.stopPropagation() + } + + const onKeyDown = (event: React.KeyboardEvent): void => { + // prevents event from bubbling when `Enter` is pressed + if (event.keyCode === 13) { + event.stopPropagation() + } + } + + return ( + + + + ) +} + +export default ExplorerButton diff --git a/apps/tx-builder/src/components/buttons/Identicon/index.tsx b/apps/tx-builder/src/components/buttons/Identicon/index.tsx new file mode 100644 index 00000000..5ddaac4b --- /dev/null +++ b/apps/tx-builder/src/components/buttons/Identicon/index.tsx @@ -0,0 +1,32 @@ +import * as React from 'react' + +import makeBlockie from 'ethereum-blockies-base64' +import styled from 'styled-components' + +export const identiconSizes = { + xs: '10px', + sm: '16px', + md: '32px', + lg: '40px', + xl: '48px', + xxl: '60px', +} + +type Props = { + address: string + size: keyof typeof identiconSizes +} + +const StyledImg = styled.img<{ size: keyof typeof identiconSizes }>` + height: ${({ size }) => identiconSizes[size]}; + width: ${({ size }) => identiconSizes[size]}; + border-radius: 50%; +` + +const Identicon = ({ size = 'md', address, ...rest }: Props): React.ReactElement => { + const iconSrc = React.useMemo(() => makeBlockie(address), [address]) + + return +} + +export default Identicon diff --git a/apps/tx-builder/src/components/forms/fields/AddressInput.tsx b/apps/tx-builder/src/components/forms/fields/AddressInput.tsx new file mode 100644 index 00000000..102c5701 --- /dev/null +++ b/apps/tx-builder/src/components/forms/fields/AddressInput.tsx @@ -0,0 +1,208 @@ +import React, { ReactElement, useState, ChangeEvent, useEffect, useCallback, useRef } from 'react' +import InputAdornment from '@material-ui/core/InputAdornment' +import CircularProgress from '@material-ui/core/CircularProgress' + +import { + addNetworkPrefix, + checksumAddress, + getAddressWithoutNetworkPrefix, + getNetworkPrefix, + isChecksumAddress, + isValidAddress, + isValidEnsName, +} from '../../../utils/address' +import TextFieldInput, { TextFieldInputProps } from './TextFieldInput' +import useThrottle from '../../../hooks/useThrottle' + +type AddressInputProps = { + name: string + address: string + networkPrefix?: string + showNetworkPrefix?: boolean + defaultValue?: string + disabled?: boolean + onChangeAddress: (address: string) => void + getAddressFromDomain?: (name: string) => Promise + customENSThrottleDelay?: number + showLoadingSpinner?: boolean +} & TextFieldInputProps + +function AddressInput({ + name, + address, + networkPrefix, + showNetworkPrefix = true, + disabled, + onChangeAddress, + getAddressFromDomain, + customENSThrottleDelay, + showLoadingSpinner, + InputProps, + inputProps, + hiddenLabel = false, + ...rest +}: AddressInputProps): ReactElement { + const [isLoadingENSResolution, setIsLoadingENSResolution] = useState(false) + const defaultInputValue = addPrefix(address, networkPrefix, showNetworkPrefix) + const inputRef = useRef({ value: defaultInputValue }) + const throttle = useThrottle() + + // we checksum & include the network prefix in the input if showNetworkPrefix is set to true + const updateInputValue = useCallback( + (value = '') => { + if (inputRef.current) { + const checksumAddress = checksumValidAddress(value) + inputRef.current.value = addPrefix(checksumAddress, networkPrefix, showNetworkPrefix) + } + }, + [networkPrefix, showNetworkPrefix], + ) + + const resolveDomainName = useCallback(async () => { + const isEnsName = isValidEnsName(address) + + if (isEnsName && getAddressFromDomain) { + try { + setIsLoadingENSResolution(true) + const resolvedAddress = await getAddressFromDomain(address) + onChangeAddress(checksumValidAddress(resolvedAddress)) + // we update the input value + updateInputValue(resolvedAddress) + } catch (e) { + onChangeAddress(address) + } finally { + setIsLoadingENSResolution(false) + } + } + }, [address, getAddressFromDomain, onChangeAddress, updateInputValue]) + + // ENS name resolution + useEffect(() => { + if (getAddressFromDomain) { + throttle(resolveDomainName, customENSThrottleDelay) + } + }, [getAddressFromDomain, resolveDomainName, customENSThrottleDelay, throttle]) + + // if address changes from outside (Like Loaded from a QR code) we update the input value + useEffect(() => { + const inputValue = inputRef.current?.value + const inputWithoutPrefix = getAddressWithoutNetworkPrefix(inputValue) + const addressWithoutPrefix = getAddressWithoutNetworkPrefix(address) + const inputPrefix = getNetworkPrefix(inputValue) + const addressPrefix = getNetworkPrefix(address) + + const isNewAddressLoaded = inputWithoutPrefix !== addressWithoutPrefix + const isNewPrefixLoaded = addressPrefix && inputPrefix !== addressPrefix + + // we check if we load a new address (both prefixed and unprefixed cases) + if (isNewAddressLoaded || isNewPrefixLoaded) { + // we update the input value + updateInputValue(address) + } + }, [address, updateInputValue]) + + // we trim, checksum & remove valid network prefix when a valid address is typed by the user + const updateAddressState = useCallback( + value => { + const inputValue = value.trim() + + const inputPrefix = getNetworkPrefix(inputValue) + const inputWithoutPrefix = getAddressWithoutNetworkPrefix(inputValue) + + // if the valid network prefix is present, we remove it from the address state + const isValidPrefix = networkPrefix === inputPrefix + const checksumAddress = checksumValidAddress(isValidPrefix ? inputWithoutPrefix : inputValue) + + onChangeAddress(checksumAddress) + }, + [networkPrefix, onChangeAddress], + ) + + // when user switch the network we update the address state + useEffect(() => { + // Because the `address` is going to change after we call `updateAddressState` + // To avoid calling `updateAddressState` twice, we check the value and the current address + const inputValue = inputRef.current?.value + if (inputValue !== address) { + updateAddressState(inputRef.current?.value) + } + }, [networkPrefix, address, updateAddressState]) + + // when user types we update the address state + function onChange(e: ChangeEvent) { + updateAddressState(e.target.value) + } + + const isLoading = isLoadingENSResolution || showLoadingSpinner + + const [shrink, setshrink] = useState(!!defaultInputValue) + + useEffect(() => { + setshrink(!!inputRef.current?.value) + }, [inputRef.current.value]) + + return ( + : InputProps?.endAdornment, + }} + inputProps={{ + ...inputProps, + ref: inputRef, + }} + InputLabelProps={{ + ...rest.InputLabelProps, + shrink: shrink || hiddenLabel || undefined, + }} + spellCheck={false} + {...rest} + /> + ) +} + +export default AddressInput + +function LoaderSpinnerAdornment() { + return ( + + + + ) +} + +// we only checksum valid addresses +function checksumValidAddress(address: string) { + if (isValidAddress(address) && !isChecksumAddress(address)) { + return checksumAddress(address) + } + + return address +} + +// we try to add the network prefix if its not present +function addPrefix( + address: string, + networkPrefix: string | undefined, + showNetworkPrefix = false, +): string { + if (!address) { + return '' + } + + if (showNetworkPrefix && networkPrefix) { + const hasPrefix = !!getNetworkPrefix(address) + + // if the address has not prefix we add it by default + if (!hasPrefix) { + return addNetworkPrefix(address, networkPrefix) + } + } + + return address +} diff --git a/apps/tx-builder/src/components/forms/fields/TextFieldInput.tsx b/apps/tx-builder/src/components/forms/fields/TextFieldInput.tsx new file mode 100644 index 00000000..1f00e40f --- /dev/null +++ b/apps/tx-builder/src/components/forms/fields/TextFieldInput.tsx @@ -0,0 +1,57 @@ +import React, { ReactElement } from 'react' +import TextFieldMui, { TextFieldProps } from '@material-ui/core/TextField' +import styled from 'styled-components' +import { errorStyles, inputLabelStyles, inputStyles } from './styles' + +export type TextFieldInputProps = { + id?: string + name: string + label: string + error?: string + helperText?: string | undefined + hiddenLabel?: boolean | undefined + showErrorsInTheLabel?: boolean | undefined +} & Omit + +function TextFieldInput({ + id, + name, + label, + error = '', + helperText, + value, + hiddenLabel, + showErrorsInTheLabel, + ...rest +}: TextFieldInputProps): ReactElement { + const hasError = !!error + + return ( + + ) +} + +const TextField = styled((props: TextFieldProps) => )` + && { + ${inputLabelStyles} + ${inputStyles} + ${errorStyles} + } +` + +export default TextFieldInput diff --git a/apps/tx-builder/src/components/forms/fields/styles.ts b/apps/tx-builder/src/components/forms/fields/styles.ts new file mode 100644 index 00000000..3581130b --- /dev/null +++ b/apps/tx-builder/src/components/forms/fields/styles.ts @@ -0,0 +1,118 @@ +import { TextFieldProps } from '@material-ui/core' +import { css } from 'styled-components' + +export const inputLabelStyles = css` + &:hover { + .MuiInputLabel-root { + &.MuiInputLabel-shrink:not(.Mui-focused):not(.Mui-disabled) { + color: ${({ theme }) => theme.palette.primary.main}; + &.Mui-error { + color: ${({ theme }) => theme.palette.error.main}; + } + } + } + } + + .MuiInputLabel-root { + font-family: ${({ theme }) => theme.typography.fontFamily}; + color: ${({ theme }) => theme.palette.text.secondary}; + font-weight: 300; + font-size: 16px; + + &.MuiInputLabel-shrink { + color: #162d45; + + &.Mui-error { + color: ${({ theme }) => theme.palette.error.main}; + } + } + &.Mui-disabled { + color: #dadada; + } + + /* Hide Label */ + ${({ hiddenLabel }) => + hiddenLabel + ? `border: 0; + border: 1px solid red; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px;` + : ''} + } +` + +export const inputStyles = css` + .MuiOutlinedInput-input:-webkit-autofill { + -webkit-text-fill-color: ${({ theme }) => theme.palette.text.primary}; + } + + .MuiOutlinedInput-root { + font-family: ${({ theme }) => theme.typography.fontFamily}; + color: ${({ theme }) => theme.palette.text.primary}; + /* Input */ + .MuiOutlinedInput-input { + &::placeholder, + &.Mui-disabled { + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'auto')}; + color: #b2bbc0; + } + } + + /* fieldset */ + .MuiOutlinedInput-notchedOutline { + ${({ hiddenLabel }) => (hiddenLabel ? 'top: 0' : '')}; + transition: border-color 0.2s ease-in-out; + border: 1px solid ${({ theme, value }) => (value ? theme.palette.border.main : '#DADADA')}; + border-radius: 6px; + legend { + display: ${({ hiddenLabel }) => (hiddenLabel ? 'none' : 'block')}; + } + } + + &:hover { + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.primary.main}; + } + } + + &.Mui-focused { + .MuiOutlinedInput-notchedOutline { + border-color: #566976; + } + &.Mui-error { + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.error.main}; + } + } + } + &.Mui-disabled { + .MuiOutlinedInput-notchedOutline { + border-color: #dadada; + } + } + } + .MuiFormLabel-filled + + .MuiOutlinedInput-root:not(:hover):not(.Mui-disabled) + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme, error }) => (error ? theme.palette.error.main : '#566976')}; + } +` + +export const errorStyles = css` + .Mui-error { + &:hover, + .Mui-focused { + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.error.main}; + } + } + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.error.main}; + } + } +` diff --git a/apps/tx-builder/src/hooks/useDebounce.ts b/apps/tx-builder/src/hooks/useDebounce.ts new file mode 100644 index 00000000..18d25366 --- /dev/null +++ b/apps/tx-builder/src/hooks/useDebounce.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react' + +const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + + return debouncedValue +} + +export default useDebounce diff --git a/apps/tx-builder/src/hooks/useThrottle.ts b/apps/tx-builder/src/hooks/useThrottle.ts new file mode 100644 index 00000000..b5c91f7d --- /dev/null +++ b/apps/tx-builder/src/hooks/useThrottle.ts @@ -0,0 +1,26 @@ +import { useRef, useCallback } from 'react' + +const DEFAULT_DELAY = 650 + +type ThrottleType = (callback: Function, delay?: number) => void + +const useThrottle: () => ThrottleType = () => { + const timerRefId = useRef | undefined>() + + const throttle = useCallback((callback, delay = DEFAULT_DELAY) => { + // If setTimeout is already scheduled, clearTimeout + if (timerRefId.current) { + clearTimeout(timerRefId.current) + } + + // Schedule the exec after a delay + timerRefId.current = setTimeout(function () { + timerRefId.current = undefined + return callback() + }, delay) + }, []) + + return throttle +} + +export default useThrottle diff --git a/apps/tx-builder/src/utils/address.ts b/apps/tx-builder/src/utils/address.ts new file mode 100644 index 00000000..73072d8e --- /dev/null +++ b/apps/tx-builder/src/utils/address.ts @@ -0,0 +1,59 @@ +import { checkAddressChecksum, toChecksumAddress, isAddress, isHexStrict } from 'web3-utils' + +const getAddressWithoutNetworkPrefix = (address = ''): string => { + const hasPrefix = address.includes(':') + + if (!hasPrefix) { + return address + } + + const [, ...addressWithoutNetworkPrefix] = address.split(':') + + return addressWithoutNetworkPrefix.join('') +} + +const getNetworkPrefix = (address = ''): string => { + const splitAddress = address.split(':') + const hasPrefixDefined = splitAddress.length > 1 + const [prefix] = splitAddress + return hasPrefixDefined ? prefix : '' +} + +const addNetworkPrefix = (address: string, prefix: string | undefined): string => { + return !!prefix ? `${prefix}:${address}` : address +} + +const checksumAddress = (address: string): string => toChecksumAddress(address) + +const isChecksumAddress = (address?: string): boolean => { + if (address) { + return checkAddressChecksum(address) + } + + return false +} + +const isValidAddress = (address?: string): boolean => { + if (address) { + // `isAddress` do not require the string to start with `0x` + // `isHexStrict` ensures the address to start with `0x` aside from being a valid hex string + return isHexStrict(address) && isAddress(address) + } + + return false +} + +// Based on https://docs.ens.domains/dapp-developer-guide/resolving-names +// [...] a correct integration of ENS treats any dot-separated name as a potential ENS name [...] +const validENSRegex = new RegExp(/[^\[\]]+\.[^\[\]]/) +const isValidEnsName = (name: string): boolean => validENSRegex.test(name) + +export { + getAddressWithoutNetworkPrefix, + getNetworkPrefix, + addNetworkPrefix, + checksumAddress, + isChecksumAddress, + isValidAddress, + isValidEnsName, +} diff --git a/apps/tx-builder/src/utils/strings.ts b/apps/tx-builder/src/utils/strings.ts new file mode 100644 index 00000000..2cebc15e --- /dev/null +++ b/apps/tx-builder/src/utils/strings.ts @@ -0,0 +1,25 @@ +export const textShortener = ( + text: string, + charsStart: number, + charsEnd: number, + separator = '...', +): string => { + const amountOfCharsToKeep = charsEnd + charsStart + + if (amountOfCharsToKeep >= text.length || !amountOfCharsToKeep) { + // no need to shorten + return text + } + + const r = new RegExp(`^(.{${charsStart}}).+(.{${charsEnd}})$`) + const matchResult = r.exec(text) + + if (!matchResult) { + // if for any reason the exec returns null, the text remains untouched + return text + } + + const [, textStart, textEnd] = matchResult + + return `${textStart}${separator}${textEnd}` +} From f0a67ea0c655cfdb917bad1be08a0a51d1e110b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=B3vis=20Neto?= Date: Wed, 25 Sep 2024 11:08:58 +0200 Subject: [PATCH 2/2] chore: copy safe-wallet theme to tx-builder --- .../src/theme/SafeThemeProvider.tsx | 17 + apps/tx-builder/src/theme/lightPalette.ts | 65 +++ apps/tx-builder/src/theme/safeTheme.ts | 520 ++++++++++++++++++ apps/tx-builder/src/theme/typography.ts | 50 ++ 4 files changed, 652 insertions(+) create mode 100644 apps/tx-builder/src/theme/SafeThemeProvider.tsx create mode 100644 apps/tx-builder/src/theme/lightPalette.ts create mode 100644 apps/tx-builder/src/theme/safeTheme.ts create mode 100644 apps/tx-builder/src/theme/typography.ts diff --git a/apps/tx-builder/src/theme/SafeThemeProvider.tsx b/apps/tx-builder/src/theme/SafeThemeProvider.tsx new file mode 100644 index 00000000..784f3a3f --- /dev/null +++ b/apps/tx-builder/src/theme/SafeThemeProvider.tsx @@ -0,0 +1,17 @@ +import { useMemo, type FC } from 'react' +import { type PaletteMode, type Theme } from '@mui/material' +import { ThemeProvider } from '@material-ui/core' +import createSafeTheme from './safeTheme' + +type SafeThemeProviderProps = { + children: (theme: Theme) => React.ReactNode + mode: PaletteMode +} + +const SafeThemeProvider: FC = ({ children, mode }) => { + const theme = useMemo(() => createSafeTheme(mode), [mode]) + + return {children(theme)} +} + +export default SafeThemeProvider diff --git a/apps/tx-builder/src/theme/lightPalette.ts b/apps/tx-builder/src/theme/lightPalette.ts new file mode 100644 index 00000000..a47daca3 --- /dev/null +++ b/apps/tx-builder/src/theme/lightPalette.ts @@ -0,0 +1,65 @@ +const lightPalette = { + text: { + primary: '#121312', + secondary: '#A1A3A7', + disabled: '#DDDEE0', + }, + primary: { + dark: '#3c3c3c', + main: '#121312', + light: '#636669', + }, + secondary: { + dark: '#0FDA6D', + main: '#12FF80', + light: '#B0FFC9', + background: '#EFFFF4', + }, + border: { + main: '#A1A3A7', + light: '#DCDEE0', + background: '#F4F4F4', + }, + error: { + dark: '#AC2C3B', + main: '#FF5F72', + light: '#FFB4BD', + background: '#FFE6EA', + }, + success: { + dark: '#028D4C', + main: '#00B460', + light: '#72F5B8', + background: '#EFFAF1', + }, + info: { + dark: '#52BFDC', + main: '#5FDDFF', + light: '#B7F0FF', + background: '#EFFCFF', + }, + warning: { + dark: '#C04C32', + main: '#FF8061', + light: '#FFBC9F', + background: '#FFF1E0', + }, + background: { + default: '#F4F4F4', + main: '#F4F4F4', + paper: '#FFFFFF', + light: '#EFFFF4', + }, + backdrop: { + main: '#636669', + }, + logo: { + main: '#121312', + background: '#EEEFF0', + }, + static: { + main: '#121312', + }, +} + +export default lightPalette diff --git a/apps/tx-builder/src/theme/safeTheme.ts b/apps/tx-builder/src/theme/safeTheme.ts new file mode 100644 index 00000000..97f356d4 --- /dev/null +++ b/apps/tx-builder/src/theme/safeTheme.ts @@ -0,0 +1,520 @@ +import type { Theme, PaletteMode } from '@mui/material' +import { alpha } from '@mui/material' +import type { Shadows } from '@mui/material/styles' +import { createTheme } from '@mui/material/styles' + +import palette from './lightPalette' +import typography from './typography' + +export const base = 8 + +declare module '@mui/material/styles' { + // Custom color palettes + export interface Palette { + border: Palette['primary'] + logo: Palette['primary'] + backdrop: Palette['primary'] + static: Palette['primary'] + } + + export interface PaletteOptions { + border: PaletteOptions['primary'] + logo: PaletteOptions['primary'] + backdrop: PaletteOptions['primary'] + static: PaletteOptions['primary'] + } + + export interface TypeBackground { + main: string + light: string + } + + // Custom color properties + export interface PaletteColor { + background?: string + } + + export interface SimplePaletteColorOptions { + background?: string + } +} + +declare module '@mui/material/SvgIcon' { + export interface SvgIconPropsColorOverrides { + border: unknown + } +} + +declare module '@mui/material/Button' { + export interface ButtonPropsSizeOverrides { + stretched: true + } + + export interface ButtonPropsColorOverrides { + background: true + } + + export interface ButtonPropsVariantOverrides { + danger: true + } +} + +declare module '@mui/material/IconButton' { + export interface IconButtonPropsColorOverrides { + border: true + } +} + +const createSafeTheme = (mode: PaletteMode): Theme => { + const isDarkMode = mode === 'dark' + const shadowColor = palette.primary.light + + return createTheme({ + palette: { + mode: isDarkMode ? 'dark' : 'light', + ...palette, + }, + spacing: base, + shape: { + borderRadius: 6, + }, + shadows: [ + 'none', + isDarkMode + ? `0 0 2px ${shadowColor}` + : `0 1px 4px ${shadowColor}0a, 0 4px 10px ${shadowColor}14`, + isDarkMode + ? `0 0 2px ${shadowColor}` + : `0 1px 4px ${shadowColor}0a, 0 4px 10px ${shadowColor}14`, + isDarkMode + ? `0 0 2px ${shadowColor}` + : `0 2px 20px ${shadowColor}0a, 0 8px 32px ${shadowColor}14`, + isDarkMode + ? `0 0 2px ${shadowColor}` + : `0 8px 32px ${shadowColor}0a, 0 24px 60px ${shadowColor}14`, + ...Array(20).fill('none'), + ] as Shadows, + typography, + components: { + MuiTableCell: { + styleOverrides: { + head: ({ theme }) => ({ + ...theme.typography.body1, + color: theme.palette.primary.light, + }), + }, + }, + MuiButton: { + variants: [ + { + props: { size: 'stretched' }, + style: { + padding: '12px 48px', + }, + }, + { + props: { variant: 'danger' }, + style: ({ theme }) => ({ + backgroundColor: theme.palette.error.background, + color: theme.palette.error.main, + '&:hover': { + color: theme.palette.error.dark, + backgroundColor: theme.palette.error.light, + }, + }), + }, + ], + styleOverrides: { + sizeSmall: { + fontSize: '14px', + padding: '8px 24px', + }, + sizeMedium: { + fontSize: '16px', + padding: '12px 24px', + }, + root: ({ theme }) => ({ + borderRadius: theme.shape.borderRadius, + fontWeight: 'bold', + lineHeight: 1.25, + borderColor: theme.palette.primary.main, + textTransform: 'none', + '&:hover': { + boxShadow: 'none', + }, + }), + outlined: { + border: '2px solid', + '&:hover': { + border: '2px solid', + }, + }, + sizeLarge: { fontSize: '16px' }, + }, + }, + MuiAccordion: { + variants: [ + { + props: { variant: 'elevation' }, + style: ({ theme }) => ({ + border: 'none', + boxShadow: '0', + '&:not(:last-of-type)': { + borderRadius: '0 !important', + borderBottom: `1px solid ${theme.palette.border.light}`, + }, + '&:last-of-type': { + borderBottomLeftRadius: '8px', + }, + }), + }, + ], + styleOverrides: { + root: ({ theme }) => ({ + transition: 'background 0.2s, border 0.2s', + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.border.light}`, + overflow: 'hidden', + + '&::before': { + content: 'none', + }, + + '&:hover': { + borderColor: theme.palette.secondary.light, + }, + + '&:hover > .MuiAccordionSummary-root': { + background: theme.palette.background.light, + }, + + '&.Mui-expanded': { + margin: 0, + borderColor: theme.palette.secondary.light, + }, + + '&.Mui-expanded > .MuiAccordionSummary-root': { + background: theme.palette.background.light, + }, + }), + }, + }, + MuiAccordionSummary: { + styleOverrides: { + root: { + '&.Mui-expanded': { + minHeight: '48px', + }, + }, + content: { + '&.Mui-expanded': { + margin: '12px 0', + }, + }, + }, + }, + MuiAccordionDetails: { + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(2), + }), + }, + }, + MuiCard: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: theme.shape.borderRadius, + boxSizing: 'border-box', + border: '2px solid transparent', + boxShadow: 'none', + }), + }, + }, + MuiDialog: { + defaultProps: { + fullWidth: true, + }, + }, + MuiDialogContent: { + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(3), + }), + }, + }, + MuiDivider: { + styleOverrides: { + root: ({ theme }) => ({ + borderColor: theme.palette.border.light, + }), + }, + }, + MuiPaper: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + outlined: ({ theme }) => ({ + borderWidth: 2, + borderColor: theme.palette.border.light, + }), + root: ({ theme }) => ({ + borderRadius: theme.shape.borderRadius, + backgroundImage: 'none', + }), + }, + }, + MuiPopover: { + defaultProps: { + elevation: 2, + }, + styleOverrides: { + paper: { + overflow: 'visible', + }, + }, + }, + MuiIconButton: { + styleOverrides: { + sizeSmall: { + padding: '4px', + }, + }, + }, + MuiToggleButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, + MuiChip: { + styleOverrides: { + colorSuccess: ({ theme }) => ({ + backgroundColor: theme.palette.secondary.light, + height: '24px', + }), + }, + }, + MuiAlert: { + styleOverrides: { + standardError: ({ theme }) => ({ + '& .MuiAlert-icon': { + color: theme.palette.error.main, + }, + '&.MuiPaper-root': { + backgroundColor: theme.palette.error.background, + }, + border: `1px solid ${theme.palette.error.main}`, + }), + standardInfo: ({ theme }) => ({ + '& .MuiAlert-icon': { + color: theme.palette.info.main, + }, + '&.MuiPaper-root': { + backgroundColor: theme.palette.info.background, + }, + border: `1px solid ${theme.palette.info.main}`, + }), + standardSuccess: ({ theme }) => ({ + '& .MuiAlert-icon': { + color: theme.palette.success.main, + }, + '&.MuiPaper-root': { + backgroundColor: theme.palette.success.background, + }, + border: `1px solid ${theme.palette.success.main}`, + }), + standardWarning: ({ theme }) => ({ + '& .MuiAlert-icon': { + color: theme.palette.warning.main, + }, + '&.MuiPaper-root': { + backgroundColor: theme.palette.warning.background, + }, + border: `1px solid ${theme.palette.warning.main}`, + }), + root: ({ theme }) => ({ + color: theme.palette.text.primary, + padding: '12px 16px', + }), + }, + }, + MuiTableHead: { + styleOverrides: { + root: ({ theme }) => ({ + '& .MuiTableCell-root': { + borderBottom: `1px solid ${theme.palette.border.light}`, + }, + + [theme.breakpoints.down('sm')]: { + '& .MuiTableCell-root:first-of-type': { + paddingRight: theme.spacing(1), + }, + + '& .MuiTableCell-root:not(:first-of-type):not(:last-of-type)': { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + + '& .MuiTableCell-root:last-of-type': { + paddingLeft: theme.spacing(1), + }, + }, + }), + }, + }, + MuiTableBody: { + styleOverrides: { + root: ({ theme }) => ({ + '& .MuiTableCell-root': { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + borderBottom: 'none', + }, + + [theme.breakpoints.down('sm')]: { + '& .MuiTableCell-root:first-of-type': { + paddingRight: theme.spacing(1), + }, + + '& .MuiTableCell-root:not(:first-of-type):not(:last-of-type)': { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + + '& .MuiTableCell-root:last-of-type': { + paddingLeft: theme.spacing(1), + }, + }, + + '& .MuiTableRow-root': { + transition: 'background-color 0.2s', + '&:not(:last-of-type)': { + borderBottom: `1px solid ${theme.palette.border.light}`, + }, + }, + + '& .MuiTableRow-root:hover': { + backgroundColor: theme.palette.background.light, + }, + '& .MuiTableRow-root.Mui-selected': { + backgroundColor: theme.palette.background.light, + }, + }), + }, + }, + MuiCheckbox: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.palette.primary.main, + }), + }, + }, + MuiOutlinedInput: { + styleOverrides: { + notchedOutline: ({ theme }) => ({ + borderColor: theme.palette.border.main, + }), + root: ({ theme }) => ({ + borderColor: theme.palette.border.main, + }), + }, + }, + MuiSvgIcon: { + styleOverrides: { + fontSizeSmall: { + width: '1rem', + height: '1rem', + }, + }, + }, + MuiFilledInput: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: 4, + backgroundColor: theme.palette.background.paper, + border: '1px solid transparent', + transition: 'border-color 0.2s', + + '&:hover, &:focus, &.Mui-focused': { + backgroundColor: theme.palette.background.paper, + borderColor: theme.palette.primary.main, + }, + }), + }, + }, + MuiSelect: { + defaultProps: { + MenuProps: { + sx: { + '& .MuiPaper-root': { + overflow: 'auto', + }, + }, + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: ({ theme }) => ({ + ...theme.typography.body2, + color: theme.palette.background.main, + backgroundColor: theme.palette.text.primary, + '& .MuiLink-root': { + color: isDarkMode ? theme.palette.background.main : theme.palette.secondary.main, + textDecorationColor: isDarkMode + ? theme.palette.background.main + : theme.palette.secondary.main, + }, + '& .MuiLink-root:hover': { + color: isDarkMode ? theme.palette.text.secondary : theme.palette.secondary.light, + }, + }), + arrow: ({ theme }) => ({ + color: theme.palette.text.primary, + }), + }, + }, + MuiBackdrop: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: alpha(theme.palette.backdrop.main, 0.75), + }), + }, + }, + MuiSwitch: { + defaultProps: { + color: isDarkMode ? undefined : 'success', + }, + styleOverrides: { + thumb: () => ({ + boxShadow: + '0px 2px 6px -1px rgba(0, 0, 0, 0.2), 0px 1px 4px rgba(0, 0, 0, 0.14), 0px 1px 4px rgba(0, 0, 0, 0.14)', + }), + }, + }, + MuiLink: { + styleOverrides: { + root: ({ theme }) => ({ + fontWeight: 700, + '&:hover': { + color: theme.palette.primary.light, + }, + }), + }, + }, + MuiLinearProgress: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.palette.border.light, + }), + }, + }, + }, + }) +} + +export const defaultTheme = createSafeTheme('light') + +export default createSafeTheme diff --git a/apps/tx-builder/src/theme/typography.ts b/apps/tx-builder/src/theme/typography.ts new file mode 100644 index 00000000..3058ccd9 --- /dev/null +++ b/apps/tx-builder/src/theme/typography.ts @@ -0,0 +1,50 @@ +import type { TypographyOptions } from '@mui/material/styles/createTypography' + +const safeFontFamily = 'DM Sans, sans-serif' + +const typography: TypographyOptions = { + fontFamily: safeFontFamily, + h1: { + fontSize: '32px', + lineHeight: '36px', + fontWeight: 700, + }, + h2: { + fontSize: '27px', + lineHeight: '34px', + fontWeight: 700, + }, + h3: { + fontSize: '24px', + lineHeight: '30px', + }, + h4: { + fontSize: '20px', + lineHeight: '26px', + }, + h5: { + fontSize: '16px', + fontWeight: 700, + }, + body1: { + fontSize: '16px', + lineHeight: '22px', + }, + body2: { + fontSize: '14px', + lineHeight: '20px', + }, + caption: { + fontSize: '12px', + lineHeight: '16px', + letterSpacing: '0.4px', + }, + overline: { + fontSize: '11px', + lineHeight: '14px', + textTransform: 'uppercase', + letterSpacing: '1px', + }, +} + +export default typography