diff --git a/package.json b/package.json index 635f9088..b464f019 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "author": "Gnosis (https://gnosis.pm)", "license": "MIT", "dependencies": { + "web3-utils": "^1.6.0", "react-media": "^1.10.0" }, "devDependencies": { @@ -33,6 +34,7 @@ "@babel/preset-react": "^7.16.0", "@babel/preset-typescript": "^7.16.0", "@material-ui/core": "^4.12.3", + "@material-ui/icons": "^4.11.0", "@storybook/addon-actions": "^6.3.12", "@storybook/addon-docs": "^6.3.12", "@storybook/addon-links": "^6.3.12", diff --git a/src/inputs/AddressInput/AddressInput.stories.tsx b/src/inputs/AddressInput/AddressInput.stories.tsx new file mode 100644 index 00000000..68ed328f --- /dev/null +++ b/src/inputs/AddressInput/AddressInput.stories.tsx @@ -0,0 +1,323 @@ +import React, { useState, useEffect } from 'react'; +import { InputAdornment } from '@material-ui/core'; +import styled from 'styled-components'; +import CheckCircle from '@material-ui/icons/CheckCircle'; +import { Typography } from '@material-ui/core'; + +import AddressInput from './index'; +import { isValidAddress } from '../../utils/address'; +import { Switch } from '..'; + +export default { + title: 'Inputs/AddressInput', + component: AddressInput, + parameters: { + componentSubtitle: 'Address field input with several variants', + }, +}; + +const onSubmit = (e: React.FormEvent) => e.preventDefault(); + +export const SimpleAddressInput = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + const [showNetworkPrefix, setShowNetworkPrefix] = useState(true); + + const getAddressFromDomain = () => + new Promise((resolve) => { + setTimeout( + () => resolve('0x83eC7B0506556a7749306D69681aDbDbd08f0769'), + 1200 + ); + }); + + return ( +
+ + + Show Network Prefix (rin) + + setAddress(address)} + getAddressFromDomain={getAddressFromDomain} + /> + + Address value in the State:{' '} + +
+        {address || ' '}
+      
+ + You can use ENS names (like safe.test) with the getAddressFromDomain + prop + + + ); +}; + +export const AddressInputWithNetworkPrefix = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + return ( +
+ setAddress(address)} + /> +
Address in the state: {address}
+ + ); +}; + +export const AddressInputWithValidation = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + const [hasError, setHasError] = useState( + () => !isValidAddress(address) + ); + + useEffect(() => { + setHasError(!isValidAddress(address)); + }, [address]); + + const error = 'Invalid Address'; + + return ( +
+ setAddress(address)} + /> + + ); +}; + +export const AddressInputWithoutPrefix = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + return ( +
+ setAddress(address)} + /> + + ); +}; + +export const AddressInputWithENSResolution = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + const getAddressFromDomain = () => + new Promise((resolve) => { + setTimeout( + () => resolve('0x83eC7B0506556a7749306D69681aDbDbd08f0769'), + 2000 + ); + }); + + return ( +
+ setAddress(address)} + getAddressFromDomain={getAddressFromDomain} + /> + + ); +}; + +export const SafeAddressInputValidation = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + const [isValidSafeAddress, setIsValidSafeAddress] = useState(false); + const [showLoadingSpinner, setShowLoadingSpinner] = useState(false); + + // check if address is the SafeAddress + useEffect(() => { + setShowLoadingSpinner(true); + setIsValidSafeAddress(false); + + const timeId = setTimeout(() => { + const isValidSafeAddress = + address === '0x83eC7B0506556a7749306D69681aDbDbd08f0769'; + setIsValidSafeAddress(isValidSafeAddress); + setShowLoadingSpinner(false); + }, 1200); + + return () => { + clearTimeout(timeId); + }; + }, [address]); + + const error = 'Address given is not a valid Safe address'; + + const showError = !isValidSafeAddress && !showLoadingSpinner; + + return ( +
+ setAddress(address)} + showLoadingSpinner={showLoadingSpinner} + InputProps={{ + endAdornment: isValidSafeAddress && ( + + + + ), + }} + /> + + ); +}; + +export const AddressInputLoading = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + return ( +
+ setAddress(address)} + /> + + ); +}; + +export const AddressInputWithAdornment = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + return ( +
+ + + + ), + }} + address={address} + onChangeAddress={(address) => setAddress(address)} + /> + + ); +}; + +export const AddressInputDisabled = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + return ( +
+ setAddress(address)} + /> + + ); +}; + +export const AddressInputWithErrors = (): React.ReactElement => { + const [address, setAddress] = useState( + '0x83eC7B0506556a7749306D69681aDbDbd08f0769' + ); + + return ( +
+ setAddress(address)} + error={'Invalid Address'} + /> + + ); +}; + +const CheckIconAddressAdornment = styled(CheckCircle)` + color: #03ae60; + height: 20px; +`; diff --git a/src/inputs/AddressInput/index.tsx b/src/inputs/AddressInput/index.tsx new file mode 100644 index 00000000..199bc9c7 --- /dev/null +++ b/src/inputs/AddressInput/index.tsx @@ -0,0 +1,150 @@ +import React, { ReactElement, useState, ChangeEvent, useEffect } from 'react'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +import { trimSpaces } from '../../utils/strings'; +import { + addNetworkPrefix, + checksumAddress, + getAddressWithoutNetworkPrefix, + getNetworkPrefix, + isChecksumAddress, + isValidAddress, + isValidEnsName, +} from '../../utils/address'; +import useThrottle from '../../utils/useThrottle'; +import TextFieldInput, { TextFieldInputProps } from '../TextFieldInput'; + +type AddressInputProps = { + name: string; + address: string; + networkPrefix?: string; + showNetworkPrefix?: boolean; + defaultValue?: string; + disabled?: boolean; + onChangeAddress: (address: string) => void; + getAddressFromDomain?: (name: string) => Promise; + showLoadingSpinner?: boolean; +} & TextFieldInputProps; + +function AddressInput({ + name, + address, + networkPrefix, + showNetworkPrefix, + disabled, + onChangeAddress, + getAddressFromDomain, + showLoadingSpinner, + InputProps, + ...rest +}: AddressInputProps): ReactElement { + const [prefix, setPrefix] = useState(networkPrefix); + const [isLoadingENSResolution, setIsLoadingENSResolution] = useState(false); + + const throttle = useThrottle(); + + // ENS resolution + useEffect(() => { + const resolveDomainName = async (ENSName: string) => { + try { + setIsLoadingENSResolution(true); + const address = (await getAddressFromDomain?.(ENSName)) as string; + onChangeAddress(checksumValidAddress(address)); + setPrefix(showNetworkPrefix ? networkPrefix : ''); + } catch (e) { + onChangeAddress(checksumValidAddress(ENSName)); + setPrefix(''); + } finally { + setIsLoadingENSResolution(false); + } + }; + + const isEnsName = isValidEnsName(address); + + if (isEnsName && getAddressFromDomain) { + throttle(() => resolveDomainName(address)); + } + }, [ + address, + getAddressFromDomain, + networkPrefix, + showNetworkPrefix, + onChangeAddress, + throttle, + ]); + + // Network prefix + useEffect(() => { + setPrefix(networkPrefix); + }, [networkPrefix]); + + useEffect(() => { + if (showNetworkPrefix) { + setPrefix(networkPrefix); + } else { + setPrefix(''); + } + }, [showNetworkPrefix, networkPrefix]); + + const onChange = (e: ChangeEvent) => { + const value = trimSpaces(e.target.value); + + const prefix = getNetworkPrefix(value); + const address = getAddressWithoutNetworkPrefix(value); + + const isValidPrefix = + value.includes(':') && !value.startsWith(':') && prefix === networkPrefix; + + if (isValidPrefix) { + setPrefix(prefix); + onChangeAddress(checksumValidAddress(address)); + } else { + setPrefix(''); + onChangeAddress(checksumValidAddress(value)); + } + }; + + const value = addNetworkPrefix(address, prefix); + + const isLoading = isLoadingENSResolution || showLoadingSpinner; + + return ( + + ) : ( + InputProps?.endAdornment + ), + }} + 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; +} diff --git a/src/inputs/TextField/index.tsx b/src/inputs/TextField/index.tsx index f247f909..90995bc0 100644 --- a/src/inputs/TextField/index.tsx +++ b/src/inputs/TextField/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import TextFieldMui, { StandardTextFieldProps, TextFieldProps, @@ -44,6 +44,9 @@ const CustomTextField = styled((props: TextFieldProps) => ( } `; +/** + * @deprecated This TextField Component is coupled to React Final Form use TextFieldInput instead + */ function TextField({ input: inputProps, meta, @@ -62,6 +65,13 @@ function TextField({ ) : null, readOnly, }; + + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.warn('TextField is deprecated, use TextFieldInput instead'); + } + }, []); + return ( ); } diff --git a/src/inputs/TextFieldInput/TextFieldInput.stories.tsx b/src/inputs/TextFieldInput/TextFieldInput.stories.tsx new file mode 100644 index 00000000..dee464b5 --- /dev/null +++ b/src/inputs/TextFieldInput/TextFieldInput.stories.tsx @@ -0,0 +1,267 @@ +import TextFieldInput from '.'; +import React, { useState } from 'react'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import { Icon } from '../..'; +import { Switch } from '..'; +import { Typography } from '@material-ui/core'; +import styled from 'styled-components'; + +export default { + title: 'Inputs/TextFieldInput', + component: TextFieldInput, + parameters: { + componentSubtitle: 'TextField field input with several variants', + }, +}; + +const onSubmit = (e: React.FormEvent) => e.preventDefault(); + +export const SimpleTextField = (): React.ReactElement => { + const [value, setValue] = useState(''); + return ( +
+ setValue(e.target.value)} + /> + + ); +}; + +export const TextFieldWithErrorsInTheLabel = (): React.ReactElement => { + const [value, setValue] = useState(''); + const [hasError, setHasError] = useState(true); + const [hiddenLabel, setHiddenLabel] = useState(false); + const [showErrorsInTheLabel, setShowErrorsInTheLabel] = + useState(false); + + const error = 'This field has an error'; + + return ( +
+ + + An Error is present + + + Hide Label + + + + Show Errors in The Label + + setValue(e.target.value)} + /> + + ); +}; + +const StyledTextField = styled(TextFieldInput)` + && { + .MuiFilledInput-root { + background-color: lightgreen; + width: 200px; + transition: width 1s ease-out; + } + + .MuiFilledInput-root.Mui-focused { + width: 400px; + } + + .MuiFormLabel-root.Mui-focused { + color: ${({ error, theme }) => + error ? theme.colors.error : 'darkgreen'}; + } + + .MuiInputLabel-filled { + color: ${({ theme, error }) => (error ? theme.colors.error : 'purple')}; + } + + .MuiFilledInput-underline:after { + border-bottom: 2px solid + ${({ theme, error }) => (error ? theme.colors.error : 'orange')}; + } + } +`; + +export const CustomCSSTextField = (): React.ReactElement => { + const [value, setValue] = useState('Focus me! :D'); + + return ( +
+ setValue(e.target.value)} + /> +
+        {`
+         const StyledTextField = styled(TextFieldInput)\`
+         && {
+           .MuiFilledInput-root {
+             background-color: lightgreen;
+             width: 200px;
+             transition: width 1s ease-out;
+           }
+     
+           .MuiFilledInput-root.Mui-focused {
+             width: 400px;
+           }
+     
+           .MuiFormLabel-root.Mui-focused {
+             color: \${({ error, theme }) =>
+               error ? theme.colors.error : 'darkgreen'};
+           }
+     
+           .MuiInputLabel-filled {
+             color: \${({ theme, error }) =>
+               error ? theme.colors.error : 'purple'};
+           }
+     
+           .MuiFilledInput-underline:after {
+             border-bottom: 2px solid
+               \${({ theme, error }) => (error ? theme.colors.error : 'orange')};
+           }
+         }
+       \`;
+      `}
+      
+ + ); +}; + +export const TextFieldWithErrors = (): React.ReactElement => { + const [value, setValue] = useState('this field has an error'); + + const error = 'this field has an error'; + + return ( +
+ setValue(e.target.value)} + /> + + ); +}; + +export const DisabledTextField = (): React.ReactElement => { + const [value, setValue] = useState('this field is disabled'); + return ( +
+ setValue(e.target.value)} + /> + + ); +}; + +export const NumberTextField = (): React.ReactElement => { + const [value, setValue] = useState('100'); + return ( +
+ setValue(e.target.value)} + /> + + ); +}; + +export const StartAdornmentTextField = (): React.ReactElement => { + const [value, setValue] = useState(''); + // see https://mui.com/components/text-fields/#input-adornments for more details + return ( +
+ + + + ), + }} + onChange={(e) => setValue(e.target.value)} + /> + + ); +}; + +export const EndAdornmentTextField = (): React.ReactElement => { + const [value, setValue] = useState(''); + // see https://mui.com/components/text-fields/#input-adornments for more details + return ( +
+ + + + ), + }} + onChange={(e) => setValue(e.target.value)} + /> + + ); +}; + +export const TextFieldWithHiddenLabel = (): React.ReactElement => { + const [value, setValue] = useState(''); + const [hiddenLabel, setHiddenLabel] = useState(true); + return ( +
+ + Hide Label: + + setValue(e.target.value)} + /> + + ); +}; diff --git a/src/inputs/TextFieldInput/index.tsx b/src/inputs/TextFieldInput/index.tsx new file mode 100644 index 00000000..64f0c32a --- /dev/null +++ b/src/inputs/TextFieldInput/index.tsx @@ -0,0 +1,116 @@ +import React, { ReactElement } from 'react'; +import TextFieldMui, { TextFieldProps } from '@material-ui/core/TextField'; +import styled from 'styled-components'; + +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, + error = '', + helperText, + value, + hiddenLabel = !value, + showErrorsInTheLabel = true, + ...rest +}: TextFieldInputProps): ReactElement { + const hasError = !!error; + + return ( + + ); +} + +type StyledTextFieldProps = { + showErrorsInTheLabel?: boolean | undefined; +} & TextFieldProps; + +const TextField = styled( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ showErrorsInTheLabel, ...props }: StyledTextFieldProps) => ( + + ) +)` + && { + width: 400px; + + .MuiFilledInput-input { + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'auto')}; + } + + .MuiInputLabel-filled { + color: ${({ theme, error, disabled }) => + error + ? theme.colors.error + : disabled + ? theme.colors.disabled + : theme.colors.primary}; + } + + .MuiFilledInput-underline:before { + border-bottom: 0; + } + + .MuiFilledInput-underline:after { + border-bottom: 2px solid + ${({ theme, error }) => + error ? theme.colors.error : theme.colors.primary}; + } + + .MuiInputBase-input { + text-overflow: ellipsis; + letter-spacing: 0.5px; + } + + .MuiInputLabel-root { + ${({ hiddenLabel, error, showErrorsInTheLabel }) => + hiddenLabel || (error && showErrorsInTheLabel) + ? `border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px;` + : ''} + } + + .MuiFormHelperText-root.Mui-error { + ${({ error, showErrorsInTheLabel }) => + error && showErrorsInTheLabel + ? `position: absolute; top: 5px; margin-left: 12px;` + : ''} + } + + .MuiInputBase-input { + ${({ error, showErrorsInTheLabel }) => + error && showErrorsInTheLabel ? ' padding: 27px 12px 10px;' : ''} + } + } +`; + +export default TextFieldInput; diff --git a/src/inputs/index.ts b/src/inputs/index.ts index 05cefeee..4fdf85a9 100644 --- a/src/inputs/index.ts +++ b/src/inputs/index.ts @@ -6,3 +6,5 @@ export { default as RadioButtons } from './RadioButtons'; export { default as Select } from './Select'; export { default as Switch } from './Switch'; export { default as TextField } from './TextField'; +export { default as AddressInput } from './AddressInput'; +export { default as TextFieldInput } from './TextFieldInput'; diff --git a/src/utils/address.ts b/src/utils/address.ts new file mode 100644 index 00000000..203018e4 --- /dev/null +++ b/src/utils/address.ts @@ -0,0 +1,67 @@ +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/src/utils/strings.ts b/src/utils/strings.ts index 326a6d5d..fd9010d7 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -24,5 +24,7 @@ export const textShortener = ( return `${textStart}${separator}${textEnd}`; }; +export const trimSpaces = (value = ''): string => value.trim(); + export const isString = (value: unknown): value is string => typeof value === 'string'; diff --git a/src/utils/useThrottle.ts b/src/utils/useThrottle.ts new file mode 100644 index 00000000..343f1766 --- /dev/null +++ b/src/utils/useThrottle.ts @@ -0,0 +1,30 @@ +import { useRef, useCallback } from 'react'; + +const DEFAULT_DELAY = 650; + +// eslint-disable-next-line @typescript-eslint/ban-types +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/tests/__snapshots__/colors.stories.storyshot b/tests/__snapshots__/colors.stories.storyshot index 78e55579..130e5e5d 100644 --- a/tests/__snapshots__/colors.stories.storyshot +++ b/tests/__snapshots__/colors.stories.storyshot @@ -2,13 +2,13 @@ exports[`Storyshots Utils/Colors Colors Sample 1`] = `

  • ,
    ,
    ,
    ,
    ,
    , diff --git a/tests/inputs/AddressInput/__snapshots__/AddressInput.stories.storyshot b/tests/inputs/AddressInput/__snapshots__/AddressInput.stories.storyshot new file mode 100644 index 00000000..5945b18d --- /dev/null +++ b/tests/inputs/AddressInput/__snapshots__/AddressInput.stories.storyshot @@ -0,0 +1,575 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Inputs/AddressInput Address Input Disabled 1`] = ` +
    +
    + +
    + +
    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input Loading 1`] = ` +
    +
    + +
    + +
    +
    + + + +
    +
    +
    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input With Adornment 1`] = ` +
    +
    + +
    + +
    + + + +
    +
    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input With ENS Resolution 1`] = ` +
    +
    + +
    + +
    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input With Errors 1`] = ` +
    +
    + +
    + +
    +

    + Invalid Address +

    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input With Network Prefix 1`] = ` +
    +
    + +
    + +
    +
    +
    +    Address in the state: 
    +    0x83eC7B0506556a7749306D69681aDbDbd08f0769
    +  
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input With Validation 1`] = ` +
    +
    + +
    + +
    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Address Input Without Prefix 1`] = ` +
    +
    + +
    + +
    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Safe Address Input Validation 1`] = ` +
    +
    + +
    + +
    +

    + Address given is not a valid Safe address +

    +
    +
    +`; + +exports[`Storyshots Inputs/AddressInput Simple Address Input 1`] = ` +
    +

    + + + + + + + + + + Show Network Prefix (rin) +

    +
    + +
    + +
    +
    +

    + Address value in the State: + +

    +
    +    0x83eC7B0506556a7749306D69681aDbDbd08f0769
    +  
    +

    + You can use ENS names (like safe.test) with the getAddressFromDomain prop +

    +
    +`; diff --git a/tests/inputs/Button/__snapshots__/button.stories.storyshot b/tests/inputs/Button/__snapshots__/button.stories.storyshot index b5da44ff..60d243c2 100644 --- a/tests/inputs/Button/__snapshots__/button.stories.storyshot +++ b/tests/inputs/Button/__snapshots__/button.stories.storyshot @@ -2,7 +2,7 @@ exports[`Storyshots Inputs/Button Disabled Button 1`] = `