feat(settings): add searchable navigation

This commit is contained in:
Carlos Valente
2026-08-01 17:09:34 +02:00
committed by Carlos Valente
parent bdd815678b
commit 9e42f18299
11 changed files with 341 additions and 162 deletions
@@ -1,3 +1,13 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs,
ul {
list-style: none;
@@ -6,22 +16,20 @@ ul {
}
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
border-radius: 2px;
&:focus {
&:focus-visible {
background-color: $gray-1000;
outline: 0;
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&:hover {
@@ -32,14 +40,21 @@ ul {
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active {
color: $blue-400;
background-color: $gray-1100;
color: $ui-white;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
}
&.highlight {
@@ -62,6 +77,9 @@ ul {
font-size: $inner-section-text-size;
&.active {
color: $blue-400;
color: $ui-white;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
}
}
@@ -1,10 +1,18 @@
import { Fragment } from 'react';
import { Fragment, useState } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import * as Panel from '../panel-utils/PanelUtils';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss';
@@ -16,23 +24,57 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
const firstResult = results[0];
if (!firstResult) {
return null;
}
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
return firstResult.id as SettingsOptionId;
}
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const results = filterSettingsOptions(options, query);
const handleSearchSubmit = () => {
const target = getFirstResultId(results, query);
if (target) {
setLocation(target);
setQuery('');
}
};
return (
<ul className={style.tabs}>
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
<div className={style.container}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
{results.length === 0 ? (
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
) : (
<ul className={style.tabs}>
{results.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
);
}
@@ -44,7 +86,15 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation();
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
const hasSelectedChild = Boolean(
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
);
const classes = cx([
style.primary,
isSelected && !hasSelectedChild && style.active,
hasSelectedChild && style.groupActive,
panel.highlight && style.highlight,
]);
return (
<Fragment key={panel.id}>
@@ -75,6 +125,7 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
}
}}
className={secondaryClasses}
tabIndex={0}
role='button'
>
{secondary.label}
@@ -0,0 +1,24 @@
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0.25rem 1rem 0.25rem 0.25rem;
}
.icon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.input {
padding-left: 2rem;
padding-right: 2rem;
}
.clear {
position: absolute;
right: 0.25rem;
}
@@ -0,0 +1,60 @@
import { KeyboardEvent, useRef } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import style from './SettingsSearch.module.scss';
interface SettingsSearchProps {
query: string;
onQueryChange: (query: string) => void;
onSubmit: () => void;
}
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
const searchRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// Do not let the settings panel close while the user is clearing a search.
event.stopPropagation();
onQueryChange('');
return;
}
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<div className={style.search}>
<IoSearch className={style.icon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder='Search settings'
className={style.input}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.clear}
onClick={() => {
onQueryChange('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
);
}