Skip to content

Commit

Permalink
feat: track widget width and add responsive styling (Uniswap#346)
Browse files Browse the repository at this point in the history
* feat: track widget width throughout and add responsive styling

* fix: ResizeObserver polyfill

* fix: rename ResizeObserver
  • Loading branch information
just-toby authored Jan 5, 2023
1 parent f60105f commit 7b3b454
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 48 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"react-window": "^1.8.5",
"rebass": "^4.0.7",
"redux": ">=4.1.2",
"resize-observer-polyfill": "^1.5.1",
"setimmediate": "^1.0.5",
"styled-components": ">=5",
"tiny-invariant": "^1.2.0",
Expand Down
16 changes: 11 additions & 5 deletions src/components/Swap/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SwapApprovalState } from 'hooks/swap/useSwapApproval'
import { useIsWrap } from 'hooks/swap/useWrapCallback'
import { usePrefetchCurrencyColor } from 'hooks/useCurrencyColor'
import { PriceImpact } from 'hooks/usePriceImpact'
import { useIsWideWidget } from 'hooks/useWidgetWidth'
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react'
import { TradeState } from 'state/routing/types'
import { Field } from 'state/swap'
Expand All @@ -28,22 +29,20 @@ const Balance = styled(ThemedText.Body2)`
transition: color 0.25s ease-in-out;
`

