feat(widgets): ChainSearchMenu improvements (#4795)

### Description

- Updated ChainSearchMenu with improvements mentioned in #4779 
- Update ChainSearchMenu story with stories related to the new changes

### Drive-by changes

No

### Related issues

Fixes #4779

### Backward compatibility

Yes

### Testing

Manual testing and visual testing with storybook

---------

Co-authored-by: J M Rossy <jm.rossy@gmail.com>
pull/4804/head
Jason Guo 3 weeks ago committed by GitHub
parent db91968372
commit 86a0bb9198
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/proud-horses-smash.md
  2. 49
      typescript/widgets/src/chains/ChainSearchMenu.tsx
  3. 59
      typescript/widgets/src/components/SearchMenu.tsx
  4. 8
      typescript/widgets/src/components/TextInput.tsx
  5. 40
      typescript/widgets/src/stories/ChainSearchMenu.stories.tsx

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/widgets': patch
---
- Update ChainSearchMenu with improvements

@ -19,7 +19,7 @@ import { ChainAddMenu } from './ChainAddMenu.js';
import { ChainDetailsMenu } from './ChainDetailsMenu.js';
import { ChainLogo } from './ChainLogo.js';
enum ChainSortByOption {
export enum ChainSortByOption {
Name = 'name',
ChainId = 'chain id',
Protocol = 'protocol',
@ -30,6 +30,8 @@ enum FilterTestnetOption {
Mainnet = 'mainnet',
}
type DefaultSortField = ChainSortByOption | 'custom';
interface ChainFilterState {
type?: FilterTestnetOption;
protocol?: ProtocolType;
@ -53,13 +55,15 @@ export interface ChainSearchMenuProps {
) => void;
onClickChain: (chain: ChainMetadata) => void;
// Replace the default 2nd column (deployer) with custom data
customListItemField?: CustomListItemField;
customListItemField?: CustomListItemField | null;
// Auto-navigate to a chain details menu
showChainDetails?: ChainName;
// Auto-navigate to a chain add menu
showAddChainMenu?: boolean;
// Include add button above list
showAddChainButton?: boolean;
// Field by which data will be sorted by default
defaultSortField?: DefaultSortField;
}
export function ChainSearchMenu({
@ -71,6 +75,7 @@ export function ChainSearchMenu({
showChainDetails,
showAddChainButton,
showAddChainMenu,
defaultSortField,
}: ChainSearchMenuProps) {
const [drilldownChain, setDrilldownChain] = React.useState<
ChainName | undefined
@ -87,7 +92,7 @@ export function ChainSearchMenu({
}, [chainMetadata]);
const { ListComponent, searchFn, sortOptions, defaultSortState } =
useCustomizedListItems(customListItemField);
useCustomizedListItems(customListItemField, defaultSortField);
if (drilldownChain && mergedMetadata[drilldownChain]) {
const isLocalOverrideChain = !chainMetadata[drilldownChain];
@ -150,7 +155,7 @@ function ChainListItem({
customField,
}: {
data: ChainMetadata;
customField?: CustomListItemField;
customField?: CustomListItemField | null;
}) {
return (
<>
@ -167,16 +172,18 @@ function ChainListItem({
</div>
</div>
</div>
<div className="htw-text-left htw-overflow-hidden">
<div className="htw-text-sm truncate">
{customField
? customField.data[chain.name].display || 'Unknown'
: chain.deployer?.name || 'Unknown deployer'}
</div>
<div className="htw-text-[0.7rem] htw-text-gray-500">
{customField ? customField.header : 'Deployer'}
{customField !== null && (
<div className="htw-text-left htw-overflow-hidden">
<div className="htw-text-sm truncate">
{customField
? customField.data[chain.name].display || 'Unknown'
: chain.deployer?.name || 'Unknown deployer'}
</div>
<div className="htw-text-[0.7rem] htw-text-gray-500">
{customField ? customField.header : 'Deployer'}
</div>
</div>
</div>
)}
</>
);
}
@ -281,7 +288,10 @@ function chainSearch({
* This is useful because SearchMenu will do handle the list item rendering and
* management but the custom data is more or a chain-search-specific concern
*/
function useCustomizedListItems(customListItemField) {
function useCustomizedListItems(
customListItemField,
defaultSortField?: DefaultSortField,
) {
// Create closure of ChainListItem but with customField pre-bound
const ListComponent = useCallback(
({ data }: { data: ChainMetadata<{ disabled?: boolean }> }) => (
@ -306,16 +316,19 @@ function useCustomizedListItems(customListItemField) {
[customListItemField],
) as ChainSortByOption[];
// Sort by the custom field by default, if one is provided
// Sort by defaultSortField initially, if value is "custom", sort using custom field by default
const defaultSortState = useMemo(
() =>
customListItemField
defaultSortField
? {
sortBy: customListItemField.header,
sortBy:
defaultSortField === 'custom' && customListItemField
? customListItemField.header
: defaultSortField,
sortOrder: SortOrderOption.Desc,
}
: undefined,
[customListItemField],
[defaultSortField, customListItemField],
) as SortState<ChainSortByOption> | undefined;
return { ListComponent, searchFn, sortOptions, defaultSortState };

@ -1,5 +1,12 @@
import clsx from 'clsx';
import React, { ComponentType, useMemo, useState } from 'react';
import React, {
ComponentType,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { deepEquals, isObject, toTitleCase } from '@hyperlane-xyz/utils';
@ -79,6 +86,7 @@ export function SearchMenu<
);
const [filterState, setFilterState] =
useState<FilterState>(defaultFilterState);
const inputRef = useRef<HTMLInputElement>(null);
const results = useMemo(
() =>
@ -91,13 +99,31 @@ export function SearchMenu<
[data, searchQuery, sortState, filterState, searchFn],
);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (results.length === 1) {
const item = results[0];
isEditMode ? onClickEditItem(item) : onClickItem(item);
}
},
[results, isEditMode],
);
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className="htw-flex htw-flex-col htw-gap-2">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder={placeholder}
/>
<form onSubmit={handleSubmit}>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder={placeholder}
ref={inputRef}
/>
</form>
<div className="htw-flex htw-items-center htw-justify-between">
<div className="htw-flex htw-items-center htw-gap-5">
<SortDropdown
@ -157,7 +183,10 @@ export function SearchMenu<
);
}
function SearchBar(props: InputProps) {
const SearchBar = React.forwardRef(function SearchBar(
{ onChange, value, ...props }: InputProps,
ref: React.Ref<HTMLInputElement>,
) {
return (
<div className="htw-relative">
<SearchIcon
@ -165,13 +194,25 @@ function SearchBar(props: InputProps) {
height={18}
className="htw-absolute htw-left-4 htw-top-1/2 -htw-translate-y-1/2 htw-opacity-50"
/>
<TextInput
onChange={onChange}
value={value}
ref={ref}
{...props}
className="htw-w-full htw-rounded-lg htw-px-11 htw-py-3"
className="htw-bg-inherit focus:htw-bg-inherit htw-border htw-border-gray-200 focus:htw-border-gray-400 htw-w-full htw-rounded-lg htw-px-11 htw-py-3"
/>
{value && onChange && (
<IconButton
className="htw-absolute htw-right-4 htw-top-1/3 htw-opacity-50"
onClick={() => onChange('')}
>
<XIcon width={14} height={14} />
</IconButton>
)}
</div>
);
}
});
function SortDropdown<SortBy extends string>({
options,

@ -8,13 +8,17 @@ export type InputProps = Omit<
className?: string;
};
export function TextInput({ onChange, className, ...props }: InputProps) {
export function _TextInput(
{ onChange, className, ...props }: InputProps,
ref: React.Ref<HTMLInputElement>,
) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (onChange) onChange(e?.target?.value || '');
};
return (
<input
ref={ref}
type="text"
autoComplete="off"
onChange={handleChange}
@ -23,3 +27,5 @@ export function TextInput({ onChange, className, ...props }: InputProps) {
/>
);
}
export const TextInput = React.forwardRef(_TextInput);

@ -3,7 +3,10 @@ import { Meta, StoryObj } from '@storybook/react';
import { chainMetadata } from '@hyperlane-xyz/registry';
import { pick } from '@hyperlane-xyz/utils';
import { ChainSearchMenu } from '../chains/ChainSearchMenu.js';
import {
ChainSearchMenu,
ChainSortByOption,
} from '../chains/ChainSearchMenu.js';
const meta = {
title: 'ChainSearchMenu',
@ -36,6 +39,41 @@ export const WithCustomField = {
},
} satisfies Story;
export const WithCustomFieldAsNull = {
args: {
chainMetadata: pick(chainMetadata, ['alfajores', 'arbitrum', 'ethereum']),
onChangeOverrideMetadata: () => {},
customListItemField: null,
showAddChainButton: true,
},
} satisfies Story;
export const WithDefaultSortField = {
args: {
chainMetadata: chainMetadata,
onChangeOverrideMetadata: () => {},
showAddChainButton: true,
defaultSortField: ChainSortByOption.Protocol,
},
} satisfies Story;
export const WithDefaultSortFieldAsCustom = {
args: {
chainMetadata: pick(chainMetadata, ['alfajores', 'arbitrum', 'ethereum']),
onChangeOverrideMetadata: () => {},
showAddChainButton: true,
customListItemField: {
header: 'Warp Routes',
data: {
alfajores: { display: '1 token', sortValue: 1 },
arbitrum: { display: '2 tokens', sortValue: 2 },
ethereum: { display: '1 token', sortValue: 1 },
},
},
defaultSortField: 'custom',
},
} satisfies Story;
export const WithOverrideChain = {
args: {
chainMetadata: pick(chainMetadata, ['alfajores']),

Loading…
Cancel
Save