mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-06 23:09:08 +00:00
feat(settings): add searchable navigation
This commit is contained in:
committed by
Carlos Valente
parent
bdd815678b
commit
9e42f18299
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user