mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
* 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>
266 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|