From f73f6c7f0d3c16c1cbb105c889dc46e9add21ecd Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins <matiss@mja.lv> Date: Tue, 7 May 2024 17:18:51 +0100 Subject: [PATCH] :recycle: (tooltip) refactoring to react-aria (vol.3) (#2631) --- .../desktop-client/e2e/transactions.test.js | 2 +- .../components/autocomplete/Autocomplete.tsx | 228 +++++----- .../autocomplete/CategoryAutocomplete.tsx | 14 +- .../src/components/common/Input.tsx | 4 +- .../src/components/common/Popover.tsx | 30 +- .../src/components/common/Select.tsx | 2 +- .../components/filters/FilterExpression.tsx | 17 +- .../src/components/filters/FilterMenu.tsx | 63 ++- .../src/components/filters/FiltersMenu.jsx | 429 ++++++++++-------- .../src/components/filters/NameFilter.tsx | 7 +- .../filters/SavedFilterMenuButton.tsx | 27 +- .../src/components/select/DateSelect.tsx | 61 +-- .../transactions/TransactionsTable.test.jsx | 71 +-- upcoming-release-notes/2631.md | 6 + 14 files changed, 523 insertions(+), 438 deletions(-) create mode 100644 upcoming-release-notes/2631.md diff --git a/packages/desktop-client/e2e/transactions.test.js b/packages/desktop-client/e2e/transactions.test.js index 2d2f40dbf..f0ea08d79 100644 --- a/packages/desktop-client/e2e/transactions.test.js +++ b/packages/desktop-client/e2e/transactions.test.js @@ -46,7 +46,7 @@ test.describe('Transactions', () => { await expect(datepicker).toMatchThemeScreenshots(); // Select "is xxxxx" - await datepicker.getByRole('button', { name: '20' }).click(); + await datepicker.getByText('20', { exact: true }).click(); await filterTooltip.applyButton.click(); // Assert that there are no transactions diff --git a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx index 2f0b15b34..dfd66b245 100644 --- a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx @@ -16,11 +16,11 @@ import { css } from 'glamor'; import { SvgRemove } from '../../icons/v2'; import { useResponsive } from '../../ResponsiveProvider'; -import { theme, type CSSProperties, styles } from '../../style'; +import { theme, styles } from '../../style'; import { Button } from '../common/Button'; import { Input } from '../common/Input'; +import { Popover } from '../common/Popover'; import { View } from '../common/View'; -import { Tooltip } from '../tooltips'; type CommonAutocompleteProps<T extends Item> = { focused?: boolean; @@ -31,8 +31,6 @@ type CommonAutocompleteProps<T extends Item> = { onChange?: (value: string) => void; }; suggestions?: T[]; - tooltipStyle?: CSSProperties; - tooltipProps?: ComponentProps<typeof Tooltip>; renderInput?: (props: ComponentProps<typeof Input>) => ReactNode; renderItems?: ( items: T[], @@ -214,8 +212,6 @@ function SingleAutocomplete<T extends Item>({ labelProps = {}, inputProps = {}, suggestions, - tooltipStyle, - tooltipProps, renderInput = defaultRenderInput, renderItems = defaultRenderItems, itemToString = defaultItemToString, @@ -253,6 +249,8 @@ function SingleAutocomplete<T extends Item>({ onClose?.(); }; + const triggerRef = useRef(null); + const { isNarrowWidth } = useResponsive(); const narrowInputStyle = isNarrowWidth ? { @@ -442,115 +440,122 @@ function SingleAutocomplete<T extends Item>({ // can't use a View here, but we can fake it be using the // className <div className={`view ${css({ display: 'flex' })}`} {...containerProps}> - {renderInput( - getInputProps({ - focused, - ...inputProps, - onFocus: e => { - inputProps.onFocus?.(e); - - if (openOnFocus) { - open(); - } - }, - onBlur: e => { - // Should this be e.nativeEvent - e['preventDownshiftDefault'] = true; - inputProps.onBlur?.(e); - - if (!closeOnBlur) return; - - if (clearOnBlur) { - if (e.target.value === '') { - onSelect?.(null, e.target.value); - setSelectedItem(null); - close(); - return; + <View ref={triggerRef} style={{ flexShrink: 0 }}> + {renderInput( + getInputProps({ + focused, + ...inputProps, + onFocus: e => { + inputProps.onFocus?.(e); + + if (openOnFocus) { + open(); } + }, + onBlur: e => { + // Should this be e.nativeEvent + e['preventDownshiftDefault'] = true; + inputProps.onBlur?.(e); + + if (!closeOnBlur) return; + + if (clearOnBlur) { + if (e.target.value === '') { + onSelect?.(null, e.target.value); + setSelectedItem(null); + close(); + return; + } - // If not using table behavior, reset the input on blur. Tables - // handle saving the value on blur. - const value = selectedItem ? getItemId(selectedItem) : null; - - resetState(value); - } else { - close(); - } - }, - onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => { - const { onKeyDown } = inputProps || {}; - - // If the dropdown is open, an item is highlighted, and the user - // pressed enter, always capture that and handle it ourselves - if (isOpen) { - if (e.key === 'Enter') { - if (highlightedIndex != null) { - if ( - inst.lastChangeType === - Downshift.stateChangeTypes.itemMouseEnter - ) { - // If the last thing the user did was hover an item, intentionally - // ignore the default behavior of selecting the item. It's too - // common to accidentally hover an item and then save it - e.preventDefault(); - } else { - // Otherwise, stop propagation so that the table navigator - // doesn't handle it + // If not using table behavior, reset the input on blur. Tables + // handle saving the value on blur. + const value = selectedItem ? getItemId(selectedItem) : null; + + resetState(value); + } else { + close(); + } + }, + onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => { + const { onKeyDown } = inputProps || {}; + + // If the dropdown is open, an item is highlighted, and the user + // pressed enter, always capture that and handle it ourselves + if (isOpen) { + if (e.key === 'Enter') { + if (highlightedIndex != null) { + if ( + inst.lastChangeType === + Downshift.stateChangeTypes.itemMouseEnter + ) { + // If the last thing the user did was hover an item, intentionally + // ignore the default behavior of selecting the item. It's too + // common to accidentally hover an item and then save it + e.preventDefault(); + } else { + // Otherwise, stop propagation so that the table navigator + // doesn't handle it + e.stopPropagation(); + } + } else if (!strict) { + // Handle it ourselves e.stopPropagation(); + onSelect(value, (e.target as HTMLInputElement).value); + return onSelectAfter(); + } else { + // No highlighted item, still allow the table to save the item + // as `null`, even though we're allowing the table to move + e.preventDefault(); + onKeyDown?.(e); } - } else if (!strict) { - // Handle it ourselves - e.stopPropagation(); - onSelect(value, (e.target as HTMLInputElement).value); - return onSelectAfter(); - } else { - // No highlighted item, still allow the table to save the item - // as `null`, even though we're allowing the table to move + } else if (shouldSaveFromKey(e)) { e.preventDefault(); onKeyDown?.(e); } - } else if (shouldSaveFromKey(e)) { - e.preventDefault(); - onKeyDown?.(e); } - } - // Handle escape ourselves - if (e.key === 'Escape') { - e.nativeEvent['preventDownshiftDefault'] = true; + // Handle escape ourselves + if (e.key === 'Escape') { + e.nativeEvent['preventDownshiftDefault'] = true; - if (!embedded) { - e.stopPropagation(); - } + if (!embedded) { + e.stopPropagation(); + } - fireUpdate( - onUpdate, - strict, - suggestions, - null, - getItemId(originalItem), - ); - - setValue(getItemName(originalItem)); - setSelectedItem(findItem(strict, suggestions, originalItem)); - setHighlightedIndex(null); - if (embedded) { - open(); - } else { - close(); + fireUpdate( + onUpdate, + strict, + suggestions, + null, + getItemId(originalItem), + ); + + setValue(getItemName(originalItem)); + setSelectedItem( + findItem(strict, suggestions, originalItem), + ); + setHighlightedIndex(null); + if (embedded) { + open(); + } else { + close(); + } } - } - }, - onChange: (e: ChangeEvent<HTMLInputElement>) => { - const { onChange } = inputProps || {}; - onChange?.(e.target.value); - }, - }), - )} + }, + onChange: (e: ChangeEvent<HTMLInputElement>) => { + const { onChange } = inputProps || {}; + onChange?.(e.target.value); + }, + }), + )} + </View> {isOpen && filtered.length > 0 && (embedded ? ( - <View style={{ marginTop: 5 }} data-testid="autocomplete"> + <View + style={{ ...styles.darkScrollbar, marginTop: 5 }} + data-testid="autocomplete" + > {renderItems( filtered, getItemProps, @@ -559,17 +564,20 @@ function SingleAutocomplete<T extends Item>({ )} </View> ) : ( - <Tooltip - position="bottom-stretch" + <Popover + triggerRef={triggerRef} + placement="bottom start" offset={2} + isOpen={isOpen} + onOpenChange={close} + isNonModal style={{ - padding: 0, + ...styles.darkScrollbar, backgroundColor: theme.menuAutoCompleteBackground, color: theme.menuAutoCompleteText, minWidth: 200, - ...tooltipStyle, + width: triggerRef.current?.clientWidth, }} - {...tooltipProps} data-testid="autocomplete" > {renderItems( @@ -578,7 +586,7 @@ function SingleAutocomplete<T extends Item>({ highlightedIndex, inputValue, )} - </Tooltip> + </Popover> ))} </div> )} @@ -626,13 +634,8 @@ function MultiAutocomplete<T extends Item>({ ...props }: MultiAutocompleteProps<T>) { const [focused, setFocused] = useState(false); - const lastSelectedItems = useRef<typeof selectedItems>(); const selectedItemIds = selectedItems.map(getItemId); - useEffect(() => { - lastSelectedItems.current = selectedItems; - }); - function onRemoveItem(id: T['id']) { const items = selectedItemIds.filter(i => i !== id); onSelect(items); @@ -669,9 +672,6 @@ function MultiAutocomplete<T extends Item>({ onSelect={onAddItem} highlightFirst strict={strict} - tooltipProps={{ - forceLayout: lastSelectedItems.current !== selectedItems, - }} renderInput={inputProps => ( <View style={{ diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index a742bcbfc..b9b418a54 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -70,6 +70,14 @@ function CategoryList({ }: CategoryListProps) { let lastGroup: string | undefined | null = null; + const filteredItems = useMemo( + () => + showHiddenItems + ? items + : items.filter(item => !item.hidden && !item.group?.hidden), + [showHiddenItems, items], + ); + return ( <View> <View @@ -79,7 +87,7 @@ function CategoryList({ ...(!embedded && { maxHeight: 175 }), }} > - {items.map((item, idx) => { + {filteredItems.map((item, idx) => { if (item.id === 'split') { return renderSplitTransactionButton({ key: 'split', @@ -89,10 +97,6 @@ function CategoryList({ }); } - if ((item.hidden || item.group?.hidden) && !showHiddenItems) { - return <Fragment key={item.id} />; - } - const showGroup = item.cat_group !== lastGroup; const groupName = `${item.group?.name}${item.group?.hidden ? ' (hidden)' : ''}`; lastGroup = item.cat_group; diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index 576d933ba..317c23a76 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -66,6 +66,8 @@ export function Input({ )}`} {...nativeProps} onKeyDown={e => { + nativeProps.onKeyDown?.(e); + if (e.key === 'Enter' && onEnter) { onEnter(e); } @@ -73,8 +75,6 @@ export function Input({ if (e.key === 'Escape' && onEscape) { onEscape(e); } - - nativeProps.onKeyDown?.(e); }} onBlur={e => { onUpdate?.(e.target.value); diff --git a/packages/desktop-client/src/components/common/Popover.tsx b/packages/desktop-client/src/components/common/Popover.tsx index 94b029f74..834c24e1c 100644 --- a/packages/desktop-client/src/components/common/Popover.tsx +++ b/packages/desktop-client/src/components/common/Popover.tsx @@ -1,16 +1,42 @@ import { type ComponentProps } from 'react'; import { Popover as ReactAriaPopover } from 'react-aria-components'; +import { css } from 'glamor'; + import { styles } from '../../style'; type PopoverProps = ComponentProps<typeof ReactAriaPopover>; -export const Popover = ({ style = {}, ...props }: PopoverProps) => { +export const Popover = ({ + style = {}, + shouldCloseOnInteractOutside, + ...props +}: PopoverProps) => { return ( <ReactAriaPopover placement="bottom end" offset={0} - style={{ ...styles.tooltip, padding: 0, ...style }} + className={`${css({ + ...styles.tooltip, + ...styles.lightScrollbar, + padding: 0, + ...style, + })}`} + shouldCloseOnInteractOutside={element => { + // Disable closing the popover when a reach listbox is clicked (Select component) + if ( + element.getAttribute('data-reach-listbox-list') !== null || + element.getAttribute('data-reach-listbox-option') !== null + ) { + return false; + } + + if (shouldCloseOnInteractOutside) { + return shouldCloseOnInteractOutside(element); + } + + return true; + }} {...props} /> ); diff --git a/packages/desktop-client/src/components/common/Select.tsx b/packages/desktop-client/src/components/common/Select.tsx index f5fd9c26b..06c26d712 100644 --- a/packages/desktop-client/src/components/common/Select.tsx +++ b/packages/desktop-client/src/components/common/Select.tsx @@ -98,7 +98,7 @@ export function Select<Value extends string>({ </ListboxButton> <ListboxPopover style={{ - zIndex: 10000, + zIndex: 100000, outline: 0, borderRadius: styles.menuBorderRadius, backgroundColor: theme.menuBackground, diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index 5a258e951..e72a657cb 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { mapField, friendlyOp } from 'loot-core/src/shared/rules'; import { integerToCurrency } from 'loot-core/src/shared/util'; @@ -10,6 +10,7 @@ import { import { SvgDelete } from '../../icons/v0'; import { type CSSProperties, theme } from '../../style'; import { Button } from '../common/Button'; +import { Popover } from '../common/Popover'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { Value } from '../rules/Value'; @@ -39,6 +40,7 @@ export function FilterExpression({ onDelete, }: FilterExpressionProps) { const [editing, setEditing] = useState(false); + const triggerRef = useRef(null); const field = subfieldFromFilter({ field: originalField, value }); @@ -55,6 +57,7 @@ export function FilterExpression({ }} > <Button + ref={triggerRef} type="bare" disabled={customName != null} onClick={() => setEditing(true)} @@ -89,7 +92,15 @@ export function FilterExpression({ }} /> </Button> - {editing && ( + + <Popover + triggerRef={triggerRef} + placement="bottom start" + isOpen={editing} + onOpenChange={() => setEditing(false)} + style={{ width: 275, padding: 15, color: theme.menuItemText }} + data-testid="filters-menu-tooltip" + > <FilterEditor field={originalField} op={op} @@ -102,7 +113,7 @@ export function FilterExpression({ onSave={onChange} onClose={() => setEditing(false)} /> - )} + </Popover> </View> ); } diff --git a/packages/desktop-client/src/components/filters/FilterMenu.tsx b/packages/desktop-client/src/components/filters/FilterMenu.tsx index a392fd177..b56649567 100644 --- a/packages/desktop-client/src/components/filters/FilterMenu.tsx +++ b/packages/desktop-client/src/components/filters/FilterMenu.tsx @@ -1,54 +1,49 @@ import React from 'react'; import { Menu } from '../common/Menu'; -import { MenuTooltip } from '../common/MenuTooltip'; import { type SavedFilter } from './SavedFilterMenuButton'; export function FilterMenu({ - onClose, filterId, onFilterMenuSelect, }: { - onClose: () => void; filterId: SavedFilter; onFilterMenuSelect: (item: string) => void; }) { return ( - <MenuTooltip width={200} onClose={onClose}> - <Menu - onMenuSelect={item => { - onFilterMenuSelect(item); - }} - items={ - !filterId.id + <Menu + onMenuSelect={item => { + onFilterMenuSelect(item); + }} + items={ + !filterId.id + ? [ + { name: 'save-filter', text: 'Save new filter' }, + { name: 'clear-filter', text: 'Clear all conditions' }, + ] + : filterId.id !== null && filterId.status === 'saved' ? [ + { name: 'rename-filter', text: 'Rename' }, + { name: 'delete-filter', text: 'Delete' }, + Menu.line, + { + name: 'save-filter', + text: 'Save new filter', + disabled: true, + }, + { name: 'clear-filter', text: 'Clear all conditions' }, + ] + : [ + { name: 'rename-filter', text: 'Rename' }, + { name: 'update-filter', text: 'Update condtions' }, + { name: 'reload-filter', text: 'Revert changes' }, + { name: 'delete-filter', text: 'Delete' }, + Menu.line, { name: 'save-filter', text: 'Save new filter' }, { name: 'clear-filter', text: 'Clear all conditions' }, ] - : filterId.id !== null && filterId.status === 'saved' - ? [ - { name: 'rename-filter', text: 'Rename' }, - { name: 'delete-filter', text: 'Delete' }, - Menu.line, - { - name: 'save-filter', - text: 'Save new filter', - disabled: true, - }, - { name: 'clear-filter', text: 'Clear all conditions' }, - ] - : [ - { name: 'rename-filter', text: 'Rename' }, - { name: 'update-filter', text: 'Update condtions' }, - { name: 'reload-filter', text: 'Revert changes' }, - { name: 'delete-filter', text: 'Delete' }, - Menu.line, - { name: 'save-filter', text: 'Save new filter' }, - { name: 'clear-filter', text: 'Clear all conditions' }, - ] - } - /> - </MenuTooltip> + } + /> ); } diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index db8459deb..1897bd296 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -25,6 +25,7 @@ import { theme } from '../../style'; import { Button } from '../common/Button'; import { HoverTarget } from '../common/HoverTarget'; import { Menu } from '../common/Menu'; +import { Popover } from '../common/Popover'; import { Select } from '../common/Select'; import { Stack } from '../common/Stack'; import { Text } from '../common/Text'; @@ -39,6 +40,8 @@ import { subfieldFromFilter } from './subfieldFromFilter'; import { subfieldToOptions } from './subfieldToOptions'; import { updateFilterReducer } from './updateFilterReducer'; +let isDatepickerClick = false; + const filterFields = [ 'date', 'account', @@ -80,171 +83,166 @@ function ConfigureField({ } return ( - <Tooltip - position="bottom-left" - style={{ padding: 15, color: theme.menuItemText }} - width={275} - onClose={() => dispatch({ type: 'close' })} - data-testid="filters-menu-tooltip" - > - <FocusScope> - <View style={{ marginBottom: 10 }}> - <Stack direction="row" align="flex-start"> - {field === 'amount' || field === 'date' ? ( - <Select - bare - options={ - field === 'amount' + <FocusScope> + <View style={{ marginBottom: 10 }}> + <Stack direction="row" align="flex-start"> + {field === 'amount' || field === 'date' ? ( + <Select + bare + options={ + field === 'amount' + ? [ + ['amount', 'Amount'], + ['amount-inflow', 'Amount (inflow)'], + ['amount-outflow', 'Amount (outflow)'], + ] + : field === 'date' ? [ - ['amount', 'Amount'], - ['amount-inflow', 'Amount (inflow)'], - ['amount-outflow', 'Amount (outflow)'], + ['date', 'Date'], + ['month', 'Month'], + ['year', 'Year'], ] - : field === 'date' - ? [ - ['date', 'Date'], - ['month', 'Month'], - ['year', 'Year'], - ] - : null - } - value={subfield} - onChange={sub => { - setSubfield(sub); - - if (sub === 'month' || sub === 'year') { - dispatch({ type: 'set-op', op: 'is' }); - } - }} - style={{ borderWidth: 1 }} - /> - ) : ( - titleFirst(mapField(field)) - )} - <View style={{ flex: 1 }} /> - </Stack> - </View> - - <View - style={{ - color: theme.pageTextLight, - marginBottom: 10, - }} - > - {field === 'saved' && 'Existing filters will be cleared'} - </View> + : null + } + value={subfield} + onChange={sub => { + setSubfield(sub); - <Stack - direction="row" - align="flex-start" - spacing={1} - style={{ flexWrap: 'wrap' }} - > - {type === 'boolean' ? ( - <> - <OpButton - key="true" - op="true" - selected={value === true} - onClick={() => { - dispatch({ type: 'set-op', op: 'is' }); - dispatch({ type: 'set-value', value: true }); - }} - /> - <OpButton - key="false" - op="false" - selected={value === false} - onClick={() => { + if (sub === 'month' || sub === 'year') { dispatch({ type: 'set-op', op: 'is' }); - dispatch({ type: 'set-value', value: false }); - }} - /> - </> + } + }} + style={{ borderWidth: 1 }} + /> ) : ( - <> - <Stack - direction="row" - align="flex-start" - spacing={1} - style={{ flexWrap: 'wrap' }} - > - {ops.slice(0, 3).map(currOp => ( - <OpButton - key={currOp} - op={currOp} - selected={currOp === op} - onClick={() => dispatch({ type: 'set-op', op: currOp })} - /> - ))} - </Stack> - <Stack - direction="row" - align="flex-start" - spacing={1} - style={{ flexWrap: 'wrap' }} - > - {ops.slice(3, ops.length).map(currOp => ( - <OpButton - key={currOp} - op={currOp} - selected={currOp === op} - onClick={() => dispatch({ type: 'set-op', op: currOp })} - /> - ))} - </Stack> - </> + titleFirst(mapField(field)) )} + <View style={{ flex: 1 }} /> </Stack> + </View> - <form action="#"> - {type !== 'boolean' && ( - <GenericInput - inputRef={inputRef} - field={field} - subfield={subfield} - type={ - type === 'id' && (op === 'contains' || op === 'doesNotContain') - ? 'string' - : type - } - value={value} - multi={op === 'oneOf' || op === 'notOneOf'} - style={{ marginTop: 10 }} - onChange={v => dispatch({ type: 'set-value', value: v })} - /> - )} + <View + style={{ + color: theme.pageTextLight, + marginBottom: 10, + }} + > + {field === 'saved' && 'Existing filters will be cleared'} + </View> - <Stack - direction="row" - justify="flex-end" - align="center" - style={{ marginTop: 15 }} - > - <View style={{ flex: 1 }} /> - <Button - type="primary" - onClick={e => { - e.preventDefault(); - onApply({ - field, - op, - value, - options: subfieldToOptions(field, subfield), - }); + <Stack + direction="row" + align="flex-start" + spacing={1} + style={{ flexWrap: 'wrap' }} + > + {type === 'boolean' ? ( + <> + <OpButton + key="true" + op="true" + selected={value === true} + onClick={() => { + dispatch({ type: 'set-op', op: 'is' }); + dispatch({ type: 'set-value', value: true }); + }} + /> + <OpButton + key="false" + op="false" + selected={value === false} + onClick={() => { + dispatch({ type: 'set-op', op: 'is' }); + dispatch({ type: 'set-value', value: false }); }} + /> + </> + ) : ( + <> + <Stack + direction="row" + align="flex-start" + spacing={1} + style={{ flexWrap: 'wrap' }} + > + {ops.slice(0, 3).map(currOp => ( + <OpButton + key={currOp} + op={currOp} + selected={currOp === op} + onClick={() => dispatch({ type: 'set-op', op: currOp })} + /> + ))} + </Stack> + <Stack + direction="row" + align="flex-start" + spacing={1} + style={{ flexWrap: 'wrap' }} > - Apply - </Button> - </Stack> - </form> - </FocusScope> - </Tooltip> + {ops.slice(3, ops.length).map(currOp => ( + <OpButton + key={currOp} + op={currOp} + selected={currOp === op} + onClick={() => dispatch({ type: 'set-op', op: currOp })} + /> + ))} + </Stack> + </> + )} + </Stack> + + <form action="#"> + {type !== 'boolean' && ( + <GenericInput + inputRef={inputRef} + field={field} + subfield={subfield} + type={ + type === 'id' && (op === 'contains' || op === 'doesNotContain') + ? 'string' + : type + } + value={value} + multi={op === 'oneOf' || op === 'notOneOf'} + style={{ marginTop: 10 }} + onChange={v => { + dispatch({ type: 'set-value', value: v }); + }} + /> + )} + + <Stack + direction="row" + justify="flex-end" + align="center" + style={{ marginTop: 15 }} + > + <View style={{ flex: 1 }} /> + <Button + type="primary" + onClick={e => { + e.preventDefault(); + onApply({ + field, + op, + value, + options: subfieldToOptions(field, subfield), + }); + }} + > + Apply + </Button> + </Stack> + </form> + </FocusScope> ); } export function FilterButton({ onApply, compact, hover, exclude }) { const filters = useFilters(); + const triggerRef = useRef(null); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; @@ -322,61 +320,90 @@ export function FilterButton({ onApply, compact, hover, exclude }) { return ( <View> - <HoverTarget - style={{ flexShrink: 0 }} - renderContent={() => - hover && ( - <Tooltip - position="bottom-left" - style={{ - lineHeight: 1.5, - padding: '6px 10px', - backgroundColor: theme.menuBackground, - color: theme.menuItemText, - }} - > - <Text>Filters</Text> - </Tooltip> - ) - } + <View ref={triggerRef}> + <HoverTarget + style={{ flexShrink: 0 }} + renderContent={() => + hover && ( + <Tooltip + position="bottom-left" + style={{ + lineHeight: 1.5, + padding: '6px 10px', + backgroundColor: theme.menuBackground, + color: theme.menuItemText, + }} + > + <Text>Filters</Text> + </Tooltip> + ) + } + > + {compact ? ( + <CompactFiltersButton + onClick={() => dispatch({ type: 'select-field' })} + /> + ) : ( + <FiltersButton onClick={() => dispatch({ type: 'select-field' })} /> + )} + </HoverTarget> + </View> + + <Popover + triggerRef={triggerRef} + placement="bottom start" + isOpen={state.fieldsOpen} + onOpenChange={() => dispatch({ type: 'close' })} + data-testid="filters-select-tooltip" > - {compact ? ( - <CompactFiltersButton - onClick={() => dispatch({ type: 'select-field' })} + <Menu + onMenuSelect={name => { + dispatch({ type: 'configure', field: name }); + }} + items={filterFields + .filter(f => (exclude ? !exclude.includes(f[0]) : true)) + .map(([name, text]) => ({ + name, + text: titleFirst(text), + }))} + /> + </Popover> + + <Popover + triggerRef={triggerRef} + placement="bottom start" + isOpen={state.condOpen} + onOpenChange={() => { + dispatch({ type: 'close' }); + }} + shouldCloseOnInteractOutside={element => { + // Datepicker selections for some reason register 2x clicks + // We want to keep the popover open after selecting a date. + // So we ignore the "close" event on selection + the subsequent event. + if (element.dataset.pikaYear) { + isDatepickerClick = true; + return false; + } + if (isDatepickerClick) { + isDatepickerClick = false; + return false; + } + + return true; + }} + style={{ width: 275, padding: 15, color: theme.menuItemText }} + data-testid="filters-menu-tooltip" + > + {state.field && ( + <ConfigureField + field={state.field} + op={state.op} + value={state.value} + dispatch={dispatch} + onApply={onValidateAndApply} /> - ) : ( - <FiltersButton onClick={() => dispatch({ type: 'select-field' })} /> )} - </HoverTarget> - {state.fieldsOpen && ( - <Tooltip - position="bottom-left" - style={{ padding: 0 }} - onClose={() => dispatch({ type: 'close' })} - data-testid="filters-select-tooltip" - > - <Menu - onMenuSelect={name => { - dispatch({ type: 'configure', field: name }); - }} - items={filterFields - .filter(f => (exclude ? !exclude.includes(f[0]) : true)) - .map(([name, text]) => ({ - name, - text: titleFirst(text), - }))} - /> - </Tooltip> - )} - {state.condOpen && ( - <ConfigureField - field={state.field} - op={state.op} - value={state.value} - dispatch={dispatch} - onApply={onValidateAndApply} - /> - )} + </Popover> </View> ); } diff --git a/packages/desktop-client/src/components/filters/NameFilter.tsx b/packages/desktop-client/src/components/filters/NameFilter.tsx index 3165921f9..0e4285e85 100644 --- a/packages/desktop-client/src/components/filters/NameFilter.tsx +++ b/packages/desktop-client/src/components/filters/NameFilter.tsx @@ -3,13 +3,11 @@ import React, { useRef, useEffect } from 'react'; import { theme } from '../../style'; import { Button } from '../common/Button'; import { Input } from '../common/Input'; -import { MenuTooltip } from '../common/MenuTooltip'; import { Stack } from '../common/Stack'; import { Text } from '../common/Text'; import { FormField, FormLabel } from '../forms'; export function NameFilter({ - onClose, menuItem, name, setName, @@ -17,7 +15,6 @@ export function NameFilter({ onAddUpdate, err, }: { - onClose: () => void; menuItem: string; name: string; setName: (item: string) => void; @@ -34,7 +31,7 @@ export function NameFilter({ }, []); return ( - <MenuTooltip width={325} onClose={onClose}> + <> {menuItem !== 'update-filter' && ( <form> <Stack @@ -74,6 +71,6 @@ export function NameFilter({ <Text style={{ color: theme.errorText }}>{err}</Text> </Stack> )} - </MenuTooltip> + </> ); } diff --git a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx index dc1aa9dc6..10e1176fa 100644 --- a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx +++ b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import { type RuleConditionEntity } from 'loot-core/types/models/rule'; import { SvgExpandArrow } from '../../icons/v0'; import { Button } from '../common/Button'; +import { Popover } from '../common/Popover'; import { Text } from '../common/Text'; import { View } from '../common/View'; @@ -37,6 +38,7 @@ export function SavedFilterMenuButton({ const [nameOpen, setNameOpen] = useState(false); const [adding, setAdding] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const triggerRef = useRef(null); const [err, setErr] = useState(null); const [menuItem, setMenuItem] = useState(''); const [name, setName] = useState(filterId.name); @@ -157,6 +159,7 @@ export function SavedFilterMenuButton({ <View> {filters.length > 0 && ( <Button + ref={triggerRef} type="bare" style={{ marginTop: 10 }} onClick={() => { @@ -180,16 +183,26 @@ export function SavedFilterMenuButton({ <SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} /> </Button> )} - {menuOpen && ( + + <Popover + triggerRef={triggerRef} + isOpen={menuOpen} + onOpenChange={() => setMenuOpen(false)} + style={{ width: 200 }} + > <FilterMenu - onClose={() => setMenuOpen(false)} filterId={filterId} onFilterMenuSelect={onFilterMenuSelect} /> - )} - {nameOpen && ( + </Popover> + + <Popover + triggerRef={triggerRef} + isOpen={nameOpen} + onOpenChange={() => setNameOpen(false)} + style={{ width: 325 }} + > <NameFilter - onClose={() => setNameOpen(false)} menuItem={menuItem} name={name} setName={setName} @@ -197,7 +210,7 @@ export function SavedFilterMenuButton({ onAddUpdate={onAddUpdate} err={err} /> - )} + </Popover> </View> ); } diff --git a/packages/desktop-client/src/components/select/DateSelect.tsx b/packages/desktop-client/src/components/select/DateSelect.tsx index ba3e5431b..badd799fa 100644 --- a/packages/desktop-client/src/components/select/DateSelect.tsx +++ b/packages/desktop-client/src/components/select/DateSelect.tsx @@ -27,10 +27,10 @@ import { import { stringToInteger } from 'loot-core/src/shared/util'; import { useLocalPref } from '../../hooks/useLocalPref'; -import { type CSSProperties, theme } from '../../style'; +import { theme } from '../../style'; import { Input } from '../common/Input'; +import { Popover } from '../common/Popover'; import { View } from '../common/View'; -import { Tooltip } from '../tooltips'; import DateSelectLeft from './DateSelect.left.png'; import DateSelectRight from './DateSelect.right.png'; @@ -174,7 +174,6 @@ function defaultShouldSaveFromKey(e) { type DateSelectProps = { containerProps?: ComponentProps<typeof View>; inputProps?: ComponentProps<typeof Input>; - tooltipStyle?: CSSProperties; value: string; isOpen?: boolean; embedded?: boolean; @@ -191,7 +190,6 @@ type DateSelectProps = { export function DateSelect({ containerProps, inputProps, - tooltipStyle, value: defaultValue, isOpen, embedded, @@ -323,17 +321,23 @@ export function DateSelect({ } const maybeWrapTooltip = content => { - return embedded ? ( - content - ) : ( - <Tooltip - position="bottom-left" + if (embedded) { + return open ? content : null; + } + + return ( + <Popover + triggerRef={inputRef} + placement="bottom start" offset={2} - style={{ padding: 0, minWidth: 225, ...tooltipStyle }} + isOpen={open} + isNonModal + onOpenChange={() => setOpen(false)} + style={{ minWidth: 225 }} data-testid="date-select-tooltip" > {content} - </Tooltip> + </Popover> ); }; @@ -381,24 +385,23 @@ export function DateSelect({ } }} /> - {open && - maybeWrapTooltip( - <DatePicker - ref={picker} - value={selectedValue} - firstDayOfWeekIdx={firstDayOfWeekIdx} - dateFormat={dateFormat} - onUpdate={date => { - setSelectedValue(format(date, dateFormat)); - onUpdate?.(format(date, 'yyyy-MM-dd')); - }} - onSelect={date => { - setValue(format(date, dateFormat)); - onSelect(format(date, 'yyyy-MM-dd')); - setOpen(false); - }} - />, - )} + {maybeWrapTooltip( + <DatePicker + ref={picker} + value={selectedValue} + firstDayOfWeekIdx={firstDayOfWeekIdx} + dateFormat={dateFormat} + onUpdate={date => { + setSelectedValue(format(date, dateFormat)); + onUpdate?.(format(date, 'yyyy-MM-dd')); + }} + onSelect={date => { + setValue(format(date, dateFormat)); + onSelect(format(date, 'yyyy-MM-dd')); + setOpen(false); + }} + />, + )} </View> ); } diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index 075813883..8df4a8c76 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -440,10 +440,12 @@ describe('Transactions', () => { const categories = categoryGroups.flatMap(group => group.categories); const input = await editField(container, 'category', 2); - const tooltip = container.querySelector('[data-testid="autocomplete"]'); - expect(tooltip).toBeTruthy(); expect( - [...tooltip.querySelectorAll('[data-testid*="category-item"]')].length, + [ + ...screen + .getByTestId('autocomplete') + .querySelectorAll('[data-testid*="category-item"]'), + ].length, ).toBe(categoryGroups.length + categories.length); await userEvent.clear(input); @@ -451,7 +453,9 @@ describe('Transactions', () => { // Make sure the list is filtered, the right items exist, and the // first item is highlighted - let items = tooltip.querySelectorAll('[data-testid*="category-item"]'); + let items = screen + .getByTestId('autocomplete') + .querySelectorAll('[data-testid*="category-item"]'); expect(items.length).toBe(2); expect(items[0].textContent).toBe('Usual Expenses'); expect(items[1].textContent).toBe('General 129.87'); @@ -461,7 +465,9 @@ describe('Transactions', () => { await userEvent.clear(input); await userEvent.type(input, 'Usual Expenses'); - items = tooltip.querySelectorAll('[data-testid$="category-item"]'); + items = screen + .getByTestId('autocomplete') + .querySelectorAll('[data-testid$="category-item"]'); expect(items.length).toBe(0); }); @@ -469,16 +475,19 @@ describe('Transactions', () => { const { container, getTransactions } = renderTransactions(); const input = await editField(container, 'category', 2); - const tooltip = container.querySelector('[data-testid="autocomplete"]'); // No item should be highlighted - let highlighted = tooltip.querySelector('[data-highlighted]'); + let highlighted = screen + .getByTestId('autocomplete') + .querySelector('[data-highlighted]'); expect(highlighted).toBeNull(); await userEvent.keyboard('[ArrowDown][ArrowDown][ArrowDown][ArrowDown]'); // The right item should be highlighted - highlighted = tooltip.querySelector('[data-highlighted]'); + highlighted = screen + .getByTestId('autocomplete') + .querySelector('[data-highlighted]'); expect(highlighted).not.toBeNull(); expect(highlighted.textContent).toBe('General 129.87'); @@ -509,18 +518,22 @@ describe('Transactions', () => { await editField(container, 'category', 2); - let tooltip = container.querySelector('[data-testid="autocomplete"]'); - // Make sure none of the items are highlighted - const items = tooltip.querySelectorAll('[data-testid$="category-item"]'); - let highlighted = tooltip.querySelector('[data-highlighted]'); + const items = screen + .getByTestId('autocomplete') + .querySelectorAll('[data-testid$="category-item"]'); + let highlighted = screen + .getByTestId('autocomplete') + .querySelector('[data-highlighted]'); expect(highlighted).toBeNull(); // Hover over an item await userEvent.hover(items[2]); // Make sure the expected category is highlighted - highlighted = tooltip.querySelector('[data-highlighted]'); + highlighted = screen + .getByTestId('autocomplete') + .querySelector('[data-highlighted]'); expect(highlighted).not.toBeNull(); expect(highlighted.textContent).toBe('General 129.87'); @@ -535,8 +548,7 @@ describe('Transactions', () => { ); // It should still be editing the category - tooltip = container.querySelector('[data-testid="autocomplete"]'); - expect(tooltip).toBe(null); + expect(screen.queryByTestId('autocomplete')).toBe(null); expectToBeEditingField(container, 'category', 2); }); @@ -545,16 +557,19 @@ describe('Transactions', () => { const input = await editField(container, 'category', 2); const oldCategory = getTransactions()[2].category; - const tooltip = container.querySelector('[data-testid="autocomplete"]'); - const items = tooltip.querySelectorAll('[data-testid$="category-item"]'); + const items = screen + .getByTestId('autocomplete') + .querySelectorAll('[data-testid$="category-item"]'); // Hover over a few of the items to highlight them await userEvent.hover(items[2]); await userEvent.hover(items[3]); // Make sure one of them is highlighted - const highlighted = tooltip.querySelectorAll('[data-highlighted]'); + const highlighted = screen + .getByTestId('autocomplete') + .querySelectorAll('[data-highlighted]'); expect(highlighted).toHaveLength(1); // Navigate away from the field with the keyboard @@ -618,8 +633,7 @@ describe('Transactions', () => { expect(input.value).toBe(oldValue); // The tooltip be closed - const tooltip = container.querySelector('[data-testid="autocomplete"]'); - expect(tooltip).toBeNull(); + expect(screen.queryByTestId('autocomplete')).toBeNull(); }); test('adding a new transaction works', async () => { @@ -667,10 +681,7 @@ describe('Transactions', () => { await userEvent.type(input, '55.00'); await editNewField(container, 'category'); - const splitButton = document.body.querySelector( - '[data-testid="autocomplete"] [data-testid="split-transaction-button"]', - ); - await userEvent.click(splitButton); + await userEvent.click(screen.getByTestId('split-transaction-button')); await waitForAutocomplete(); await waitForAutocomplete(); await waitForAutocomplete(); @@ -789,10 +800,6 @@ describe('Transactions', () => { } let input = await editField(container, 'category', 0); - const tooltip = container.querySelector('[data-testid="autocomplete"]'); - const splitButton = tooltip.querySelector( - '[data-testid="split-transaction-button"]', - ); // Make it clear that we are expected a negative transaction expect(getTransactions()[0].amount).toBe(-2777); @@ -800,7 +807,7 @@ describe('Transactions', () => { // Make sure splitting a transaction works expect(getTransactions().length).toBe(5); - await userEvent.click(splitButton); + await userEvent.click(screen.getByTestId('split-transaction-button')); await waitForAutocomplete(); expect(getTransactions().length).toBe(6); @@ -910,17 +917,13 @@ describe('Transactions', () => { const { container, getTransactions } = renderTransactions(); let input = await editField(container, 'category', 0); - const tooltip = container.querySelector('[data-testid="autocomplete"]'); - const splitButton = tooltip.querySelector( - '[data-testid="split-transaction-button"', - ); // The first transaction should always be a negative amount expect(getTransactions()[0].amount).toBe(-2777); // Add two new split transactions expect(getTransactions().length).toBe(5); - await userEvent.click(splitButton); + await userEvent.click(screen.getByTestId('split-transaction-button')); await waitForAutocomplete(); await userEvent.click( container.querySelector('[data-testid="add-split-button"]'), diff --git a/upcoming-release-notes/2631.md b/upcoming-release-notes/2631.md new file mode 100644 index 000000000..bb664186d --- /dev/null +++ b/upcoming-release-notes/2631.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Migrating native `Tooltip` component to react-aria Tooltip/Popover (vol.3) -- GitLab