Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-settings-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
sable: minor
---

Add a search bar to the settings sidebar.
60 changes: 35 additions & 25 deletions src/app/components/SettingsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type SettingsShellProps<Id extends string> = {
renderHeader?: (closeButton: ReactNode) => ReactNode;
/** Render custom footer below the menu (inside the PageNav column). */
footer?: ReactNode;
searchBar?: ReactNode;
searchResults?: ReactNode;
/** Optional wrapper rendered around the entire component (e.g. SwipeableOverlayWrapper). */
wrapper?: (children: ReactNode) => ReactNode;
/** Wrap the section viewport content (e.g. SettingsLinkProvider). Receives the viewport element. */
Expand Down Expand Up @@ -66,6 +68,8 @@ export function SettingsShell<Id extends string>({
requestClose,
renderHeader,
footer,
searchBar,
searchResults,
wrapper,
renderSection,
showCloseInHeader,
Expand Down Expand Up @@ -104,35 +108,41 @@ export function SettingsShell<Id extends string>({
)}
</PageNavHeader>
<Box grow="Yes" direction="Column">
{searchBar && (
<Box style={{ padding: config.space.S200 }} shrink="No">
{searchBar}
</Box>
)}
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{visibleIds.map((id) => {
const section = sections[id];
const isActive = active === id;
const IconComponent =
isActive && section.activeIcon ? section.activeIcon : section.icon;
{searchResults ??
visibleIds.map((id) => {
const section = sections[id];
const isActive = active === id;
const IconComponent =
isActive && section.activeIcon ? section.activeIcon : section.icon;

return (
<MenuItem
key={id}
variant="Background"
radii="400"
aria-pressed={isActive}
before={settingsNavIcon(IconComponent, isActive)}
onClick={() => onSelect(id)}
>
<Text
style={{
fontWeight: isActive ? config.fontWeight.W600 : undefined,
}}
size={menuItemTextSize}
truncate
return (
<MenuItem
key={id}
variant="Background"
radii="400"
aria-pressed={isActive}
before={settingsNavIcon(IconComponent, isActive)}
onClick={() => onSelect(id)}
>
{section.label}
</Text>
</MenuItem>
);
})}
<Text
style={{
fontWeight: isActive ? config.fontWeight.W600 : undefined,
}}
size={menuItemTextSize}
truncate
>
{section.label}
</Text>
</MenuItem>
);
})}
</div>
</PageNavContent>
{footer}
Expand Down
59 changes: 57 additions & 2 deletions src/app/features/settings/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { ComponentType, ReactNode } from 'react';
import { DesktopIcon, PersonSimpleCircleIcon, type IconProps } from '@phosphor-icons/react';
import { Avatar, Box, Button, config, Text } from 'folds';
import { Avatar, Box, Button, config, Input, MenuItem, Text } from 'folds';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useUserProfile } from '$hooks/useUserProfile';
import { useMatrixClient } from '$hooks/useMatrixClient';
Expand All @@ -20,9 +20,11 @@ import {
GearSix,
Info,
Keyboard,
MagnifyingGlass,
menuIcon,
SignOut,
Palette,
sizedIcon,
Smiley,
Terminal,
User,
Expand All @@ -41,6 +43,7 @@ import { KeyboardShortcuts } from './keyboard-shortcuts';
import { Notifications } from './notifications';
import { PerMessageProfilePage } from './Persona/ProfilesPage';
import { settingsSections, type SettingsSectionId } from './routes';
import { searchSettings, type SettingsSearchEntry } from './settingsSearch';
import { useSettingsFocus } from './useSettingsFocus';
import { SettingsLinkProvider } from './SettingsLinkContext';
import { useSettingsLinkBaseUrl } from './useSettingsLinkBaseUrl';
Expand Down Expand Up @@ -146,6 +149,7 @@ const settingsSectionComponents = {
type ControlledSettingsProps = {
activeSection?: SettingsSectionId | null;
onSelectSection?: (section: SettingsSectionId) => void;
onSelectSetting?: (section: SettingsSectionId, focus: string) => void;
onBack?: () => void;
requestClose: () => void;
initialPage?: SettingsPages;
Expand All @@ -165,6 +169,7 @@ function SettingsSectionProvider({ children, section, baseUrl }: SectionWrapperP
export function Settings({
activeSection,
onSelectSection,
onSelectSetting,
onBack,
requestClose,
initialPage,
Expand Down Expand Up @@ -273,6 +278,16 @@ export function Settings({
[showPersona, isDesktop]
);

const [searchQuery, setSearchQuery] = useState('');
const searchResults = useMemo(() => searchSettings(searchQuery), [searchQuery]);
const isSearching = searchQuery.trim().length > 0;
const menuItemTextSize = screenSize === ScreenSize.Mobile ? 'T400' : 'T300';

const handleSearchResultSelect = (entry: SettingsSearchEntry) => {
setSearchQuery('');
onSelectSetting?.(entry.section, entry.focusId);
};

const renderHeader = useMemo(
() =>
(closeButton: ReactNode): ReactNode => (
Expand Down Expand Up @@ -305,8 +320,48 @@ export function Settings({
requestClose={handleRequestClose}
renderHeader={renderHeader}
showCloseInHeader={visibleSection === null}
menuItemTextSize={screenSize === ScreenSize.Mobile ? 'T400' : 'T300'}
menuItemTextSize={menuItemTextSize}
closeButtonAriaLabel="Close settings"
searchBar={
<Input
variant="SurfaceVariant"
size="400"
placeholder="Search settings"
maxLength={50}
value={searchQuery}
onChange={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
before={sizedIcon(MagnifyingGlass, '50')}
style={{ width: '100%' }}
/>
}
searchResults={
isSearching ? (
searchResults.length > 0 ? (
searchResults.map((entry) => (
<MenuItem
key={`${entry.section}-${entry.focusId}`}
variant="Background"
radii="400"
before={sizedIcon(MagnifyingGlass, '50')}
onClick={() => handleSearchResultSelect(entry)}
>
<Box direction="Column">
<Text size={menuItemTextSize} truncate>
{entry.label}
</Text>
<Text size="T200" truncate>
{entry.sectionLabel}
</Text>
</Box>
</MenuItem>
))
) : (
<Box style={{ padding: config.space.S300 }} alignItems="Center" justifyContent="Center">
<Text size="T300">No results found</Text>
</Box>
)
) : undefined
}
renderSection={(viewport) =>
visibleSection ? (
<SettingsSectionProvider section={visibleSection} baseUrl={settingsLinkBaseUrl}>
Expand Down
8 changes: 8 additions & 0 deletions src/app/features/settings/SettingsRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,19 @@ export function SettingsRoute({ routeSection }: SettingsRouteProps) {
});
};

const handleSelectSetting = (nextSection: SettingsSectionId, focus: string) => {
navigate(getSettingsPath(nextSection, focus), {
replace: shallowBackgroundState,
state: location.state,
});
};

return (
<Settings
activeSection={activeSection}
onBack={requestBack}
onSelectSection={handleSelectSection}
onSelectSetting={handleSelectSetting}
requestClose={requestClose}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/settings/settingsLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ export const buildSettingsLink = (
focus?: string
): string => withOriginBaseUrl(baseUrl, withSettingsLinkAction(getSettingsPath(section, focus)));

const humanizeSettingsLinkPart = (value: string): string =>
export const humanizeSettingsLinkPart = (value: string): string =>
value
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
Expand Down
30 changes: 30 additions & 0 deletions src/app/features/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { settingsSections, type SettingsSectionId } from './routes';
import { humanizeSettingsLinkPart, settingsLinkFocusIdsBySection } from './settingsLink';

export type SettingsSearchEntry = {
section: SettingsSectionId;
focusId: string;
label: string;
sectionLabel: string;
};

const settingsSectionLabel = Object.fromEntries(
settingsSections.map((section) => [section.id, section.label])
) as Record<SettingsSectionId, string>;

const searchIndex: SettingsSearchEntry[] = settingsSections.flatMap((section) =>
settingsLinkFocusIdsBySection[section.id].map((focusId) => ({
section: section.id,
focusId,
label: humanizeSettingsLinkPart(focusId),
sectionLabel: settingsSectionLabel[section.id],
}))
);

export const searchSettings = (query: string): SettingsSearchEntry[] => {
const q = query.trim().toLowerCase();
if (!q) return [];
return searchIndex.filter(
(entry) => entry.label.toLowerCase().includes(q) || entry.sectionLabel.toLowerCase().includes(q)
);
};
Loading