Skip to content

Commit 548467e

Browse files
committed
feat(home): update HomeTopNav layout and improve accessibility
1 parent 44791a0 commit 548467e

1 file changed

Lines changed: 163 additions & 126 deletions

File tree

apps/console/src/pages/home/HomeTopNav.tsx

Lines changed: 163 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
/**
22
* HomeTopNav
33
*
4-
* Top navigation bar for the Home (workspace) landing page.
4+
* Single-row top navigation bar for the Home (workspace) landing page.
55
*
6-
* Rationale: The Home page deliberately uses a horizontal top-menu layout
7-
* (instead of the left sidebar used by individual applications) so users can
8-
* visually distinguish the workspace landing page from in-app routes. The
9-
* left sidebar is reserved for `/apps/:appName/*` where rich, contextual
10-
* navigation is needed.
6+
* [Logo + Product] [Global search (⌘K)]
7+
* [Activity | Help] [Theme | Locale] [Workspace | User]
118
*
12-
* Layout:
13-
* - Left: Workspace switcher (organization) + "Home" link
14-
* - Right: Search (⌘K), Activity feed, Help, Theme, Locale, User profile
9+
* The page title (rendered by `HomePage`) and any primary page actions live
10+
* in the page body, keeping the chrome compact. See
11+
* {@link ./HomeLayout.tsx} for composition.
1512
*
1613
* @module
1714
*/
@@ -29,8 +26,17 @@ import {
2926
DropdownMenuLabel,
3027
DropdownMenuSeparator,
3128
DropdownMenuGroup,
29+
Separator,
3230
} from '@object-ui/components';
33-
import { Home, Search, HelpCircle, LogOut, Settings, User as UserIcon, ChevronsUpDown } from 'lucide-react';
31+
import {
32+
Search,
33+
HelpCircle,
34+
LogOut,
35+
Settings,
36+
User as UserIcon,
37+
ChevronsUpDown,
38+
Boxes,
39+
} from 'lucide-react';
3440
import { useAuth, getUserInitials } from '@object-ui/auth';
3541
import { useObjectTranslation } from '@object-ui/i18n';
3642
import { WorkspaceSwitcher } from '../../components/WorkspaceSwitcher';
@@ -43,11 +49,7 @@ interface HomeTopNavProps {
4349
activities?: ActivityItem[];
4450
}
4551

