Files
actual/packages/component-library/src/Menu.tsx
Matiss Janis Aboltins 65dee4c627 lint: a11y issue fixes (#6679)
* Fix accessibility issues: use semantic HTML and correct tab indices

- Replace View with semantic h1 in ModalHeader
- Fix tabIndex from 1 to 0 in Menu component
- Remove disabled oxlint accessibility rules
- Update components to use proper semantic HTML elements

* Refactor button elements to semantic HTML in Autocomplete and CategoryAutocomplete components

- Replace button elements with div/View while maintaining role="button" for accessibility.
- Update styles and props accordingly to ensure consistent behavior.
- Adjust onClick types in Item and SecondaryItem components for better type safety.

* Add release notes for upcoming maintenance updates addressing various accessibility issues

* Refactor autocomplete components to improve text alignment and button semantics

- Added textAlign: 'left' style to AccountItem and PayeeItem for consistent text alignment.
- Removed type="button" from CategoryItem to streamline button semantics.
- Updated ItemContent to use the Button component instead of a button element, enhancing accessibility and consistency.

* Refactor budget and report components to improve text alignment

- Removed font: 'inherit' style from EnvelopeBudgetComponents and TrackingBudgetComponents for cleaner styling.
- Updated ActionableGridListItem and ReportCard components to replace font: 'inherit' with textAlign: 'left' for consistent text alignment.

* Update ActionableGridListItem to include font inheritance for improved styling consistency

* Refactor button elements to use the Button component for consistency and improved semantics

- Updated various components (EnvelopeBudgetComponents, IncomeCategoryMonth, CategoryMonth, ActionableGridListItem, ReportCard, DesktopLinkedNotes) to replace native button elements with the custom Button component.
- Adjusted styles and event handlers to align with the new Button implementation, ensuring consistent behavior and accessibility across the application.

* Update Button and ActionableGridListItem styles for consistency

- Set a fixed borderRadius of 4 for the Button component, ensuring uniformity across variants.
- Adjusted ActionableGridListItem to have a borderRadius of 0 for a cleaner design.

* Update CategoryAutocomplete to include button type attribute for improved semantics

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6679

* Update VRT screenshot for Payees search functionality

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-19 18:19:13 +00:00

266 lines
6.9 KiB
TypeScript

import {
useEffect,
useRef,
useState,
type ComponentProps,
type ComponentType,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
type 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;
Menu.label = 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>
);
}