Skip to content

Commit e5bd006

Browse files
committed
nav: mobile FAB overlay with full keyboard and ARIA support
Replaces the checkbox-toggle navigation with a fixed FAB overlay on small screens. Includes focus trap, Escape/blur-to-close, inert on closed links, and conditional dialog ARIA gated to mobile via a --breakpoint CSS variable monitored by ResizeObserver. Desktop nav is unchanged. Also adds accessibility labels to nav links and home page section icons, aria-live on the footer easter egg, last-visited travel on the home page, and a reusable breakpoint utility module.
1 parent 5e4f64d commit e5bd006

16 files changed

Lines changed: 418 additions & 166 deletions

File tree

Justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ serve: thumbs js
2525
trap 'kill $(jobs -p) 2>/dev/null' EXIT
2626
node _build.mjs --watch &
2727
watchexec -w assets/gallery --exts jpg -- ./_scripts/generate-gallery-thumbnails.sh &
28-
bundle exec jekyll serve
28+
bundle exec jekyll serve --livereload --host 0.0.0.0
2929
3030
# Create a new note: just new "Title" [slug]
3131
new title slug="":

_data/navigation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
label: Projects
88
path: /projects
99
- tag: notes
10-
icon: files
10+
icon: newspaper
1111
label: Notes
1212
path: /notes
1313
- tag: gallery

_includes/footer.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<footer class="wrapper">
22
<div class="links">
3-
&copy; 2016 - {{ 'now' | date: "%Y" }} <span id="footer-name">Rareș Nistor</span>
3+
&copy; 2016 - {{ 'now' | date: "%Y" }} <span id="footer-name" aria-live="polite" aria-atomic="true">Rareș Nistor</span>
44
</div>
55
<!-- <div class="nz">
66
<img src="{{ '/assets/kiwi.svg' }}" alt="" height="16">

_includes/navigation.html

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
<nav>
2-
<input type="checkbox" id="navigation-toggle">
3-
<label for="navigation-toggle">
4-
{%- include navlogo.html -%}
5-
<div class="navigation-toggle-hint"><i class="bi bi-list"></i></div>
6-
</label>
2+
<div class="nav-blur"></div>
3+
4+
<button class="nav-fab" tabindex="0"><i class="bi bi-list"></i></button>
5+
6+
<div class="nav-logo">{%- include navlogo.html -%}</div>
7+
78
<ul class="nav-links">
89
{% for navigation in site.data.navigation %}
910
{% assign page_tag = page.nav_tag | default: layout.nav_tag %}
1011
{% assign classes = '' %}
1112
{% if page_tag == navigation.tag %}
1213
{% assign classes = 'class="current"' %}
1314
{% endif %}
14-
<li><a href="{{ navigation.path | relative_url }}" {{ classes }}>
15-
<i class="bi bi-{{ navigation.icon }}"></i>
16-
<span class="navigation-label {% if navigation.expanded %}navigation-label-expanded{% endif %}">{{ navigation.label }}</span>
15+
<li><a href="{{ navigation.path | relative_url }}" {{ classes }} aria-label="{{ navigation.label }}">
16+
<i class="bi bi-{{ navigation.icon }}" aria-hidden="true"></i>
17+
<span class="navigation-label {% if navigation.expanded %}navigation-label-expanded{% endif %}" aria-hidden="true">{{ navigation.label }}</span>
1718
</a></li>
1819
{% endfor %}
1920
</ul>

_javascript/main/footer.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const footerNames = [
2+
"Rareș Nistor",
3+
"Raresh Nistor",
4+
"Razz Nistor",
5+
"Razza",
6+
"Рареш Нистор",
7+
"ラレシュ ニストル",
8+
"Ραρές Νίστορ",
9+
];
10+
11+
export default function setupFooter() {
12+
const footerName = document.getElementById("footer-name");
13+
if (footerName) {
14+
let index = 0;
15+
16+
footerName.addEventListener("click", () => {
17+
index = (index + 1) % footerNames.length;
18+
footerName.textContent = footerNames[index];
19+
});
20+
}
21+
}