const InputColumn = styled(Column)<{ disableHover?: boolean }>`
const InputColumn = styled(Column)<{ disableHover?: boolean; isWide: boolean }>`
background-color: ${({ theme }) => theme.module};
border-radius: ${({ theme }) => theme.borderRadius - 0.25}em;
margin-bottom: 4px;
padding: 1em 0 1.5em 0;
padding: ${({ isWide }) => (isWide ? '1rem 0' : '1rem 0 1.5rem')};
position: relative;
&:before {
background-size: 100%;
border: 1px solid ${({ theme }) => theme.module};
border-radius: inherit;
box-sizing: border-box;
content: '';
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
Expand Down Expand Up @@ -105,6 +104,7 @@ export function FieldWrapper({

const [amount, updateAmount] = useSwapAmount(field)
const [currency, updateCurrency] = useSwapCurrency(field)
const isWideWidget = useIsWideWidget()

const wrapper = useRef<HTMLDivElement>(null)
const [input, setInput] = useState<TokenInputHandle | null>(null)
Expand Down Expand Up @@ -139,7 +139,13 @@ export function FieldWrapper({
}, [input, maxAmount, updateAmount])

return (
<InputColumn disableHover={isDisabled || !currency} ref={wrapper} onClick={onClick} className={className}>
<InputColumn
isWide={isWideWidget}
disableHover={isDisabled || !currency}
ref={wrapper}
onClick={onClick}
className={className}
>
<TokenInput
ref={setInput}
field={field}
Expand Down
8 changes: 5 additions & 3 deletions src/components/Swap/Output.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useSwapCurrency, useSwapInfo } from 'hooks/swap'
import useCurrencyColor from 'hooks/useCurrencyColor'
import { useIsWideWidget } from 'hooks/useWidgetWidth'
import { atom } from 'jotai'
import { useAtomValue } from 'jotai/utils'
import { Field } from 'state/swap'
Expand All @@ -10,12 +11,12 @@ import { FieldWrapper } from './Input'

export const colorAtom = atom<string | undefined>(undefined)

const OutputWrapper = styled(FieldWrapper)<{ hasColor?: boolean | null }>`
const OutputWrapper = styled(FieldWrapper)<{ hasColor?: boolean | null; isWide: boolean }>`
border-bottom: 1px solid ${({ theme }) => theme.container};
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
padding: 1.75em 0 1.25em 0;
padding: ${({ isWide }) => (isWide ? '1rem 0' : '1.5rem 0 1rem')};
// Set transitions to reduce color flashes when switching color/token.
// When color loads, transition the background so that it transitions from the empty or last state, but not _to_ the empty state.
Expand All @@ -32,13 +33,14 @@ export default function Output() {
const [currency] = useSwapCurrency(Field.OUTPUT)
const overrideColor = useAtomValue(colorAtom)
const dynamicColor = useCurrencyColor(currency)
const isWideWidget = useIsWideWidget()
const color = overrideColor || dynamicColor
// different state true/null/false allow smoother color transition
const hasColor = currency ? Boolean(color) || null : false

return (
<DynamicThemeProvider color={color}>
<OutputWrapper field={Field.OUTPUT} impact={impact} hasColor={hasColor} />
<OutputWrapper isWide={isWideWidget} field={Field.OUTPUT} impact={impact} hasColor={hasColor} />
</DynamicThemeProvider>
)
}
2 changes: 1 addition & 1 deletion src/components/Swap/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import WidgetWrapper from 'components/WidgetWrapper'
import { StrictMode } from 'react'
import styled from 'styled-components/macro'
import { Provider as ThemeProvider, Theme } from 'theme'

import Column from '../Column'
import Row from '../Row'
import { WidgetWrapper } from '../Widget'
import ReverseButton from './ReverseButton'

const LoadingWrapper = styled.div`
Expand Down
42 changes: 3 additions & 39 deletions src/components/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,14 @@ import { Provider as TokenListProvider } from 'hooks/useTokenList'
import { Provider as Web3Provider, ProviderProps as Web3Props } from 'hooks/web3'
import { Provider as I18nProvider } from 'i18n'
import { Provider as AtomProvider } from 'jotai'
import { PropsWithChildren, StrictMode, useMemo, useState } from 'react'
import { PropsWithChildren, StrictMode, useState } from 'react'
import { Provider as ReduxProvider } from 'react-redux'
import { store } from 'state'
import { MulticallUpdater } from 'state/multicall'
import styled, { keyframes } from 'styled-components/macro'
import { Provider as ThemeProvider, Theme } from 'theme'

export const WidgetWrapper = styled.div<{ width?: number | string }>`
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
background-color: ${({ theme }) => theme.container};
border-radius: ${({ theme }) => theme.borderRadius}em;
box-sizing: border-box;
color: ${({ theme }) => theme.primary};
display: flex;
flex-direction: column;
font-size: 16px;
font-smooth: always;
font-variant: none;
min-height: 360px;
min-width: 300px;
padding: 8px;
position: relative;
user-select: none;
width: ${({ width }) => width && (isNaN(Number(width)) ? width : `${width}px`)};
* {
box-sizing: border-box;
font-family: ${({ theme }) => (typeof theme.fontFamily === 'string' ? theme.fontFamily : theme.fontFamily.font)};
@supports (font-variation-settings: normal) {
font-family: ${({ theme }) => (typeof theme.fontFamily === 'string' ? undefined : theme.fontFamily.variable)};
}
}
`
import WidgetWrapper from './WidgetWrapper'

const slideInLeft = keyframes`
from {
Expand Down Expand Up @@ -100,19 +72,11 @@ export interface WidgetProps extends Flags, TransactionEventHandlers, Web3Props,
}

export default function Widget(props: PropsWithChildren<WidgetProps>) {
const width = useMemo(() => {
if (props.width && props.width < 300) {
console.warn(`Widget width must be at least 300px (you set it to ${props.width}). Falling back to 300px.`)
return 300
}
return props.width ?? 360
}, [props.width])

const [dialog, setDialog] = useState<HTMLDivElement | null>(props.dialog || null)
return (
<StrictMode>
<ThemeProvider theme={props.theme}>
<WidgetWrapper width={width} className={props.className}>
<WidgetWrapper width={props.width} className={props.className}>
<I18nProvider locale={props.locale}>
<DialogWrapper ref={setDialog} />
<DialogProvider value={props.dialog || dialog}>
Expand Down
71 changes: 71 additions & 0 deletions src/components/WidgetWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useIsWideWidget, useWidgetWidth } from 'hooks/useWidgetWidth'
import { renderComponent } from 'test'

import WidgetWrapper from './WidgetWrapper'

const widgetWidthValueTestId = 'widgetWidthValue'
const widgetWidthTypeTestId = 'widgetWidthType'
const widgetIsWideTestId = 'widgetIsWide'

function TestComponent() {
const widgetWidth = useWidgetWidth()
const isWide = useIsWideWidget()
return (
<div>
<div data-testid={widgetWidthValueTestId}>{widgetWidth}</div>
<div data-testid={widgetIsWideTestId}>{isWide ? 'wide' : 'narrow'}</div>
<div data-testid={widgetWidthTypeTestId}>{typeof widgetWidth}</div>
</div>
)
}

describe('WidgetWrapper', () => {
it('should handle valid number width, narrow', () => {
const component = renderComponent(
<WidgetWrapper width={300}>
<TestComponent />
</WidgetWrapper>
)
// 300 is the lowest width allowed
expect(component.getByTestId(widgetWidthValueTestId).textContent).toBe('300')
expect(component.getByTestId(widgetWidthTypeTestId).textContent).toBe('number')
expect(component.getByTestId(widgetIsWideTestId).textContent).toBe('narrow')
})

it('should handle valid number width, wide', () => {
const component = renderComponent(
<WidgetWrapper width={500}>
<TestComponent />
</WidgetWrapper>
)
// 300 is the lowest width allowed
expect(component.getByTestId(widgetWidthValueTestId).textContent).toBe('500')
expect(component.getByTestId(widgetWidthTypeTestId).textContent).toBe('number')
expect(component.getByTestId(widgetIsWideTestId).textContent).toBe('wide')
})

it('should handle invalid number width', () => {
const component = renderComponent(
<WidgetWrapper width={200}>
<TestComponent />
</WidgetWrapper>
)
// 300 is the lowest width allowed
expect(component.getByTestId(widgetWidthValueTestId).textContent).toBe('300')
expect(component.getByTestId(widgetWidthTypeTestId).textContent).toBe('number')
expect(component.getByTestId(widgetIsWideTestId).textContent).toBe('narrow')
})

it('should handle undefined width', () => {
const component = renderComponent(
<WidgetWrapper width={undefined}>
<TestComponent />
</WidgetWrapper>
)

// We default to 360px if width is undefined
expect(component.getByTestId(widgetWidthValueTestId).textContent).toBe('360')
expect(component.getByTestId(widgetWidthTypeTestId).textContent).toBe('number')
expect(component.getByTestId(widgetIsWideTestId).textContent).toBe('narrow')
})
})
85 changes: 85 additions & 0 deletions src/components/WidgetWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { WidgetWidthProvider } from 'hooks/useWidgetWidth'
import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'
import ResizeObserver from 'resize-observer-polyfill'
import styled from 'styled-components/macro'
import toLength from 'utils/toLength'

const HORIZONTAL_PADDING = 8

const StyledWidgetWrapper = styled.div<{ width: number | string }>`
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
background-color: ${({ theme }) => theme.container};
border-radius: ${({ theme }) => theme.borderRadius}em;
box-sizing: border-box;
color: ${({ theme }) => theme.primary};
display: flex;
flex-direction: column;
font-size: 16px;
font-smooth: always;
font-variant: none;
min-height: 360px;
min-width: 300px;
padding: ${HORIZONTAL_PADDING}px;
position: relative;
user-select: none;
width: ${({ width }) => toLength(width)};
* {
box-sizing: border-box;
font-family: ${({ theme }) => (typeof theme.fontFamily === 'string' ? theme.fontFamily : theme.fontFamily.font)};
@supports (font-variation-settings: normal) {
font-family: ${({ theme }) => (typeof theme.fontFamily === 'string' ? undefined : theme.fontFamily.variable)};
}
}
`

interface WidgetWrapperProps {
width: number | string | undefined
className?: string | undefined
}

export default function WidgetWrapper(props: PropsWithChildren<WidgetWrapperProps>) {
const initialWidth: string | number = useMemo(() => {
if (props.width && props.width < 300) {
console.warn(`Widget width must be at least 300px (you set it to ${props.width}). Falling back to 300px.`)
return 300
}
return props.width ?? 360
}, [props.width])

/**
* We need to manually track the width of the widget because the width prop could be a string
* like "100%" or "400px" instead of a number.
*/
const ref = useRef<HTMLDivElement>(null)
const [wrapperWidth, setWidgetWidth] = useState<number>(
toLength(initialWidth) === initialWidth
? 360 // If the initial width is a string, use default width until the ResizeObserver gives us the true width as a number.
: (initialWidth as number)
)
useEffect(() => {
const observer = new ResizeObserver((entries) => {
// contentRect doesn't include padding or borders
const { width } = entries[0].contentRect
setWidgetWidth(width + 2 * HORIZONTAL_PADDING)
})
const current = ref.current
if (current) {
observer.observe(ref.current)
}
return () => {
if (current) {
observer.unobserve(current)
}
}
}, [])

return (
<StyledWidgetWrapper width={initialWidth} className={props.className} ref={ref}>
<WidgetWidthProvider width={wrapperWidth}>{props.children}</WidgetWidthProvider>
</StyledWidgetWrapper>
)
}
20 changes: 20 additions & 0 deletions src/hooks/useWidgetWidth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext, PropsWithChildren, useContext } from 'react'

const WidgetWidthContext = createContext<number>(0)

interface WidgetWidthProviderProps {
width: number
}

export function WidgetWidthProvider({ width, children }: PropsWithChildren<WidgetWidthProviderProps>) {
return <WidgetWidthContext.Provider value={width}>{children}</WidgetWidthContext.Provider>
}

export function useWidgetWidth(): number {
return useContext(WidgetWidthContext)
}

export function useIsWideWidget(): boolean {
const widgetWidth = useWidgetWidth()
return widgetWidth > 420
}
13 changes: 13 additions & 0 deletions src/utils/toLength.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Converts a number to a CSS length string. If the value is not a number, it is returned as is.
* If the value is a number, we treat it like a pixel amount.
*
* @param length CSS length value, either a string like "100%" or "100px" or a number like 100
*/
export default function toLength(length: number | string): string {
if (isNaN(Number(length))) {
return length as string
} else {
return `${length}px`
}
}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11791,6 +11791,11 @@ reselect@^4.0.0, reselect@^4.1.5:
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.5.tgz#852c361247198da6756d07d9296c2b51eddb79f6"
integrity sha512-uVdlz8J7OO+ASpBYoz1Zypgx0KasCY20H+N8JD13oUMtPvSHQuscrHop4KbXrbsBcdB9Ds7lVK7eRkBIfO43vQ==

resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==

resolve-cwd@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"
Expand Down

0 comments on commit 7b3b454

Please sign in to comment.