Skip to content
Snippets Groups Projects
Unverified Commit 62dbe3ac authored by Julian Dominguez-Schatz's avatar Julian Dominguez-Schatz Committed by GitHub
Browse files

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
parent bbff5437
No related branches found
No related tags found
No related merge requests found
import { import {
type ReactElement,
type ReactNode, type ReactNode,
useEffect, useEffect,
useRef, useRef,
...@@ -15,8 +14,9 @@ import { Toggle } from './Toggle'; ...@@ -15,8 +14,9 @@ import { Toggle } from './Toggle';
import { View } from './View'; import { View } from './View';
const MenuLine: unique symbol = Symbol('menu-line'); const MenuLine: unique symbol = Symbol('menu-line');
const MenuLabel: unique symbol = Symbol('menu-label');
Menu.line = MenuLine; Menu.line = MenuLine;
Menu.label = Symbol('menu-label'); Menu.label = MenuLabel;
type KeybindingProps = { type KeybindingProps = {
keyName: ReactNode; keyName: ReactNode;
...@@ -30,9 +30,9 @@ function Keybinding({ keyName }: KeybindingProps) { ...@@ -30,9 +30,9 @@ function Keybinding({ keyName }: KeybindingProps) {
); );
} }
export type MenuItem = { type MenuItemObject<NameType, Type extends string | symbol = string> = {
type?: string | symbol; type?: Type;
name: string; name: NameType;
disabled?: boolean; disabled?: boolean;
icon?: ComponentType<SVGProps<SVGSVGElement>>; icon?: ComponentType<SVGProps<SVGSVGElement>>;
iconSize?: number; iconSize?: number;
...@@ -42,17 +42,28 @@ export type MenuItem = { ...@@ -42,17 +42,28 @@ export type MenuItem = {
tooltip?: string; 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; header?: ReactNode;
footer?: ReactNode; footer?: ReactNode;
items: Array<T | typeof Menu.line>; items: Array<MenuItem<NameType>>;
onMenuSelect?: (itemName: T['name']) => void; onMenuSelect?: (itemName: NameType) => void;
style?: CSSProperties; style?: CSSProperties;
className?: string; className?: string;
getItemStyle?: (item: T) => CSSProperties; getItemStyle?: (item: MenuItemObject<NameType>) => CSSProperties;
}; };
export function Menu<T extends MenuItem>({ export function Menu<const NameType = string>({
header, header,
footer, footer,
items: allItems, items: allItems,
...@@ -60,7 +71,7 @@ export function Menu<T extends MenuItem>({ ...@@ -60,7 +71,7 @@ export function Menu<T extends MenuItem>({
style, style,
className, className,
getItemStyle, getItemStyle,
}: MenuProps<T>) { }: MenuProps<NameType>) {
const elRef = useRef<HTMLDivElement>(null); const elRef = useRef<HTMLDivElement>(null);
const items = allItems.filter(x => x); const items = allItems.filter(x => x);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
...@@ -99,7 +110,7 @@ export function Menu<T extends MenuItem>({ ...@@ -99,7 +110,7 @@ export function Menu<T extends MenuItem>({
case 'Enter': case 'Enter':
e.preventDefault(); e.preventDefault();
const item = items[hoveredIndex || 0]; const item = items[hoveredIndex || 0];
if (hoveredIndex !== null && item !== Menu.line) { if (hoveredIndex !== null && item !== Menu.line && !isLabel(item)) {
onMenuSelect?.(item.name); onMenuSelect?.(item.name);
} }
break; break;
...@@ -129,7 +140,7 @@ export function Menu<T extends MenuItem>({ ...@@ -129,7 +140,7 @@ export function Menu<T extends MenuItem>({
<View style={{ borderTop: '1px solid ' + theme.menuBorder }} /> <View style={{ borderTop: '1px solid ' + theme.menuBorder }} />
</View> </View>
); );
} else if (item.type === Menu.label) { } else if (isLabel(item)) {
return ( return (
<Text <Text
key={idx} key={idx}
...@@ -152,7 +163,7 @@ export function Menu<T extends MenuItem>({ ...@@ -152,7 +163,7 @@ export function Menu<T extends MenuItem>({
return ( return (
<View <View
role="button" role="button"
key={item.name} key={String(item.name)}
style={{ style={{
cursor: 'default', cursor: 'default',
padding: 10, padding: 10,
...@@ -166,14 +177,18 @@ export function Menu<T extends MenuItem>({ ...@@ -166,14 +177,18 @@ export function Menu<T extends MenuItem>({
backgroundColor: theme.menuItemBackgroundHover, backgroundColor: theme.menuItemBackgroundHover,
color: theme.menuItemTextHover, color: theme.menuItemTextHover,
}), }),
...getItemStyle?.(item), ...(!isLabel(item) && getItemStyle?.(item)),
}} }}
onPointerEnter={() => setHoveredIndex(idx)} onPointerEnter={() => setHoveredIndex(idx)}
onPointerLeave={() => setHoveredIndex(null)} onPointerLeave={() => setHoveredIndex(null)}
onPointerUp={e => { onPointerUp={e => {
e.stopPropagation(); e.stopPropagation();
if (!item.disabled && item.toggle === undefined) { if (
!item.disabled &&
item.toggle === undefined &&
!isLabel(item)
) {
onMenuSelect?.(item.name); onMenuSelect?.(item.name);
} }
}} }}
...@@ -193,17 +208,18 @@ export function Menu<T extends MenuItem>({ ...@@ -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} {item.text}
</label> </label>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
<Toggle <Toggle
id={item.name} id={String(item.name)}
checked={item.toggle} checked={item.toggle}
onColor={theme.pageTextPositive} onColor={theme.pageTextPositive}
style={{ marginLeft: 5 }} style={{ marginLeft: 5 }}
onToggle={() => onToggle={() =>
!item.disabled && !item.disabled &&
!isLabel(item) &&
item.toggle !== undefined && item.toggle !== undefined &&
onMenuSelect?.(item.name) onMenuSelect?.(item.name)
} }
......
...@@ -8,15 +8,17 @@ import { Menu } from './Menu'; ...@@ -8,15 +8,17 @@ import { Menu } from './Menu';
import { Popover } from './Popover'; import { Popover } from './Popover';
import { View } from './View'; import { View } from './View';
function isValueOption<Value extends string>( function isValueOption<Value>(
option: [Value, string] | typeof Menu.line, option: readonly [Value, string] | typeof Menu.line,
): option is [Value, string] { ): option is [Value, string] {
return option !== Menu.line; return option !== Menu.line;
} }
type SelectProps<Value extends string> = { export type SelectOption<Value = string> = [Value, string] | typeof Menu.line;
type SelectProps<Value> = {
bare?: boolean; bare?: boolean;
options: Array<[Value, string] | typeof Menu.line>; options: Array<readonly [Value, string] | typeof Menu.line>;
value: Value; value: Value;
defaultLabel?: string; defaultLabel?: string;
onChange?: (newValue: Value) => void; onChange?: (newValue: Value) => void;
...@@ -38,7 +40,7 @@ type SelectProps<Value extends string> = { ...@@ -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="1" onChange={handleOnChange} />
* // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="3" defaultLabel="Select an option" 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, bare,
options, options,
value, value,
......
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 * as monthUtils from 'loot-core/src/shared/months';
import { type CategoryEntity } from 'loot-core/types/models/category'; import { type CategoryEntity } from 'loot-core/types/models/category';
...@@ -12,7 +12,7 @@ import { Information } from '../alerts'; ...@@ -12,7 +12,7 @@ import { Information } from '../alerts';
import { Button } from '../common/Button2'; import { Button } from '../common/Button2';
import { Menu } from '../common/Menu'; import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover'; import { Popover } from '../common/Popover';
import { Select } from '../common/Select'; import { Select, type SelectOption } from '../common/Select';
import { Text } from '../common/Text'; import { Text } from '../common/Text';
import { Tooltip } from '../common/Tooltip'; import { Tooltip } from '../common/Tooltip';
import { View } from '../common/View'; import { View } from '../common/View';
...@@ -139,10 +139,9 @@ export function ReportSidebar({ ...@@ -139,10 +139,9 @@ export function ReportSidebar({
}; };
const rangeOptions = useMemo(() => { const rangeOptions = useMemo(() => {
const options: ComponentProps<typeof Select>['options'] = const options: SelectOption[] = ReportOptions.dateRange
ReportOptions.dateRange .filter(f => f[customReportItems.interval as keyof dateRangeProps])
.filter(f => f[customReportItems.interval as keyof dateRangeProps]) .map(option => [option.description, option.description]);
.map(option => [option.description, option.description]);
// Append separator if necessary // Append separator if necessary
if (dateRangeLine > 0) { if (dateRangeLine > 0) {
......
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({ export function SaveReportMenu({
onMenuSelect, onMenuSelect,
...@@ -11,65 +11,55 @@ export function SaveReportMenu({ ...@@ -11,65 +11,55 @@ export function SaveReportMenu({
savedStatus: string; savedStatus: string;
listReports: number; listReports: number;
}) { }) {
const savedMenu: ComponentPropsWithoutRef<typeof Menu> = const savedMenu: MenuItem[] =
savedStatus === 'saved' savedStatus === 'saved'
? { ? [
items: [ { name: 'rename-report', text: 'Rename' },
{ name: 'rename-report', text: 'Rename' }, { name: 'delete-report', text: 'Delete' },
{ name: 'delete-report', text: 'Delete' }, Menu.line,
Menu.line, ]
], : [];
}
: {
items: [],
};
const modifiedMenu: ComponentPropsWithoutRef<typeof Menu> = const modifiedMenu: MenuItem[] =
savedStatus === 'modified' savedStatus === 'modified'
? { ? [
items: [ { name: 'rename-report', text: 'Rename' },
{ name: 'rename-report', text: 'Rename' }, {
{ name: 'update-report',
name: 'update-report', text: 'Update report',
text: 'Update report', },
}, {
{ name: 'reload-report',
name: 'reload-report', text: 'Revert changes',
text: 'Revert changes', },
}, { name: 'delete-report', text: 'Delete' },
{ name: 'delete-report', text: 'Delete' }, Menu.line,
Menu.line, ]
], : [];
}
: {
items: [],
};
const unsavedMenu: ComponentPropsWithoutRef<typeof Menu> = { const unsavedMenu: MenuItem[] = [
items: [ {
{ name: 'save-report',
name: 'save-report', text: 'Save new report',
text: 'Save new report', },
}, {
{ name: 'reset-report',
name: 'reset-report', text: 'Reset to default',
text: 'Reset to default', },
}, Menu.line,
Menu.line, {
{ name: 'choose-report',
name: 'choose-report', text: 'Choose Report',
text: 'Choose Report', disabled: listReports > 0 ? false : true,
disabled: listReports > 0 ? false : true, },
}, ];
],
};
return ( return (
<Menu <Menu
onMenuSelect={item => { onMenuSelect={item => {
onMenuSelect(item); onMenuSelect(item);
}} }}
items={[...savedMenu.items, ...modifiedMenu.items, ...unsavedMenu.items]} items={[...savedMenu, ...modifiedMenu, ...unsavedMenu]}
/> />
); );
} }
---
category: Maintenance
authors: [jfdoming]
---
Refine Menu/Select types to allow broader types for the value/name attribute
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment