Skip to content

Commit e806701

Browse files
committed
Add home link functionality to Navbar component
- Introduced a `home` prop to the Navbar component, allowing for a customizable home link that wraps the logo and title. - Added `renderHomeLink` prop for custom rendering of the home link. - Updated documentation in AGENTS.md and related files to reflect the new home link feature. - Enhanced tests to verify the rendering and functionality of the home link in various scenarios.
1 parent 4e448af commit e806701

7 files changed

Lines changed: 191 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pplethai/components": patch
3+
---
4+
5+
Add Navbar home navigation: logo and title share one clickable brand link with customizable `home` target and `renderHomeLink` (mirrors menu `renderLink` for routers). Pass `home={false}` to disable.

AGENTS.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,16 @@ function AppLayout() {
225225
title="ระบบดีไซน์"
226226
items={items}
227227
pathname={pathname}
228+
renderHomeLink={({ home, className, children, onNavigate }) => (
229+
<NavLink
230+
to={home.href}
231+
end={home.end}
232+
onClick={onNavigate}
233+
className={({ isActive }) => className(isActive)}
234+
>
235+
{children}
236+
</NavLink>
237+
)}
228238
renderLink={({ item, className, onNavigate }) => (
229239
<NavLink
230240
to={item.href}
@@ -252,10 +262,12 @@ function AppLayout() {
252262

253263
| Prop | Type | Required | Notes |
254264
|---|---|---|---|
255-
| `title` | `string` || Shown beside logo |
265+
| `title` | `string` || Shown beside logo (clickable with logo when home link is enabled) |
256266
| `items` | `NavbarItem[]` || `{ href, label, end? }` |
267+
| `home` | `NavbarHome \| false` || Logo + title link; default `{ href: "/", end: true }`; `false` disables |
257268
| `pathname` | `string` || Drives active state + closes mobile menu on nav |
258-
| `renderLink` | `(props: NavbarLinkRenderProps) => ReactNode` || Custom link renderer (router) |
269+
| `renderHomeLink` | `(props: NavbarHomeLinkRenderProps) => ReactNode` || Custom home/brand link renderer (router) |
270+
| `renderLink` | `(props: NavbarLinkRenderProps) => ReactNode` || Custom menu link renderer (router) |
259271
| `logo` | `ReactNode` || Defaults to `<Logo size="sm" className="text-primary" />` |
260272
| `mobileMenuAriaLabel` | `{ open: string; close: string }` || Defaults to Thai labels |
261273
| `navAriaLabel` | `string` || Defaults to `"เมนูหลัก"` |
@@ -1379,7 +1391,8 @@ type GapVariants, type ContainerVariants, type GradientToken
13791391
// Icon & branding
13801392
Icon, iconVariants, type IconProps
13811393
Logo, logoVariants, type LogoProps
1382-
Navbar, navLinkClassName, type NavbarItem, type NavbarLinkRenderProps, type NavbarProps
1394+
Navbar, navLinkClassName, type NavbarHome, type NavbarHomeLinkRenderProps,
1395+
type NavbarItem, type NavbarLinkRenderProps, type NavbarProps
13831396
13841397
// Layout
13851398
Stack, type StackProps

apps/docs/src/components/Layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export function Layout() {
3333
title="ระบบดีไซน์ พรรคประชาชน"
3434
items={navItems}
3535
pathname={pathname}
36+
renderHomeLink={({ home, className, children, onNavigate }) => (
37+
<NavLink
38+
to={home.href}
39+
className={({ isActive }) => className(isActive)}
40+
end={home.end}
41+
onClick={onNavigate}
42+
>
43+
{children}
44+
</NavLink>
45+
)}
3646
renderLink={({ item, className, onNavigate }) => (
3747
<NavLink
3848
to={item.href}

apps/docs/src/pages/components/navbar.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export default function NavbarPage() {
2020
title="ระบบดีไซน์ตัวอย่าง"
2121
items={demoItems}
2222
pathname="/components"
23+
renderHomeLink={({ home, className, children, onNavigate }) => (
24+
<NavLink
25+
to={home.href}
26+
className={({ isActive }) => className(isActive)}
27+
end={home.end}
28+
onClick={onNavigate}
29+
>
30+
{children}
31+
</NavLink>
32+
)}
2333
renderLink={({ item, className, onNavigate }) => (
2434
<NavLink
2535
to={item.href}
@@ -48,6 +58,16 @@ function MyLayout() {
4858
title="ระบบดีไซน์"
4959
items={navItems}
5060
pathname={pathname}
61+
renderHomeLink={({ home, className, children, onNavigate }) => (
62+
<NavLink
63+
to={home.href}
64+
className={({ isActive }) => className(isActive)}
65+
end={home.end}
66+
onClick={onNavigate}
67+
>
68+
{children}
69+
</NavLink>
70+
)}
5171
renderLink={({ item, className, onNavigate }) => (
5272
<NavLink
5373
to={item.href}
@@ -85,9 +105,20 @@ function MyLayout() {
85105
},
86106
]}
87107
props={[
88-
{ prop: "title", type: "string", required: true, description: "ชื่อระบบที่แสดงข้างโลโก้" },
108+
{ prop: "title", type: "string", required: true, description: "ชื่อระบบที่แสดงข้างโลโก้ (คลิกได้ร่วมกับโลโก้เมื่อเปิด home link)" },
89109
{ prop: "items", type: "NavbarItem[]", required: true, description: "รายการลิงก์ในเมนู" },
110+
{
111+
prop: "home",
112+
type: "NavbarHome | false",
113+
default: '{ href: "/", end: true }',
114+
description: "ลิงก์หน้าแรกสำหรับโลโก้+ชื่อ — ส่ง false เพื่อปิด",
115+
},
90116
{ prop: "pathname", type: "string", default: '""', description: "ใช้ระบุ active state และปิดเมนูมือถือเมื่อย้าย route" },
117+
{
118+
prop: "renderHomeLink",
119+
type: "(props: NavbarHomeLinkRenderProps) => ReactNode",
120+
description: "custom renderer สำหรับโลโก้+ชื่อ (เช่น NavLink) — default เป็น <a href>",
121+
},
91122
{
92123
prop: "renderLink",
93124
type: "(props: NavbarLinkRenderProps) => ReactNode",
@@ -114,6 +145,13 @@ function MyLayout() {
114145
},
115146
]}
116147
extraPropTables={[
148+
{
149+
title: "NavbarHome",
150+
rows: [
151+
{ prop: "href", type: "string", required: true, description: "URL หน้าแรก" },
152+
{ prop: "end", type: "boolean", default: "false", description: "เปิดเฉพาะเมื่อ pathname ตรงเป๊ะ (default ของ home คือ true)" },
153+
],
154+
},
117155
{
118156
title: "NavbarItem",
119157
rows: [

packages/components/src/components.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,47 @@ describe("@pplethai/components", () => {
8787
expect(screen.getByRole("link", { name: /tokens/i })).toHaveAttribute("aria-current", "page");
8888
});
8989

90+
it("renders Navbar home link around logo and title", () => {
91+
render(
92+
<Navbar
93+
title="Design system"
94+
items={[{ href: "/tokens", label: "Tokens" }]}
95+
pathname="/tokens"
96+
/>,
97+
);
98+
const homeLink = screen.getByRole("link", { name: /design system/i });
99+
expect(homeLink).toHaveAttribute("href", "/");
100+
expect(homeLink).toContainElement(screen.getByRole("heading", { name: /design system/i }));
101+
});
102+
103+
it("supports custom Navbar home href and renderer", () => {
104+
render(
105+
<Navbar
106+
title="App"
107+
home={{ href: "/dashboard", end: true }}
108+
items={[]}
109+
pathname="/dashboard"
110+
renderHomeLink={({ home, className: linkClassName, children, onNavigate }) => (
111+
<a
112+
href={home.href}
113+
className={linkClassName(true)}
114+
onClick={onNavigate}
115+
data-testid="home"
116+
>
117+
{children}
118+
</a>
119+
)}
120+
/>,
121+
);
122+
expect(screen.getByTestId("home")).toHaveAttribute("href", "/dashboard");
123+
});
124+
125+
it("can disable Navbar home link", () => {
126+
render(<Navbar title="App" home={false} items={[]} pathname="/" />);
127+
expect(screen.queryByRole("link", { name: /app/i })).not.toBeInTheDocument();
128+
expect(screen.getByRole("heading", { name: /app/i })).toBeInTheDocument();
129+
});
130+
90131
it("renders Spinner as indeterminate by default", () => {
91132
render(<Spinner data-testid="spinner" />);
92133
const spinner = screen.getByTestId("spinner");

packages/components/src/components/navbar.tsx

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ export type NavbarItem = {
3636
end?: boolean;
3737
};
3838

39-
function isNavbarItemActive(item: NavbarItem, pathname: string): boolean {
39+
export type NavbarHome = {
40+
href: string;
41+
/** When true, only mark active on exact href match (e.g. home "/"). */
42+
end?: boolean;
43+
};
44+
45+
function isNavbarItemActive(item: Pick<NavbarItem, "href" | "end">, pathname: string): boolean {
4046
if (item.end) {
4147
return pathname === item.href;
4248
}
@@ -52,13 +58,27 @@ export interface NavbarLinkRenderProps {
5258
onNavigate: () => void;
5359
}
5460

61+
export interface NavbarHomeLinkRenderProps {
62+
home: NavbarHome;
63+
className: (isActive: boolean) => string;
64+
children: React.ReactNode;
65+
onNavigate: () => void;
66+
}
67+
5568
export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
5669
title: string;
5770
items: NavbarItem[];
71+
/**
72+
* Logo + title link target. Defaults to `{ href: "/", end: true }`.
73+
* Pass `false` to render a non-interactive brand area.
74+
*/
75+
home?: NavbarHome | false;
5876
/** Current path for default anchor links and closing the mobile menu on navigation. */
5977
pathname?: string;
6078
/** Custom link renderer (e.g. React Router `NavLink`). Defaults to `<a href>`. */
6179
renderLink?: (props: NavbarLinkRenderProps) => React.ReactNode;
80+
/** Custom home/brand link renderer. Defaults to `<a href>`. */
81+
renderHomeLink?: (props: NavbarHomeLinkRenderProps) => React.ReactNode;
6282
logo?: React.ReactNode;
6383
mobileMenuAriaLabel?: { open: string; close: string };
6484
navAriaLabel?: string;
@@ -70,11 +90,23 @@ export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
7090
variant?: NavbarVariant;
7191
}
7292

93+
function homeLinkClassName(isLight: boolean) {
94+
return cn(
95+
"inline-flex min-w-0 max-w-full items-center gap-1 rounded-md outline-none transition-opacity md:gap-2",
96+
"focus-visible:ring-2 focus-visible:ring-offset-2",
97+
isLight
98+
? "text-foreground hover:opacity-80 focus-visible:ring-ring focus-visible:ring-offset-background"
99+
: "text-secondary-foreground hover:opacity-90 focus-visible:ring-white/50 focus-visible:ring-offset-transparent",
100+
);
101+
}
102+
73103
export function Navbar({
74104
title,
75105
items,
106+
home: homeProp,
76107
pathname = "",
77108
renderLink,
109+
renderHomeLink,
78110
logo = <Logo size="sm" className="shrink-0 text-primary" />,
79111
mobileMenuAriaLabel = { open: "เปิดเมนู", close: "ปิดเมนู" },
80112
navAriaLabel = "เมนูหลัก",
@@ -112,6 +144,50 @@ export function Navbar({
112144

113145
const linkRenderer = renderLink ?? defaultRenderLink;
114146

147+
const resolvedHome: NavbarHome | false = homeProp === false ? false : (homeProp ?? { href: "/", end: true });
148+
149+
const brand = (
150+
<>
151+
{logo}
152+
<h1 className="min-w-0 truncate font-heading text-base font-medium md:text-xl">{title}</h1>
153+
</>
154+
);
155+
156+
const defaultRenderHomeLink = ({
157+
home,
158+
className: homeClassName,
159+
children,
160+
onNavigate,
161+
}: NavbarHomeLinkRenderProps) => {
162+
const active = isNavbarItemActive(home, pathname);
163+
return (
164+
<a
165+
href={home.href}
166+
className={homeClassName(active)}
167+
onClick={onNavigate}
168+
aria-current={active ? "page" : undefined}
169+
>
170+
{children}
171+
</a>
172+
);
173+
};
174+
175+
const homeLinkRenderer = renderHomeLink ?? defaultRenderHomeLink;
176+
177+
const brandBlock =
178+
resolvedHome === false ? (
179+
<Stack gap="xs" className="min-w-0 flex-row items-center md:gap-2">
180+
{brand}
181+
</Stack>
182+
) : (
183+
homeLinkRenderer({
184+
home: resolvedHome,
185+
className: () => homeLinkClassName(isLight),
186+
children: brand,
187+
onNavigate: closeMenu,
188+
})
189+
);
190+
115191
const renderNavItem = (item: NavbarItem, mobile: boolean) =>
116192
linkRenderer({
117193
item,
@@ -135,12 +211,7 @@ export function Navbar({
135211
className={isLight ? "max-md:py-2 md:py-4" : "max-md:py-4 md:py-6"}
136212
>
137213
<Inline justify="between" align="center" className="w-full">
138-
<Stack gap="xs" className="min-w-0 flex-row items-center md:gap-2">
139-
{logo}
140-
<div className="min-w-0">
141-
<h1 className="truncate font-heading text-base font-medium md:text-xl">{title}</h1>
142-
</div>
143-
</Stack>
214+
{brandBlock}
144215
<button
145216
type="button"
146217
className={cn(

packages/components/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export {
1515
Navbar,
1616
getNavbarVariant,
1717
isInMiniAppUserAgent,
18+
type NavbarHome,
19+
type NavbarHomeLinkRenderProps,
1820
type NavbarItem,
1921
type NavbarLinkRenderProps,
2022
type NavbarProps,

0 commit comments

Comments
 (0)