From 62dbe3acf5a2f124422504abf55ab71eec196640 Mon Sep 17 00:00:00 2001 From: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com> Date: Sun, 8 Sep 2024 13:16:50 -0400 Subject: [PATCH] Refine `Menu`/`Select` types to allow broader types for the `value`/`name` attribute (#3391) * Refine `Menu`/`Select` types to allow arbitrary value types * Add release notes --- .../src/components/common/Menu.tsx | 52 +++++++---- .../src/components/common/Select.tsx | 12 ++- .../src/components/reports/ReportSidebar.tsx | 11 +-- .../src/components/reports/SaveReportMenu.tsx | 92 +++++++++---------- upcoming-release-notes/3391.md | 6 ++ 5 files changed, 93 insertions(+), 80 deletions(-) create mode 100644 upcoming-release-notes/3391.md diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx index 55372c02c..e83bf2b6a 100644 --- a/packages/desktop-client/src/components/common/Menu.tsx +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -1,5 +1,4 @@ import { - type ReactElement, type ReactNode, useEffect, useRef, @@ -15,8 +14,9 @@ import { Toggle } from './Toggle'; import { View } from './View'; const MenuLine: unique symbol = Symbol('menu-line'); +const MenuLabel: unique symbol = Symbol('menu-label'); Menu.line = MenuLine; -Menu.label = Symbol('menu-label'); +Menu.label = MenuLabel; type KeybindingProps = { keyName: ReactNode; @@ -30,9 +30,9 @@ function Keybinding({ keyName }: KeybindingProps) { ); } -export type MenuItem = { - type?: string | symbol; - name: string; +type MenuItemObject<NameType, Type extends string | symbol = string> = { + type?: Type; + name: NameType; disabled?: boolean; icon?: ComponentType<SVGProps<SVGSVGElement>>; iconSize?: number; @@ -42,17 +42,28 @@ export type MenuItem = { tooltip?: string; }; -type MenuProps<T extends MenuItem = MenuItem> = { +export type MenuItem<NameType = string> = + | MenuItemObject<NameType> + | MenuItemObject<string, typeof Menu.label> + | typeof Menu.line; + +function isLabel<T>( + item: MenuItemObject<T> | MenuItemObject<string, typeof Menu.label>, +): item is MenuItemObject<string, typeof Menu.label> { + return item.type === Menu.label; +} + +type MenuProps<NameType> = { header?: ReactNode; footer?: ReactNode; - items: Array<T | typeof Menu.line>; - onMenuSelect?: (itemName: T['name']) => void; + items: Array<MenuItem<NameType>>; + onMenuSelect?: (itemName: NameType) => void; style?: CSSProperties; className?: string; - getItemStyle?: (item: T) => CSSProperties; + getItemStyle?: (item: MenuItemObject<NameType>) => CSSProperties; }; -export function Menu<T extends MenuItem>({ +export function Menu<const NameType = string>({ header, footer, items: allItems, @@ -60,7 +71,7 @@ export function Menu<T extends MenuItem>({ style, className, getItemStyle, -}: MenuProps<T>) { +}: MenuProps<NameType>) { const elRef = useRef<HTMLDivElement>(null); const items = allItems.filter(x => x); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); @@ -99,7 +110,7 @@ export function Menu<T extends MenuItem>({ case 'Enter': e.preventDefault(); const item = items[hoveredIndex || 0]; - if (hoveredIndex !== null && item !== Menu.line) { + if (hoveredIndex !== null && item !== Menu.line && !isLabel(item)) { onMenuSelect?.(item.name); } break; @@ -129,7 +140,7 @@ export function Menu<T extends MenuItem>({ <View style={{ borderTop: '1px solid ' + theme.menuBorder }} /> </View> ); - } else if (item.type === Menu.label) { + } else if (isLabel(item)) { return ( <Text key={idx} @@ -152,7 +163,7 @@ export function Menu<T extends MenuItem>({ return ( <View role="button" - key={item.name} + key={String(item.name)} style={{ cursor: 'default', padding: 10, @@ -166,14 +177,18 @@ export function Menu<T extends MenuItem>({ backgroundColor: theme.menuItemBackgroundHover, color: theme.menuItemTextHover, }), - ...getItemStyle?.(item), + ...(!isLabel(item) && getItemStyle?.(item)), }} onPointerEnter={() => setHoveredIndex(idx)} onPointerLeave={() => setHoveredIndex(null)} onPointerUp={e => { e.stopPropagation(); - if (!item.disabled && item.toggle === undefined) { + if ( + !item.disabled && + item.toggle === undefined && + !isLabel(item) + ) { onMenuSelect?.(item.name); } }} @@ -193,17 +208,18 @@ export function Menu<T extends MenuItem>({ </> ) : ( <> - <label htmlFor={item.name} title={item.tooltip}> + <label htmlFor={String(item.name)} title={item.tooltip}> {item.text} </label> <View style={{ flex: 1 }} /> <Toggle - id={item.name} + id={String(item.name)} checked={item.toggle} onColor={theme.pageTextPositive} style={{ marginLeft: 5 }} onToggle={() => !item.disabled && + !isLabel(item) && item.toggle !== undefined && onMenuSelect?.(item.name) } diff --git a/packages/desktop-client/src/components/common/Select.tsx b/packages/desktop-client/src/components/common/Select.tsx index 38a5be455..c2fae346e 100644 --- a/packages/desktop-client/src/components/common/Select.tsx +++ b/packages/desktop-client/src/components/common/Select.tsx @@ -8,15 +8,17 @@ import { Menu } from './Menu'; import { Popover } from './Popover'; import { View } from './View'; -function isValueOption<Value extends string>( - option: [Value, string] | typeof Menu.line, +function isValueOption<Value>( + option: readonly [Value, string] | typeof Menu.line, ): option is [Value, string] { return option !== Menu.line; } -type SelectProps<Value extends string> = { +export type SelectOption<Value = string> = [Value, string] | typeof Menu.line; + +type SelectProps<Value> = { bare?: boolean; - options: Array<[Value, string] | typeof Menu.line>; + options: Array<readonly [Value, string] | typeof Menu.line>; value: Value; defaultLabel?: string; onChange?: (newValue: Value) => void; @@ -38,7 +40,7 @@ type SelectProps<Value extends string> = { * // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="1" onChange={handleOnChange} /> * // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="3" defaultLabel="Select an option" onChange={handleOnChange} /> */ -export function Select<Value extends string>({ +export function Select<const Value = string>({ bare, options, value, diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.tsx b/packages/desktop-client/src/components/reports/ReportSidebar.tsx index 3bf4fdcf3..0424fba5b 100644 --- a/packages/desktop-client/src/components/reports/ReportSidebar.tsx +++ b/packages/desktop-client/src/components/reports/ReportSidebar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState, type ComponentProps } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import * as monthUtils from 'loot-core/src/shared/months'; import { type CategoryEntity } from 'loot-core/types/models/category'; @@ -12,7 +12,7 @@ import { Information } from '../alerts'; import { Button } from '../common/Button2'; import { Menu } from '../common/Menu'; import { Popover } from '../common/Popover'; -import { Select } from '../common/Select'; +import { Select, type SelectOption } from '../common/Select'; import { Text } from '../common/Text'; import { Tooltip } from '../common/Tooltip'; import { View } from '../common/View'; @@ -139,10 +139,9 @@ export function ReportSidebar({ }; const rangeOptions = useMemo(() => { - const options: ComponentProps<typeof Select>['options'] = - ReportOptions.dateRange - .filter(f => f[customReportItems.interval as keyof dateRangeProps]) - .map(option => [option.description, option.description]); + const options: SelectOption[] = ReportOptions.dateRange + .filter(f => f[customReportItems.interval as keyof dateRangeProps]) + .map(option => [option.description, option.description]); // Append separator if necessary if (dateRangeLine > 0) { diff --git a/packages/desktop-client/src/components/reports/SaveReportMenu.tsx b/packages/desktop-client/src/components/reports/SaveReportMenu.tsx index d6eb390f0..ad1409e82 100644 --- a/packages/desktop-client/src/components/reports/SaveReportMenu.tsx +++ b/packages/desktop-client/src/components/reports/SaveReportMenu.tsx @@ -1,6 +1,6 @@ -import React, { type ComponentPropsWithoutRef } from 'react'; +import React from 'react'; -import { Menu } from '../common/Menu'; +import { Menu, type MenuItem } from '../common/Menu'; export function SaveReportMenu({ onMenuSelect, @@ -11,65 +11,55 @@ export function SaveReportMenu({ savedStatus: string; listReports: number; }) { - const savedMenu: ComponentPropsWithoutRef<typeof Menu> = + const savedMenu: MenuItem[] = savedStatus === 'saved' - ? { - items: [ - { name: 'rename-report', text: 'Rename' }, - { name: 'delete-report', text: 'Delete' }, - Menu.line, - ], - } - : { - items: [], - }; + ? [ + { name: 'rename-report', text: 'Rename' }, + { name: 'delete-report', text: 'Delete' }, + Menu.line, + ] + : []; - const modifiedMenu: ComponentPropsWithoutRef<typeof Menu> = + const modifiedMenu: MenuItem[] = savedStatus === 'modified' - ? { - items: [ - { name: 'rename-report', text: 'Rename' }, - { - name: 'update-report', - text: 'Update report', - }, - { - name: 'reload-report', - text: 'Revert changes', - }, - { name: 'delete-report', text: 'Delete' }, - Menu.line, - ], - } - : { - items: [], - }; + ? [ + { name: 'rename-report', text: 'Rename' }, + { + name: 'update-report', + text: 'Update report', + }, + { + name: 'reload-report', + text: 'Revert changes', + }, + { name: 'delete-report', text: 'Delete' }, + Menu.line, + ] + : []; - const unsavedMenu: ComponentPropsWithoutRef<typeof Menu> = { - items: [ - { - name: 'save-report', - text: 'Save new report', - }, - { - name: 'reset-report', - text: 'Reset to default', - }, - Menu.line, - { - name: 'choose-report', - text: 'Choose Report', - disabled: listReports > 0 ? false : true, - }, - ], - }; + const unsavedMenu: MenuItem[] = [ + { + name: 'save-report', + text: 'Save new report', + }, + { + name: 'reset-report', + text: 'Reset to default', + }, + Menu.line, + { + name: 'choose-report', + text: 'Choose Report', + disabled: listReports > 0 ? false : true, + }, + ]; return ( <Menu onMenuSelect={item => { onMenuSelect(item); }} - items={[...savedMenu.items, ...modifiedMenu.items, ...unsavedMenu.items]} + items={[...savedMenu, ...modifiedMenu, ...unsavedMenu]} /> ); } diff --git a/upcoming-release-notes/3391.md b/upcoming-release-notes/3391.md new file mode 100644 index 000000000..a09102eac --- /dev/null +++ b/upcoming-release-notes/3391.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [jfdoming] +--- + +Refine Menu/Select types to allow broader types for the value/name attribute -- GitLab