Skip to content

Commit eed3300

Browse files
authored
feat(frontend): add menu component (#53)
* feat(frontend): add menu component * feat(frontend): add menu snapshot
1 parent 558f0a2 commit eed3300

File tree

8 files changed

+138
-27
lines changed

8 files changed

+138
-27
lines changed

frontend/app/.server/locales/gcweb-en.json

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"language": "English",
3+
"app": {
4+
"menu": "Menu"
5+
},
36
"nav": {
47
"skip-to-content": "Skip to main content",
58
"skip-to-about": "Skip to About this site"

frontend/app/.server/locales/gcweb-fr.json

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"language": "Français",
3+
"app": {
4+
"menu": "Menu"
5+
},
36
"nav": {
47
"skip-to-content": "Passer au contenu principal",
58
"skip-to-about": "Passer à « À propos de ce site »"

frontend/app/components/menu.tsx

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ComponentProps} from "react";
2+
import { useState } from "react"
3+
import { useTranslation } from "react-i18next";
4+
import { cn } from "~/utils/tailwind-utils";
5+
import { InlineLink } from "./inline-link";
6+
7+
type MenuItemProps = ComponentProps<typeof InlineLink>
8+
9+
export function MenuItem({children, ...props}: MenuItemProps) {
10+
return (
11+
<InlineLink
12+
role="menuitem"
13+
id="menu-item"
14+
className="hover:text-blue-950 active:text-white focus:text-blue-400 text-md text-white block px-4 py-2 text-md hover:bg-slate-300 focus:bg-slate-600 active:bg-slate-800 text-md"
15+
{...props}
16+
>
17+
{children}
18+
</InlineLink>
19+
)
20+
}
21+
22+
interface MenuProps {
23+
className?: string;
24+
children: React.ReactNode;
25+
}
26+
27+
export function Menu({ className, children }: MenuProps) {
28+
const { t } = useTranslation(['gcweb']);
29+
const [open, setOpen] = useState(false);
30+
const baseClassName = cn(`${open ? "bg-slate-900 text-white hover:bg-slate-800" : "bg-slate-700 text-white hover:bg-slate-600"} hover:underline text-lg inline-flex justify-center space-x-2 rounded-b-md border-b border-l border-r border-slate-700 px-4 py-2 ring-black ring-opacity-5`);
31+
32+
const onClick = () => {
33+
setOpen((value) => !value)
34+
}
35+
36+
return (
37+
<div className="relative inline-block text-left">
38+
<button
39+
onClick={onClick}
40+
className={cn(baseClassName, className)}
41+
aria-haspopup={true}
42+
aria-expanded={open}
43+
>
44+
<span>{t('gcweb:app.menu')}</span>
45+
{open ? (
46+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
47+
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
48+
</svg>
49+
) : (
50+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6 my-auto">
51+
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
52+
</svg>
53+
)}
54+
</button>
55+
{open && (
56+
<div className="origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg bg-slate-700 ring-1 ring-black ring-opacity-5"
57+
role="menu"
58+
aria-orientation="vertical"
59+
aria-labelledby="menu-button"
60+
tabIndex={-1}
61+
>
62+
<div className="py-1" role="none">
63+
{children}
64+
</div>
65+
</div>
66+
)}
67+
</div>
68+
)
69+
}

frontend/app/routes/protected/index.tsx

+6-12
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { Trans, useTranslation } from 'react-i18next';
55
import type { Route } from './+types/index';
66

77
import { requireAuth } from '~/.server/utils/auth-utils';
8-
import { InlineLink } from '~/components/inline-link';
98
import { getFixedT } from '~/i18n-config.server';
109
import { handle as parentHandle } from '~/routes/protected/layout';
10+
import { Menu, MenuItem } from '~/components/menu';
1111

1212
export const handle = {
1313
i18nNamespace: [...parentHandle.i18nNamespace, 'protected'],
@@ -28,24 +28,18 @@ export default function Index() {
2828

2929
return (
3030
<div className="mb-8">
31+
<Menu>
32+
<MenuItem to="/">{t('protected:index.home')}</MenuItem>
33+
<MenuItem file="routes/protected/admin.tsx">{t('protected:index.admin-dashboard')}</MenuItem>
34+
<MenuItem file="routes/public/index.tsx">{t('protected:index.public')}</MenuItem>
35+
</Menu>
3136
<p className="mt-8 text-lg">
3237
<Trans
3338
i18nKey="protected:index.resources"
3439
components={{ mark: <mark /> }}
3540
values={{ resource: t('protected:resource') }}
3641
/>
3742
</p>
38-
<ul className="ml-8 mt-8 list-disc">
39-
<li>
40-
<InlineLink file="routes/protected/admin.tsx">{t('protected:index.admin-dashboard')}</InlineLink>
41-
</li>
42-
<li>
43-
<InlineLink file="routes/public/index.tsx">{t('protected:index.public')}</InlineLink>
44-
</li>
45-
<li>
46-
<InlineLink to="/">{t('protected:index.home')}</InlineLink>
47-
</li>
48-
</ul>
4943
</div>
5044
);
5145
}

frontend/app/routes/protected/layout.tsx

+11-9
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,21 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
4444
decoding="async"
4545
/>
4646
</AppLink>
47-
<LanguageSwitcher>{t('gcweb:language-switcher.alt-lang')}</LanguageSwitcher>
47+
<div className="text-right">
48+
<LanguageSwitcher>{t('gcweb:language-switcher.alt-lang')}</LanguageSwitcher>
49+
{!!loaderData.name && (
50+
<div className="mt-4 text-right">
51+
<p className="font-bold">{loaderData.name.toString()}</p>
52+
<p>
53+
<InlineLink to="/auth/logout">Logout</InlineLink>
54+
</p>
55+
</div>
56+
)}
57+
</div>
4858
</div>
4959
</div>
5060
</header>
5161
<main className="container">
52-
{!!loaderData.name && (
53-
<div className="mt-4 text-right">
54-
<p>{loaderData.name.toString()}</p>
55-
<p>
56-
<InlineLink to="/auth/logout">Logout</InlineLink>
57-
</p>
58-
</div>
59-
)}
6062
<Outlet />
6163
<PageDetails buildDate={BUILD_DATE} buildVersion={BUILD_VERSION} pageId={pageId} />
6264
</main>

frontend/app/routes/public/index.tsx

+4-6
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next';
44

55
import type { Route } from './+types/index';
66

7-
import { InlineLink } from '~/components/inline-link';
87
import { PageTitle } from '~/components/page-title';
98
import { getFixedT } from '~/i18n-config.server';
109
import { handle as parentHandle } from '~/routes/public/layout';
10+
import { Menu, MenuItem } from '~/components/menu';
1111

1212
export const handle = {
1313
i18nNamespace: [...parentHandle.i18nNamespace, 'public'],
@@ -27,13 +27,11 @@ export default function Index() {
2727

2828
return (
2929
<>
30+
<Menu>
31+
<MenuItem file="routes/protected/index.tsx">{t('public:index.navigate')}</MenuItem>
32+
</Menu>
3033
<PageTitle>{t('public:index.page-title')}</PageTitle>
3134
<p className="mt-8">{t('public:index.about')}</p>
32-
<ul className="ml-8 mt-8 list-disc">
33-
<li>
34-
<InlineLink file="routes/protected/index.tsx">{t('public:index.navigate')}</InlineLink>
35-
</li>
36-
</ul>
3735
</>
3836
);
3937
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`Menu > should correctly render a Menu with a MenuItem when the file property is provided > expected html 1`] = `"<div class="relative inline-block text-left"><button class="bg-slate-700 text-white hover:bg-slate-600 hover:underline text-lg inline-flex justify-center space-x-2 rounded-b-md border-b border-l border-r border-slate-700 px-4 py-2 ring-black ring-opacity-5" aria-haspopup="true" aria-expanded="false"><span>gcweb:app.menu</span><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 my-auto"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path></svg></button></div>"`;
4+
5+
exports[`Menu > should correctly render a Menu with a MenuItem when the to property is provided > expected html 1`] = `"<div class="relative inline-block text-left"><button class="bg-slate-700 text-white hover:bg-slate-600 hover:underline text-lg inline-flex justify-center space-x-2 rounded-b-md border-b border-l border-r border-slate-700 px-4 py-2 ring-black ring-opacity-5" aria-haspopup="true" aria-expanded="false"><span>gcweb:app.menu</span><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 my-auto"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path></svg></button></div>"`;
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { render } from '@testing-library/react';
2+
import { createRoutesStub } from 'react-router';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { Menu, MenuItem } from '~/components/menu';
6+
7+
describe('Menu', () => {
8+
beforeEach(() => {
9+
vi.spyOn(console, 'error').mockImplementation(() => {});
10+
});
11+
12+
it('should correctly render a Menu with a MenuItem when the file property is provided', () => {
13+
const RoutesStub = createRoutesStub([
14+
{
15+
path: '/fr/public',
16+
Component: () => <MenuItem file="routes/public/index.tsx">This is a test</MenuItem>,
17+
},
18+
]);
19+
20+
const { container } = render(<Menu><RoutesStub initialEntries={['/fr/public']} /></Menu>);
21+
22+
expect(container.innerHTML).toMatchSnapshot('expected html');
23+
});
24+
25+
it('should correctly render a Menu with a MenuItem when the to property is provided', () => {
26+
const RoutesStub = createRoutesStub([
27+
{
28+
path: '/fr/public',
29+
Component: () => <MenuItem to="https://example.com/">This is a test</MenuItem>,
30+
},
31+
]);
32+
33+
const { container } = render(<Menu><RoutesStub initialEntries={['/fr/public']} /></Menu>);
34+
35+
expect(container.innerHTML).toMatchSnapshot('expected html');
36+
});
37+
});

0 commit comments

Comments
 (0)