diff --git a/public/tradingview.css b/public/tradingview.css new file mode 100644 index 000000000..dca4ba146 --- /dev/null +++ b/public/tradingview.css @@ -0,0 +1,4 @@ +:root { + --tv-color-platform-background: var(--page-card, hsl(212deg, 28%, 17%)); + --tv-color-pane-background: var(--default, hsl(219deg, 40%, 11%)); +} diff --git a/src/components/RadioButtonGroupInput/RadioButtonGroupInput.tsx b/src/components/RadioButtonGroupInput/RadioButtonGroupInput.tsx index ba8abb11f..9c25415a0 100644 --- a/src/components/RadioButtonGroupInput/RadioButtonGroupInput.tsx +++ b/src/components/RadioButtonGroupInput/RadioButtonGroupInput.tsx @@ -43,11 +43,17 @@ function useSelectedButtonBackgroundMove( if (movingButton && targetButton) { movingButton.style.width = `${targetButton.offsetWidth}px`; movingButton.style.left = `${targetButton.offsetLeft}px`; - if (newValue !== undefined) { + if (newValue !== undefined && movingButton.style.opacity !== '0') { movingButton.classList.add('transition-ready'); } else { movingButton?.classList.remove('transition-ready'); } + movingButton.style.opacity = '1'; + } + // "remove" button if the target was not found + else if (movingButton) { + movingButton?.classList.remove('transition-ready'); + movingButton.style.opacity = '0'; } }, [value, refsByValue, movingButton] @@ -72,7 +78,10 @@ function useSelectedButtonBackgroundMove( interface Props { className?: string; buttonClassName?: string; - values: { [value in T]: ReactNode } | Map | T[]; + values: + | { readonly [value in T]: ReactNode } + | Map + | readonly T[]; value: T; onChange: (value: T) => void; } @@ -87,21 +96,24 @@ export default function RadioButtonGroupInput({ const [movingAssetRef, createRefForValue] = useSelectedButtonBackgroundMove(value); const entries = useMemo(() => { + const valueIsNumber = typeof value === 'number'; return Array.isArray(values) - ? values.map<[T, string]>((value) => [value, `${value}`]) + ? values.filter(Boolean).map<[T, string]>((value) => [value, `${value}`]) : values instanceof Map ? Array.from(values.entries()) : (Object.entries(values).map(([value, description]) => [ - value, + valueIsNumber ? Number(value) : value, description, ]) as [T, string][]); - }, [values]); + }, [value, values]); const selectedIndex = entries.findIndex( ([entryValue]) => entryValue === value ); const includedIndexes = useMemo(() => { return ( entries + // do not display falsy description buttons + .filter(([_key, value]) => !!value) .map((_, index, entries) => { // cumulate weightings let result = 0; @@ -156,6 +168,9 @@ export default function RadioButtonGroupInput({ const currentIndex = includedIndexes.includes(index); const nextIndex = includedIndexes.includes(index + 1); + // skip those with no description + if (!description) return []; + // include button if required or if excluding it // will not reduce the number of shown buttons if (currentIndex || (previousIndex && nextIndex)) { diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index b0544b8ca..b758a1b05 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -12,6 +12,7 @@ export default function Table< getRowKey, context, rowDescription = 'Data', + messageOnEmptyData, filtered = false, }: { className?: string; @@ -27,6 +28,7 @@ export default function Table< getRowKey?: (row: DataRow) => string | number; context: Context; rowDescription?: string; + messageOnEmptyData?: ReactNode; filtered?: boolean; }) { if (columns.length !== headings.length) { @@ -85,9 +87,11 @@ export default function Table< {data ? ( - <> - No {filtered ? 'Matching' : ''} {rowDescription} Found - + messageOnEmptyData || ( + <> + No {filtered ? 'Matching' : ''} {rowDescription} Found + + ) ) : ( <>Loading... )} diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 1d0b7da86..6dfd8880f 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -4,7 +4,7 @@ import './Tabs.scss'; export interface Tab { nav: ReactNode; - Tab: React.FunctionComponent; + tab?: ReactNode; } export default function Tabs({ tabs, @@ -23,7 +23,7 @@ export default function Tabs({ givenTabIndex !== undefined ? [givenTabIndex, givenSetTabIndex] : [defaultTabIndex, defaultSetTabIndex]; - const { Tab } = tabs[tabIndex]; + const { tab } = tabs[tabIndex]; return (
@@ -47,9 +47,7 @@ export default function Tabs({ })}
-
- -
+
{tab}
); diff --git a/src/components/TokenInputGroup/TokenInputGroup.scss b/src/components/TokenInputGroup/TokenInputGroup.scss index 1fc12bc60..e8bc3b7f1 100644 --- a/src/components/TokenInputGroup/TokenInputGroup.scss +++ b/src/components/TokenInputGroup/TokenInputGroup.scss @@ -9,8 +9,6 @@ border-radius: 1rem; border: 1px solid transparent; background-color: var(--default); - grid-template-columns: auto 1fr; - grid-template-rows: auto 1fr auto; color: var(--default-alt); display: grid; row-gap: 2px; @@ -21,7 +19,7 @@ background-color: hsla(0, 73%, 97%, 1); &, .token-group-balance button { - border: 1px solid var(--error); + border-color: var(--error); } .token-group-title, .token-picker-toggle, @@ -34,41 +32,24 @@ } .token-picker-toggle { - grid-column: 1; text-align: start; - grid-row-start: 2; - grid-row-end: 4; - grid-row: 2 / 4; - row-gap: 2px; - .token-chain { margin-bottom: -2px; } } .token-group-title { - grid-column: 1; font-size: font-size.$text-m; - text-align: start; - grid-row: 1; font-weight: normal; - align-self: center; } .token-group-balance { font-size: font-size.$text-m; - grid-column: 2; - grid-row: 1; text-align: end; - display: flex; - gap: paddings.$p-3; - margin-left: auto; } .token-group-value { font-size: font-size.$text-m; - grid-column: 2; - grid-row: 3; text-align: end; } @@ -78,12 +59,7 @@ background-color: transparent; border: 0px none transparent; font-size: font-size.$input; - color: var(--default-alt); - grid-column: 2; - text-align: end; - grid-row: 2; outline: none; - margin-top: margins.$m-2; } &:disabled { diff --git a/src/components/TokenInputGroup/TokenInputGroup.tsx b/src/components/TokenInputGroup/TokenInputGroup.tsx index 3125bb145..069bba264 100644 --- a/src/components/TokenInputGroup/TokenInputGroup.tsx +++ b/src/components/TokenInputGroup/TokenInputGroup.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { ReactNode, useCallback, useMemo, useRef } from 'react'; import BigNumber from 'bignumber.js'; import TokenPicker from '../TokenPicker'; @@ -29,6 +29,9 @@ interface InputGroupProps { disabled?: boolean; disabledInput?: boolean; disabledToken?: boolean; + inactive?: boolean; + header?: ReactNode; + subHeader?: ReactNode; maxValue?: number; } @@ -46,9 +49,12 @@ export default function TokenInputGroup({ token, denom = token?.base, defaultAssetMode, + header, + subHeader, disabled = false, disabledInput = disabled, disabledToken = disabled, + inactive = false, maxValue: givenMaxValue, }: InputGroupProps) { const onPickerChange = useCallback( @@ -72,7 +78,8 @@ export default function TokenInputGroup({ }, [value, price]); const { data: balance } = useBankBalanceDisplayAmount(denom); - const maxValue = givenMaxValue || balance; + const maxValue = givenMaxValue ?? balance; + const inputRef = useRef(null); return (
inputRef.current?.focus()} + onKeyDown={() => undefined} + role="button" + tabIndex={-1} > - {maxValue && ( -
- Available{' '} - {formatAmount(maxValue, { - useGrouping: true, - })} -
- )} - {!disabledInput && token && maxValue && Number(maxValue) > 0 && ( - - +
+ )} + +
+
+ +
+
+ { + return { + // set width as minimum amount available + minWidth: '100%', + width: 0, + }; + }, [])} + /> + - MAX - - - - )} - - { - return { - // set width as minimum amount available - minWidth: '100%', - width: 0, - }; - }, [])} - /> - {secondaryValue} + {secondaryValue} + +
+
); } diff --git a/src/components/TokenPicker/SelectionModal.tsx b/src/components/TokenPicker/SelectionModal.tsx index 63d209cc4..b311371c3 100644 --- a/src/components/TokenPicker/SelectionModal.tsx +++ b/src/components/TokenPicker/SelectionModal.tsx @@ -58,16 +58,17 @@ export interface SelectionModalChild { className?: string; disabled?: boolean; value: T | undefined; - open: () => void; + open?: () => void; } export type SelectionModal = Omit< SelectionModalDialog, - 'isOpen' | 'setIsOpen' + 'isOpen' | 'setIsOpen' | 'onChange' > & { buttonClassName?: string; bodyClassName?: string; disabled?: boolean; ChildComponent: FunctionComponent>; + onChange?: SelectionModalDialog['onChange']; }; export default function SelectionModal({ buttonClassName, @@ -75,6 +76,7 @@ export default function SelectionModal({ disabled, ChildComponent, value, + onChange, ...modalProps }: SelectionModal) { // set modal logic above Dialog to prevent the dialog state from rendering @@ -96,14 +98,15 @@ export default function SelectionModal({ .join(' ')} disabled={disabled} value={value} - open={open} + open={onChange && open} /> - {isOpen && ( + {isOpen && onChange && ( className={bodyClassName} setIsOpen={setIsOpen} isOpen={isOpen} value={value} + onChange={onChange} {...modalProps} /> )} diff --git a/src/components/TokenPicker/TokenPairPicker.tsx b/src/components/TokenPicker/TokenPairPicker.tsx index c79046aa4..bb43cd3a7 100644 --- a/src/components/TokenPicker/TokenPairPicker.tsx +++ b/src/components/TokenPicker/TokenPairPicker.tsx @@ -45,7 +45,7 @@ function OpenTokenPairPickerButton({ className?: string; value: TokenPair | undefined; disabled?: boolean; - open: () => void; + open?: () => void; }) { const [tokenA, tokenB] = value || []; return ( diff --git a/src/components/TokenPicker/TokenPicker.tsx b/src/components/TokenPicker/TokenPicker.tsx index 3cd2e5d0e..72bc25b78 100644 --- a/src/components/TokenPicker/TokenPicker.tsx +++ b/src/components/TokenPicker/TokenPicker.tsx @@ -28,7 +28,7 @@ import './TokenPicker.scss'; interface TokenPickerProps { className?: string; - onChange: (newToken: Token | undefined) => void; + onChange?: (newToken: Token | undefined) => void; exclusion?: Token | undefined; value: Token | undefined; disabled?: boolean; @@ -50,7 +50,7 @@ function OpenTokenPickerButton({ className?: string; value: Token | undefined; disabled?: boolean; - open: () => void; + open?: () => void; showChain?: boolean; }) { return ( @@ -59,6 +59,7 @@ function OpenTokenPickerButton({ className={[className, 'my-1'].filter(Boolean).join(' ')} onClick={open} disabled={disabled} + tabIndex={open ? 0 : -1} > {value?.logo_URIs ? ( demoNTRN" + } + } + + .limit-price { + & { + padding: paddings.$p-4; + border-radius: 1rem; + border: 1px solid transparent; + background-color: var(--default); + } + .numeric-value-input { + padding: paddings.$p-0; + border: 1px solid transparent; + background-color: var(--default); + color: white; + & input { + align-self: center; + color: white; + font-size: 1.5em; + } + } + .radio-button-group-switch { + & { + flex: 0 0 auto; + } + .moving-background { + background-color: var(--token-search-bg); + } + } + &.limit-price--error { + &, + .radio-button-group-switch { + color: var(--error); + background-color: hsla(0, 73%, 97%, 1); + border-color: var(--error); + } + .numeric-value-input, + button, + input, + input:disabled { + background-color: hsla(0, 73%, 97%, 1); + color: var(--error); + } + } + } + + .limit-expiry-selection { + min-width: 20em; // sized so that all options can fit without layout wrapping + } + .radio-button-group-switch { + flex: 0 0 auto; + } + + .direction-button-row { + & { + z-index: 1; + position: relative; + } + .direction-button { + width: 1.6em; + height: 1.6em; + border-radius: 100%; + background: var(--page-card-border); + font-size: 1.25em; + } + } + + .radio-button-group-switch { + &.text-s button { + padding: paddings.$p-2 paddings.$p-sm; + font-size: font-size.$text-s; + } } .numeric-value-input { color: hsl(218deg, 11%, 65%); - background-color: hsla(216, 20%, 25%, 1); - border: 1px solid var(--page-card-border); + background-color: var(--default); + border: 1px solid var(--page-card); border-radius: paddings.$p-3; + padding: paddings.$p-3 paddings.$p-4; input { font-size: 1rem; font-weight: 500; @@ -76,7 +143,11 @@ align-items: baseline; } - .limit-order__confirm-button { - font-size: 1.25rem; + .select-input { + .select-input-selection, + .select-input-group, + .select-input-group label { + background-color: var(--default); + } } } diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index b06a61431..852d154eb 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -1,9 +1,9 @@ import Long from 'long'; import BigNumber from 'bignumber.js'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { faArrowDown, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { - createContext, + ReactNode, useCallback, useContext, useEffect, @@ -16,9 +16,6 @@ import type { MsgPlaceLimitOrder, } from '@duality-labs/neutronjs/types/codegen/neutron/dex/tx'; -import TabsCard from './TabsCard'; -import Tabs from '../Tabs'; - import { Token, getBaseDenomAmount, @@ -43,14 +40,16 @@ import { import { useChainFeeToken } from '../../lib/web3/hooks/useTokens'; import { useNativeChain } from '../../lib/web3/hooks/useChains'; import { useTokenPairMapLiquidity } from '../../lib/web3/hooks/useTickLiquidity'; -import { useRealtimePrice } from '../stats/hooks'; +import { useRealtimeDisplayPrice } from '../stats/hooks'; -import RangeListSliderInput from '../inputs/RangeInput/RangeListSliderInput'; +import TokenInputGroup from '../TokenInputGroup'; +import NumberInput from '../inputs/NumberInput'; import { LimitOrderContextProvider, LimitOrderFormContext, LimitOrderFormSetContext, } from './LimitOrderContext'; +import RadioButtonGroupInput from '../RadioButtonGroupInput/RadioButtonGroupInput'; import SelectInput from '../inputs/SelectInput'; import { timeUnits } from '../../lib/utils/time'; import { @@ -59,21 +58,24 @@ import { timePeriodLabels, TimePeriod, AllowedLimitOrderTypeKey, - inputOrderTypes, - orderTypeTextMap, nonImmediateOrderTypes, immediateOrderTypes, } from '../../lib/web3/utils/limitOrders'; import { - DexTickUpdateEvent, + getTxEventsEndDisplayPrice, + getTxEventsEndTickIndexInToOut, mapEventAttributes, } from '../../lib/web3/utils/events'; import { displayPriceToTickIndex, + priceToTickIndex, tickIndexToPrice, } from '../../lib/web3/utils/ticks'; import { guessInvertedOrder } from '../../lib/web3/utils/pairs'; +import AssetIcon from '../assets/AssetIcon'; +import AssetSymbol from '../assets/AssetName'; + import Drawer from '../Drawer'; const { REACT_APP__MAX_TICK_INDEXES = '' } = import.meta.env; @@ -82,257 +84,373 @@ const [, priceMaxIndex = Number.MAX_SAFE_INTEGER] = const defaultExecutionType: AllowedLimitOrderTypeKey = 'FILL_OR_KILL'; -function formatNumericAmount(defaultValue = '') { - return (amount: number | string) => { - return amount - ? formatAmount( - amount, - { useGrouping: false }, - { reformatSmallValues: false } - ) - : defaultValue; - }; -} - -const TabContext = createContext< - [ - tabIndex?: number, - setTabIndex?: React.Dispatch> - ] ->([]); - export default function LimitOrderCard({ tokenA, tokenB, + getSetLimitPrice, + setPriceIndication, }: { tokenA?: Token; tokenB?: Token; + getSetLimitPrice?: (setLimitPrice: (limitPrice: string) => void) => void; + setPriceIndication?: React.Dispatch>; }) { return ( - - { - return [ - { - nav: 'Buy', - Tab: () => , - }, - { - nav: 'Sell', - Tab: () => , - }, - ]; - }, [tokenA, tokenB])} - /> - +
+ {tokenA && tokenB && ( + + + + )} +
); } -function LimitOrderNav({ - tokenA, - tokenB, - sell = false, -}: { - tokenA?: Token; - tokenB?: Token; - sell?: boolean; -}) { - const [tabIndex, setTabIndex] = useContext(TabContext); - const tabs = useMemo(() => { - const props = { tokenA, tokenB, sell }; - return [ - { - nav: 'Limit', - Tab: () => , - }, - { - nav: 'Market', - Tab: () => , - }, - ]; - }, [tokenA, tokenB, sell]); +const modeTabs = ['Buy', 'Sell'] as const; +type ModeTab = typeof modeTabs[number]; - return ( -
- - - -
- ); +const priceTabs = ['Swap', 'Limit'] as const; +type PriceTab = typeof priceTabs[number]; + +const expirationOptions = { + none: 'None', + '1 hours': '1 hour', + '1 days': '1 day', + '1 weeks': '1 week', + custom: 'Custom', +} as const; +type ExpirationOptions = keyof typeof expirationOptions; + +const buyLimitPriceOptions = { + 0: 'Market', + 1: '-1%', + 5: '-5%', + 10: '-10%', + [-1]: 'Custom', +} as const; +const sellLimitPriceOptions = { + 0: 'Market', + 1: '+1%', + 5: '+5%', + 10: '+10%', + [-1]: 'Custom', +} as const; +type LimitPriceOptions = + | keyof typeof buyLimitPriceOptions + | keyof typeof sellLimitPriceOptions; + +function getShortcutExpirationTime( + expiration: ExpirationOptions +): [number, TimePeriod] | undefined { + const timeString = expiration.split(' '); + if (timeString.length >= 2) { + const [timeAmount, timePeriod] = timeString; + return [Number(timeAmount), timePeriod as TimePeriod]; + } +} +function getCustomExpirationTimeMs(timeAmount: number, timePeriod: TimePeriod) { + return timeAmount && timePeriod + ? timePeriod === 'years' || timePeriod === 'months' + ? (() => { + const date = new Date(); + if (timePeriod === 'years') { + date.setFullYear(date.getFullYear() + 1); + } + if (timePeriod === 'months') { + date.setMonth(date.getMonth() + 1); + } + return date; + })().getTime() + : new Date(Date.now() + timeAmount * timeUnits[timePeriod]).getTime() + : NaN; } -const userBankBalanceRangePercentages = [0, 0.1, 0.25, 0.5, 0.75, 1]; +function getExpirationTimeMs( + expiration: ExpirationOptions, + formState: { timeAmount?: string; timePeriod?: TimePeriod } +) { + const [timeAmount, timePeriod]: [number, TimePeriod] = + getShortcutExpirationTime(expiration) || [ + Number(formState.timeAmount ?? 1), + formState.timePeriod || 'days', + ]; + return getCustomExpirationTimeMs(timeAmount, timePeriod); +} function LimitOrder({ tokenA, tokenB, - sell: sellMode = false, - showLimitPrice = false, + getSetLimitPrice, + setPriceIndication, }: { - tokenA?: Token; - tokenB?: Token; - sell?: boolean; - showLimitPrice?: boolean; + tokenA: Token; + tokenB: Token; + getSetLimitPrice?: (setLimitPrice: (limitPrice: string) => void) => void; + setPriceIndication?: React.Dispatch>; }) { - const buyMode = !sellMode; - const [denomA, denomB] = [getTokenId(tokenA), getTokenId(tokenB)]; + const [modeTab, setModeTab] = useState(modeTabs[0]); + const [priceTab, setPriceTab] = useState(priceTabs[0]); const formState = useContext(LimitOrderFormContext); const formSetState = useContext(LimitOrderFormSetContext); + // keep card trade state after switching token order + // or reset state if changing pairs + const previousPageStateRef = useRef([tokenA, tokenB]); + useEffect(() => { + const [previousTokenA, previousTokenB] = previousPageStateRef.current; + // change the card state if the tokens have changed + if (previousTokenA && previousTokenB && tokenA && tokenB) { + // flip trade state if token pair is exactly flipped + if ( + getTokenId(previousTokenA) === getTokenId(tokenB) && + getTokenId(previousTokenB) === getTokenId(tokenA) + ) { + // flip limit price + formSetState.setLimitPrice?.((limitPrice) => { + return Number(limitPrice) > 0 + ? new BigNumber(1).div(limitPrice).precision(6).toFixed() + : limitPrice; + }); + // flip buy/sell + setModeTab((modeTab) => (modeTab === 'Buy' ? 'Sell' : 'Buy')); + } + // partially "reset" form state for a different pair + else if ( + getTokenId(previousTokenA) !== getTokenId(tokenA) || + getTokenId(previousTokenB) !== getTokenId(tokenB) + ) { + // remove limit price + formSetState.setLimitPrice?.(''); + // set market price + setLimitOption(0); + // zero the amounts + formSetState.setAmountInOut?.(['', '']); + // everything else can stay: expiry, Swap/Limit, Buy/Sell + } + } + // update values in state + previousPageStateRef.current = [tokenA, tokenB]; + }, [formSetState, tokenA, tokenB]); + + const [expiration, setExpiration] = useState('none'); + // allow an expiration change to change the custom time amount and period + const switchExpiration = useCallback( + (newExpiration: ExpirationOptions) => { + // when selecting custom time: prefill the form with the previous shortcut + if (newExpiration === 'custom') { + const timeParts = expiration.split(' ') as [string, TimePeriod]; + if (timeParts.length > 1) { + const [timeAmount, timePeriod] = timeParts; + formSetState.setTimeAmount?.(timeAmount); + formSetState.setTimePeriod?.(timePeriod); + } + } + setExpiration(newExpiration); + }, + [formSetState, expiration] + ); + const hasExpiry = expiration !== 'none'; + + const switchModeTab = useCallback( + (newModeTab: ModeTab) => { + // change tab + setModeTab(newModeTab); + // set new amount and estimated exchange rate + if (modeTab !== newModeTab) { + formSetState.setAmountInOut?.(([a, b]) => [b, a]); + } + }, + [formSetState, modeTab] + ); + + const switchPriceTab = useCallback( + (newPriceTab: PriceTab) => { + // change tab + setPriceTab(newPriceTab); + // enforce changed execution type + const newExec = + newPriceTab === 'Limit' ? 'GOOD_TIL_TIME' : 'FILL_OR_KILL'; + formSetState.setExecution?.(newExec); + }, + [formSetState] + ); + + const cardModeNav = ( +
+

Trade

+
+ + className="order-type-input my-4" + values={priceTabs} + value={priceTab} + onChange={switchPriceTab} + /> +
+
+ + className="order-type-input my-4" + values={modeTabs} + value={modeTab} + onChange={switchModeTab} + /> +
+
+ ); + + const buyMode = modeTab === 'Buy'; + const [denomA, denomB] = [getTokenId(tokenA), getTokenId(tokenB)]; + const tokenIn = !buyMode ? tokenB : tokenA; const tokenOut = buyMode ? tokenB : tokenA; - const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; + const denomIn = getTokenId(tokenIn); const { data: userBalanceTokenIn, isLoading: isLoadingUserBalanceTokenIn } = useBankBalanceBaseAmount(denomIn); - const { data: userBalanceTokenOut } = useBankBalanceBaseAmount(denomOut); const { data: userBalanceTokenInDisplayAmount } = useBankBalanceDisplayAmount(denomIn); const { meta } = useTokenPairMapLiquidity([denomA, denomB]); + const setAmountIn = useCallback( + (v: string) => formSetState?.setAmountInOut?.([v, '']), + [formSetState] + ); + const setAmountOut = useCallback( + (v: string) => formSetState?.setAmountInOut?.(['', v]), + [formSetState] + ); + const [{ isValidating: isValidatingSwap, error }, swapRequest] = useSwap( [denomA, denomB].filter((denom): denom is string => !!denom) ); const { address, connectWallet } = useWeb3(); - const [tokenInBalanceFraction, setTokenInBalanceFraction] = - useState(); - - const buyAmountSimulatedMsgPlaceLimitOrder = useMemo< - MsgPlaceLimitOrder | undefined - >(() => { - if (address && userBalanceTokenIn && tokenInBalanceFraction !== undefined) { - const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; - if ( - denomIn && - denomOut && - userBalanceTokenIn && - Number(userBalanceTokenIn) > 0 && - tokenInBalanceFraction > 0 - ) { - return { - amount_in: new BigNumber(userBalanceTokenIn) - .multipliedBy(tokenInBalanceFraction) - .toFixed(0), - token_in: denomIn, - token_out: denomOut, - creator: address, - receiver: address, - order_type: orderTypeEnum.IMMEDIATE_OR_CANCEL, - // don't use a limit to find target amount in - tick_index_in_to_out: Long.fromNumber(priceMaxIndex), - }; - } - } - }, [address, tokenIn, tokenInBalanceFraction, tokenOut, userBalanceTokenIn]); - const { - data: buyAmountSimulationResult, - isValidating: isValidatingBuyAmountSimulationResult, - } = useSimulatedLimitOrderResult(buyAmountSimulatedMsgPlaceLimitOrder, { - // invalidate the request cache when the liquidity height changes - keepPreviousData: - Number(buyAmountSimulatedMsgPlaceLimitOrder?.amount_in) > 0, - memo: `height of ${meta?.height}`, - }); - - const { - amountInDisplayAmount, - amountInBaseAmount, - amountOutDisplayAmount, - amountOutBaseAmount, - } = useMemo(() => { + const { amountInBaseAmount, amountOutBaseAmount } = useMemo(() => { if (tokenIn && tokenOut) { - // get amount in from input in sell mode or the slider in buy mode - const amountInBaseAmount = !buyMode - ? // get amount in from sell mode (in base amount to round input correctly) - getBaseDenomAmount(tokenIn, formState.amount || 0) - : // in buy mode get the input slider value only if defined - (tokenInBalanceFraction !== undefined && - new BigNumber(userBalanceTokenIn || 0) - .multipliedBy(tokenInBalanceFraction || 0) - .toFixed(0)) || - undefined; - // get amount out from buy mode - const amountOutBaseAmount = - (buyMode || undefined) && - (buyAmountSimulationResult || isValidatingBuyAmountSimulationResult - ? // if we have a buy simulation result then show it or loading state - buyAmountSimulationResult?.response?.taker_coin_out.amount ?? '' - : // else use value directly (in base amount to round input correctly) - getBaseDenomAmount(tokenOut, formState.amount || 0)); - // return converted values for convenience return { - amountInBaseAmount, - amountInDisplayAmount: !buyMode - ? formState.amount - : amountInBaseAmount && - getDisplayDenomAmount(tokenIn, amountInBaseAmount), - amountOutBaseAmount, - amountOutDisplayAmount: - buyAmountSimulationResult || isValidatingBuyAmountSimulationResult - ? amountOutBaseAmount && - getDisplayDenomAmount(tokenOut, amountOutBaseAmount, { - // output a little more rounded than usual for form inputs - fractionalDigits: 3, - significantDigits: 5, - }) - : formState.amount, + amountInBaseAmount: formState.amountIn + ? getBaseDenomAmount(tokenIn, formState.amountIn || 0) + : undefined, + amountOutBaseAmount: formState.amountOut + ? getBaseDenomAmount(tokenOut, formState.amountOut || 0) + : undefined, }; } return {}; - }, [ - buyAmountSimulationResult, - buyMode, - formState.amount, - isValidatingBuyAmountSimulationResult, - tokenInBalanceFraction, - tokenIn, - tokenOut, - userBalanceTokenIn, - ]); + }, [tokenIn, tokenOut, formState.amountIn, formState.amountOut]); + + const [, currentPriceAtoB] = useRealtimeDisplayPrice(tokenA, tokenB); + const currentPriceInToOut = currentPriceAtoB + ? tokenA === tokenIn + ? currentPriceAtoB + : 1 / currentPriceAtoB + : undefined; - const [, currentPrice] = useRealtimePrice(tokenIn, tokenOut); + const [limitOption, setLimitOption] = useState(0); + + // combine offset and current price to get dynamic limit price + const offsetLimitPrice = useMemo(() => { + if (currentPriceAtoB && limitOption >= 0) { + return formatAmount( + new BigNumber(currentPriceAtoB) + .multipliedBy(1 + (buyMode ? -limitOption : limitOption) / 100) + .toFixed(), + { maximumSignificantDigits: 6 } + ); + } + }, [buyMode, currentPriceAtoB, limitOption]); + + const switchLimitOption = useCallback( + (limitOption: LimitPriceOptions) => { + // set value + setLimitOption(limitOption); + // remove custom limit price if setting one here + if (Number(limitOption) >= 0) { + formSetState.setLimitPrice?.(''); + } else { + formSetState.setLimitPrice?.(offsetLimitPrice || ''); + } + }, + [formSetState, offsetLimitPrice] + ); + + // pass up a callback for parents to set the limit price here + useEffect(() => { + getSetLimitPrice?.((limitPriceString) => { + const limitPrice = Number(limitPriceString); + if (currentPriceAtoB && limitPrice && !isNaN(limitPrice)) { + setPriceTab('Limit'); + switchLimitOption(-1); + setModeTab(currentPriceAtoB > limitPrice ? 'Buy' : 'Sell'); + formSetState.setLimitPrice?.(limitPriceString); + } else { + switchLimitOption(0); + } + }); + }, [currentPriceAtoB, formSetState, getSetLimitPrice, switchLimitOption]); + + // detect when the user has asked for a limit "outside liquidity bounds" + // in these cases the simulation won't help because it can only compute the + // immediate result of the msg tx: the future amountOut must be estimated + const outsideBounds = + // ensure bounds can be calculated + currentPriceAtoB && + formState.limitPrice && + formState.execution && + // check if the user is using a limit order + // ie. the user may have requested a price outside liquidity bounds + priceTab === 'Limit' && + // check if the user is using a non-immediate order type + // ie. the order may not be filled immediately in the simulation + nonImmediateOrderTypes.includes( + orderTypeEnum[formState.execution] as number + ) && + // check if the user requested a price outside liquidity bounds + // ie. the order may not be filled immediately in the simulation + (buyMode + ? new BigNumber(formState.limitPrice).lt(currentPriceAtoB) + : new BigNumber(formState.limitPrice).gt(currentPriceAtoB)); + + // the best estimated price will depend on whether the limit is out of bounds + const [lastKnownPrice, setLastKnownPrice] = useState(0); + const estimatedPriceInToOut = + outsideBounds && formState.limitPrice + ? // calculate limitPriceInToOut value + tokenA === tokenIn + ? new BigNumber(formState.limitPrice).toNumber() + : new BigNumber(1).dividedBy(formState.limitPrice).toNumber() + : // or use last known price (for more accuracy) + // falling back to current price (for less accuracy) + lastKnownPrice && Number.isFinite(lastKnownPrice) + ? lastKnownPrice + : currentPriceInToOut || 0; const simulatedMsgPlaceLimitOrder: MsgPlaceLimitOrder | undefined = useMemo(() => { const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; const execution = formState.execution; - const timePeriod = formState.timePeriod; - const timeAmount = Number(formState.timeAmount ?? NaN); - const limitPrice = Number(formState.limitPrice || NaN); // do not allow 0 - // calculate the expiration time in JS epoch (milliseconds) - const expirationTimeMs = - timeAmount && timePeriod - ? new Date(Date.now() + timeAmount * timeUnits[timePeriod]).getTime() - : NaN; + // find limit price from custom limit price or limit price offset + // calculation, and do not pass 0: allow NaN to act as market price + const limitPrice = Number( + formState.limitPrice || (priceTab === 'Limit' ? offsetLimitPrice : NaN) + ); // find amounts in/out for the order // in buy mode: buy the amount out with the user's available balance - const amountIn = - amountInBaseAmount || - // use bank balance in buy mode if amount was not defined by slider - (buyMode ? userBalanceTokenIn : undefined); + const amountIn = amountInBaseAmount || userBalanceTokenIn; // use amount out to set the order limit only if the amount in is not set const maxAmountOut = amountOutBaseAmount; // check format of request if ( execution && - (execution === 'GOOD_TIL_TIME' ? !isNaN(expirationTimeMs) : true) && - (execution === 'GOOD_TIL_TIME' ? timePeriod !== undefined : true) && address && denomIn && denomOut && @@ -368,13 +486,13 @@ function LimitOrder({ order_type: orderTypeEnum[execution] as LimitOrderType, // if no limit assume market value tick_index_in_to_out: - limitTickIndexInToOut !== undefined + limitTickIndexInToOut !== undefined && priceTab === 'Limit' ? Long.fromNumber(limitTickIndexInToOut.toNumber()) : Long.fromNumber(priceMaxIndex), }; // optional params // only add maxOut for buy orders - if (tokenOut && maxAmountOut && currentPrice) { + if (tokenOut && maxAmountOut && estimatedPriceInToOut) { // set max out on taker orders if ( immediateOrderTypes.includes(orderTypeEnum[execution] as number) @@ -385,7 +503,7 @@ function LimitOrder({ } // set an estimated max in instead for non-immediate orders else { - // note: this is a bit or an awkward estimation that goes from the + // note: this is a bit of an awkward estimation that goes from the // user's set limit to the current price const estimatedTokenIn = Number(maxAmountOut) * @@ -393,7 +511,7 @@ function LimitOrder({ tickIndexToPrice( limitTickIndexInToOut ?? new BigNumber(priceMaxIndex) ), - currentPrice + estimatedPriceInToOut ).toNumber(); msgPlaceLimitOrder.amount_in = BigNumber.min( msgPlaceLimitOrder.amount_in, @@ -402,11 +520,19 @@ function LimitOrder({ } } // only add expiration time to timed limit orders - if (execution === 'GOOD_TIL_TIME' && !isNaN(expirationTimeMs)) { - msgPlaceLimitOrder.expiration_time = { - seconds: Long.fromNumber(Math.round(expirationTimeMs / 1000)), - nanos: 0, - }; + if (formState.execution === 'GOOD_TIL_TIME') { + const expirationTimeMs = getExpirationTimeMs(expiration, { + timeAmount: '1', + timePeriod: 'days', + }); + if (hasExpiry && !isNaN(expirationTimeMs)) { + msgPlaceLimitOrder.expiration_time = { + seconds: Long.fromNumber(Math.round(expirationTimeMs / 1000)), + nanos: 0, + }; + } else { + msgPlaceLimitOrder.order_type = orderTypeEnum['GOOD_TIL_CANCELLED']; + } } return msgPlaceLimitOrder; } @@ -415,36 +541,47 @@ function LimitOrder({ amountInBaseAmount, amountOutBaseAmount, buyMode, - currentPrice, + estimatedPriceInToOut, + expiration, formState.execution, formState.limitPrice, - formState.timeAmount, - formState.timePeriod, + hasExpiry, + offsetLimitPrice, + priceTab, tokenIn, tokenOut, userBalanceTokenIn, ]); - const lastSimulatedMsgPlaceLimitOrder = useRef(); + // keep track of whether the simulation result was calculated on the given + // user inputs (but the dex price may be different) by tracking last msg + const [lastSimulatedMsgPlaceLimitOrder, setLastSimulatedMsgPlaceLimitOrder] = + useState(); + const simulationResultMatchesInput = useMemo(() => { + return ( + simulatedMsgPlaceLimitOrder !== undefined && + lastSimulatedMsgPlaceLimitOrder === simulatedMsgPlaceLimitOrder + ); + }, [lastSimulatedMsgPlaceLimitOrder, simulatedMsgPlaceLimitOrder]); + const { data: simulationResult, isValidating: isValidatingSimulation } = useSimulatedLimitOrderResult(simulatedMsgPlaceLimitOrder, { // if the limit order payload hasn't changed then keep the previous data keepPreviousData: // don't keep when input goes to 0 - (Number(simulatedMsgPlaceLimitOrder?.amount_in) > 0 && - // if in buy amount calculation mode let it pass - buyAmountSimulationResult?.response !== undefined) || + Number(simulatedMsgPlaceLimitOrder?.amount_in) > 0 || // don't change if input exists and hasn't changed - (simulatedMsgPlaceLimitOrder !== undefined && - lastSimulatedMsgPlaceLimitOrder.current === - simulatedMsgPlaceLimitOrder), + simulationResultMatchesInput, // invalidate the request cache when the liquidity height changes memo: `height of ${meta?.height}`, }); // update last simulatedMsgPlaceLimitOrder for comparison on the next render useEffect(() => { - lastSimulatedMsgPlaceLimitOrder.current = simulatedMsgPlaceLimitOrder; - }, [simulatedMsgPlaceLimitOrder]); + // set only after simulation result has been found + if (!isValidatingSimulation) { + setLastSimulatedMsgPlaceLimitOrder(simulatedMsgPlaceLimitOrder); + } + }, [isValidatingSimulation, simulatedMsgPlaceLimitOrder]); const onFormSubmit = useCallback( function (event?: React.FormEvent) { @@ -452,42 +589,34 @@ function LimitOrder({ // define function to get the current market limit index with a tolerance const getMarketLimitIndex = () => { - // calculate last price out from result - const lastPriceEvent = simulationResult?.result?.events.findLast( - (event) => event.type === 'TickUpdate' - ); - - // if market limit simulation made no update to the liquidity then skip - if (!lastPriceEvent) { - return; - } - - const denomIn = getTokenId(tokenIn); - - // calculate tolerance from user slippage settings + // calculate tolerance (in ticks) from user slippage settings // set tiny minimum of tolerance as the frontend calculations // don't always exactly align with the backend calculations - const tolerance = Math.max(1e-12, Number(formState.slippage) || 0); - const toleranceFactor = 1 + tolerance; + const slippage = Number(formState.slippagePercent) / 100 || 0; + const toleranceTicks = priceToTickIndex(new BigNumber(1 + slippage)) + .decimalPlaces(0, BigNumber.ROUND_UP) + .toNumber(); + // calculate last price out from matching event results - const lastPrice = - mapEventAttributes(lastPriceEvent)?.attributes; - const direction = - lastPrice.TokenIn === denomIn - ? lastPrice.TokenIn === lastPrice.TokenZero - : lastPrice.TokenIn === lastPrice.TokenOne; - - return direction - ? Math.floor(Number(lastPrice.TickIndex) / toleranceFactor) - : Math.floor(Number(lastPrice.TickIndex) * toleranceFactor); + const events = simulationResult?.result?.events.map(mapEventAttributes); + const lastTickIndex = events && getTxEventsEndTickIndexInToOut(events); + + // return the limit with tolerance + return ( + lastTickIndex && + // apply tolerance in the in->out direction to the result tick index + lastTickIndex + toleranceTicks + ); }; - const tickIndexLimitInToOut = formState.limitPrice - ? displayPriceToTickIndex( - new BigNumber(formState.limitPrice), - tokenIn, - tokenOut - ) + // find limit price from custom limit price or limit price offset + // calculation, and do not pass 0: allow NaN to raise errors + const limitPrice = Number( + formState.limitPrice || (limitOption > 0 ? offsetLimitPrice : NaN) + ); + + const tickIndexLimitInToOut = limitPrice + ? displayPriceToTickIndex(new BigNumber(limitPrice), tokenIn, tokenOut) ?.multipliedBy(buyMode ? 1 : -1) ?.toNumber() : getMarketLimitIndex(); @@ -498,13 +627,35 @@ function LimitOrder({ ...simulatedMsgPlaceLimitOrder, tick_index_in_to_out: Long.fromNumber(tickIndexLimitInToOut), }; + + // only add expiration time to timed limit orders + if (formState.execution === 'GOOD_TIL_TIME') { + const expirationTimeMs = getExpirationTimeMs(expiration, { + timeAmount: formState.timeAmount, + timePeriod: formState.timePeriod, + }); + if (hasExpiry && !isNaN(expirationTimeMs)) { + msgPlaceLimitOrder.expiration_time = { + seconds: Long.fromNumber(Math.round(expirationTimeMs / 1000)), + nanos: 0, + }; + } else { + msgPlaceLimitOrder.order_type = orderTypeEnum['GOOD_TIL_CANCELLED']; + } + } + const gasEstimate = simulationResult?.gasInfo?.gasUsed.toNumber(); swapRequest(msgPlaceLimitOrder, (gasEstimate || 0) * 1.5); } }, [ formState.limitPrice, - formState.slippage, + formState.slippagePercent, + formState.execution, + formState.timeAmount, + formState.timePeriod, + limitOption, + offsetLimitPrice, tokenIn, tokenOut, buyMode, @@ -512,6 +663,8 @@ function LimitOrder({ simulationResult?.result?.events, simulationResult?.gasInfo?.gasUsed, swapRequest, + expiration, + hasExpiry, ] ); @@ -593,16 +746,65 @@ function LimitOrder({ } }, [nativeChain, setChainFeeToken]); - const [lastKnownPrice, setLastKnownPrice] = useState(0); useEffect(() => { if (simulationResult?.response) { const price = new BigNumber(simulationResult.response.coin_in.amount).div( simulationResult.response.taker_coin_out.amount ); + // track last price state setLastKnownPrice(price.toNumber()); } }, [simulationResult?.response]); + // update the price indication from the form + useEffect(() => { + if (setPriceIndication) { + // set limit price + if (priceTab === 'Limit' && limitOption !== 0) { + setPriceIndication(Number(formState.limitPrice || offsetLimitPrice)); + } + // set market price "slippage" result: based on trade simulation end price + else if ( + priceTab === 'Swap' && + tokenA && + tokenB && + simulationResult?.result + ) { + // get end price of simulated tx + const events = simulationResult.result.events.map(mapEventAttributes); + const displayPrice = getTxEventsEndDisplayPrice(events, tokenA, tokenB); + // prevent showing "incorrect side" price by limiting to current price + // note: this isn't strictly correct but looks better: a race condition + // can form because endDisplayPrice is a chain response result and + // currentPriceAtoB is an indexer response result. + const possibleDisplayPrices: number[] = [ + displayPrice?.toNumber(), + currentPriceAtoB, + ].filter((price): price is number => typeof price === 'number'); + setPriceIndication( + (buyMode + ? Math.max(...possibleDisplayPrices) + : Math.min(...possibleDisplayPrices)) || undefined + ); + } + // set market price if available: no simulation result + else { + setPriceIndication(currentPriceAtoB || undefined); + } + } + }, [ + buyMode, + currentPriceAtoB, + formState.limitPrice, + limitOption, + offsetLimitPrice, + priceTab, + setPriceIndication, + simulationResult?.result, + tokenA, + tokenB, + ]); + // find coinIn and coinOut from the simulation results const tranchedReserves = (simulatedMsgPlaceLimitOrder && @@ -622,125 +824,153 @@ function LimitOrder({ ) ).toNumber() : // use the market price or NaN - currentPrice ?? 0) || 0; - const coinIn = Number(simulationResult?.response?.coin_in.amount || 0); + currentPriceInToOut ?? 0) || 0; + const coinIn = + isValidatingSimulation && lastKnownPrice + ? amountOutBaseAmount && lastKnownPrice > 0 + ? // don't estimate higher than the user's balance because the simulate + // function won't accept an input higher than the user's balance + BigNumber.min( + userBalanceTokenIn || 0, + new BigNumber(amountOutBaseAmount).multipliedBy( + estimatedPriceInToOut + ) + ).toNumber() + : 0 + : Number(simulationResult?.response?.coin_in.amount || 0); const coinOut = - Number(simulationResult?.response?.taker_coin_out.amount || 0) + - tranchedAmountOut; + isValidatingSimulation && lastKnownPrice + ? amountInBaseAmount && lastKnownPrice > 0 + ? // don't estimate higher than the user's balance because the simulate + // function won't accept an input higher than the user's balance + BigNumber.min( + userBalanceTokenIn || 0, + new BigNumber(amountInBaseAmount) + ) + .dividedBy(estimatedPriceInToOut || 1) + .toNumber() + : 0 + : Number(simulationResult?.response?.taker_coin_out.amount || 0) + + tranchedAmountOut; + + const valueIn = useMemo(() => { + return ( + formState?.amountIn || + formatAmount(getDisplayDenomAmount(tokenIn, coinIn) || 0, { + maximumSignificantDigits: 5, + }) + ); + }, [coinIn, formState?.amountIn, tokenIn]); + const valueOut = useMemo(() => { + return ( + formState?.amountOut || + formatAmount(getDisplayDenomAmount(tokenOut, coinOut) || 0, { + maximumSignificantDigits: 5, + }) + ); + }, [coinOut, formState?.amountOut, tokenOut]); + + const hasInsufficientFunds = new BigNumber( + userBalanceTokenInDisplayAmount || 0 + ).isLessThan(Number(valueIn) || Number(valueOut) || 0); // disable fieldset with no address because the estimation requires a signed client const fieldset = ( -
-
- { - formSetState.setAmount?.(value); - setTokenInBalanceFraction(undefined); +
+ + { + // and remove generated market offset price + if (limitOption >= 0) { + switchLimitOption(-1); + } + // set price + formSetState.setLimitPrice?.(price); }} - suffix={tokenB?.symbol} - format={formatNumericAmount('')} + limitOptions={buyMode ? buyLimitPriceOptions : sellLimitPriceOptions} + limitOption={limitOption} + onLimitOptionChange={switchLimitOption} /> -
- { - const numericValue = Math.max(0, Math.min(1, Number(value) || 0)); - if (buyMode) { - formSetState.setAmount?.(''); - setTokenInBalanceFraction(numericValue); - } else { - setTokenInBalanceFraction(undefined); - const display = new BigNumber( +
 
+ +
+ - {showLimitPrice && ( -
- ='}`} - value={formState.limitPrice ?? ''} - placeholder="market" - onChange={formSetState.setLimitPrice} - suffix={tokenA && tokenB && `${tokenA.symbol} per ${tokenB.symbol}`} - format={formatNumericAmount('')} - /> -
- )} -
-
- Order type{' '} - - ? - -
- - className="flex col m-0 p-0" - list={inputOrderTypes} - getLabel={(key = defaultExecutionType) => orderTypeTextMap[key]} - value={formState.execution} - onChange={formSetState.setExecution} - floating - /> - -
+ )}`) + } + onValueChanged={setAmountIn} + token={tokenIn} + variant={hasInsufficientFunds && 'error'} + inactive={!formState?.amountIn && isValidatingSimulation} + value={valueIn} + disabledInput={isLoadingUserBalanceTokenIn} + > +
+
+ +
+
+ +
+
+ +
+
Expiry
+
+ + className="order-type-input text-s" + values={expirationOptions} + value={expiration} + onChange={switchExpiration} + /> +
+
+
+ +
{ + setExpiration('custom'); + formSetState.setTimeAmount?.(v); + }} /> - className="flex col m-0 p-0" + className="focusable flex col m-0 p-0" list={timePeriods.slice()} getLabel={(key = 'days') => timePeriodLabels[key]} value={formState.timePeriod} @@ -772,41 +1002,31 @@ function LimitOrder({
-
- -
{warning ? ( // show a warning if an amount has been entered, but the form fails validation
{warning}
- ) : Number(formState.amount) && simulationResult && !coinIn ? ( + ) : simulationResult && + ((Number(formState.amountIn) && !coinOut) || + (Number(formState.amountOut) && !coinIn)) ? ( // show a warning if an amount in, but no amount out
@@ -821,39 +1041,35 @@ function LimitOrder({
) : null}
- -
- + {buyMode ? 'Buy' : 'Sell'} + + ) : ( + )} - suffix={tokenIn?.symbol} - /> +
); - return
{fieldset}
; + return ( + <> + {cardModeNav} +
{fieldset}
+ + ); } function NumericInputRow({ @@ -870,12 +1086,12 @@ function NumericInputRow({ readOnly = false, }: { className?: string; - prefix?: string; + prefix?: ReactNode; value: string; placeholder?: string; onInput?: (value: string) => void; onChange?: (value: string) => void; - suffix?: string; + suffix?: ReactNode; min?: number; max?: number; format?: (value: number) => string; @@ -908,14 +1124,19 @@ function NumericInputRow({ return (
inputRef.current?.focus()} + onKeyDown={() => inputRef.current?.focus()} + role="button" + tabIndex={0} > {prefix && (
{prefix}
)} +
{prefix} {tooltip && {tooltip}}
-
- {loading && } +
+ + + + + {value} + + {suffix && ( + {suffix} + )} +
+
+ ); +} + +function LimitPriceInput({ + className, + tokenA, + tokenB, + limitPrice, + onLimitPriceChange, + limitOptions, + limitOption, + onLimitOptionChange, +}: { + className?: string; + tokenA: Token; + tokenB: Token; + limitPrice: string; + onLimitPriceChange: (limitPrice: string) => void; + limitOptions: Record; + limitOption: LimitPriceOptions; + onLimitOptionChange: (limitOption: LimitPriceOptions) => void; +}) { + const inputRef = useRef(null); + const handleClick = useCallback((event: { target: EventTarget }) => { + // only focus input if click comes from an unfocusable element + const target = event.target as HTMLElement | null; + if (target && target.tabIndex < 0) { + inputRef.current?.focus(); + } + }, []); + const handleLimitOptionChange = useCallback( + (limitOption: LimitPriceOptions) => { + // action callback + onLimitOptionChange(limitOption); + // focus input when "custom" is selected + if (limitOption < 0) { + inputRef.current?.select(); + } + }, + [onLimitOptionChange] + ); + return ( +
+
+ When 1 +
+ +
+
+ +
+ is worth +
+
+ +
+
+ +
+
+ +
+
+
+
+ + className="mt-3 order-type-input text-s" + values={limitOptions} + value={limitOption} + onChange={handleLimitOptionChange} + />
-
{value}
- {suffix &&
{suffix}
}
); } diff --git a/src/components/cards/LimitOrderContext.tsx b/src/components/cards/LimitOrderContext.tsx index 79759de3d..87b161f10 100644 --- a/src/components/cards/LimitOrderContext.tsx +++ b/src/components/cards/LimitOrderContext.tsx @@ -12,20 +12,21 @@ import { } from '../../lib/web3/utils/limitOrders'; interface FormState { - amount: string; + amountIn: string; + amountOut: string; limitPrice: string; timeAmount: string; timePeriod: TimePeriod; execution: AllowedLimitOrderTypeKey; - slippage: string; + slippagePercent: string; } interface FormSetState { - setAmount: Dispatch>; + setAmountInOut: Dispatch>; setLimitPrice: Dispatch>; setTimeAmount: Dispatch>; setTimePeriod: Dispatch>; setExecution: Dispatch>; - setSlippage: Dispatch>; + setSlippagePercent: Dispatch>; } export const LimitOrderFormContext = createContext>({}); @@ -40,40 +41,49 @@ export function LimitOrderContextProvider({ defaultExecutionType: AllowedLimitOrderTypeKey; children: ReactNode; }) { - const [amount, setAmount] = useState(''); + const [amountInOut, setAmountInOut] = useState<[string, string]>(['', '']); const [limitPrice, setLimitPrice] = useState(''); - const [timeAmount, setTimeAmount] = useState('28'); - const [timePeriod, setTimePeriod] = useState('days'); + const [timeAmount, setTimeAmount] = useState('1'); + const [timePeriod, setTimePeriod] = useState('hours'); const [execution, setExecution] = useState(defaultExecutionType); - const [slippage, setSlippage] = useState(''); + const [slippagePercent, setSlippagePercent] = useState('0.5'); const state = useMemo(() => { + const [amountIn, amountOut] = amountInOut; return { - amount, + amountIn, + amountOut, limitPrice, timeAmount, timePeriod, execution, - slippage, + slippagePercent, }; - }, [amount, limitPrice, timeAmount, timePeriod, execution, slippage]); + }, [ + amountInOut, + limitPrice, + timeAmount, + timePeriod, + execution, + slippagePercent, + ]); const setState = useMemo(() => { return { - setAmount, + setAmountInOut, setLimitPrice, setTimeAmount, setTimePeriod, setExecution, - setSlippage, + setSlippagePercent, }; }, [ - setAmount, + setAmountInOut, setLimitPrice, setTimeAmount, setTimePeriod, setExecution, - setSlippage, + setSlippagePercent, ]); return ( diff --git a/src/components/cards/PriceCard.tsx b/src/components/cards/PriceCard.tsx index 92b04decc..43900e29e 100644 --- a/src/components/cards/PriceCard.tsx +++ b/src/components/cards/PriceCard.tsx @@ -6,7 +6,7 @@ import AssetSymbol from '../assets/AssetName'; import { Token } from '../../lib/web3/utils/tokens'; import { formatPrice } from '../../lib/utils/number'; import { useSimplePrice } from '../../lib/tokenPrices'; -import { useRealtimePrice } from '../stats/hooks'; +import { useRealtimeDisplayPrice } from '../stats/hooks'; import './PriceCard.scss'; @@ -57,7 +57,7 @@ export function PairPriceCard({ tokenA: Token; tokenB: Token; }) { - const [, currentPriceBtoA] = useRealtimePrice(tokenA, tokenB); + const [, currentPriceBtoA] = useRealtimeDisplayPrice(tokenA, tokenB); return ( ; + tabIndex?: number; + setTabIndex?: React.Dispatch>; }) { const [tabIndex, setTabIndex] = useState(0); - const { Tab } = tabs[tabIndex]; + const { tab } = tabs[givenTabIndex ?? tabIndex]; return (
setTabIndex(index)} + onClick={() => (givenSetTabIndex ?? setTabIndex)(index)} > {tab.nav} @@ -41,9 +45,7 @@ export default function TabsCard({ })}
-
- -
+
{tab}
); diff --git a/src/components/inputs/SelectInput/SelectInput.scss b/src/components/inputs/SelectInput/SelectInput.scss index 81cf7b5ad..548ebbebf 100644 --- a/src/components/inputs/SelectInput/SelectInput.scss +++ b/src/components/inputs/SelectInput/SelectInput.scss @@ -12,6 +12,7 @@ margin-bottom: margins.$m-3; padding: paddings.$p-4; background-color: var(--default); + text-align: left; } .select-input-selection svg { diff --git a/src/components/inputs/SelectInput/SelectInput.tsx b/src/components/inputs/SelectInput/SelectInput.tsx index 9bbd6ff9c..26cecf437 100644 --- a/src/components/inputs/SelectInput/SelectInput.tsx +++ b/src/components/inputs/SelectInput/SelectInput.tsx @@ -128,11 +128,11 @@ export default function SelectInput({ .join(' ')} > + +
+ + + +
+
+ Math.abs(getOrderOfMagnitude(resolution)) + ) + ) + 1.5 + }em`, + }} + > +
+ + className="focusable col flex my-0 p-0" + list={bucketResolutions} + value={bucketResolution} + getLabel={(value) => value} + onChange={setBucketResolution} + floating + /> +
+
+ + {orderbookDepth} +
+
B
+ {bucketBuyPercent !== undefined && ( + <> +
+ {(100 * bucketBuyPercent).toFixed()}% +
+
+
+
+ {(100 - 100 * bucketBuyPercent).toFixed()}% +
+ + )} +
S
+
+ + ); +} + +const styles = { + boxWidth1: { + width: '0.2em', + }, + boxWidth2: { + width: '0.45em', + }, + boxWidth3: { + width: '0.6em', + }, + boxWidth4: { + width: '0.6em', + }, + boxWidth5: { + width: '0.8em', + }, +}; + +function getBucketResolutions(currentPrice?: number | null): number[] { + const startingOrderOfMagnitude = currentPrice + ? getOrderOfMagnitude(currentPrice) + : 0; + return Array.from({ length: 4 }).map((_, index) => { + return Math.pow(10, startingOrderOfMagnitude) / Math.pow(10, index); + }); +} + +function EmptyRow() { + return ( + +   + + ); } function OrderbookListRow({ - tick, - previousTicks, - token, - reserveKey, + className, + price: bucketPrice, + reserves, + reserveValueUSD, + weight, priceDecimalPlaces = 6, amountDecimalPlaces = 2, + variant, + active, + hovered, + onHighlight, + onClick, }: { - tick: TickInfo | undefined; - previousTicks: TickInfo[]; - token: Token; - reserveKey: 'reserve0' | 'reserve1'; + className?: string; + price: number; + reserves: number; + reserveValueUSD?: number; + weight?: number; priceDecimalPlaces?: number; amountDecimalPlaces?: number; + variant?: 'success' | 'error'; + active?: boolean | 0; + hovered?: boolean | 0; + onHighlight?: (bucketPrice: number | undefined) => void; + onClick?: (bucketPrice: number) => void; }) { - const { data: price } = useSimplePrice(token); - // add empty row - if (!tick) { - return ( - -   - - ); - } - // add tick row - const previousTokenATick = previousTicks.find((prev) => { - return prev.tickIndex1To0 === tick.tickIndex1To0; - }); - const diff = previousTokenATick - ? tick[reserveKey].minus(previousTokenATick[reserveKey]) - : new BigNumber(0); + // keep track of hover state and use as a price indication in parents + const onHover = useCallback( + (bucketPrice: number | undefined) => { + // set parent state + onHighlight?.(bucketPrice); + }, + [onHighlight] + ); + const onHoverIn = useCallback>( + () => onHover(bucketPrice), + [onHover, bucketPrice] + ); + const onHoverOut = useCallback>( + () => onHover(undefined), + [onHover] + ); - const value = getTokenValue(token, tick[reserveKey], price); return ( - - - {formatAmount(tick.price1To0.toNumber(), { + onClick?.(bucketPrice)} + > + + {formatAmount(bucketPrice, { minimumFractionDigits: priceDecimalPlaces, maximumFractionDigits: priceDecimalPlaces, })} - - {formatAmount(value ?? '...', { + +
+ {formatAmount(reserves ?? '...', { minimumFractionDigits: amountDecimalPlaces, maximumFractionDigits: amountDecimalPlaces, })} + +
+
+ {formatAmount(reserveValueUSD ?? '...', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+ ); } diff --git a/src/pages/Orderbook/OrderbookTradesList.scss b/src/pages/Orderbook/OrderbookTradesList.scss new file mode 100644 index 000000000..9a9d2dffe --- /dev/null +++ b/src/pages/Orderbook/OrderbookTradesList.scss @@ -0,0 +1,11 @@ +@import url('./OrderbookList.scss'); + +.orderbook-trades-list { + overflow: auto; + + th { + position: sticky; + top: 0; + background-color: var(--page-card); + } +} diff --git a/src/pages/Orderbook/OrderbookTradesList.tsx b/src/pages/Orderbook/OrderbookTradesList.tsx index f661d8e83..fdbdcf9a6 100644 --- a/src/pages/Orderbook/OrderbookTradesList.tsx +++ b/src/pages/Orderbook/OrderbookTradesList.tsx @@ -1,3 +1,4 @@ +import BigNumber from 'bignumber.js'; import { useEffect, useMemo, useState } from 'react'; import useTransactionTableData, { @@ -10,16 +11,19 @@ import { useSimplePrice } from '../../lib/tokenPrices'; import { Token, getTokenId, getTokenValue } from '../../lib/web3/utils/tokens'; import { - getLastPrice, + DexPlaceLimitOrderEvent, getSpentTokenAmount, + getTxEventsEndDisplayPrice, mapEventAttributes, } from '../../lib/web3/utils/events'; +import useOnDexMsg from '../../lib/web3/hooks/useOnDexMsg'; +import { useOrderedTokenPair } from '../../lib/web3/hooks/useTokenPairs'; -import './OrderbookList.scss'; +import './OrderbookTradesList.scss'; // ensure that a certain amount of rows are shown in the card -const pageSize = 20; -const tradeListSize = 18; +const pageSize = 30; +const tradeListSize = 30; export default function OrderBookTradesList({ tokenA, @@ -28,13 +32,23 @@ export default function OrderBookTradesList({ tokenA: Token; tokenB: Token; }) { - const { data } = useTransactionTableData({ + const { data, isLoading, refetch } = useTransactionTableData({ tokenA, tokenB, action: 'PlaceLimitOrder', pageSize, }); + // subscribe to updates of the pair's limit orders + const denoms = useOrderedTokenPair([getTokenId(tokenA), getTokenId(tokenB)]); + useOnDexMsg( + 'PlaceLimitOrder', + () => { + refetch({ cancelRefetch: false }); + }, + denoms ? { TokenZero: denoms[0], TokenOne: denoms[1] } : null + ); + const [tradeList, setTradeList] = useState>([]); useEffect(() => { if (data) { @@ -72,7 +86,7 @@ export default function OrderBookTradesList({ : undefined; return ( -
+
{/* minimize the first column width */} @@ -86,6 +100,11 @@ export default function OrderBookTradesList({ + + + + + {tradeList.map((tx, index, txs) => { if (index < tradeListSize) { @@ -104,6 +123,11 @@ export default function OrderBookTradesList({ return null; } })} + {(!tradeList || !tradeList.length) && ( + + + + )}
Time
{isLoading ? 'Loading...' : 'No Data'}
@@ -137,18 +161,18 @@ function OrderbookTradeListRow({ return previousTx?.tx_result.events.map(mapEventAttributes) || []; }, [previousTx]); - const lastPrice = useMemo(() => { - return getLastPrice(events, { tokenA, tokenB }); + const lastDisplayPrice = useMemo((): BigNumber | undefined => { + return getTxEventsEndDisplayPrice(events, tokenA, tokenB); }, [events, tokenA, tokenB]); const prevPrice = useMemo(() => { - return getLastPrice(previousEvents, { tokenA, tokenB }); + return getTxEventsEndDisplayPrice(previousEvents, tokenA, tokenB); }, [previousEvents, tokenA, tokenB]); const diff = useMemo(() => { - return lastPrice !== undefined && prevPrice !== undefined - ? lastPrice.minus(prevPrice).toNumber() + return lastDisplayPrice !== undefined && prevPrice !== undefined + ? lastDisplayPrice.minus(prevPrice).toNumber() : 0; - }, [lastPrice, prevPrice]); + }, [lastDisplayPrice, prevPrice]); const value = useMemo(() => { const amountSpentA = getSpentTokenAmount(events, { matchToken: tokenA }); @@ -176,8 +200,8 @@ function OrderbookTradeListRow({ )} - {lastPrice - ? formatAmount(lastPrice?.toNumber(), { + {lastDisplayPrice + ? formatAmount(lastDisplayPrice?.toNumber(), { minimumFractionDigits: priceDecimalPlaces, maximumFractionDigits: priceDecimalPlaces, }) diff --git a/src/pages/Orderbook/useBuckets.ts b/src/pages/Orderbook/useBuckets.ts new file mode 100644 index 000000000..daa2f1f4c --- /dev/null +++ b/src/pages/Orderbook/useBuckets.ts @@ -0,0 +1,155 @@ +import BigNumber from 'bignumber.js'; +import { useMemo } from 'react'; + +import { useRealtimeDisplayPrice } from '../../components/stats/hooks'; +import { + ReserveDataRow, + useTokenPairMapLiquidity, +} from '../../lib/web3/hooks/useTickLiquidity'; + +import { tickIndexToDisplayPrice } from '../../lib/web3/utils/ticks'; +import { + Token, + getDisplayDenomAmount, + getTokenId, +} from '../../lib/web3/utils/tokens'; +import { getOrderOfMagnitude } from '../../lib/utils/number'; + +export type Bucket = [ + innerBound: number, + outerBound: number, + displayReserves: number +]; +export type Buckets = [bucketsA: Bucket[], bucketsB: Bucket[], height: number]; + +export default function useBucketsByPriceResolution( + tokenA?: Token, + tokenB?: Token, + bucketResolution?: number +): Buckets | undefined { + const [tokenIdA, tokenIdB] = [getTokenId(tokenA), getTokenId(tokenB)]; + const [, currentPrice] = useRealtimeDisplayPrice(tokenA, tokenB); + const { data: [tokenAReserves, tokenBReserves] = [], meta: { height } = {} } = + useTokenPairMapLiquidity([tokenIdA, tokenIdB]); + + // return bucketReserves + return useMemo((): Buckets | undefined => { + // return no buckets if the current price is not yet defined + if (!tokenA || !tokenB || !currentPrice || !bucketResolution || !height) { + return undefined; + } + // continue + const resolutionMagnitude = getOrderOfMagnitude(bucketResolution); + const zero = new BigNumber(0); + const reduceReservesToBuckets = (inverseDirection: boolean) => { + const getDisplayPrice = !inverseDirection + ? // get token A prices + (tickIndex: number): BigNumber => + tickIndexToDisplayPrice(new BigNumber(tickIndex), tokenA, tokenB) || + zero + : // get token B prices + (tickIndex: number): BigNumber => + tickIndexToDisplayPrice( + new BigNumber(tickIndex).negated(), + tokenA, + tokenB + ) || zero; + // define reducer to accumulate buckets + return ( + acc: Bucket[], + [tickIndex, reserves]: ReserveDataRow + ): Bucket[] => { + const lastValue = acc.at(-1); + const outerBound = lastValue?.[1]; + const displayPrice = getDisplayPrice(tickIndex); + // does value belong in the last bucket? + if ( + lastValue && + outerBound !== undefined && + (inverseDirection + ? displayPrice.isLessThanOrEqualTo(outerBound) + : displayPrice.isGreaterThanOrEqualTo(outerBound)) + ) { + lastValue[2] += reserves; + return acc; + } + // does value belong in a new bucket? + else if ( + lastValue || + (inverseDirection + ? displayPrice.isGreaterThanOrEqualTo(currentPrice) + : displayPrice.isLessThanOrEqualTo(currentPrice)) + ) { + // find the bucket limits from current value and bucket resolution + const priceMagnitude = getOrderOfMagnitude(displayPrice.toNumber()); + const precision = priceMagnitude - resolutionMagnitude + 1; + // get outer bound from the precision (or fallback to lowest value) + const outerBound = + precision > 0 + ? inverseDirection + ? displayPrice + .precision(precision, BigNumber.ROUND_UP) + .toNumber() + : displayPrice + .precision(precision, BigNumber.ROUND_DOWN) + .toNumber() + : 0; + // get inner bound based on outer bound, but limit to current price + const innerBound = inverseDirection + ? Math.max( + // use BigNumber to avoid float imprecision (eg: 0.2+0.1) + new BigNumber(outerBound).minus(bucketResolution).toNumber(), + currentPrice + ) + : Math.min( + // use BigNumber to avoid float imprecision (eg: 0.2+0.1) + new BigNumber(outerBound).plus(bucketResolution).toNumber(), + currentPrice + ); + // add the new bucket + if (inverseDirection) { + acc.push([innerBound, outerBound, reserves]); + } else { + acc.push([innerBound, outerBound, reserves]); + } + } + return acc; + }; + }; + // use the correct reducer to reduce reserves into buckets + // in order from closest-to-current-price to furthest-from-current-price + return [ + Array.from((tokenAReserves || []).entries()) + // ensure order from nearly-ordered map + .sort(([a], [b]) => b - a) + // group reserves into buckets + .reduce>(reduceReservesToBuckets(false), []) + // translate reserves into displayReserves + .map(([innerBound, outerBound, reserves]) => [ + innerBound, + outerBound, + Number(getDisplayDenomAmount(tokenA, reserves)), + ]), + Array.from((tokenBReserves || []).entries()) + // ensure order from nearly-ordered map + .sort(([a], [b]) => b - a) + // group reserves into buckets + .reduce>(reduceReservesToBuckets(true), []) + // translate reserves into displayReserves + .map(([innerBound, outerBound, reserves]) => [ + innerBound, + outerBound, + Number(getDisplayDenomAmount(tokenB, reserves)), + ]), + height, + ]; + }, [ + tokenA, + tokenB, + currentPrice, + bucketResolution, + tokenAReserves, + tokenBReserves, + height, + ]); +} diff --git a/src/pages/Pool/PoolOverview.tsx b/src/pages/Pool/PoolOverview.tsx index 1c483dc19..87e8ef2ab 100644 --- a/src/pages/Pool/PoolOverview.tsx +++ b/src/pages/Pool/PoolOverview.tsx @@ -36,6 +36,9 @@ import { import useTransactionTableData, { Tx } from './hooks/useTransactionTableData'; import { useUserHasDeposits } from '../../lib/web3/hooks/useUserDeposits'; import { useSimplePrice } from '../../lib/tokenPrices'; +import { useOrderedTokenPair } from '../../lib/web3/hooks/useTokenPairs'; +import useOnDexMsg from '../../lib/web3/hooks/useOnDexMsg'; + import { formatAmount, formatCurrency } from '../../lib/utils/number'; import { formatRelativeTime } from '../../lib/utils/time'; @@ -212,19 +215,19 @@ function PoolOverviewTable({ return [ { nav: 'All', - Tab: () => , + tab: , }, { nav: 'Swaps', - Tab: () => , + tab: , }, { nav: 'Adds', - Tab: () => , + tab: , }, { nav: 'Removes', - Tab: () => , + tab: , }, ]; // intermediary component to avoid repeating tokenA={tokenA} tokenB={tokenB} @@ -281,6 +284,16 @@ function TransactionsTable({ }) { const query = useTransactionTableData({ tokenA, tokenB, action }); + // subscribe to updates of the pair's limit orders + const denoms = useOrderedTokenPair([getTokenId(tokenA), getTokenId(tokenB)]); + useOnDexMsg( + 'PlaceLimitOrder', + () => { + query.refetch({ cancelRefetch: false }); + }, + denoms ? { TokenZero: denoms[0], TokenOne: denoms[1] } : null + ); + const columns = useMemo(() => { return transactionTableHeadings.map( (heading: TransactionTableColumnKey) => { diff --git a/src/pages/Pool/hooks/useTransactionTableData.ts b/src/pages/Pool/hooks/useTransactionTableData.ts index ca3419628..67d796551 100644 --- a/src/pages/Pool/hooks/useTransactionTableData.ts +++ b/src/pages/Pool/hooks/useTransactionTableData.ts @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Token, getTokenId } from '../../../lib/web3/utils/tokens'; @@ -46,41 +45,40 @@ export interface GetTxsEventResponseManuallyType { const blockTimestamps: { [height: string]: string } = {}; -export default function useTransactionTableData({ - tokenA, - tokenB, - account, - action, - pageSize = 10, - orderByAscending, -}: { - tokenA: Token; - tokenB: Token; - account?: WalletAddress | null; - action?: DexMessageAction; - pageSize?: number; - orderByAscending?: boolean; -}) { +export default function useTransactionTableData( + // passing opts=null means to abort: don't request, return an empty result + opts: { + tokenA: Token; + tokenB: Token; + account?: WalletAddress | null; + action?: DexMessageAction; + page?: number; + pageSize?: number; + orderByAscending?: boolean; + } | null +) { + const { + tokenA, + tokenB, + account, + action, + page = 1, + pageSize = 10, + orderByAscending, + } = opts || {}; const tokenIdA = getTokenId(tokenA) || ''; const tokenIdB = getTokenId(tokenB) || ''; - const [pageOffset] = useState(0); return useQuery({ - queryKey: [ - 'events', - tokenIdA, - tokenIdB, - action, - account, - pageSize, - pageOffset, - ], - enabled: !!(tokenIdA && tokenIdB), + queryKey: ['events', tokenIdA, tokenIdB, action, account, page, pageSize], + enabled: !opts || !!(tokenIdA && tokenIdB), queryFn: async (): Promise => { const invertedOrder = guessInvertedOrder([tokenIdA, tokenIdB]); - // disable if account is specifically `null` (eg. wallet is not connected) - if (account === null) { + // disable if opts is specifically not set (eg. wallet is not connected) + // note: this is important so there is a solid answer to the request + // disabling the query for account=null will return a loading state + if (!opts) { return { txs: [], total_count: '0', @@ -106,7 +104,7 @@ export default function useTransactionTableData({ * action ? `message.action='${action}'` : '', * ].filter(Boolean), * orderBy: cosmos.tx.v1beta1.OrderBySDKType.ORDER_BY_ASC, - * page: Long.fromString(pageOffset + 1), + * page: Long.fromString(page), * limit: Long.fromString(pageSize), * }); * @@ -131,7 +129,7 @@ export default function useTransactionTableData({ .join(' AND ') )}"`, `per_page=${pageSize}`, - `page=${pageOffset + 1}`, + `page=${page}`, `order_by="${orderByAscending ? 'asc' : 'desc'}"`, ].join('&')}` ); diff --git a/src/styles/components/cards.scss b/src/styles/components/cards.scss index 8090820a5..baf3793a5 100644 --- a/src/styles/components/cards.scss +++ b/src/styles/components/cards.scss @@ -22,7 +22,7 @@ $page-card-padding-width: paddings.$p-lg; .page-card { padding: $page-card-padding-width; border-radius: 1rem; - border: 1px solid hsla(217, 19%, 27%, 1); + border: 1px solid var(--page-card-border); background-color: var(--page-card); box-shadow: 0px 2px 4px -2px rgba(0, 0, 0, 0.05), 0px 4px 6px -1px rgba(0, 0, 0, 0.1); @@ -32,6 +32,21 @@ $page-card-padding-width: paddings.$p-lg; display: flex; position: relative; + // indicate that an element or group is focusable + .focusable, + .focusable > .select-input-selection { + & { + border-color: var(--page-card); + } + &:hover, + &:focus-within { + border-color: var(--page-card-border); + } + input { + outline: none; + } + } + .page-card { margin-top: 0; margin-bottom: 0; diff --git a/src/styles/globals.scss b/src/styles/globals.scss index b38ae2f39..708b3e258 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -77,6 +77,12 @@ a { width: 100vw; max-width: calc(1440px + 10vw); } +@media (min-width: 1440px) { + .container .decontainer { + margin: 0 calc(1440px / 2 - 100vw / 2); + padding: 0 5vw; + } +} .row, .col { @@ -258,6 +264,7 @@ fieldset:disabled *:not(:has(> *)) { input { background-color: transparent; border: none; + color: var(--default-alt); } hr {