Skip to content

Commit f1e41ca

Browse files
authored
Add navbar component (#8)
* Initial attempt * Stabilize navbar * Reformat styleHelper * Minor touch-ups * Minor touchups * Fix formatting issue * Cache navbar section elements * Attach listener to navbar section and navbar item * Add managed navbar * Remove redundant optional chaining expressions
1 parent 23cd50b commit f1e41ca

File tree

10 files changed

+453
-8
lines changed

10 files changed

+453
-8
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import type { NonEmptyArray } from 'types/utilityTypes';
3+
import Navbar, { NavbarItem, NavbarSection } from './navbar';
4+
5+
export function useManagedNavbar({
6+
brandElement,
7+
leftSections,
8+
rightSections,
9+
onSelectionChanged
10+
}: {
11+
brandElement: React.ReactElement | string,
12+
leftSections: NonEmptyArray<NavbarSection>,
13+
rightSections: NonEmptyArray<NavbarSection>,
14+
onSelectionChanged?: (selection: { section: string, item: string | null } | null) => void
15+
}): [element: React.ReactElement, selection: { section: string, item: string | null } | null] {
16+
const [selected, setSelected] = React.useState<{ section: string, item: string | null } | null>(null);
17+
18+
function onSelection(section: string, item: string | null) {
19+
setSelected({ section, item });
20+
21+
if (onSelectionChanged) {
22+
onSelectionChanged(selected);
23+
}
24+
}
25+
26+
function setFocussed(target: NavbarSection | NavbarItem | null | undefined, value: boolean) {
27+
if (!target) {
28+
return;
29+
}
30+
31+
if (target.styleOptions) {
32+
target.styleOptions.focussed = value;
33+
} else {
34+
target.styleOptions = { focussed: value };
35+
}
36+
}
37+
38+
if (selected) {
39+
const selectedSection = [...leftSections, ...rightSections].find(it => it.name === selected?.section);
40+
const selectedItem = selected?.item ? selectedSection?.items.find(it => it.name === selected?.item) : null;
41+
42+
setFocussed(selectedSection, true);
43+
setFocussed(selectedItem, true);
44+
}
45+
46+
47+
const el = <Navbar brandElement={brandElement}
48+
leftSections={leftSections} rightSections={rightSections}
49+
onSelection={onSelection}
50+
/>;
51+
52+
return [el, selected];
53+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
@use '../style/globals';
2+
3+
$section-group-margin: 1rem;
4+
$section-item-margin: 0.5rem;
5+
$section-margin: 1rem;
6+
7+
nav {
8+
align-items: center;
9+
text-align: center;
10+
11+
position: sticky;
12+
width: 100%;
13+
14+
color: globals.$color-font-secondary;
15+
background-color: globals.$color-secondary;
16+
17+
button {
18+
position: absolute;
19+
top: 0;
20+
bottom: 0;
21+
right: 1.5rem;
22+
23+
width: 1.5rem;
24+
height: 1.5rem;
25+
26+
margin: auto 0;
27+
28+
z-index: 4;
29+
30+
border: none;
31+
color: white;
32+
background-color: unset;
33+
background-image: url('../../static/img/hamburger-icon.svg');
34+
background-position: center;
35+
background-size: contain;
36+
filter: invert(1);
37+
}
38+
39+
.ruler {
40+
height: 0.1rem;
41+
width: 80%;
42+
43+
margin: 0 auto;
44+
45+
background-color: globals.$color-primary;
46+
}
47+
48+
.brand {
49+
position: sticky;
50+
top: 0;
51+
z-index: 3;
52+
53+
box-sizing: border-box;
54+
padding: $section-group-margin 0;
55+
56+
font-size: 2rem;
57+
font-weight: bold;
58+
59+
background-color: globals.$color-secondary;
60+
}
61+
62+
.expansion-card {
63+
position: absolute;
64+
width: 100%;
65+
66+
z-index: 2;
67+
68+
transition: all 1s;
69+
70+
background-color: globals.$color-secondary;
71+
72+
&.collapsed {
73+
transform: translateY(-100%);
74+
}
75+
}
76+
77+
.section-group {
78+
@mixin group() {
79+
padding: ($section-group-margin/ 2) 0;
80+
}
81+
82+
&-primary {
83+
@include group();
84+
}
85+
86+
&-secondary {
87+
@include group();
88+
}
89+
}
90+
91+
.section {
92+
margin: $section-margin 0;
93+
94+
h1 {
95+
margin: 0;
96+
97+
font-size: 1.2rem;
98+
font-weight: bold;
99+
}
100+
101+
ul {
102+
margin: 0;
103+
padding: 0;
104+
105+
li {
106+
margin: $section-item-margin 0;
107+
list-style: none;
108+
}
109+
}
110+
}
111+
112+
.focussed {
113+
text-decoration: underline;
114+
}
115+
}
116+
117+
@media screen and (min-width: 800px) {
118+
nav {
119+
button {
120+
display: none;
121+
}
122+
123+
.ruler {
124+
width: 0.1rem;
125+
height: auto;
126+
127+
align-self: normal;
128+
}
129+
130+
.expansion-card {
131+
display: flex;
132+
flex-direction: row;
133+
align-content: center;
134+
135+
width: unset;
136+
position: relative;
137+
138+
padding-bottom: 1rem;
139+
140+
&.collapsed {
141+
transform: translateY(0);
142+
}
143+
}
144+
145+
.section-group {
146+
display: flex;
147+
flex-direction: row;
148+
align-items: center;
149+
150+
justify-content: center;
151+
152+
margin: 0 $section-group-margin;
153+
154+
&-primary {
155+
flex-grow: 2;
156+
transform: none !important;
157+
}
158+
&-secondary {
159+
flex-grow: 1;
160+
}
161+
}
162+
163+
.section {
164+
ul {
165+
display: flex;
166+
flex-direction: row;
167+
168+
justify-content: center;
169+
170+
li {
171+
margin: 0 (2 * $section-item-margin);
172+
}
173+
}
174+
175+
margin: 0 (2 * $section-margin);
176+
}
177+
}
178+
}
179+
180+
@media screen and (min-width: 1200px) {
181+
nav {
182+
display: flex;
183+
flex-direction: row;
184+
185+
padding: $section-group-margin;
186+
187+
.brand {
188+
padding: 0;
189+
flex-grow: 1;
190+
}
191+
192+
.expansion-card {
193+
padding-bottom: 0;
194+
flex-grow: 3;
195+
}
196+
}
197+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import navbarStyles from './navbar.module.scss';
2+
3+
import * as React from 'react';
4+
import { buildClassNames } from 'components/style/styleHelper';
5+
import { NonEmptyArray } from 'types/utilityTypes';
6+
7+
export interface NavbarSection {
8+
name: string,
9+
content?: React.ReactElement | null,
10+
items: Array<NavbarItem>,
11+
styleOptions?: {
12+
focussed?: boolean
13+
}
14+
}
15+
16+
export interface NavbarItem {
17+
name: string,
18+
content?: React.ReactElement | null
19+
styleOptions?: {
20+
focussed?: boolean
21+
}
22+
}
23+
24+
export default function Navbar(
25+
{
26+
brandElement,
27+
leftSections,
28+
rightSections,
29+
onSelection
30+
}: {
31+
brandElement: React.ReactElement | string,
32+
leftSections: NonEmptyArray<NavbarSection>,
33+
rightSections: NonEmptyArray<NavbarSection>,
34+
onSelection?: (section: string, item: string | null) => void
35+
}
36+
): React.ReactElement {
37+
const [leftSectionElements, rightSectionElements] = React.useMemo(() => {
38+
function buildSelectableElement<E extends HTMLElement>(
39+
tagName: E["tagName"],
40+
customProps: React.HTMLAttributes<E> & React.Attributes,
41+
contentData: Pick<NavbarItem, "name" | "content">,
42+
selectionData: Parameters<NonNullable<typeof onSelection>>,
43+
styleOptions: { focussed: boolean | undefined }
44+
): React.ReactElement {
45+
// Fast path: Element is supposed not to be visible
46+
if (contentData.content === null) {
47+
return <></>;
48+
}
49+
50+
const combinedProps = Object.assign<React.HTMLAttributes<HTMLElement>, React.HTMLAttributes<E>>({
51+
onClick: () => onSelection && onSelection(...selectionData),
52+
className: buildClassNames(navbarStyles, [], [["focussed"]], [styleOptions.focussed ?? false])
53+
}, customProps);
54+
55+
const content = contentData.content ?? contentData.name;
56+
57+
return React.createElement<React.HTMLAttributes<E>, E>(tagName, combinedProps, [content]);
58+
}
59+
60+
function buildSectionElement(section: NavbarSection): React.ReactElement {
61+
const buildSectionHeaderElement = (() => {
62+
return buildSelectableElement<HTMLHeadingElement>(
63+
"h1", {}, section, [section.name, null],
64+
{ focussed: section.styleOptions?.focussed }
65+
);
66+
});
67+
const buildSectionItemElement = ((item: NavbarItem) => {
68+
return buildSelectableElement<HTMLLIElement>("li", {
69+
key: item.name
70+
}, item, [section.name, item.name], { focussed: item.styleOptions?.focussed ?? false });
71+
});
72+
73+
return <div className={navbarStyles["section"]}>
74+
{buildSectionHeaderElement()}
75+
<ul>
76+
{section.items.map(buildSectionItemElement)}
77+
</ul>
78+
</div>;
79+
}
80+
81+
return [
82+
leftSections.map(buildSectionElement),
83+
rightSections.map(buildSectionElement)
84+
];
85+
}, [leftSections, rightSections]);
86+
87+
const [collapsed, setCollapsed] = React.useState<boolean>(true);
88+
89+
function toggleCollapsed() {
90+
setCollapsed(previous => !previous);
91+
}
92+
93+
return <nav>
94+
<button onClick={toggleCollapsed} />
95+
<div className={buildClassNames(navbarStyles, ["section-group", "brand"], [["collapsed"]], [collapsed])}>
96+
{brandElement}
97+
</div>
98+
<div className={navbarStyles["ruler"]} />
99+
<div className={buildClassNames(navbarStyles, ["expansion-card"], [["collapsed"]], [collapsed])}>
100+
<div className={buildClassNames(navbarStyles, ["section-group", "section-group-primary"], [["collapsed"]], [collapsed])}>
101+
{leftSectionElements}
102+
</div>
103+
<div className={navbarStyles["ruler"]} />
104+
<div className={buildClassNames(navbarStyles, ["section-group", "section-group-secondary"], [["collapsed"]], [collapsed])}>
105+
{rightSectionElements}
106+
</div>
107+
</div>
108+
</nav>;
109+
}
Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
$color-primary: darkred;
2-
$color-secondary: red;
1+
$color-primary: red;
2+
$color-secondary: black;
33
$color-font-primary: black;
4-
$color-font-secondary: darkgray;
4+
$color-font-secondary: white;
55

6-
html, body {
6+
html, body, #app {
77
font-size: 11pt;
8+
margin: 0;
9+
padding: 0;
10+
11+
overflow: visible;
812
}

0 commit comments

Comments
 (0)