feat: Replace settings sidebar with dropdown on mobile (#117)

## Summary

- On mobile, the settings page navigation sidebar was taking up most of
the screen, leaving little room for the actual content.
- Replaced the sidebar with a compact `<Select>` dropdown on mobile
(below `lg` breakpoint), showing all nav items grouped by section.
- The desktop sidebar remains completely unchanged.

## Screenshots

### Mobile - Dropdown (closed)
![Mobile dropdown
closed](https://raw.githubusercontent.com/whisper-money/whisper-money/b564639/settings-mobile-dropdown.png)

### Mobile - Dropdown (open)
![Mobile dropdown
open](https://raw.githubusercontent.com/whisper-money/whisper-money/b564639/settings-mobile-dropdown-open.png)

### Desktop - Sidebar (unchanged)
![Desktop
sidebar](https://raw.githubusercontent.com/whisper-money/whisper-money/b564639/settings-desktop-sidebar.png)

## Test plan

- [x] Verify the dropdown appears on mobile viewport and navigates
correctly when selecting items
- [x] Verify the sidebar is hidden on mobile and shown on desktop
- [x] Verify desktop sidebar behavior is unchanged
- [x] Verify dark mode works correctly for the dropdown
This commit is contained in:
Víctor Falcón 2026-02-12 21:26:48 +01:00 committed by GitHub
parent d1ba18932e
commit b69138df60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 102 additions and 10 deletions

View File

@ -4,6 +4,16 @@ import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Setting
import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { edit as editAccount } from '@/routes/account';
@ -17,7 +27,7 @@ import {
type NavItem,
} from '@/types';
import { __ } from '@/utils/i18n';
import { Link, usePage } from '@inertiajs/react';
import { Link, router, usePage } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';
const getNavItems = (
@ -103,6 +113,68 @@ const getNavItems = (
: []),
];
function renderMobileNavGroups(
items: (NavItem | NavSectionHeader | NavDivider)[],
) {
const elements: React.ReactNode[] = [];
let currentGroupLabel: string | null = null;
let currentGroupItems: NavItem[] = [];
const flushGroup = () => {
if (currentGroupItems.length === 0) {
return;
}
const groupKey = currentGroupLabel ?? 'default';
if (currentGroupLabel) {
elements.push(
<SelectGroup key={groupKey}>
<SelectLabel>{__(currentGroupLabel)}</SelectLabel>
{currentGroupItems.map((item) => (
<SelectItem
key={resolveUrl(item.href)}
value={resolveUrl(item.href)}
>
{__(item.title)}
</SelectItem>
))}
</SelectGroup>,
);
} else {
elements.push(
...currentGroupItems.map((item) => (
<SelectItem
key={resolveUrl(item.href)}
value={resolveUrl(item.href)}
>
{__(item.title)}
</SelectItem>
)),
);
}
currentGroupItems = [];
};
for (const item of items) {
if (item.type === 'divider') {
flushGroup();
elements.push(<SelectSeparator key={`sep-${elements.length}`} />);
currentGroupLabel = null;
} else if (item.type === 'section-header') {
flushGroup();
currentGroupLabel = item.title;
} else {
currentGroupItems.push(item);
}
}
flushGroup();
return elements;
}
export default function SettingsLayout({ children }: PropsWithChildren) {
const { subscriptionsEnabled, auth, features } =
usePage<SharedData>().props;
@ -121,6 +193,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
openBankingEnabled,
);
const activeNavItem = sidebarNavItems.find(
(item): item is NavItem =>
item.type === 'nav-item' && isSameUrl(currentPath, item.href),
);
return (
<div className="px-4 py-6">
<Heading
@ -129,20 +206,37 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
/>
<div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
{/* Mobile: dropdown select */}
<div className="lg:hidden">
<Select
value={
activeNavItem
? resolveUrl(activeNavItem.href)
: undefined
}
onValueChange={(value) => router.visit(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={__('Navigate to...')} />
</SelectTrigger>
<SelectContent>
{renderMobileNavGroups(sidebarNavItems)}
</SelectContent>
</Select>
</div>
{/* Desktop: sidebar nav */}
<aside className="hidden w-48 lg:block">
<nav className="flex flex-col space-y-1 space-x-0">
{sidebarNavItems.map((item, index) => {
if ('type' in item && item.type === 'divider') {
if (item.type === 'divider') {
return (
<Separator
key={`divider-${index}`}
className="my-2 ml-0 opacity-0"
/>
);
} else if (
'type' in item &&
item.type === 'section-header'
) {
} else if (item.type === 'section-header') {
return (
<h2
key={`section-header-${index}`}
@ -178,10 +272,8 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
</nav>
</aside>
<Separator className="my-6 lg:hidden" />
<div className="flex-1 md:max-w-4xl">
<section className="max-w-3xl space-y-12">
<section className="mt-6 max-w-3xl space-y-12 lg:mt-0">
{children}
</section>
</div>