AddressFieldDropdown.tsx
1 import React from 'react'; 2 import { connect } from 'react-redux'; 3 4 import translate, { translateRaw } from 'translations'; 5 import { AppState } from 'features/reducers'; 6 import { transactionActions, transactionSelectors } from 'features/transaction'; 7 import { addressBookSelectors } from 'features/addressBook'; 8 import { walletSelectors } from 'features/wallet'; 9 import { Address, Identicon } from 'components/ui'; 10 import './AddressFieldDropdown.scss'; 11 12 interface Props { 13 addressInput: string; 14 dropdownThreshold?: number; 15 labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>; 16 recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>; 17 onEntryClick(address: string): void; 18 } 19 20 interface State { 21 activeIndex: number | null; 22 } 23 24 class AddressFieldDropdownClass extends React.Component<Props, State> { 25 public state = { 26 activeIndex: null 27 }; 28 29 private exists: boolean = true; 30 31 public componentDidMount() { 32 window.addEventListener('keydown', this.handleKeyDown); 33 } 34 35 public componentWillUnmount() { 36 window.removeEventListener('keydown', this.handleKeyDown); 37 this.exists = false; 38 } 39 40 public render() { 41 const { addressInput } = this.props; 42 43 return ( 44 this.getIsVisible() && ( 45 <ul className="AddressFieldDropdown" role="listbox"> 46 {this.getFilteredLabels().length > 0 ? ( 47 this.renderDropdownItems() 48 ) : ( 49 <li className="AddressFieldDropdown-dropdown-item AddressFieldDropdown-dropdown-item-no-match"> 50 <i className="fa fa-warning" /> {translate('NO_LABEL_FOUND_CONTAINING')} "{ 51 addressInput 52 }". 53 </li> 54 )} 55 </ul> 56 ) 57 ); 58 } 59 60 private renderDropdownItems = () => { 61 const { onEntryClick } = this.props; 62 const { activeIndex } = this.state; 63 64 return this.getFilteredLabels().map( 65 ({ address, label }: { address: string; label: string }, index: number) => { 66 const isActive = activeIndex === index; 67 const className = `AddressFieldDropdown-dropdown-item ${ 68 isActive ? 'AddressFieldDropdown-dropdown-item--active' : '' 69 }`; 70 71 return ( 72 <li 73 key={address} 74 role="option" 75 className={className} 76 onClick={() => onEntryClick(address)} 77 title={`${translateRaw('SEND_TO')}${label}`} 78 > 79 <div className="AddressFieldDropdown-dropdown-item-identicon"> 80 <Identicon address={address} /> 81 </div> 82 <strong className="AddressFieldDropdown-dropdown-item-label">{label}</strong> 83 <em className="AddressFieldDropdown-dropdown-item-address"> 84 <Address address={address} /> 85 </em> 86 </li> 87 ); 88 } 89 ); 90 }; 91 92 private getFormattedRecentAddresses = (): { [label: string]: string } => { 93 const { labelAddresses, recentAddresses } = this.props; 94 // Hash existing entries by address for performance. 95 const addressesInBook: { [address: string]: boolean } = Object.values(labelAddresses).reduce( 96 (prev: { [address: string]: boolean }, next: string) => { 97 prev[next] = true; 98 return prev; 99 }, 100 {} 101 ); 102 // Make recent addresses sequential. 103 let recentAddressCount: number = 0; 104 const addresses = recentAddresses.reduce((prev: { [label: string]: string }, next: string) => { 105 // Prevent duplication. 106 if (addressesInBook[next]) { 107 return prev; 108 } 109 prev[ 110 translateRaw('RECENT_ADDRESS_NUMBER', { $number: (++recentAddressCount).toString() }) 111 ] = next; 112 113 return prev; 114 }, {}); 115 116 return addresses; 117 }; 118 119 private getFilteredLabels = () => { 120 const { addressInput, labelAddresses } = this.props; 121 const formattedRecentAddresses = this.getFormattedRecentAddresses(); 122 123 return Object.keys({ ...labelAddresses, ...formattedRecentAddresses }) 124 .filter(label => label.toLowerCase().includes(addressInput)) 125 .map(label => ({ address: labelAddresses[label] || formattedRecentAddresses[label], label })) 126 .slice(0, 5); 127 }; 128 129 private getIsVisible = () => { 130 const { addressInput, dropdownThreshold = 3 } = this.props; 131 132 return addressInput.length >= dropdownThreshold && this.getFilteredLabels().length > 0; 133 }; 134 135 private setActiveIndex = (activeIndex: number | null) => this.setState({ activeIndex }); 136 137 private clearActiveIndex = () => this.setActiveIndex(null); 138 139 //#region Keyboard Controls 140 private handleKeyDown = (e: KeyboardEvent) => { 141 if (this.getIsVisible()) { 142 switch (e.key) { 143 case 'Enter': 144 e.preventDefault(); 145 return this.handleEnterKeyDown(); 146 case 'ArrowUp': 147 e.preventDefault(); 148 return this.handleUpArrowKeyDown(); 149 case 'ArrowDown': 150 e.preventDefault(); 151 return this.handleDownArrowKeyDown(); 152 default: 153 return; 154 } 155 } 156 }; 157 158 private handleEnterKeyDown = () => { 159 const { onEntryClick } = this.props; 160 const { activeIndex } = this.state; 161 162 if (activeIndex !== null) { 163 const filteredLabels = this.getFilteredLabels(); 164 165 filteredLabels.forEach(({ address }, index) => { 166 if (activeIndex === index) { 167 onEntryClick(address); 168 } 169 }); 170 171 if (this.exists) { 172 this.clearActiveIndex(); 173 } 174 } 175 }; 176 177 private handleUpArrowKeyDown = () => { 178 const { activeIndex: previousActiveIndex } = this.state; 179 const filteredLabelCount = this.getFilteredLabels().length; 180 181 let activeIndex = 182 previousActiveIndex === null ? filteredLabelCount - 1 : previousActiveIndex - 1; 183 184 // Loop back to end 185 if (activeIndex < 0) { 186 activeIndex = filteredLabelCount - 1; 187 } 188 189 this.setState({ activeIndex }); 190 }; 191 192 private handleDownArrowKeyDown = () => { 193 const { activeIndex: previousActiveIndex } = this.state; 194 const filteredLabelCount = this.getFilteredLabels().length; 195 196 let activeIndex = previousActiveIndex === null ? 0 : previousActiveIndex + 1; 197 198 // Loop back to beginning 199 if (activeIndex >= filteredLabelCount) { 200 activeIndex = 0; 201 } 202 203 this.setState({ activeIndex }); 204 }; 205 //#endregion Keyboard Controls 206 } 207 208 //#region Uncontrolled 209 /** 210 * @desc The `onChangeOverride` prop needs to work 211 * with actual events, but also needs a value to be directly passed in 212 * occasionally. This interface allows us to skip all of the other FormEvent 213 * properties and methods. 214 */ 215 interface FakeFormEvent { 216 currentTarget: { 217 value: string; 218 }; 219 } 220 221 interface UncontrolledAddressFieldDropdownProps { 222 value: string; 223 labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>; 224 recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>; 225 dropdownThreshold?: number; 226 onChangeOverride(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void; 227 } 228 229 /** 230 * @desc The uncontrolled dropdown changes the address input onClick, 231 * as well as calls the onChange override, but does not update the `currentTo` 232 * property in the Redux store. 233 */ 234 function RawUncontrolledAddressFieldDropdown({ 235 value, 236 onChangeOverride, 237 labelAddresses, 238 recentAddresses, 239 dropdownThreshold 240 }: UncontrolledAddressFieldDropdownProps) { 241 const onEntryClick = (address: string) => onChangeOverride({ currentTarget: { value: address } }); 242 243 return ( 244 <AddressFieldDropdownClass 245 addressInput={value} 246 onEntryClick={onEntryClick} 247 labelAddresses={labelAddresses} 248 recentAddresses={recentAddresses} 249 dropdownThreshold={dropdownThreshold} 250 /> 251 ); 252 } 253 254 const UncontrolledAddressFieldDropdown = connect((state: AppState) => ({ 255 labelAddresses: addressBookSelectors.getLabelAddresses(state), 256 recentAddresses: walletSelectors.getRecentAddresses(state) 257 }))(RawUncontrolledAddressFieldDropdown); 258 //#endregion Uncontrolled 259 260 //#region Controlled 261 interface ControlledAddressFieldDropdownProps { 262 currentTo: ReturnType<typeof transactionSelectors.getToRaw>; 263 labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>; 264 recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>; 265 setCurrentTo: transactionActions.TSetCurrentTo; 266 dropdownThreshold?: number; 267 } 268 269 /** 270 * @desc The controlled dropdown connects directly to the Redux store, 271 * modifying the `currentTo` property onChange. 272 */ 273 function RawControlledAddressFieldDropdown({ 274 currentTo, 275 labelAddresses, 276 recentAddresses, 277 setCurrentTo, 278 dropdownThreshold 279 }: ControlledAddressFieldDropdownProps) { 280 return ( 281 <AddressFieldDropdownClass 282 addressInput={currentTo} 283 onEntryClick={setCurrentTo} 284 labelAddresses={labelAddresses} 285 recentAddresses={recentAddresses} 286 dropdownThreshold={dropdownThreshold} 287 /> 288 ); 289 } 290 291 const ControlledAddressFieldDropdown = connect( 292 (state: AppState) => ({ 293 currentTo: transactionSelectors.getToRaw(state), 294 labelAddresses: addressBookSelectors.getLabelAddresses(state), 295 recentAddresses: walletSelectors.getRecentAddresses(state) 296 }), 297 { 298 setCurrentTo: transactionActions.setCurrentTo 299 } 300 )(RawControlledAddressFieldDropdown); 301 //#endregion Controlled 302 303 interface AddressFieldDropdownProps { 304 controlled: boolean; 305 value?: string; 306 dropdownThreshold?: number; 307 onChangeOverride?(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void; 308 } 309 310 export default function AddressFieldDropdown({ 311 controlled = true, 312 ...props 313 }: AddressFieldDropdownProps) { 314 const Dropdown = controlled ? ControlledAddressFieldDropdown : UncontrolledAddressFieldDropdown; 315 316 return <Dropdown {...props} />; 317 }