Do dropdown better

This commit is contained in:
Connor Bryan 2018-07-12 15:23:23 -05:00
parent 1c601f58e4
commit af12e36606
2 changed files with 177 additions and 86 deletions

View File

@ -8,38 +8,19 @@ import { addressBookSelectors } from 'features/addressBook';
import { Address, Identicon } from 'components/ui'; import { Address, Identicon } from 'components/ui';
import './AddressFieldDropdown.scss'; import './AddressFieldDropdown.scss';
/** interface Props {
* @desc The `onChangeOverride` prop needs to work addressInput: string;
* with actual events, but also needs a value to be directly passed in
* occasionally. This interface allows us to skip all of the other FormEvent
* properties and methods.
*/
interface FakeFormEvent {
currentTarget: {
value: string;
};
}
interface StateProps {
value?: string;
dropdownThreshold?: number; dropdownThreshold?: number;
labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>; labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
currentTo: ReturnType<typeof transactionSelectors.getToRaw>; onEntryClick(address: string): void;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
} }
interface DispatchProps {
setCurrentTo: transactionActions.TSetCurrentTo;
}
type Props = StateProps & DispatchProps;
interface State { interface State {
activeIndex: number | null; activeIndex: number | null;
} }
class AddressFieldDropdown extends React.Component<Props> { class AddressFieldDropdownClass extends React.Component<Props, State> {
public state: State = { public state = {
activeIndex: null activeIndex: null
}; };
@ -52,67 +33,77 @@ class AddressFieldDropdown extends React.Component<Props> {
} }
public render() { public render() {
const { value, currentTo, dropdownThreshold = 3 } = this.props; const { addressInput } = this.props;
const stringInQuestion = value != null ? value : currentTo;
const noMatchContent = stringInQuestion.startsWith('0x') ? null : (
<li className="AddressFieldDropdown-dropdown-item AddressFieldDropdown-dropdown-item-no-match">
<i className="fa fa-warning" /> {translate('NO_LABEL_FOUND_CONTAINING')} "{stringInQuestion}".
</li>
);
return stringInQuestion.length >= dropdownThreshold ? ( return (
<ul className="AddressFieldDropdown" role="listbox"> this.getIsVisible() && (
{this.getFilteredLabels().length > 0 ? this.renderDropdownItems() : noMatchContent} <ul className="AddressFieldDropdown" role="listbox">
</ul> {this.getFilteredLabels().length > 0 ? (
) : null; this.renderDropdownItems()
) : (
<li className="AddressFieldDropdown-dropdown-item AddressFieldDropdown-dropdown-item-no-match">
<i className="fa fa-warning" /> {translate('NO_LABEL_FOUND_CONTAINING')} "{
addressInput
}".
</li>
)}
</ul>
)
);
} }
private renderDropdownItems = () => private renderDropdownItems = () => {
this.getFilteredLabels().map((filteredLabel, index: number) => { const { onEntryClick } = this.props;
const { onChangeOverride, setCurrentTo } = this.props; const { activeIndex } = this.state;
const { activeIndex } = this.state;
const { address, label } = filteredLabel;
const isActive = activeIndex === index;
const className = `AddressFieldDropdown-dropdown-item ${
isActive ? 'AddressFieldDropdown-dropdown-item--active' : ''
}`;
return ( return this.getFilteredLabels().map(
<li ({ address, label }: { address: string; label: string }, index: number) => {
key={address} const isActive = activeIndex === index;
className={className} const className = `AddressFieldDropdown-dropdown-item ${
onClick={() => isActive ? 'AddressFieldDropdown-dropdown-item--active' : ''
onChangeOverride }`;
? onChangeOverride({ currentTarget: { value: address } })
: setCurrentTo(address) return (
} <li
role="option" key={address}
title={`${translateRaw('SEND_TO')}${label}`} role="option"
> className={className}
<div className="AddressFieldDropdown-dropdown-item-identicon"> onClick={() => onEntryClick(address)}
<Identicon address={address} /> title={`${translateRaw('SEND_TO')}${label}`}
</div> >
<strong className="AddressFieldDropdown-dropdown-item-label">{label}</strong> <div className="AddressFieldDropdown-dropdown-item-identicon">
<em className="AddressFieldDropdown-dropdown-item-address"> <Identicon address={address} />
<Address address={address} /> </div>
</em> <strong className="AddressFieldDropdown-dropdown-item-label">{label}</strong>
</li> <em className="AddressFieldDropdown-dropdown-item-address">
); <Address address={address} />
}); </em>
</li>
);
}
);
};
private getFilteredLabels = () => { private getFilteredLabels = () => {
const { value, currentTo } = this.props; const { addressInput, labelAddresses } = this.props;
const includedString = value != null ? value : currentTo.toLowerCase();
return Object.keys(this.props.labelAddresses) return Object.keys(labelAddresses)
.filter(label => label.toLowerCase().includes(includedString)) .filter(label => label.toLowerCase().includes(addressInput))
.map(label => ({ address: this.props.labelAddresses[label], label })) .map(label => ({ address: labelAddresses[label], label }))
.slice(0, 5); .slice(0, 5);
}; };
private getIsVisible = () => private getIsVisible = () => {
this.props.currentTo.length > 1 && this.getFilteredLabels().length > 0; const { addressInput, dropdownThreshold = 3 } = this.props;
return addressInput.length >= dropdownThreshold && this.getFilteredLabels().length > 0;
};
private setActiveIndex = (activeIndex: number | null) => this.setState({ activeIndex });
private clearActiveIndex = () => this.setActiveIndex(null);
//#region Keyboard Controls
private handleKeyDown = (e: KeyboardEvent) => { private handleKeyDown = (e: KeyboardEvent) => {
if (this.getIsVisible()) { if (this.getIsVisible()) {
switch (e.key) { switch (e.key) {
@ -132,6 +123,7 @@ class AddressFieldDropdown extends React.Component<Props> {
}; };
private handleEnterKeyDown = () => { private handleEnterKeyDown = () => {
const { onEntryClick } = this.props;
const { activeIndex } = this.state; const { activeIndex } = this.state;
if (activeIndex !== null) { if (activeIndex !== null) {
@ -139,7 +131,7 @@ class AddressFieldDropdown extends React.Component<Props> {
filteredLabels.forEach(({ address }, index) => { filteredLabels.forEach(({ address }, index) => {
if (activeIndex === index) { if (activeIndex === index) {
this.props.setCurrentTo(address); onEntryClick(address);
} }
}); });
@ -175,16 +167,108 @@ class AddressFieldDropdown extends React.Component<Props> {
this.setState({ activeIndex }); this.setState({ activeIndex });
}; };
//#endregion Keyboard Controls
private setActiveIndex = (activeIndex: number | null) => this.setState({ activeIndex });
private clearActiveIndex = () => this.setActiveIndex(null);
} }
export default connect( //#region Uncontrolled
/**
* @desc The `onChangeOverride` prop needs to work
* with actual events, but also needs a value to be directly passed in
* occasionally. This interface allows us to skip all of the other FormEvent
* properties and methods.
*/
interface FakeFormEvent {
currentTarget: {
value: string;
};
}
interface UncontrolledAddressFieldDropdownProps {
value: string;
labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
dropdownThreshold?: number;
onChangeOverride(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
}
/**
* @desc The uncontrolled dropdown changes the address input onClick,
* as well as calls the onChange override, but does not update the `currentTo`
* property in the Redux store.
*/
function RawUncontrolledAddressFieldDropdown({
value,
onChangeOverride,
labelAddresses,
dropdownThreshold
}: UncontrolledAddressFieldDropdownProps) {
const onEntryClick = (address: string) => onChangeOverride({ currentTarget: { value: address } });
return (
<AddressFieldDropdownClass
addressInput={value}
onEntryClick={onEntryClick}
labelAddresses={labelAddresses}
dropdownThreshold={dropdownThreshold}
/>
);
}
const UncontrolledAddressFieldDropdown = connect((state: AppState) => ({
labelAddresses: addressBookSelectors.getLabelAddresses(state)
}))(RawUncontrolledAddressFieldDropdown);
//#endregion Uncontrolled
//#region Controlled
interface ControlledAddressFieldDropdownProps {
currentTo: ReturnType<typeof transactionSelectors.getToRaw>;
labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
setCurrentTo: transactionActions.TSetCurrentTo;
dropdownThreshold?: number;
}
/**
* @desc The controlled dropdown connects directly to the Redux store,
* modifying the `currentTo` property onChange.
*/
function RawControlledAddressFieldDropdown({
currentTo,
labelAddresses,
setCurrentTo,
dropdownThreshold
}: ControlledAddressFieldDropdownProps) {
return (
<AddressFieldDropdownClass
addressInput={currentTo}
onEntryClick={setCurrentTo}
labelAddresses={labelAddresses}
dropdownThreshold={dropdownThreshold}
/>
);
}
const ControlledAddressFieldDropdown = connect(
(state: AppState) => ({ (state: AppState) => ({
labelAddresses: addressBookSelectors.getLabelAddresses(state), currentTo: transactionSelectors.getToRaw(state),
currentTo: transactionSelectors.getToRaw(state) labelAddresses: addressBookSelectors.getLabelAddresses(state)
}), }),
{ setCurrentTo: transactionActions.setCurrentTo } {
)(AddressFieldDropdown); setCurrentTo: transactionActions.setCurrentTo
}
)(RawControlledAddressFieldDropdown);
//#endregion Controlled
interface AddressFieldDropdownProps {
controlled: boolean;
value?: string;
dropdownThreshold?: number;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
}
export default function AddressFieldDropdown({
controlled = true,
...props
}: AddressFieldDropdownProps) {
const Dropdown = controlled ? ControlledAddressFieldDropdown : UncontrolledAddressFieldDropdown;
return <Dropdown {...props} />;
}

View File

@ -94,8 +94,14 @@ class AddressInputFactoryClass extends Component<Props> {
addr = addHexPrefix(currentTo.value ? currentTo.value.toString('hex') : '0'); addr = addHexPrefix(currentTo.value ? currentTo.value.toString('hex') : '0');
} }
/**
* @desc If passed a value and an onChangeOverride function,
* infer that the dropdown should be uncontrolled.
*/
const controlled = value == null && !onChangeOverride;
return ( return (
<div className={`AddressInput form-group`}> <div className={'AddressInput form-group'}>
<div className={inputClassName}> <div className={inputClassName}>
<Query <Query
params={['readOnly']} params={['readOnly']}
@ -115,8 +121,9 @@ class AddressInputFactoryClass extends Component<Props> {
{isFocused && {isFocused &&
!isENSAddress && ( !isENSAddress && (
<AddressFieldDropdown <AddressFieldDropdown
onChangeOverride={onChangeOverride} controlled={controlled}
value={value} value={value}
onChangeOverride={onChangeOverride}
dropdownThreshold={dropdownThreshold} dropdownThreshold={dropdownThreshold}
/> />
)} )}