mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
* [AI] Switch typecheck from tsc to tsgo and fix Menu type narrowing * [autofix.ci] apply automated fixes * Add .gitignore for dist directory, update typecheck script in package.json to use -b flag, and remove noEmit option from tsconfig.json files in ci-actions and desktop-electron packages. Introduce typesVersions in loot-core package.json for improved type handling. * Refactor SelectedTransactionsButton to improve type safety and readability. Updated items prop to use spread operator for conditional rendering of menu items, ensuring proper type annotations with MenuItem. This change enhances the clarity of the component's structure and maintains TypeScript compliance. * Update tsconfig.json in desktop-electron package to maintain consistent formatting for plugins section. No functional changes made. * [autofix.ci] apply automated fixes * Update package.json and yarn.lock to add TypeScript 5.8.0 dependency. Adjust typesVersions in loot-core package.json for improved type handling. Enhance tsconfig.json in sync-server package to enable strictFunctionTypes for better type safety. * Enhance tsconfig.json in ci-actions package by adding composite option for improved project references and build performance. * [AI] Revert typescript to 5.9.3 for ts-node compatibility Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com> * [AI] Update yarn.lock after TypeScript version change Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com> * Refactor Menu component for improved type safety and readability. Updated type assertions for Menu.line and Menu.label, simplified type checks in filtering and selection logic, and enhanced conditional rendering of menu items. This change ensures better TypeScript compliance and maintains clarity in the component's structure. * Refactor Select and OpenIdForm components to improve type safety and simplify logic. Updated item mapping to handle Menu.line more effectively, enhancing clarity in selection processes. Adjusted SelectedTransactionsButton to streamline item creation and improve readability. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
264 lines
6.9 KiB
TypeScript
264 lines
6.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import type {
|
|
ComponentProps,
|
|
ComponentType,
|
|
CSSProperties,
|
|
KeyboardEvent,
|
|
ReactNode,
|
|
SVGProps,
|
|
} from 'react';
|
|
|
|
import { Button } from './Button';
|
|
import { Text } from './Text';
|
|
import { theme } from './theme';
|
|
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 as typeof MenuLine;
|
|
Menu.label = MenuLabel as typeof MenuLabel;
|
|
|
|
type KeybindingProps = {
|
|
keyName: ReactNode;
|
|
};
|
|
|
|
function Keybinding({ keyName }: KeybindingProps) {
|
|
return (
|
|
<Text style={{ fontSize: 10, color: theme.menuKeybindingText }}>
|
|
{keyName}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
export type MenuItemObject<NameType, Type extends string | symbol = string> = {
|
|
type?: Type;
|
|
name: NameType;
|
|
disabled?: boolean;
|
|
icon?: ComponentType<SVGProps<SVGSVGElement>>;
|
|
iconSize?: number;
|
|
text: string;
|
|
key?: string;
|
|
toggle?: boolean;
|
|
tooltip?: string;
|
|
};
|
|
|
|
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<MenuItem<NameType>>;
|
|
onMenuSelect?: (itemName: NameType) => void;
|
|
style?: CSSProperties;
|
|
className?: string;
|
|
getItemStyle?: (item: MenuItemObject<NameType>) => CSSProperties;
|
|
slot?: ComponentProps<typeof Button>['slot'];
|
|
};
|
|
|
|
export function Menu<const NameType = string>({
|
|
header,
|
|
footer,
|
|
items: allItems,
|
|
onMenuSelect,
|
|
style,
|
|
className,
|
|
getItemStyle,
|
|
slot,
|
|
}: MenuProps<NameType>) {
|
|
const elRef = useRef<HTMLDivElement>(null);
|
|
const items = allItems.filter(x => x);
|
|
const filteredItems = items.filter(
|
|
item => item && item !== Menu.line && item.type !== Menu.label,
|
|
);
|
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
const currentIndex = filteredItems.indexOf(items[hoveredIndex || 0]);
|
|
const transformIndex = (idx: number) => items.indexOf(filteredItems[idx]);
|
|
|
|
function hoverPrevious() {
|
|
setHoveredIndex(
|
|
hoveredIndex === null ? 0 : transformIndex(Math.max(currentIndex - 1, 0)),
|
|
);
|
|
}
|
|
|
|
function hoverNext() {
|
|
setHoveredIndex(
|
|
hoveredIndex === null
|
|
? 0
|
|
: transformIndex(Math.min(currentIndex + 1, filteredItems.length - 1)),
|
|
);
|
|
}
|
|
|
|
function selectItem() {
|
|
const item = items[hoveredIndex || 0];
|
|
if (
|
|
hoveredIndex !== null &&
|
|
item !== Menu.line &&
|
|
!isLabel(item) &&
|
|
!item.disabled
|
|
) {
|
|
onMenuSelect?.(item.name);
|
|
}
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
switch (e.key) {
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
hoverPrevious();
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
hoverNext();
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
selectItem();
|
|
break;
|
|
default:
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const activeElement = document.activeElement;
|
|
|
|
if (
|
|
activeElement &&
|
|
(['input', 'select', 'textarea'].includes(
|
|
activeElement.tagName.toLowerCase(),
|
|
) ||
|
|
activeElement.hasAttribute('contenteditable') ||
|
|
activeElement.getAttribute('role') === 'textbox')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const el = elRef.current;
|
|
el?.focus();
|
|
}, []);
|
|
|
|
return (
|
|
<View
|
|
className={className}
|
|
style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }}
|
|
tabIndex={0}
|
|
onKeyDown={onKeyDown}
|
|
innerRef={elRef}
|
|
>
|
|
{header}
|
|
{items.map((item, idx) => {
|
|
if (item === Menu.line) {
|
|
return (
|
|
<View key={idx} style={{ margin: '3px 0px' }}>
|
|
<View style={{ borderTop: '1px solid ' + theme.menuBorder }} />
|
|
</View>
|
|
);
|
|
} else if (isLabel(item)) {
|
|
return (
|
|
<Text
|
|
key={idx}
|
|
style={{
|
|
color: theme.menuItemTextHeader,
|
|
fontSize: 11,
|
|
lineHeight: '1em',
|
|
textTransform: 'uppercase',
|
|
margin: '3px 9px',
|
|
marginTop: 5,
|
|
}}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const Icon = item.icon;
|
|
|
|
return (
|
|
<Button
|
|
excludeFromTabOrder
|
|
key={String(item.name)}
|
|
variant="bare"
|
|
slot={slot}
|
|
style={{
|
|
cursor: 'default',
|
|
padding: 10,
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
color: theme.menuItemText,
|
|
...(item.disabled && { color: theme.buttonBareDisabledText }),
|
|
...(!item.disabled &&
|
|
hoveredIndex === idx && {
|
|
backgroundColor: theme.menuItemBackgroundHover,
|
|
color: theme.menuItemTextHover,
|
|
}),
|
|
...(!isLabel(item) && getItemStyle?.(item)),
|
|
}}
|
|
onHoverStart={() => setHoveredIndex(idx)}
|
|
onHoverEnd={() => setHoveredIndex(null)}
|
|
onPress={() => {
|
|
if (
|
|
!item.disabled &&
|
|
item.toggle === undefined &&
|
|
!isLabel(item)
|
|
) {
|
|
onMenuSelect?.(item.name);
|
|
}
|
|
}}
|
|
>
|
|
{/* Force it to line up evenly */}
|
|
{item.toggle === undefined ? (
|
|
<>
|
|
{Icon && (
|
|
<Icon
|
|
width={item.iconSize || 10}
|
|
height={item.iconSize || 10}
|
|
style={{ marginRight: 7, width: item.iconSize || 10 }}
|
|
/>
|
|
)}
|
|
<Text title={item.tooltip}>{item.text}</Text>
|
|
<View style={{ flex: 1 }} />
|
|
</>
|
|
) : (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
flex: 1,
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<label htmlFor={String(item.name)} title={item.tooltip}>
|
|
{item.text}
|
|
</label>
|
|
<Toggle
|
|
id={String(item.name)}
|
|
isOn={item.toggle}
|
|
style={{ marginLeft: 5 }}
|
|
onToggle={() =>
|
|
!item.disabled &&
|
|
!isLabel(item) &&
|
|
item.toggle !== undefined &&
|
|
onMenuSelect?.(item.name)
|
|
}
|
|
/>
|
|
</View>
|
|
)}
|
|
{item.key && <Keybinding keyName={item.key} />}
|
|
</Button>
|
|
);
|
|
})}
|
|
{footer}
|
|
</View>
|
|
);
|
|
}
|