diff --git a/web/public/assets/js/app/__tests__/mobile-menu.test.js b/web/public/assets/js/app/__tests__/mobile-menu.test.js new file mode 100644 index 0000000..d03cde5 --- /dev/null +++ b/web/public/assets/js/app/__tests__/mobile-menu.test.js @@ -0,0 +1,455 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { __test__, initializeMobileMenu } from '../mobile-menu.js'; + +const { createMobileMenuController, resolveFocusableElements } = __test__; + +function createClassList() { + const values = new Set(); + return { + add(...names) { + names.forEach(name => values.add(name)); + }, + remove(...names) { + names.forEach(name => values.delete(name)); + }, + contains(name) { + return values.has(name); + } + }; +} + +function createElement(tagName = 'div', initialId = '') { + const listeners = new Map(); + const attributes = new Map(); + if (initialId) { + attributes.set('id', String(initialId)); + } + return { + tagName: tagName.toUpperCase(), + attributes, + classList: createClassList(), + dataset: {}, + hidden: false, + parentNode: null, + nextSibling: null, + setAttribute(name, value) { + attributes.set(name, String(value)); + }, + getAttribute(name) { + return attributes.has(name) ? attributes.get(name) : null; + }, + addEventListener(event, handler) { + listeners.set(event, handler); + }, + dispatchEvent(event) { + const key = typeof event === 'string' ? event : event?.type; + const handler = listeners.get(key); + if (handler) { + handler(event); + } + }, + appendChild(node) { + this.lastAppended = node; + return node; + }, + insertBefore(node, nextSibling) { + this.lastInserted = { node, nextSibling }; + return node; + }, + focus() { + globalThis.document.activeElement = this; + }, + querySelector() { + return null; + }, + querySelectorAll() { + return []; + } + }; +} + +function createDomStub() { + const originalDocument = globalThis.document; + const registry = new Map(); + const documentStub = { + body: createElement('body'), + activeElement: null, + querySelectorAll() { + return []; + }, + getElementById(id) { + return registry.get(id) || null; + } + }; + globalThis.document = documentStub; + return { + documentStub, + registry, + cleanup() { + globalThis.document = originalDocument; + } + }; +} + +function createWindowStub(matches = true) { + const listeners = new Map(); + const mediaListeners = new Map(); + return { + matchMedia() { + return { + matches, + addEventListener(event, handler) { + mediaListeners.set(event, handler); + } + }; + }, + addEventListener(event, handler) { + listeners.set(event, handler); + }, + dispatchEvent(event) { + const key = typeof event === 'string' ? event : event?.type; + const handler = listeners.get(key); + if (handler) { + handler(event); + } + }, + dispatchMediaChange() { + const handler = mediaListeners.get('change'); + if (handler) { + handler(); + } + } + }; +} + +function createWindowStubWithListener(matches = true) { + const listeners = new Map(); + let mediaHandler = null; + return { + matchMedia() { + return { + matches, + addListener(handler) { + mediaHandler = handler; + } + }; + }, + addEventListener(event, handler) { + listeners.set(event, handler); + }, + dispatchMediaChange() { + if (mediaHandler) { + mediaHandler(); + } + } + }; +} + +test('mobile menu toggles open state and aria-expanded', () => { + const { documentStub, registry, cleanup } = createDomStub(); + const windowStub = createWindowStub(true); + + const menuToggle = createElement('button'); + const menu = createElement('div'); + const menuPanel = createElement('div'); + const closeButton = createElement('button'); + const navLink = createElement('a'); + + menu.hidden = true; + menuPanel.classList.add('mobile-menu__panel'); + + menu.querySelector = selector => { + if (selector === '.mobile-menu__panel') return menuPanel; + return null; + }; + menu.querySelectorAll = selector => { + if (selector === '[data-mobile-menu-close]') return [closeButton]; + if (selector === 'a') return [navLink]; + return []; + }; + menuPanel.querySelectorAll = () => [closeButton, navLink]; + + registry.set('mobileMenuToggle', menuToggle); + registry.set('mobileMenu', menu); + + try { + const controller = createMobileMenuController({ + documentObject: documentStub, + windowObject: windowStub + }); + + controller.initialize(); + windowStub.dispatchMediaChange(); + + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + assert.equal(menu.hidden, false); + assert.equal(menuToggle.getAttribute('aria-expanded'), 'true'); + assert.equal(documentStub.body.classList.contains('menu-open'), true); + + navLink.dispatchEvent({ type: 'click' }); + assert.equal(menu.hidden, true); + + closeButton.dispatchEvent({ type: 'click' }); + assert.equal(menu.hidden, true); + assert.equal(menuToggle.getAttribute('aria-expanded'), 'false'); + } finally { + cleanup(); + } +}); + +test('mobile menu closes on escape and route changes', () => { + const { documentStub, registry, cleanup } = createDomStub(); + const windowStub = createWindowStub(true); + + const menuToggle = createElement('button'); + const menu = createElement('div'); + const menuPanel = createElement('div'); + const closeButton = createElement('button'); + + menu.hidden = true; + menuPanel.classList.add('mobile-menu__panel'); + + menu.querySelector = selector => { + if (selector === '.mobile-menu__panel') return menuPanel; + return null; + }; + menu.querySelectorAll = selector => { + if (selector === '[data-mobile-menu-close]') return [closeButton]; + return []; + }; + menuPanel.querySelectorAll = () => [closeButton]; + + registry.set('mobileMenuToggle', menuToggle); + registry.set('mobileMenu', menu); + + try { + const controller = createMobileMenuController({ + documentObject: documentStub, + windowObject: windowStub + }); + + controller.initialize(); + + menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} }); + assert.equal(menu.hidden, true); + + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + assert.equal(menu.hidden, false); + + menuPanel.dispatchEvent({ type: 'keydown', key: 'ArrowDown' }); + assert.equal(menu.hidden, false); + + menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} }); + assert.equal(menu.hidden, true); + + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + windowStub.dispatchEvent({ type: 'hashchange' }); + assert.equal(menu.hidden, true); + + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + windowStub.dispatchEvent({ type: 'popstate' }); + assert.equal(menu.hidden, true); + } finally { + cleanup(); + } +}); + +test('mobile menu traps focus within the panel', () => { + const { documentStub, registry, cleanup } = createDomStub(); + const windowStub = createWindowStub(true); + + const menuToggle = createElement('button'); + const menu = createElement('div'); + const menuPanel = createElement('div'); + const firstLink = createElement('a'); + const lastButton = createElement('button'); + + menuPanel.classList.add('mobile-menu__panel'); + menuPanel.querySelectorAll = () => [firstLink, lastButton]; + menu.querySelector = selector => { + if (selector === '.mobile-menu__panel') return menuPanel; + return null; + }; + menu.querySelectorAll = () => []; + + registry.set('mobileMenuToggle', menuToggle); + registry.set('mobileMenu', menu); + + try { + const controller = createMobileMenuController({ + documentObject: documentStub, + windowObject: windowStub + }); + + controller.initialize(); + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + + documentStub.activeElement = lastButton; + menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: false }); + assert.equal(documentStub.activeElement, firstLink); + + documentStub.activeElement = firstLink; + menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: true }); + assert.equal(documentStub.activeElement, lastButton); + } finally { + cleanup(); + } +}); + +test('resolveFocusableElements filters out aria-hidden nodes', () => { + const hiddenButton = createElement('button'); + hiddenButton.getAttribute = name => (name === 'aria-hidden' ? 'true' : null); + const openLink = createElement('a'); + const bareNode = { tagName: 'DIV' }; + const container = { + querySelectorAll() { + return [hiddenButton, bareNode, openLink]; + } + }; + + const focusables = resolveFocusableElements(container); + assert.equal(focusables.length, 1); + assert.equal(focusables[0], openLink); +}); + +test('resolveFocusableElements handles empty containers', () => { + assert.deepEqual(resolveFocusableElements(null), []); + assert.deepEqual(resolveFocusableElements({}), []); +}); + +test('mobile menu focuses the panel when no focusables exist', () => { + const { documentStub, registry, cleanup } = createDomStub(); + const windowStub = createWindowStub(true); + + const menuToggle = createElement('button'); + const menu = createElement('div'); + const menuPanel = createElement('div'); + const lastActive = createElement('button'); + + menuPanel.classList.add('mobile-menu__panel'); + menuPanel.querySelectorAll = () => []; + menu.querySelector = selector => { + if (selector === '.mobile-menu__panel') return menuPanel; + return null; + }; + menu.querySelectorAll = () => []; + + registry.set('mobileMenuToggle', menuToggle); + registry.set('mobileMenu', menu); + documentStub.activeElement = lastActive; + + try { + const controller = createMobileMenuController({ + documentObject: documentStub, + windowObject: windowStub + }); + + controller.initialize(); + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + assert.equal(documentStub.activeElement, menuPanel); + + menuToggle.dispatchEvent({ type: 'click', preventDefault() {} }); + assert.equal(documentStub.activeElement, lastActive); + } finally { + cleanup(); + } +}); + +test('mobile menu registers legacy media query listeners', () => { + const { documentStub, registry, cleanup } = createDomStub(); + const windowStub = createWindowStubWithListener(true); + + const menuToggle = createElement('button'); + const menu = createElement('div'); + const menuPanel = createElement('div'); + + menuPanel.classList.add('mobile-menu__panel'); + menu.querySelector = selector => { + if (selector === '.mobile-menu__panel') return menuPanel; + return null; + }; + menu.querySelectorAll = () => []; + + registry.set('mobileMenuToggle', menuToggle); + registry.set('mobileMenu', menu); + + try { + const controller = createMobileMenuController({ + documentObject: documentStub, + windowObject: windowStub + }); + + controller.initialize(); + windowStub.dispatchMediaChange(); + assert.equal(menuToggle.getAttribute('aria-expanded'), 'false'); + } finally { + cleanup(); + } +}); + +test('mobile menu safely no-ops without required nodes', () => { + const { documentStub, cleanup } = createDomStub(); + const windowStub = createWindowStub(true); + + try { + const controller = createMobileMenuController({ + documentObject: documentStub, + windowObject: windowStub + }); + + controller.initialize(); + controller.openMenu(); + controller.closeMenu(); + controller.syncLayout(); + assert.equal(documentStub.body.classList.contains('menu-open'), false); + } finally { + cleanup(); + } +}); + +test('initializeMobileMenu returns a controller', () => { + const { documentStub, registry, cleanup } = createDomStub(); + const windowStub = createWindowStub(true); + + const menuToggle = createElement('button'); + const menu = createElement('div'); + const menuPanel = createElement('div'); + + menuPanel.classList.add('mobile-menu__panel'); + menu.querySelector = selector => { + if (selector === '.mobile-menu__panel') return menuPanel; + return null; + }; + menu.querySelectorAll = () => []; + + registry.set('mobileMenuToggle', menuToggle); + registry.set('mobileMenu', menu); + + try { + const controller = initializeMobileMenu({ + documentObject: documentStub, + windowObject: windowStub + }); + assert.equal(typeof controller.openMenu, 'function'); + } finally { + cleanup(); + } +}); diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index e3f7944..7779de0 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -946,13 +946,19 @@ test('initializeNodeDetailPage reports an error when refresh fails', async () => throw new Error('boom'); }; const renderShortHtml = short => `${short}`; - const result = await initializeNodeDetailPage({ - document: documentStub, - refreshImpl, - renderShortHtml, - }); - assert.equal(result, false); - assert.equal(element.innerHTML.includes('Failed to load'), true); + const originalError = console.error; + console.error = () => {}; + try { + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + renderShortHtml, + }); + assert.equal(result, false); + assert.equal(element.innerHTML.includes('Failed to load'), true); + } finally { + console.error = originalError; + } }); test('initializeNodeDetailPage handles missing reference payloads', async () => { diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 0d4c4c1..d51f301 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -44,6 +44,7 @@ import { formatChatPresetTag } from './chat-format.js'; import { initializeInstanceSelector } from './instance-selector.js'; +import { initializeMobileMenu } from './mobile-menu.js'; import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js'; import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js'; import { renderChatTabs } from './chat-tabs.js'; @@ -119,6 +120,8 @@ export function initializeApp(config) { const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false; const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false; const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null; + + initializeMobileMenu({ documentObject: document, windowObject: window }); /** * Column sorter configuration for the node table. * diff --git a/web/public/assets/js/app/mobile-menu.js b/web/public/assets/js/app/mobile-menu.js new file mode 100644 index 0000000..c1a7ae8 --- /dev/null +++ b/web/public/assets/js/app/mobile-menu.js @@ -0,0 +1,271 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const MOBILE_MENU_MEDIA_QUERY = '(max-width: 900px)'; +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' +].join(', '); + +/** + * Collect the elements that can receive focus within a container. + * + * @param {?Element} container DOM node hosting focusable descendants. + * @returns {Array} Ordered list of focusable elements. + */ +function resolveFocusableElements(container) { + if (!container || typeof container.querySelectorAll !== 'function') { + return []; + } + const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)); + return candidates.filter(candidate => { + if (!candidate || typeof candidate.getAttribute !== 'function') { + return false; + } + return candidate.getAttribute('aria-hidden') !== 'true'; + }); +} + +/** + * Build a menu controller for handling toggle state, focus trapping, and + * responsive layout swapping. + * + * @param {{ + * documentObject?: Document, + * windowObject?: Window + * }} [options] + * @returns {{ + * initialize: () => void, + * openMenu: () => void, + * closeMenu: () => void, + * syncLayout: () => void + * }} + */ +function createMobileMenuController(options = {}) { + const documentObject = options.documentObject || document; + const windowObject = options.windowObject || window; + const menuToggle = documentObject.getElementById('mobileMenuToggle'); + const menu = documentObject.getElementById('mobileMenu'); + const menuPanel = menu ? menu.querySelector('.mobile-menu__panel') : null; + const closeTriggers = menu ? Array.from(menu.querySelectorAll('[data-mobile-menu-close]')) : []; + const menuLinks = menu ? Array.from(menu.querySelectorAll('a')) : []; + const body = documentObject.body; + const mediaQuery = windowObject.matchMedia + ? windowObject.matchMedia(MOBILE_MENU_MEDIA_QUERY) + : null; + let isOpen = false; + let lastActive = null; + + /** + * Toggle the ``aria-expanded`` state on the menu trigger. + * + * @param {boolean} expanded Whether the menu is open. + * @returns {void} + */ + function setExpandedState(expanded) { + if (!menuToggle || typeof menuToggle.setAttribute !== 'function') { + return; + } + menuToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + } + + /** + * Synchronize the meta row placement based on the active media query. + * + * @returns {void} + */ + function syncLayout() { + return; + } + + /** + * Open the slide-in menu and trap focus within the panel. + * + * @returns {void} + */ + function openMenu() { + if (!menu || !menuToggle || !menuPanel) { + return; + } + syncLayout(); + menu.hidden = false; + menu.classList.add('is-open'); + if (body && body.classList) { + body.classList.add('menu-open'); + } + setExpandedState(true); + isOpen = true; + lastActive = documentObject.activeElement || null; + const focusables = resolveFocusableElements(menuPanel); + const focusTarget = focusables[0] || menuPanel; + if (focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus(); + } + } + + /** + * Close the menu and restore focus to the trigger. + * + * @returns {void} + */ + function closeMenu() { + if (!menu || !menuToggle) { + return; + } + menu.classList.remove('is-open'); + menu.hidden = true; + if (body && body.classList) { + body.classList.remove('menu-open'); + } + setExpandedState(false); + isOpen = false; + if (lastActive && typeof lastActive.focus === 'function') { + lastActive.focus(); + } + } + + /** + * Toggle open or closed based on the trigger interaction. + * + * @param {Event} event Click event originating from the trigger. + * @returns {void} + */ + function handleToggleClick(event) { + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + if (isOpen) { + closeMenu(); + } else { + openMenu(); + } + } + + /** + * Trap tab focus within the menu panel while open. + * + * @param {KeyboardEvent} event Keydown event from the panel. + * @returns {void} + */ + function handleKeydown(event) { + if (!isOpen || !event) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + closeMenu(); + return; + } + if (event.key !== 'Tab') { + return; + } + const focusables = resolveFocusableElements(menuPanel); + if (!focusables.length) { + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = documentObject.activeElement; + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + } + + /** + * Close the menu when navigation state changes. + * + * @returns {void} + */ + function handleRouteChange() { + if (isOpen) { + closeMenu(); + } + } + + /** + * Attach event listeners and sync initial layout. + * + * @returns {void} + */ + function initialize() { + if (!menuToggle || !menu) { + return; + } + menuToggle.addEventListener('click', handleToggleClick); + closeTriggers.forEach(trigger => { + trigger.addEventListener('click', closeMenu); + }); + menuLinks.forEach(link => { + link.addEventListener('click', closeMenu); + }); + if (menuPanel && typeof menuPanel.addEventListener === 'function') { + menuPanel.addEventListener('keydown', handleKeydown); + } + if (mediaQuery) { + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', syncLayout); + } else if (typeof mediaQuery.addListener === 'function') { + mediaQuery.addListener(syncLayout); + } + } + if (windowObject && typeof windowObject.addEventListener === 'function') { + windowObject.addEventListener('hashchange', handleRouteChange); + windowObject.addEventListener('popstate', handleRouteChange); + } + syncLayout(); + setExpandedState(false); + } + + return { + initialize, + openMenu, + closeMenu, + syncLayout, + }; +} + +/** + * Initialize the mobile menu using the live DOM environment. + * + * @param {{ + * documentObject?: Document, + * windowObject?: Window + * }} [options] + * @returns {{ + * initialize: () => void, + * openMenu: () => void, + * closeMenu: () => void, + * syncLayout: () => void + * }} + */ +export function initializeMobileMenu(options = {}) { + const controller = createMobileMenuController(options); + controller.initialize(); + return controller; +} + +export const __test__ = { + createMobileMenuController, + resolveFocusableElements, +}; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index e0d9202..d44e2e4 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -215,25 +215,214 @@ h1 { .site-header { display: flex; - flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 56px; + padding: 4px 0; + margin-bottom: 8px; +} + +.site-header__left, +.site-header__right { + display: flex; align-items: center; gap: 12px; - margin-bottom: 8px; +} + +.site-header__left { + flex: 1 1 auto; + min-width: 0; +} + +.site-header__right { + flex: 0 0 auto; + margin-left: auto; } .site-title { display: inline-flex; align-items: center; gap: 12px; + min-width: 0; +} + +.site-title-text { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .site-title img { - width: 52px; - height: 52px; + width: 36px; + height: 36px; display: block; border-radius: 12px; } +.site-nav { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.site-nav__link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + color: var(--fg); + text-decoration: none; + border: 1px solid transparent; + font-size: 14px; +} + +.site-nav__link:hover { + background: var(--card); +} + +.site-nav__link.is-active { + border-color: var(--accent); + color: var(--accent); + background: transparent; + font-weight: 600; +} + +.site-nav__link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.menu-toggle { + display: none; +} + +.menu-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.mobile-menu { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + justify-content: flex-end; + pointer-events: none; +} + +.mobile-menu[hidden] { + display: none; +} + +.mobile-menu__backdrop { + flex: 1 1 auto; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity 200ms ease; +} + +.mobile-menu__panel { + width: min(320px, 86vw); + background: var(--bg2); + color: var(--fg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + overflow-y: auto; + transform: translateX(100%); + transition: transform 220ms ease; + box-shadow: -12px 0 32px rgba(0, 0, 0, 0.3); +} + +.mobile-menu__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.mobile-menu__title { + margin: 0; + font-size: 16px; +} + +.mobile-menu__close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.mobile-nav { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mobile-nav__link { + display: inline-flex; + align-items: center; + padding: 8px 10px; + border-radius: 10px; + color: var(--fg); + text-decoration: none; + border: 1px solid transparent; +} + +.mobile-nav__link.is-active { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.mobile-nav__link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.mobile-menu.is-open { + pointer-events: auto; +} + +.mobile-menu.is-open .mobile-menu__backdrop { + opacity: 1; +} + +.mobile-menu.is-open .mobile-menu__panel { + transform: translateX(0); +} + +.menu-open { + overflow: hidden; +} + +.section-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--fg); + text-decoration: none; + font-size: 14px; +} + +.section-link:hover { + border-color: var(--accent); + color: var(--accent); +} + +.section-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .meta { color: #555; margin-bottom: 12px; @@ -282,11 +471,29 @@ h1 { @media (max-width: 900px) { .site-header { - flex-direction: column; - align-items: flex-start; + margin-bottom: 4px; + } + + .site-header__left { + flex-wrap: nowrap; + } + + .site-header__left--federation { + flex-wrap: wrap; + } + + .site-nav { + display: none; + } + + .menu-toggle { + display: inline-flex; + } + + .instance-selector { + flex: 0 1 auto; } - .instance-selector, .instance-select { width: 100%; } @@ -296,6 +503,7 @@ h1 { } } + .pill { display: inline-block; padding: 2px 8px; @@ -1694,10 +1902,6 @@ input[type="radio"] { gap: 12px; } - .controls--full-screen { - grid-template-columns: minmax(0, 1fr) auto; - } - .controls .filter-input { width: 100%; } diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index b16fd24..1174858 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -75,16 +75,19 @@ main_classes = ["page-main"] main_classes << "page-main--dashboard" if view_mode == :dashboard main_classes << "page-main--full-screen" if full_screen_view - show_header = !full_screen_view + show_header = true show_meta_info = true show_auto_refresh_controls = view_mode != :federation show_auto_fit_toggle = %i[dashboard map].include?(view_mode) map_zoom_override = defined?(map_zoom) ? map_zoom : nil - show_info_button = !full_screen_view + show_info_button = true show_footer = !full_screen_view show_filter_input = !%i[node_detail charts federation].include?(view_mode) show_auto_refresh_toggle = show_auto_refresh_controls show_refresh_actions = show_auto_refresh_controls || view_mode == :federation + nodes_nav_href = "/nodes" + nodes_nav_active = %i[nodes node_detail].include?(view_mode) + federation_nav_enabled = !private_mode && federation_enabled controls_classes = ["controls"] controls_classes << "controls--full-screen" if full_screen_view refresh_row_classes = ["refresh-row"] @@ -101,24 +104,69 @@
"> <% if show_header %> + <% end %> -
+
<% if show_meta_info %>
"> diff --git a/web/views/shared/_nodes_table.erb b/web/views/shared/_nodes_table.erb index 01969c1..bcacaed 100644 --- a/web/views/shared/_nodes_table.erb +++ b/web/views/shared/_nodes_table.erb @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -
+