import { AppType, MetadataBySection, SettingMetadataSection, SettingSectionSource } from '@joplin/lib/models/Setting'; import * as React from 'react'; import Setting from '@joplin/lib/models/Setting'; import { _ } from '@joplin/lib/locale'; import { useCallback, useMemo, useRef } from 'react'; import { focus } from '@joplin/lib/utils/focusHandler'; import SearchInput, { OnChangeEvent } from '../lib/SearchInput/SearchInput'; import { normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text'; import { type SearchResultGroup } from './configSearch'; import highlightSearchText from './searchHighlight'; interface SectionChangeEvent { section: SettingMetadataSection; } interface Props { selection: string; onSelectionChange: (event: SectionChangeEvent)=> void; sections: MetadataBySection; searchQuery: string; onSearchQueryChange: (event: OnChangeEvent)=> void; onSearchButtonClick: ()=> void; searchResultGroups: SearchResultGroup[]; } export default function Sidebar(props: Props) { const buttonRefs = useRef([]); const isSearching = !!normalizeQuery(props.searchQuery); const matchedSectionNames = useMemo(() => { return new Set(props.searchResultGroups.map(group => group.sectionName)); }, [props.searchResultGroups]); // Making a tabbed region accessible involves supporting keyboard interaction. // See https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ for details const onKeyDown: React.KeyboardEventHandler = useCallback((event) => { const selectedIndex = props.sections.findIndex(section => section.name === props.selection); let newIndex = selectedIndex; // Determine navigation direction let isMovingUp = false; if (event.code === 'ArrowUp') { newIndex --; isMovingUp = true; } else if (event.code === 'ArrowDown') { newIndex ++; isMovingUp = false; } else if (event.code === 'Home') { newIndex = 0; isMovingUp = false; } else if (event.code === 'End') { newIndex = props.sections.length - 1; isMovingUp = true; } if (newIndex < 0) newIndex += props.sections.length; newIndex %= props.sections.length; // Skip disabled (no-match) sections during search if (isSearching) { const initialIndex = newIndex; while (!matchedSectionNames.has(props.sections[newIndex].name)) { if (isMovingUp) { newIndex--; if (newIndex < 0) newIndex += props.sections.length; } else { newIndex++; newIndex %= props.sections.length; } // Prevent infinite loop if no matched sections if (newIndex === initialIndex) break; } if (!matchedSectionNames.has(props.sections[newIndex].name)) return; } if (newIndex !== selectedIndex) { event.preventDefault(); props.onSelectionChange({ section: props.sections[newIndex] }); const targetButton = buttonRefs.current[newIndex]; if (targetButton) { focus('Sidebar', targetButton); } } }, [props.sections, props.selection, props.onSelectionChange, matchedSectionNames, isSearching]); const buttons: React.ReactNode[] = []; function renderButton(section: SettingMetadataSection, index: number) { const selected = props.selection === section.name; const hasMatch = matchedSectionNames.has(section.name); const isDisabled = isSearching && !hasMatch; const isActiveTab = selected && !isDisabled; const classNames = ['item']; if (Setting.isSubSection(section.name)) classNames.push('sub'); if (isActiveTab) classNames.push('selected'); if (isDisabled) classNames.push('disabled'); return (