diff --git a/apps/finance/app/package.json b/apps/finance/app/package.json index 2accd77b4a..ddc660758b 100644 --- a/apps/finance/app/package.json +++ b/apps/finance/app/package.json @@ -4,8 +4,8 @@ "private": true, "license": "AGPL-3.0-or-later", "dependencies": { - "@aragon/api": "^2.0.0-beta.3", - "@aragon/api-react": "^2.0.0-beta.1", + "@aragon/api": "^2.0.0-beta.4", + "@aragon/api-react": "^2.0.0-beta.4", "@aragon/templates-tokens": "^1.2.0", "@aragon/ui": "^0.40.0", "@babel/polyfill": "^7.0.0", diff --git a/apps/finance/app/src/components/AutoComplete/AutoComplete.js b/apps/finance/app/src/components/AutoComplete/AutoComplete.js new file mode 100644 index 0000000000..4151587d2e --- /dev/null +++ b/apps/finance/app/src/components/AutoComplete/AutoComplete.js @@ -0,0 +1,163 @@ +import React, { useState, useRef, useCallback } from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { Transition, animated } from 'react-spring' +import { ButtonBase, TextInput, springs, theme, unselectable } from '@aragon/ui' +import { useClickOutside, useOnBlur, useArrowKeysFocus } from '../../hooks' +import IconMagnifyingGlass from './IconMagnifyingGlass' + +const { accent, contentBackground, contentBorder, textPrimary } = theme +const identity = x => x + +function AutoComplete({ + forwardedRef, + itemButtonStyles = '', + items, + onSelect, + onChange, + renderItem = identity, + required, + value, + wide, +}) { + const ref = forwardedRef + const [opened, setOpened] = useState(true) + const wrapRef = useRef() + + const handleClose = useCallback(() => setOpened(false)) + const handleFocus = useCallback(() => setOpened(true)) + const handleSelect = useCallback( + item => e => { + e.preventDefault() + onSelect(item) + }, + [onSelect] + ) + const handleChange = useCallback(({ target: { value } }) => onChange(value), [ + onChange, + ]) + + const { containerRef, handleContainerBlur } = useArrowKeysFocus( + '.autocomplete-items' + ) + const { handleBlur } = useOnBlur(handleClose, wrapRef) + useClickOutside(handleClose, wrapRef) + + return ( +
+ +
+ +
+ + {show => + show && + (({ scale, opacity }) => ( + `scale3d(${t},${t},1)`), + }} + > + {items.map(item => ( + + + {renderItem(item, value)} + + + ))} + + )) + } + +
+ ) +} + +AutoComplete.propTypes = { + forwardedRef: PropTypes.object, + itemButtonStyles: PropTypes.string, + items: PropTypes.array.isRequired, + onSelect: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + renderItem: PropTypes.func, + required: PropTypes.bool, + value: PropTypes.string, + wide: PropTypes.bool, +} + +const Item = styled.li` + ${unselectable()}; + overflow: hidden; + cursor: pointer; +` + +const Items = styled(animated.ul)` + position: absolute; + z-index: 2; + top: 100%; + width: 100%; + padding: 8px 0; + color: ${textPrimary}; + background: ${contentBackground}; + border: 1px solid ${contentBorder}; + box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.06); + border-radius: 3px; + padding: 0; + margin: 0; + list-style: none; + + & ${Item}:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; + } + & ${Item}:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + } +` + +const AutoCompleteMemo = React.memo(AutoComplete) + +export default React.forwardRef((props, ref) => ( + +)) diff --git a/apps/finance/app/src/components/AutoComplete/AutoCompleteSelected.js b/apps/finance/app/src/components/AutoComplete/AutoCompleteSelected.js new file mode 100644 index 0000000000..27feb3e79a --- /dev/null +++ b/apps/finance/app/src/components/AutoComplete/AutoCompleteSelected.js @@ -0,0 +1,113 @@ +import React, { useRef, useCallback } from 'react' +import PropTypes from 'prop-types' +import { ButtonBase, theme } from '@aragon/ui' +import AutoComplete from './AutoComplete' + +const identity = x => x +const noop = () => null + +function AutoCompleteSelected({ + forwardedRef, + itemButtonStyles, + items, + onChange, + onSelect, // user clicks on an item in the list and thus, selects it + onSelectedClick = noop, // when item is selected and user clicks on it, opens up the input for typing + renderItem, + required, + renderSelected = identity, + selected, + selectedButtonStyles = '', + value, + wide, +}) { + const ref = forwardedRef + const selectedRef = useRef() + + const handleSelect = useCallback( + selected => { + onSelect(selected) + setTimeout(() => { + selectedRef.current.focus() + }, 0) + }, + [onChange] + ) + const handleSelectedClick = useCallback( + () => { + onSelectedClick() + setTimeout(() => { + ref.current.select() + ref.current.focus() + }, 0) + }, + [ref, selected, onChange] + ) + + if (selected) { + return ( + + {renderSelected(selected)} + + ) + } + + return ( + + ) +} + +AutoCompleteSelected.propTypes = { + forwardedRef: PropTypes.object, + itemButtonStyles: PropTypes.string, + items: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + onSelect: PropTypes.func.isRequired, + onSelectedClick: PropTypes.func, + renderItem: PropTypes.func, + renderSelected: PropTypes.func, + required: PropTypes.bool, + selected: PropTypes.object, + selectedButtonStyles: PropTypes.string, + value: PropTypes.string, + wide: PropTypes.bool, +} + +const AutoCompleteSelectedMemo = React.memo(AutoCompleteSelected) + +export default React.forwardRef((props, ref) => ( + +)) diff --git a/apps/finance/app/src/components/AutoComplete/IconMagnifyingGlass.js b/apps/finance/app/src/components/AutoComplete/IconMagnifyingGlass.js new file mode 100644 index 0000000000..58c69046bb --- /dev/null +++ b/apps/finance/app/src/components/AutoComplete/IconMagnifyingGlass.js @@ -0,0 +1,14 @@ +import React from 'react' + +const IconMagnifyingGlass = React.memo(props => { + return ( + + + + ) +}) + +export default IconMagnifyingGlass diff --git a/apps/finance/app/src/components/LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete.js b/apps/finance/app/src/components/LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete.js new file mode 100644 index 0000000000..6321b4cbb4 --- /dev/null +++ b/apps/finance/app/src/components/LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete.js @@ -0,0 +1,171 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useAragonApi } from '@aragon/api-react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { EthIdenticon, IdentityBadge, theme } from '@aragon/ui' +import AutoCompleteSelected from '../AutoComplete/AutoCompleteSelected' + +const withKey = item => ({ key: item.address, ...item }) +const sortAlphAsc = (a, b) => a.name.localeCompare(b.name) + +const LocalIdentitiesAutoComplete = React.memo( + React.forwardRef(function LocalIdentitiesAutoComplete( + { onChange, wide, value, required }, + ref + ) { + const { api } = useAragonApi() + const [items, setItems] = useState([]) + const [selected, setSelected] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + + const handleSearch = useCallback( + async term => { + if (term.length < 3) { + setItems([]) + return + } + const items = await api.searchIdentities(term).toPromise() + setItems(items.map(withKey).sort(sortAlphAsc)) + }, + [api] + ) + const handleChange = useCallback( + value => { + setSearchTerm(value) + handleSearch(value) + onChange(value) + }, + [onChange] + ) + const handleSelect = useCallback( + selected => { + const { name, address } = selected + setSearchTerm(name) + handleSearch(name) + setSelected(selected) + onChange(address) + }, + [onChange] + ) + const handleSelectedClick = () => { + setSelected(null) + onChange(selected.name) + } + const renderItem = useCallback(({ address, name }, searchTerm) => { + if (searchTerm.indexOf('0x') === 0) { + return ( + + ) + } + return ( + + ) + }) + const renderSelected = useCallback(({ address, name }) => { + return ( + + ) + }) + + useEffect(() => { + const effect = async () => { + // reset + if (value === '') { + setSelected(null) + setSearchTerm(value) + handleSearch(value) + return + } + // value coming from up the tree not from typing + if (searchTerm === '') { + const exists = await api.searchIdentities(value).toPromise() + if (exists && exists.length === 1) { + const item = exists[0] + if ( + item.name.toLowerCase() === value.toLowerCase() || + item.address.toLowerCase() === value.toLowerCase() + ) { + setSelected(item) + setSearchTerm(item.name) + handleSearch(item.name) + return + } + } + setSearchTerm(value) + } + } + effect() + }, [selected, value, api]) + + return ( + + ) + }) +) + +LocalIdentitiesAutoComplete.propTypes = { + onChange: PropTypes.func.isRequired, + required: PropTypes.bool, + value: PropTypes.string, + wide: PropTypes.bool, +} + +const Option = styled.div` + padding: 8px; + display: grid; + grid-template-columns: auto minmax(140px, 1fr); + grid-gap: 8px; + align-items: center; +` + +const Name = styled.div` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + text-align: left; + color: #000; +` + +export default LocalIdentitiesAutoComplete diff --git a/apps/finance/app/src/components/NewTransfer/Withdrawal.js b/apps/finance/app/src/components/NewTransfer/Withdrawal.js index 39522a29a9..3d2cfd1089 100644 --- a/apps/finance/app/src/components/NewTransfer/Withdrawal.js +++ b/apps/finance/app/src/components/NewTransfer/Withdrawal.js @@ -11,6 +11,7 @@ import { theme, unselectable, } from '@aragon/ui' +import LocalIdentitiesAutoComplete from '../LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete' import { toDecimals } from '../../lib/math-utils' import { addressPattern, isAddress } from '../../lib/web3-utils' @@ -67,11 +68,11 @@ class Withdrawal extends React.Component { handleSelectToken = index => { this.setState({ selectedToken: index }) } - handleRecipientUpdate = event => { + handleRecipientUpdate = value => { this.setState({ recipient: { error: NO_ERROR, - value: event.target.value, + value, }, }) } @@ -137,8 +138,11 @@ class Withdrawal extends React.Component { return tokens.length ? (

{title}

- - + { + if (!ref.current.contains(e.target)) { + cb() + } + }, + [cb, ref] + ) + + useEffect( + () => { + document.addEventListener('click', handleClick, true) + return () => { + document.removeEventListener('click', handleClick, true) + } + }, + [handleClick] + ) + + return { ref } +} + +export function useOnBlur(cb, existingRef) { + const ref = existingRef || useRef() + const handleBlur = useCallback( + e => { + if (!ref.current.contains(e.relatedTarget)) { + cb() + } + }, + [cb, ref] + ) + + return { ref, handleBlur } +} + +export function useArrowKeysFocus(query, containerRef = useRef()) { + /* eslint-enable react-hooks/rules-of-hooks */ + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + const reset = () => setHighlightedIndex(-1) + const cycleFocus = useCallback( + (e, change) => { + e.preventDefault() + const elements = document.querySelectorAll(query) + let next = highlightedIndex + change + if (next > elements.length - 1) { + next = 0 + } + if (next < 0) { + next = elements.length - 1 + } + if (!elements[next]) { + next = -1 + } + setHighlightedIndex(next) + }, + [highlightedIndex, query] + ) + const handleKeyDown = useCallback( + e => { + const { keyCode } = e + if (keyCode === KEYCODE_UP || keyCode === KEYCODE_DOWN) { + cycleFocus(e, keyCode === KEYCODE_UP ? -1 : 1) + } + }, + [cycleFocus] + ) + + const { handleBlur: handleContainerBlur } = useOnBlur(reset, containerRef) + useEffect( + () => { + if (highlightedIndex === -1) { + return + } + const elements = document.querySelectorAll(query) + if (!elements[highlightedIndex]) { + return + } + elements[highlightedIndex].focus() + }, + [highlightedIndex, query] + ) + useEffect( + () => { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, + [handleKeyDown] + ) + + return { containerRef, handleContainerBlur } +} diff --git a/apps/token-manager/app/package.json b/apps/token-manager/app/package.json index 0a46b75ea1..1f5f64f47a 100644 --- a/apps/token-manager/app/package.json +++ b/apps/token-manager/app/package.json @@ -4,8 +4,8 @@ "private": true, "license": "AGPL-3.0-or-later", "dependencies": { - "@aragon/api": "^2.0.0-beta.3", - "@aragon/api-react": "^2.0.0-beta.1", + "@aragon/api": "^2.0.0-beta.4", + "@aragon/api-react": "^2.0.0-beta.4", "@aragon/ui": "^0.40.1", "bn.js": "^4.11.6", "prop-types": "^15.7.2", diff --git a/apps/token-manager/app/src/components/AutoComplete/AutoComplete.js b/apps/token-manager/app/src/components/AutoComplete/AutoComplete.js new file mode 100644 index 0000000000..f8400634f0 --- /dev/null +++ b/apps/token-manager/app/src/components/AutoComplete/AutoComplete.js @@ -0,0 +1,168 @@ +import React, { useState, useRef, useCallback } from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { Transition, animated } from 'react-spring' +import { ButtonBase, TextInput, springs, theme, unselectable } from '@aragon/ui' +import { useClickOutside, useOnBlur, useArrowKeysFocus } from '../../hooks' +import IconMagnifyingGlass from './IconMagnifyingGlass' + +const { accent, contentBackground, contentBorder, textPrimary } = theme +const identity = x => x + +function AutoComplete({ + forwardedRef, + itemButtonStyles, + items, + onSelect, + onChange, + renderItem, + required, + value, + wide, +}) { + const ref = forwardedRef + const [opened, setOpened] = useState(true) + const wrapRef = useRef() + + const handleClose = useCallback(() => setOpened(false)) + const handleFocus = useCallback(() => setOpened(true)) + const handleSelect = useCallback( + item => e => { + e.preventDefault() + onSelect(item) + }, + [onSelect] + ) + const handleChange = useCallback(({ target: { value } }) => onChange(value), [ + onChange, + ]) + + const { containerRef, handleContainerBlur } = useArrowKeysFocus( + '.autocomplete-items' + ) + const { handleBlur } = useOnBlur(handleClose, wrapRef) + useClickOutside(handleClose, wrapRef) + + return ( +
+ +
+ +
+ + {show => + show && + (({ scale, opacity }) => ( + `scale3d(${t},${t},1)`), + }} + > + {items.map(item => ( + + + {renderItem(item, value)} + + + ))} + + )) + } + +
+ ) +} + +AutoComplete.propTypes = { + forwardedRef: PropTypes.object, + itemButtonStyles: PropTypes.string, + items: PropTypes.array.isRequired, + onSelect: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + renderItem: PropTypes.func, + required: PropTypes.bool, + value: PropTypes.string, + wide: PropTypes.bool, +} + +AutoComplete.defaultProps = { + itemButtonStyles: '', + renderItem: identity, +} + +const Item = styled.li` + ${unselectable()}; + overflow: hidden; + cursor: pointer; +` + +const Items = styled(animated.ul)` + position: absolute; + z-index: 2; + top: 100%; + width: 100%; + padding: 8px 0; + color: ${textPrimary}; + background: ${contentBackground}; + border: 1px solid ${contentBorder}; + box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.06); + border-radius: 3px; + padding: 0; + margin: 0; + list-style: none; + + & ${Item}:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; + } + & ${Item}:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + } +` + +const AutoCompleteMemo = React.memo(AutoComplete) + +export default React.forwardRef((props, ref) => ( + +)) diff --git a/apps/token-manager/app/src/components/AutoComplete/AutoCompleteSelected.js b/apps/token-manager/app/src/components/AutoComplete/AutoCompleteSelected.js new file mode 100644 index 0000000000..853135a6ee --- /dev/null +++ b/apps/token-manager/app/src/components/AutoComplete/AutoCompleteSelected.js @@ -0,0 +1,113 @@ +import React, { useRef, useCallback } from 'react' +import PropTypes from 'prop-types' +import { ButtonBase, theme } from '@aragon/ui' +import AutoComplete from './AutoComplete' + +const identity = x => x +const noop = () => null + +function AutoCompleteSelected({ + forwardedRef, + itemButtonStyles, + items, + onChange, + onSelect, // user clicks on an item (selecting the item) + onSelectedClick = noop, // user clicks on the rendered selected item (opens the input for typing) + renderItem, + required, + renderSelected = identity, + selected, + selectedButtonStyles = '', + value, + wide, +}) { + const ref = forwardedRef + const selectedRef = useRef() + + const handleSelect = useCallback( + selected => { + onSelect(selected) + setTimeout(() => { + selectedRef.current.focus() + }, 0) + }, + [onChange] + ) + const handleSelectedClick = useCallback( + () => { + onSelectedClick() + setTimeout(() => { + ref.current.select() + ref.current.focus() + }, 0) + }, + [ref, selected, onChange] + ) + + if (selected) { + return ( + + {renderSelected(selected)} + + ) + } + + return ( + + ) +} + +AutoCompleteSelected.propTypes = { + forwardedRef: PropTypes.object, + itemButtonStyles: PropTypes.string, + items: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + onSelect: PropTypes.func.isRequired, + onSelectedClick: PropTypes.func, + renderItem: PropTypes.func, + renderSelected: PropTypes.func, + required: PropTypes.bool, + selected: PropTypes.object, + selectedButtonStyles: PropTypes.string, + value: PropTypes.string, + wide: PropTypes.bool, +} + +const AutoCompleteSelectedMemo = React.memo(AutoCompleteSelected) + +export default React.forwardRef((props, ref) => ( + +)) diff --git a/apps/token-manager/app/src/components/AutoComplete/IconMagnifyingGlass.js b/apps/token-manager/app/src/components/AutoComplete/IconMagnifyingGlass.js new file mode 100644 index 0000000000..58c69046bb --- /dev/null +++ b/apps/token-manager/app/src/components/AutoComplete/IconMagnifyingGlass.js @@ -0,0 +1,14 @@ +import React from 'react' + +const IconMagnifyingGlass = React.memo(props => { + return ( + + + + ) +}) + +export default IconMagnifyingGlass diff --git a/apps/token-manager/app/src/components/LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete.js b/apps/token-manager/app/src/components/LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete.js new file mode 100644 index 0000000000..6321b4cbb4 --- /dev/null +++ b/apps/token-manager/app/src/components/LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete.js @@ -0,0 +1,171 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useAragonApi } from '@aragon/api-react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { EthIdenticon, IdentityBadge, theme } from '@aragon/ui' +import AutoCompleteSelected from '../AutoComplete/AutoCompleteSelected' + +const withKey = item => ({ key: item.address, ...item }) +const sortAlphAsc = (a, b) => a.name.localeCompare(b.name) + +const LocalIdentitiesAutoComplete = React.memo( + React.forwardRef(function LocalIdentitiesAutoComplete( + { onChange, wide, value, required }, + ref + ) { + const { api } = useAragonApi() + const [items, setItems] = useState([]) + const [selected, setSelected] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + + const handleSearch = useCallback( + async term => { + if (term.length < 3) { + setItems([]) + return + } + const items = await api.searchIdentities(term).toPromise() + setItems(items.map(withKey).sort(sortAlphAsc)) + }, + [api] + ) + const handleChange = useCallback( + value => { + setSearchTerm(value) + handleSearch(value) + onChange(value) + }, + [onChange] + ) + const handleSelect = useCallback( + selected => { + const { name, address } = selected + setSearchTerm(name) + handleSearch(name) + setSelected(selected) + onChange(address) + }, + [onChange] + ) + const handleSelectedClick = () => { + setSelected(null) + onChange(selected.name) + } + const renderItem = useCallback(({ address, name }, searchTerm) => { + if (searchTerm.indexOf('0x') === 0) { + return ( + + ) + } + return ( + + ) + }) + const renderSelected = useCallback(({ address, name }) => { + return ( + + ) + }) + + useEffect(() => { + const effect = async () => { + // reset + if (value === '') { + setSelected(null) + setSearchTerm(value) + handleSearch(value) + return + } + // value coming from up the tree not from typing + if (searchTerm === '') { + const exists = await api.searchIdentities(value).toPromise() + if (exists && exists.length === 1) { + const item = exists[0] + if ( + item.name.toLowerCase() === value.toLowerCase() || + item.address.toLowerCase() === value.toLowerCase() + ) { + setSelected(item) + setSearchTerm(item.name) + handleSearch(item.name) + return + } + } + setSearchTerm(value) + } + } + effect() + }, [selected, value, api]) + + return ( + + ) + }) +) + +LocalIdentitiesAutoComplete.propTypes = { + onChange: PropTypes.func.isRequired, + required: PropTypes.bool, + value: PropTypes.string, + wide: PropTypes.bool, +} + +const Option = styled.div` + padding: 8px; + display: grid; + grid-template-columns: auto minmax(140px, 1fr); + grid-gap: 8px; + align-items: center; +` + +const Name = styled.div` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + text-align: left; + color: #000; +` + +export default LocalIdentitiesAutoComplete diff --git a/apps/token-manager/app/src/components/Panels/AssignVotePanelContent.js b/apps/token-manager/app/src/components/Panels/AssignVotePanelContent.js index 83615d2a27..e46e43d650 100644 --- a/apps/token-manager/app/src/components/Panels/AssignVotePanelContent.js +++ b/apps/token-manager/app/src/components/Panels/AssignVotePanelContent.js @@ -3,6 +3,7 @@ import styled from 'styled-components' import { Button, Field, IconCross, Text, TextInput, Info } from '@aragon/ui' import { isAddress } from '../../web3-utils' import { fromDecimals, toDecimals, formatBalance } from '../../utils' +import LocalIdentitiesAutoComplete from '../LocalIdentitiesAutoComplete/LocalIdentitiesAutoComplete' // Any more and the number input field starts to put numbers in scientific notation const MAX_INPUT_DECIMAL_BASE = 6 @@ -35,7 +36,10 @@ class AssignVotePanelContent extends React.Component { // setTimeout is needed as a small hack to wait until the input is // on-screen before we call focus this._holderInput.current && - setTimeout(() => this._holderInput.current.focus(), 0) + setTimeout( + () => this._holderInput.current && this._holderInput.current.focus(), + 0 + ) // Upadte holder address from the props this.updateHolderAddress(mode, holderAddress) @@ -91,8 +95,8 @@ class AssignVotePanelContent extends React.Component { amountField: { ...amountField, value: event.target.value }, }) } - handleHolderChange = event => { - this.updateHolderAddress(this.props.mode, event.target.value) + handleHolderChange = value => { + this.updateHolderAddress(this.props.mode, value) } handleSubmit = event => { event.preventDefault() @@ -149,12 +153,14 @@ class AssignVotePanelContent extends React.Component { ${mode === 'assign' ? 'Recipient' : 'Account'} (must be a valid Ethereum address) `} + css="height: 62px" > -
diff --git a/apps/token-manager/app/src/hooks.js b/apps/token-manager/app/src/hooks.js new file mode 100644 index 0000000000..68d70c49db --- /dev/null +++ b/apps/token-manager/app/src/hooks.js @@ -0,0 +1,101 @@ +import { useRef, useEffect, useCallback, useState } from 'react' + +const KEYCODE_UP = 38 +const KEYCODE_DOWN = 40 + +export function useClickOutside(cb, existingRef) { + const ref = existingRef || useRef() + const handleClick = useCallback( + e => { + if (!ref.current.contains(e.target)) { + cb() + } + }, + [cb, ref] + ) + + useEffect( + () => { + document.addEventListener('click', handleClick, true) + return () => { + document.removeEventListener('click', handleClick, true) + } + }, + [handleClick] + ) + + return { ref } +} + +export function useOnBlur(cb, existingRef) { + const ref = existingRef || useRef() + const handleBlur = useCallback( + e => { + if (!ref.current.contains(e.relatedTarget)) { + cb() + } + }, + [cb, ref] + ) + + return { ref, handleBlur } +} + +/* eslint-disable react-hooks/rules-of-hooks */ +export function useArrowKeysFocus(query, containerRef = useRef()) { + /* eslint-enable react-hooks/rules-of-hooks */ + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + const reset = () => setHighlightedIndex(-1) + const cycleFocus = useCallback( + (e, change) => { + e.preventDefault() + const elements = document.querySelectorAll(query) + let next = highlightedIndex + change + if (next > elements.length - 1) { + next = 0 + } + if (next < 0) { + next = elements.length - 1 + } + if (!elements[next]) { + next = -1 + } + setHighlightedIndex(next) + }, + [highlightedIndex, query] + ) + const handleKeyDown = useCallback( + e => { + const { keyCode } = e + if (keyCode === KEYCODE_UP || keyCode === KEYCODE_DOWN) { + cycleFocus(e, keyCode === KEYCODE_UP ? -1 : 1) + } + }, + [cycleFocus] + ) + + const { handleBlur: handleContainerBlur } = useOnBlur(reset, containerRef) + useEffect( + () => { + if (highlightedIndex === -1) { + return + } + const elements = document.querySelectorAll(query) + if (!elements[highlightedIndex]) { + return + } + elements[highlightedIndex].focus() + }, + [highlightedIndex, query] + ) + useEffect( + () => { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, + [handleKeyDown] + ) + + return { containerRef, handleContainerBlur } +}