fix(shell): aria-current in der Navigation, Suche wird bei Seitenwechsel geleert, Live-Regionen bleiben gerendert
- Router: onRouteChange meldet jede neue Seite (vor dem Aufbau). Die Kopfzeile markiert damit den aktuellen Navigationslink mit aria-current="page" („Community“ auch auf der Detailseite) und leert das Suchfeld, wenn die neue Seite nicht die Startseite mit ?q= ist (bisher blieb „testbank“ auf /gestalten stehen). - Spenden-Knopf in der Kopfzeile trägt title „Unterstützen“ (zwischen 600 und 1179 px nur Symbol). - .detail-msg:empty und .auth-msg:empty sind nicht mehr display: none (Live-Regionen standen nicht im Zugänglichkeitsbaum), sondern ohne Platz im Layout, aber gerendert; Test liest die CSS-Dateien. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c62075f2d4
commit
aa9f9548ad
7 files changed
+233
-10
No files matched your search
@@ -318,8 +318,17 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leer bleibt die Zeile gerendert (Live-Region, role alert/status), nimmt aber keinen Platz ein: display: none nähme sie aus
|
||||||
|
dem Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
|
||||||
.auth-msg:empty {
|
.auth-msg:empty {
|
||||||
display: none;
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-msg-error {
|
.auth-msg-error {
|
||||||
|
|||||||
@@ -629,8 +629,17 @@
|
|||||||
color: var(--danger-text);
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leer bleibt die Meldungszeile gerendert (Live-Region), nimmt aber keinen Platz ein: mit display: none stünde sie nicht im
|
||||||
|
Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
|
||||||
.detail-msg:empty {
|
.detail-msg:empty {
|
||||||
display: none;
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-admin {
|
.detail-admin {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// Als Text lesen: vitest verarbeitet CSS-Importe (auch ?raw) nicht zu Text.
|
||||||
|
const accountCss = readFileSync(resolve(import.meta.dirname, '../account/account.css'), 'utf8');
|
||||||
|
const communityCss = readFileSync(resolve(import.meta.dirname, 'community.css'), 'utf8');
|
||||||
|
|
||||||
|
/** Die Deklarationen einer Regel, deren Auswahlkriterium genau `selector` ist (mehrere Regeln: alle zusammen). */
|
||||||
|
function declarations(css: string, selector: string): string {
|
||||||
|
const found: string[] = [];
|
||||||
|
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||||
|
const selectors = rule[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((part) => part.trim());
|
||||||
|
if (selectors.includes(selector)) found.push(rule[2]);
|
||||||
|
}
|
||||||
|
return found.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Live-Regionen (role alert/status) bleiben gerendert, auch leer', () => {
|
||||||
|
it.each([
|
||||||
|
['community.css', '.detail-msg:empty', communityCss],
|
||||||
|
['account.css', '.auth-msg:empty', accountCss],
|
||||||
|
])('%s: %s ist nicht display: none, sondern aus dem Layout genommen und optisch leer', (_file, selector, css) => {
|
||||||
|
const rule = declarations(css, selector);
|
||||||
|
expect(rule).not.toBe('');
|
||||||
|
expect(rule).not.toMatch(/display\s*:\s*none/);
|
||||||
|
expect(rule).not.toMatch(/visibility\s*:\s*hidden/);
|
||||||
|
expect(rule).toMatch(/position\s*:\s*absolute/); // kein Platz im Layout (kein Gap im Flex-Container)
|
||||||
|
expect(rule).toMatch(/width\s*:\s*1px/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router';
|
import { legacyRedirect, matchRoute, navigate, onRouteChange, ROUTE_TITLES, startRouter, titleFor } from './router';
|
||||||
|
|
||||||
describe('matchRoute', () => {
|
describe('matchRoute', () => {
|
||||||
it('ordnet / der Startseite zu', () => {
|
it('ordnet / der Startseite zu', () => {
|
||||||
@@ -131,6 +131,36 @@ describe('startRouter', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('onRouteChange: meldet jede neue Route vor dem Rendern der Seite (Start, navigate, popstate), nicht bei nur einem Hash', () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const stop = onRouteChange((route) => order.push(`route:${route.id}`));
|
||||||
|
startRouter((route) => {
|
||||||
|
order.push(`render:${route.id}`);
|
||||||
|
});
|
||||||
|
navigate('/karten');
|
||||||
|
navigate('/karten#oben'); // nur der Hash ändert sich: keine neue Seite
|
||||||
|
history.pushState(null, '', '/gestalten');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
expect(order).toEqual(['route:home', 'render:home', 'route:cards', 'render:cards', 'route:gestalten', 'render:gestalten']);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
navigate('/');
|
||||||
|
expect(order).toHaveLength(6 + 1); // nur noch das Rendern der Startseite, kein route-Eintrag mehr
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onRouteChange: mehrere Hörer, ein Hörer kann sich in der Meldung abmelden', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const stopA = onRouteChange(() => {
|
||||||
|
calls.push('a');
|
||||||
|
stopA();
|
||||||
|
});
|
||||||
|
const stopB = onRouteChange(() => calls.push('b'));
|
||||||
|
startRouter(() => undefined);
|
||||||
|
navigate('/karten');
|
||||||
|
expect(calls).toEqual(['a', 'b', 'b']);
|
||||||
|
stopB();
|
||||||
|
});
|
||||||
|
|
||||||
it('baut beim erneuten Start den alten Router ab', () => {
|
it('baut beim erneuten Start den alten Router ab', () => {
|
||||||
const cleanup = vi.fn();
|
const cleanup = vi.fn();
|
||||||
const first = vi.fn(() => cleanup);
|
const first = vi.fn(() => cleanup);
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ export function titleFor(route: Route, detailTitle?: string): string {
|
|||||||
return ROUTE_TITLES[route.id];
|
return ROUTE_TITLES[route.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routeListeners = new Set<(route: Route) => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet jede neue Seite (Start, navigate, popstate; nicht bei einem reinen Hash-Wechsel), nachdem die vorige aufgeräumt
|
||||||
|
* wurde und bevor die neue aufgebaut wird. Für Dinge außerhalb der Seiten, etwa die Kopfzeile. Gibt die Abmeldung zurück.
|
||||||
|
*/
|
||||||
|
export function onRouteChange(listener: (route: Route) => void): () => void {
|
||||||
|
routeListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
routeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
||||||
let stopCurrent: (() => void) | null = null;
|
let stopCurrent: (() => void) | null = null;
|
||||||
let renderCurrent: (() => void) | null = null;
|
let renderCurrent: (() => void) | null = null;
|
||||||
@@ -91,6 +104,7 @@ export function startRouter(render: Render): void {
|
|||||||
cleanup = undefined;
|
cleanup = undefined;
|
||||||
const route = matchRoute(location.pathname);
|
const route = matchRoute(location.pathname);
|
||||||
document.title = titleFor(route);
|
document.title = titleFor(route);
|
||||||
|
for (const listener of [...routeListeners]) listener(route);
|
||||||
cleanup = render(route, root);
|
cleanup = render(route, root);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
|
const mocks = vi.hoisted(() => ({
|
||||||
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
|
navigate: vi.fn(),
|
||||||
|
routeListeners: new Set<(route: { id: string; params: { id?: string } }) => void>(),
|
||||||
|
}));
|
||||||
|
vi.mock('../pages/router', () => ({
|
||||||
|
navigate: mocks.navigate,
|
||||||
|
onRouteChange: (fn: (route: { id: string; params: { id?: string } }) => void) => {
|
||||||
|
mocks.routeListeners.add(fn);
|
||||||
|
return () => mocks.routeListeners.delete(fn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { mountHeader } from './header';
|
import { mountHeader } from './header';
|
||||||
|
|
||||||
@@ -21,8 +30,15 @@ const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true,
|
|||||||
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
||||||
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
||||||
|
|
||||||
|
/** Lässt den Router eine Seite melden (wie startRouter/navigate es tun). */
|
||||||
|
function route(id: string, search = ''): void {
|
||||||
|
history.replaceState(null, '', `${id === 'home' ? '/' : `/${id}`}${search}`);
|
||||||
|
for (const fn of [...mocks.routeListeners]) fn({ id, params: {} });
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.navigate.mockReset();
|
mocks.navigate.mockReset();
|
||||||
|
mocks.routeListeners.clear();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
html.setAttribute('data-theme', 'dark');
|
html.setAttribute('data-theme', 'dark');
|
||||||
document.body.innerHTML =
|
document.body.innerHTML =
|
||||||
@@ -36,6 +52,7 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
dispose?.();
|
dispose?.();
|
||||||
dispose = null;
|
dispose = null;
|
||||||
|
history.replaceState(null, '', '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mountHeader: Aufbau', () => {
|
describe('mountHeader: Aufbau', () => {
|
||||||
@@ -105,6 +122,94 @@ describe('mountHeader: Suche', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: aktive Seite (aria-current)', () => {
|
||||||
|
const current = () =>
|
||||||
|
[...nav().querySelectorAll<HTMLAnchorElement>('a[aria-current]')].map((a) => [a.textContent, a.getAttribute('aria-current')]);
|
||||||
|
|
||||||
|
it('ohne gemeldete Seite ist nichts als aktuell markiert', () => {
|
||||||
|
mount();
|
||||||
|
expect(current()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite und Karten-Detailseite: „Community“', () => {
|
||||||
|
mount();
|
||||||
|
route('home');
|
||||||
|
expect(current()).toEqual([['Community', 'page']]);
|
||||||
|
route('community');
|
||||||
|
expect(current()).toEqual([['Community', 'page']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gestalten und Meine Karten', () => {
|
||||||
|
mount();
|
||||||
|
route('gestalten');
|
||||||
|
expect(current()).toEqual([['Gestalten', 'page']]);
|
||||||
|
route('cards');
|
||||||
|
expect(current()).toEqual([['Meine Karten', 'page']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Passwort-Seite: kein Link ist aktuell; die Marke bleibt unberührt', () => {
|
||||||
|
mount();
|
||||||
|
route('home');
|
||||||
|
route('reset');
|
||||||
|
expect(current()).toEqual([]);
|
||||||
|
expect(header.querySelector('.brand')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Spenden-Link (externer Link) bekommt nie aria-current', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
for (const id of ['home', 'gestalten', 'cards', 'community', 'reset']) route(id);
|
||||||
|
expect(header.querySelector('.nav-donate')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
expect(header.querySelector('.header-donate')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Suchfeld und Seitenwechsel', () => {
|
||||||
|
it('die Suche bleibt nicht stehen, wenn man auf eine andere Seite geht', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'testbank';
|
||||||
|
route('gestalten');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
input().value = 'noch was';
|
||||||
|
route('cards');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
input().value = 'und noch was';
|
||||||
|
route('community');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite ohne ?q= leert das Feld ebenfalls', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'alt';
|
||||||
|
route('home');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite mit ?q= lässt den Suchbegriff stehen (die Seite selbst setzt ihn)', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'visa';
|
||||||
|
route('home', '?q=visa');
|
||||||
|
expect(input().value).toBe('visa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite mit leerem ?q= oder nur ?tag= gilt als ohne Suche', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'alt';
|
||||||
|
route('home', '?q=');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
input().value = 'alt';
|
||||||
|
route('home', '?tag=gold');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Aufräumen meldet sich der Header beim Router ab', () => {
|
||||||
|
mount();
|
||||||
|
expect(mocks.routeListeners.size).toBe(1);
|
||||||
|
dispose?.();
|
||||||
|
dispose = null;
|
||||||
|
expect(mocks.routeListeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mountHeader: Spenden-Knopf', () => {
|
describe('mountHeader: Spenden-Knopf', () => {
|
||||||
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
|
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
|
||||||
mount('');
|
mount('');
|
||||||
@@ -123,6 +228,11 @@ describe('mountHeader: Spenden-Knopf', () => {
|
|||||||
expect(link.textContent).toContain('Unterstützen');
|
expect(link.textContent).toContain('Unterstützen');
|
||||||
expect(link.hasAttribute('data-link')).toBe(false);
|
expect(link.hasAttribute('data-link')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('der Knopf trägt title „Unterstützen“ (zwischen 600 und 1179 px zeigt er nur das Symbol)', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
expect(header.querySelector('a.header-donate')?.getAttribute('title')).toBe('Unterstützen');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mountHeader: Hell/Dunkel', () => {
|
describe('mountHeader: Hell/Dunkel', () => {
|
||||||
|
|||||||
+25
-5
@@ -3,7 +3,7 @@
|
|||||||
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
||||||
import './shell.css';
|
import './shell.css';
|
||||||
import { el, icon } from '../editor/dom';
|
import { el, icon } from '../editor/dom';
|
||||||
import { navigate } from '../pages/router';
|
import { navigate, onRouteChange, type RouteId } from '../pages/router';
|
||||||
import { toggleTheme } from './theme';
|
import { toggleTheme } from './theme';
|
||||||
|
|
||||||
export interface HeaderOptions {
|
export interface HeaderOptions {
|
||||||
@@ -17,14 +17,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
const hasDonate = opts.donateUrl.trim() !== '';
|
const hasDonate = opts.donateUrl.trim() !== '';
|
||||||
|
|
||||||
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
|
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
|
||||||
|
// Welche Seiten einen Navigationslink als aktuell zeigen („Community“ steht auch für die Detailseite einer Karte).
|
||||||
|
const navLinks: Array<{ anchor: HTMLAnchorElement; routes: readonly RouteId[] }> = [];
|
||||||
|
const navLink = (label: string, href: string, routes: readonly RouteId[]) => {
|
||||||
|
const anchor = link(label, href);
|
||||||
|
navLinks.push({ anchor, routes });
|
||||||
|
return anchor;
|
||||||
|
};
|
||||||
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
|
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
|
||||||
|
|
||||||
const nav = el(
|
const nav = el(
|
||||||
'nav',
|
'nav',
|
||||||
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
|
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
|
||||||
link('Community', '/#community'),
|
navLink('Community', '/#community', ['home', 'community']),
|
||||||
link('Gestalten', '/gestalten'),
|
navLink('Gestalten', '/gestalten', ['gestalten']),
|
||||||
link('Meine Karten', '/karten'),
|
navLink('Meine Karten', '/karten', ['cards']),
|
||||||
);
|
);
|
||||||
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
|
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
|
||||||
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
|
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
|
||||||
@@ -60,7 +67,8 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
tools.append(
|
tools.append(
|
||||||
el(
|
el(
|
||||||
'a',
|
'a',
|
||||||
{ class: 'header-donate', ...donateAttrs },
|
// title: zwischen 600 und 1179 px zeigt der Knopf nur das Symbol (Stile in shell.css).
|
||||||
|
{ class: 'header-donate', title: 'Unterstützen', ...donateAttrs },
|
||||||
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
||||||
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
|
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
|
||||||
),
|
),
|
||||||
@@ -101,9 +109,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
navigate(`/?q=${encodeURIComponent(q)}#community`);
|
navigate(`/?q=${encodeURIComponent(q)}#community`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seitenwechsel: aktuellen Navigationslink markieren und eine Suche löschen, die nicht zur neuen Seite gehört
|
||||||
|
// (nur die Startseite mit ?q= zeigt ihren Suchbegriff im Feld; die Startseite setzt ihn selbst).
|
||||||
|
const stopRoute = onRouteChange((route) => {
|
||||||
|
for (const { anchor, routes } of navLinks) {
|
||||||
|
if (routes.includes(route.id)) anchor.setAttribute('aria-current', 'page');
|
||||||
|
else anchor.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
const searching = route.id === 'home' && (new URLSearchParams(location.search).get('q') ?? '').trim() !== '';
|
||||||
|
if (!searching) input.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
|
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
stopRoute();
|
||||||
document.removeEventListener('click', onDocumentClick);
|
document.removeEventListener('click', onDocumentClick);
|
||||||
document.removeEventListener('keydown', onKeydown);
|
document.removeEventListener('keydown', onKeydown);
|
||||||
nav.remove();
|
nav.remove();
|
||||||
|
|||||||
Reference in new issue
Block a user