// @ts-strict-ignore import React, { Fragment, type ComponentProps, type ComponentPropsWithoutRef, type ReactElement, } from 'react'; import { css } from 'glamor'; import { type AccountEntity } from 'loot-core/src/types/models'; import { useAccounts } from '../../hooks/useAccounts'; import { useResponsive } from '../../ResponsiveProvider'; import { type CSSProperties, theme, styles } from '../../style'; import { TextOneLine } from '../common/TextOneLine'; import { View } from '../common/View'; import { Autocomplete } from './Autocomplete'; import { ItemHeader } from './ItemHeader'; type AccountAutocompleteItem = AccountEntity; type AccountListProps = { items: AccountAutocompleteItem[]; getItemProps: (arg: { item: AccountAutocompleteItem; }) => ComponentProps<typeof View>; highlightedIndex: number; embedded: boolean; renderAccountItemGroupHeader?: ( props: ComponentPropsWithoutRef<typeof ItemHeader>, ) => ReactElement<typeof ItemHeader>; renderAccountItem?: ( props: ComponentPropsWithoutRef<typeof AccountItem>, ) => ReactElement<typeof AccountItem>; }; function AccountList({ items, getItemProps, highlightedIndex, embedded, renderAccountItemGroupHeader = defaultRenderAccountItemGroupHeader, renderAccountItem = defaultRenderAccountItem, }: AccountListProps) { let lastItem = null; return ( <View> <View style={{ overflow: 'auto', padding: '5px 0', ...(!embedded && { maxHeight: 175 }), }} > {items.map((item, idx) => { const showGroup = lastItem ? (item.offbudget !== lastItem.offbudget && !item.closed) || (item.closed !== lastItem.closed && !item.offbudget) : true; const group = `${ item.closed ? 'Closed Accounts' : item.offbudget ? 'Off Budget' : 'For Budget' }`; lastItem = item; return [ showGroup ? ( <Fragment key={group}> {renderAccountItemGroupHeader({ title: group })} </Fragment> ) : null, <Fragment key={item.id}> {renderAccountItem({ ...(getItemProps ? getItemProps({ item }) : null), item, highlighted: highlightedIndex === idx, embedded, })} </Fragment>, ]; })} </View> </View> ); } type AccountAutocompleteProps = ComponentProps< typeof Autocomplete<AccountAutocompleteItem> > & { embedded?: boolean; includeClosedAccounts?: boolean; renderAccountItemGroupHeader?: ( props: ComponentPropsWithoutRef<typeof ItemHeader>, ) => ReactElement<typeof ItemHeader>; renderAccountItem?: ( props: ComponentPropsWithoutRef<typeof AccountItem>, ) => ReactElement<typeof AccountItem>; closeOnBlur?: boolean; }; export function AccountAutocomplete({ embedded, includeClosedAccounts = true, renderAccountItemGroupHeader, renderAccountItem, closeOnBlur, ...props }: AccountAutocompleteProps) { const accounts = useAccounts() || []; //remove closed accounts if needed //then sort by closed, then offbudget const accountSuggestions: AccountAutocompleteItem[] = accounts .filter(item => { return includeClosedAccounts ? item : !item.closed; }) .sort( (a, b) => a.closed - b.closed || a.offbudget - b.offbudget || a.sort_order - b.sort_order, ); return ( <Autocomplete strict={true} highlightFirst={true} embedded={embedded} closeOnBlur={closeOnBlur} suggestions={accountSuggestions} renderItems={(items, getItemProps, highlightedIndex) => ( <AccountList items={items} getItemProps={getItemProps} highlightedIndex={highlightedIndex} embedded={embedded} renderAccountItemGroupHeader={renderAccountItemGroupHeader} renderAccountItem={renderAccountItem} /> )} {...props} /> ); } function defaultRenderAccountItemGroupHeader( props: ComponentPropsWithoutRef<typeof ItemHeader>, ): ReactElement<typeof ItemHeader> { return <ItemHeader {...props} type="account" />; } type AccountItemProps = { item: AccountAutocompleteItem; className?: string; style?: CSSProperties; highlighted?: boolean; embedded?: boolean; }; function AccountItem({ item, className, highlighted, embedded, ...props }: AccountItemProps) { const { isNarrowWidth } = useResponsive(); const narrowStyle = isNarrowWidth ? { ...styles.mobileMenuItem, borderRadius: 0, borderTop: `1px solid ${theme.pillBorder}`, } : {}; return ( <div // List each account up to a max // Downshift calls `setTimeout(..., 250)` in the `onMouseMove` // event handler they set on this element. When this code runs // in WebKit on touch-enabled devices, taps on this element end // up not triggering the `onClick` event (and therefore delaying // response to user input) until after the `setTimeout` callback // finishes executing. This is caused by content observation code // that implements various strategies to prevent the user from // accidentally clicking content that changed as a result of code // run in the `onMouseMove` event. // // Long story short, we don't want any delay here between the user // tapping and the resulting action being performed. It turns out // there's some "fast path" logic that can be triggered in various // ways to force WebKit to bail on the content observation process. // One of those ways is setting `role="button"` (or a number of // other aria roles) on the element, which is what we're doing here. // // ref: // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783 role="button" className={`${className} ${css([ { backgroundColor: highlighted ? theme.menuAutoCompleteBackgroundHover : 'transparent', color: highlighted ? theme.menuAutoCompleteItemTextHover : theme.menuAutoCompleteItemText, padding: 4, paddingLeft: 20, borderRadius: embedded ? 4 : 0, ...narrowStyle, }, ])}`} data-testid={`${item.name}-account-item`} data-highlighted={highlighted || undefined} {...props} > <TextOneLine>{item.name}</TextOneLine> </div> ); } function defaultRenderAccountItem( props: ComponentPropsWithoutRef<typeof AccountItem>, ): ReactElement<typeof AccountItem> { return <AccountItem {...props} />; }