_javascript/main/index.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
1+
import setupFooter from "./footer";
2+
import setupNavigation from "./navigation";
3+
14
console.info(`You're more than welcome to look around here, but this whole this whole thing is on GitHub.
25
36
More info: https://razza.io/projects/razza.io
47
GitHub: https://github.com/itisrazza/razza.io
58
`);
69

7-
const footerNames = [
8-
"Rareș Nistor",
9-
"Raresh Nistor",
10-
"Razz Nistor",
11-
"Razza",
12-
"Рареш Нистор",
13-
"ラレシュ ニストル",
14-
"Ραρές Νίστορ",
15-
];
16-
17-
const footerName = document.getElementById("footer-name");
18-
if (footerName) {
19-
let index = 0;
10+
const setupFunctions: [string, () => void][] = [
11+
["footer", setupFooter],
12+
["navigation", setupNavigation],
13+
]
2014

21-
footerName.addEventListener("click", () => {
22-
index = (index + 1) % footerNames.length;
23-
footerName.textContent = footerNames[index];
24-
});
15+
for (const [name, fn] of setupFunctions) {
16+
try {
17+
fn();
18+
console.debug(`${name}: OK`);
19+
} catch (error: unknown) {
20+
console.log(`Failed to set up '${name}'`, error);
21+
}
2522
}

_javascript/main/navigation.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { getBreakpoint, onBreakpointChange } from "../misc/breakpoint";
2+
3+
const NAV_OPEN_CLASS = "nav-open";
4+
5+
const FAB_OPEN_ICON = "bi-list";
6+
const FAB_CLOSE_ICON = "bi-x-lg";
7+
8+
let isOpen = true;
9+
10+
export default function setupNavigation() {
11+
const elements = getNavigationElements();
12+
isOpen = elements.nav.classList.contains(NAV_OPEN_CLASS);
13+
14+
setupEventListeners(elements);
15+
initAriaState(elements);
16+
17+
onBreakpointChange(bp => applyBreakpoint(bp === "sm", elements));
18+
applyBreakpoint(getBreakpoint() === "sm", elements);
19+
}
20+
21+
function setupEventListeners(elements: NavigationElements) {
22+
elements.fab.addEventListener("click", onNavigationToggle(elements));
23+
elements.fab.addEventListener("keydown", (e) => {
24+
if (e.key === " ") { e.preventDefault(); onNavigationToggle(elements)(); }
25+
});
26+
elements.blur.addEventListener("click", closeNav(elements));
27+
document.addEventListener("keydown", (e) => {
28+
if (e.key === "Escape" && isOpen) closeNav(elements)();
29+
});
30+
document.addEventListener("keydown", onFocusTrap(elements));
31+
}
32+
33+
function initAriaState(elements: NavigationElements) {
34+
elements.fab.setAttribute("role", "button");
35+
elements.fab.setAttribute("aria-expanded", String(isOpen));
36+
elements.fab.setAttribute("aria-label", isOpen ? "Close navigation" : "Open navigation");
37+
if (!isOpen) elements.links.setAttribute("inert", "");
38+
}
39+
40+
function applyBreakpoint(isMobile: boolean, elements: NavigationElements) {
41+
if (isMobile) {
42+
elements.nav.setAttribute("role", "dialog");
43+
elements.nav.setAttribute("aria-modal", "true");
44+
elements.nav.setAttribute("aria-label", "Navigation");
45+
if (isOpen) closeNav(elements)();
46+
} else {
47+
elements.nav.removeAttribute("role");
48+
elements.nav.removeAttribute("aria-modal");
49+
elements.nav.removeAttribute("aria-label");
50+
if (!isOpen) {
51+
elements.nav.classList.add(NAV_OPEN_CLASS);
52+
elements.links.removeAttribute("inert");
53+
isOpen = true;
54+
}
55+
}
56+
}
57+
58+
interface NavigationElements {
59+
nav: HTMLElement,
60+
blur: HTMLDivElement,
61+
fab: HTMLDivElement,
62+
fabIcon: HTMLElement,
63+
links: HTMLUListElement,
64+
}
65+
66+
function getNavigationElements(): NavigationElements {
67+
const baseSelector = ".page-chrome > nav";
68+
69+
const nav = tryQuerySelector(baseSelector) as HTMLElement;
70+
const blur = tryQuerySelector(`${baseSelector} > .nav-blur`) as HTMLDivElement;
71+
const fab = tryQuerySelector(`${baseSelector} > .nav-fab`) as HTMLDivElement;
72+
const fabIcon = tryQuerySelector(`${baseSelector} > .nav-fab > i`) as HTMLElement;
73+
const links = tryQuerySelector(`${baseSelector} > .nav-links`) as HTMLUListElement;
74+
75+
return { nav, blur, fab, fabIcon, links };
76+
}
77+
78+
const tryQuerySelector = (selector: Parameters<typeof document.querySelector>[0]) => {
79+
const element = document.querySelector(selector);
80+
if (element == null) {
81+
throw new Error(`Couldn't query selector: ${selector}`);
82+
}
83+
84+
return element;
85+
}
86+
87+
const onNavigationToggle = (elements: NavigationElements) =>
88+
() => isOpen
89+
? closeNav(elements)()
90+
: openNav(elements)();
91+
92+
const onFocusTrap = (elements: NavigationElements) =>
93+
(e: KeyboardEvent) => {
94+
if (!isOpen || e.key !== "Tab") return;
95+
96+
const focusable: HTMLElement[] = [
97+
elements.fab,
98+
...Array.from(elements.links.querySelectorAll<HTMLElement>("a")),
99+
];
100+
101+
const first = focusable[0];
102+
const last = focusable[focusable.length - 1];
103+
104+
if (e.shiftKey && document.activeElement === first) {
105+
e.preventDefault();
106+
last.focus();
107+
} else if (!e.shiftKey && document.activeElement === last) {
108+
e.preventDefault();
109+
first.focus();
110+
}
111+
};
112+
113+
const closeNav = (elements: NavigationElements) =>
114+
() => {
115+
elements.nav.classList.remove(NAV_OPEN_CLASS);
116+
elements.fabIcon.classList.remove(FAB_CLOSE_ICON);
117+
elements.fabIcon.classList.add(FAB_OPEN_ICON);
118+
elements.fab.setAttribute("aria-expanded", "false");
119+
elements.fab.setAttribute("aria-label", "Open navigation");
120+
elements.links.setAttribute("inert", "");
121+
elements.fab.focus();
122+
isOpen = false;
123+
};
124+
125+
const openNav = (elements: NavigationElements) =>
126+
() => {
127+
elements.nav.classList.add(NAV_OPEN_CLASS);
128+
elements.fabIcon.classList.remove(FAB_OPEN_ICON);
129+
elements.fabIcon.classList.add(FAB_CLOSE_ICON);
130+
elements.fab.setAttribute("aria-expanded", "true");
131+
elements.fab.setAttribute("aria-label", "Close navigation");
132+
elements.links.removeAttribute("inert");
133+
isOpen = true;
134+
elements.links.querySelector<HTMLElement>("a")?.focus();
135+
};

_javascript/misc/breakpoint.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export type Breakpoint = "sm" | "md" | "lg";
2+
export type BreakpointChangeCallback = (breakpoint: Breakpoint) => void;
3+
type Unsubscriber = () => void;
4+
5+
const subscribers = new Set<BreakpointChangeCallback>();
6+
7+
export const getBreakpoint = (): Breakpoint =>
8+
getComputedStyle(document.documentElement)
9+
.getPropertyValue("--breakpoint").trim() as Breakpoint;
10+
11+
let lastBreakpoint: Breakpoint = getBreakpoint();
12+
13+
new ResizeObserver(() => {
14+
const bp = getBreakpoint();
15+
if (bp === lastBreakpoint) return;
16+
lastBreakpoint = bp;
17+
console.debug("BreakpointChange: resized", { breakpoint: bp });
18+
subscribers.forEach(cb => cb(bp));
19+
}).observe(document.body);
20+
21+
export const onBreakpointChange = (callback: BreakpointChangeCallback): Unsubscriber => {
22+
subscribers.add(callback);
23+
console.debug("BreakpointChange: subscribed", { callback });
24+
return () => subscribers.delete(callback);
25+
};

_javascript/misc/tryCatch.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export type Result<T, E> =
2+
| { value: T, error?: never }
3+
| { value?: never, error: E };
4+
5+
export const tryCatch : <T, E>(attempt: () => T, recover: (error: unknown) => E) => Result<T, E> = (attempt, recover) => {
6+
try {
7+
return { value: attempt() };
8+
} catch (error) {
9+
return { error: recover(error) };
10+
}
11+
}

_layouts/home.html

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
layout: chrome
33
nav_tag: home
4-
html_classes:
4+
html_classes:
55
---
66

77
<div class="home-content">
@@ -11,40 +11,56 @@
1111
</header>
1212

1313
<div class="home-links">
14-
<div class="link-section">
14+
<div class="link-section" role="group" aria-label="Projects">
1515
{% assign projects = site.projects | where: "featured", "true" %}
1616

17-
<i class="bi bi-hammer"></i>
17+
<i class="bi bi-hammer" aria-hidden="true"></i>
1818
<div class="link-group">
19-
{% for project in projects %}
19+
{% for project in projects %}
2020
{% assign logo = 'assets/logos/projects/' | append: project.slug | append: '.svg' %}
2121

2222
<a href="{{ project.url | relative_url }}"><img src="{{ logo | relative_url }}" alt="" width="16" height="16"> {{ project.title }}</a>
23-
{% endfor %}
24-
<a href="{{ 'projects' | relative }}">More <i class="bi bi-box-arrow-up-right"></i></a>
23+
{% endfor %}
24+
<a href="{{ 'projects' | relative }}">More <i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
2525
</div>
2626
</div>
27-
28-
<div class="link-section">
27+
28+
<div class="link-section" role="group" aria-label="Notes">
2929
{% assign note = site.notes | last %}
30-
31-
<i class="bi bi-newspaper"></i>
30+
31+
<i class="bi bi-newspaper" aria-hidden="true"></i>
32+
<div class="link-group">
33+
<a href="{{ note.url | relative_url }}">{{ note.title }} <span class="note-date">{{ note.date | date: "%B %e, %Y" }}</a>
34+
<a href="{{ 'notes' | relative_url }}">More <i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
35+
</div>
36+
</div>
37+
38+
<div class="link-section" role="group" aria-label="Gallery">
39+
<i class="bi bi-images" aria-hidden="true"></i>
40+
<div class="link-group">
41+
<a href="{{ 'gallery' | relative_url }}">Gallery</i></a>
42+
</div>
43+
</div>
44+
45+
<div class="link-section" role="group" aria-label="Travels">
46+
{% assign last_travel = site.travels | where_exp: "t", "t.last_visit != nil" | sort: "last_visit" | last %}
47+
48+
<i class="bi bi-compass" aria-hidden="true"></i>
3249
<div class="link-group">
33-
<a href="{{ note.url | relative_url }}">{{ note.title }} <span class="note-date">{{ note.date | date: "%B %e, %Y" }}</a>
34-
<a href="{{ 'notes' | relative_url }}">More <i class="bi bi-box-arrow-up-right"></i></a>
50+
{% if last_travel %}<a href="{{ last_travel.url | relative_url }}">{{ last_travel.title }} <span class="note-date">visited {{ last_travel.last_visit | date: "%b %Y" }}</span></a>{% endif %}
51+
<a href="{{ 'travels' | relative_url }}">More <i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
3552
</div>
3653
</div>
37-
38-
<div class="link-section">
54+
55+
<div class="link-section" role="group" aria-label="About">
3956
{% assign note = site.notes | last %}
40-
41-
<i class="bi bi-person-fill"></i>
57+
58+
<i class="bi bi-person-fill" aria-hidden="true"></i>
4259
<div class="link-group">
43-
<a href="{{ 'about' | relative_url }}">About</a>
44-
<a href="{{ 'links' | relative_url }}">Links</a>
45-
<a href="{{ 'uses' | relative_url }}">Uses</a>
60+
<a href="{{ 'about' | relative_url }}">About</a>
61+
<a href="{{ 'links' | relative_url }}">Links</a>
62+
<a href="{{ 'uses' | relative_url }}">Uses</a>
4663
</div>
4764
</div>
4865
</div>
4966
</div>
50-

0 commit comments

Comments
 (0)