46-
/**
47-
* Opens the global command palette via the same keyboard shortcut the
48-
* `CommandPalette` component listens for. This keeps the search UX
49-
* consistent with the in-app `AppHeader`.
50-
*/
52+
/** Opens the global command palette via the ⌘K/CtrlK shortcut it listens for. */
5153
function openCommandPalette() {
5254
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
5355
}
@@ -59,139 +61,174 @@ export function HomeTopNav({ activities }: HomeTopNavProps) {
5961

6062
return (
6163
<header
62-
className="sticky top-0 z-40 flex h-14 sm:h-16 w-full shrink-0 items-center gap-2 border-b bg-background px-3 sm:px-4 md:px-6"
64+
className="sticky top-0 z-40 w-full shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 shadow-[0_1px_0_rgba(0,0,0,0.04)]"
6365
data-testid="home-top-nav"
6466
>
65-
{/* Left: Workspace switcher + primary Home link */}
66-
<div className="flex items-center gap-2 min-w-0">
67-
<div className="w-56 max-w-[14rem] shrink-0">
68-
<WorkspaceSwitcher
69-
onWorkspaceChange={() => {
70-
// Reload so adapter / permissions re-initialize with the new
71-
// tenant context, matching the sidebar's prior behavior.
72-
window.location.href = `${window.location.origin}/console/home`;
73-
}}
74-
/>
75-
</div>
76-
77-
<nav className="hidden md:flex items-center gap-1 pl-1" aria-label={t('home.nav', { defaultValue: 'Home' })}>
78-
<Link
79-
to="/home"
80-
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-foreground bg-accent/50 hover:bg-accent transition-colors"
81-
data-testid="home-nav-home"
82-
>
83-
<Home className="h-4 w-4" />
84-
<span>{t('home.nav', { defaultValue: 'Home' })}</span>
85-
</Link>
86-
</nav>
87-
</div>
67+
{/* ──────────────────────────── Row 1: Global ──────────────────────────── */}
68+
<div className="flex h-12 w-full items-center gap-2 px-3 sm:px-4 md:px-6">
69+
{/* Brand */}
70+
<Link
71+
to="/home"
72+
className="flex items-center gap-2 shrink-0 rounded-md px-1.5 py-1 hover:bg-accent/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
73+
data-testid="home-brand"
74+
aria-label="ObjectStack"
75+
>
76+
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
77+
<Boxes className="h-4 w-4" />
78+
</div>
79+
<span className="hidden sm:inline text-sm font-semibold tracking-tight">
80+
ObjectStack
81+
</span>
82+
</Link>
8883

89-
{/* Right: actions */}
90-
<div className="ml-auto flex items-center gap-0.5 sm:gap-1 md:gap-2 shrink-0">
91-
{/* Search — Desktop */}
84+
{/* Search */}
9285
<button
9386
onClick={openCommandPalette}
94-
className="hidden lg:flex relative items-center gap-2 w-50 xl:w-70 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors"
87+
className="hidden lg:flex relative items-center gap-2 ml-auto w-72 xl:w-96 h-8 px-3 text-sm rounded-md border bg-muted/40 text-muted-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
9588
data-testid="home-search-trigger"
9689
>
97-
<Search className="h-4 w-4" />
98-
<span className="flex-1 text-left">{t('console.search', { defaultValue: 'Search...' })}</span>
99-
<kbd className="pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">
90+
<Search className="h-4 w-4 shrink-0" />
91+
<span className="flex-1 text-left truncate">
92+
{t('console.searchRich', {
93+
defaultValue: 'Search apps, objects, records…',
94+
})}
95+
</span>
96+
<kbd className="pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground">
10097
<span className="text-xs"></span>K
10198
</kbd>
10299
</button>
103100

104-
{/* Search — Mobile / Tablet */}
105-
<Button
106-
variant="ghost"
107-
size="icon"
108-
className="lg:hidden h-8 w-8 shrink-0"
109-
onClick={openCommandPalette}
110-
aria-label={t('console.search', { defaultValue: 'Search...' })}
111-
>
112-
<Search className="h-4 w-4" />
113-
</Button>
101+
{/* Trailing icon cluster */}
102+
<div className="ml-auto lg:ml-2 flex items-center gap-0.5 sm:gap-1 shrink-0">
103+
{/* Search — mobile/tablet */}
104+
<Button
105+
variant="ghost"
106+
size="icon"
107+
className="lg:hidden h-8 w-8 shrink-0"
108+
onClick={openCommandPalette}
109+
aria-label={t('console.search', { defaultValue: 'Search...' })}
110+
>
111+
<Search className="h-4 w-4" />
112+
</Button>
114113

115-
{/* Activity feed */}
116-
<div className="hidden sm:flex shrink-0 relative">
117-
<ActivityFeed activities={activities ?? []} />
118-
</div>
114+
{/* Activity */}
115+
<div className="hidden sm:flex shrink-0 relative">
116+
<ActivityFeed activities={activities ?? []} />
117+
</div>
119118

120-
{/* Help */}
121-
<Button
122-
variant="ghost"
123-
size="icon"
124-
asChild
125-
className="h-8 w-8 hidden md:inline-flex shrink-0"
126-
aria-label={t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' })}
127-
>
128-
<a href="https://docs.objectstack.ai" target="_blank" rel="noopener noreferrer">
129-
<HelpCircle className="h-4 w-4" />
130-
</a>
131-
</Button>
119+
{/* Help */}
120+
<Button
121+
variant="ghost"
122+
size="icon"
123+
asChild
124+
className="h-8 w-8 hidden md:inline-flex shrink-0"
125+
aria-label={t('sidebar.helpTooltip', {
126+
defaultValue: 'Help & Documentation',
127+
})}
128+
>
129+
<a
130+
href="https://docs.objectstack.ai"
131+
target="_blank"
132+
rel="noopener noreferrer"
133+
>
134+
<HelpCircle className="h-4 w-4" />
135+
</a>
136+
</Button>
132137

133-
{/* Theme toggle */}
134-
<div className="hidden sm:flex shrink-0">
135-
<ModeToggle />
136-
</div>
138+
<Separator
139+
orientation="vertical"
140+
className="hidden sm:block mx-0.5 h-5"
141+
/>
137142

138-
{/* Language switcher */}
139-
<div className="hidden sm:flex shrink-0">
140-
<LocaleSwitcher />
141-
</div>
143+
{/* Theme + Locale */}
144+
<div className="hidden sm:flex shrink-0">
145+
<ModeToggle />
146+
</div>
147+
<div className="hidden sm:flex shrink-0">
148+
<LocaleSwitcher />
149+
</div>
142150

143-
{/* User profile dropdown */}
144-
<DropdownMenu>
145-
<DropdownMenuTrigger asChild>
146-
<button
147-
className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
148-
data-testid="home-user-menu-trigger"
149-
>
150-
<Avatar className="h-8 w-8 rounded-lg">
151-
<AvatarImage src={user?.image ?? '/avatars/user.jpg'} alt={user?.name ?? 'User'} />
152-
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
153-
{getUserInitials(user)}
154-
</AvatarFallback>
155-
</Avatar>
156-
<ChevronsUpDown className="hidden md:block h-4 w-4 text-muted-foreground" />
157-
</button>
158-
</DropdownMenuTrigger>
159-
<DropdownMenuContent align="end" sideOffset={4} className="w-56">
160-
<DropdownMenuLabel className="p-0 font-normal">
161-
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
151+
<Separator
152+
orientation="vertical"
153+
className="hidden sm:block mx-0.5 h-5"
154+
/>
155+
156+
{/* Workspace switcher — self-sizing; collapses when it renders null */}
157+
<div className="hidden md:flex shrink-0 min-w-0 max-w-52">
158+
<WorkspaceSwitcher
159+
onWorkspaceChange={() => {
160+
// Reload so adapter / permissions re-initialize with the new
161+
// tenant context, matching the sidebar's prior behavior.
162+
window.location.href = `${window.location.origin}/console/home`;
163+
}}
164+
/>
165+
</div>
166+
167+
{/* User profile */}
168+
<DropdownMenu>
169+
<DropdownMenuTrigger asChild>
170+
<button
171+
className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
172+
data-testid="home-user-menu-trigger"
173+
>
162174
<Avatar className="h-8 w-8 rounded-lg">
163-
<AvatarImage src={user?.image ?? '/avatars/user.jpg'} alt={user?.name ?? 'User'} />
164-
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground">
175+
<AvatarImage
176+
src={user?.image ?? '/avatars/user.jpg'}
177+
alt={user?.name ?? 'User'}
178+
/>
179+
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
165180
{getUserInitials(user)}
166181
</AvatarFallback>
167182
</Avatar>
168-
<div className="grid flex-1 text-left text-sm leading-tight">
169-
<span className="truncate font-semibold">{user?.name ?? 'User'}</span>
170-
<span className="truncate text-xs text-muted-foreground">{user?.email ?? ''}</span>
183+
<ChevronsUpDown className="hidden md:block h-4 w-4 text-muted-foreground" />
184+
</button>
185+
</DropdownMenuTrigger>
186+
<DropdownMenuContent align="end" sideOffset={4} className="w-56">
187+
<DropdownMenuLabel className="p-0 font-normal">
188+
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
189+
<Avatar className="h-8 w-8 rounded-lg">
190+
<AvatarImage
191+
src={user?.image ?? '/avatars/user.jpg'}
192+
alt={user?.name ?? 'User'}
193+
/>
194+
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground">
195+
{getUserInitials(user)}
196+
</AvatarFallback>
197+
</Avatar>
198+
<div className="grid flex-1 text-left text-sm leading-tight">
199+
<span className="truncate font-semibold">
200+
{user?.name ?? 'User'}
201+
</span>
202+
<span className="truncate text-xs text-muted-foreground">
203+
{user?.email ?? ''}
204+
</span>
205+
</div>
171206
</div>
172-
</div>
173-
</DropdownMenuLabel>
174-
<DropdownMenuSeparator />
175-
<DropdownMenuGroup>
176-
<DropdownMenuItem onClick={() => navigate('/apps/setup/system/profile')}>
177-
<UserIcon className="mr-2 h-4 w-4" />
178-
{t('user.profile', { defaultValue: 'Profile' })}
207+
</DropdownMenuLabel>
208+
<DropdownMenuSeparator />
209+
<DropdownMenuGroup>
210+
<DropdownMenuItem
211+
onClick={() => navigate('/apps/setup/system/profile')}
212+
>
213+
<UserIcon className="mr-2 h-4 w-4" />
214+
{t('user.profile', { defaultValue: 'Profile' })}
215+
</DropdownMenuItem>
216+
<DropdownMenuItem onClick={() => navigate('/apps/setup')}>
217+
<Settings className="mr-2 h-4 w-4" />
218+
{t('sidebar.settings', { defaultValue: 'Settings' })}
219+
</DropdownMenuItem>
220+
</DropdownMenuGroup>
221+
<DropdownMenuSeparator />
222+
<DropdownMenuItem
223+
className="text-destructive focus:text-destructive"
224+
onClick={() => signOut()}
225+
>
226+
<LogOut className="mr-2 h-4 w-4" />
227+
{t('user.logout', { defaultValue: 'Log out' })}
179228
</DropdownMenuItem>
180-
<DropdownMenuItem onClick={() => navigate('/apps/setup')}>
181-
<Settings className="mr-2 h-4 w-4" />
182-
{t('sidebar.settings', { defaultValue: 'Settings' })}
183-
</DropdownMenuItem>
184-
</DropdownMenuGroup>
185-
<DropdownMenuSeparator />
186-
<DropdownMenuItem
187-
className="text-destructive focus:text-destructive"
188-
onClick={() => signOut()}
189-
>
190-
<LogOut className="mr-2 h-4 w-4" />
191-
{t('user.logout', { defaultValue: 'Log out' })}
192-
</DropdownMenuItem>
193-
</DropdownMenuContent>
194-
</DropdownMenu>
229+
</DropdownMenuContent>
230+
</DropdownMenu>
231+
</div>
195232
</div>
196233
</header>
197234
);

0 commit comments

Comments
 (0)