From 7696e4daadb26ae081e688d4c91ae0f9ec8605e9 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Thu, 5 Feb 2026 09:35:33 +0100 Subject: [PATCH] added a star icon on mobile view, which is a quick link to the starred by me, which gets filled once clicked and unfilled and links back to the actual topic overview once clicked again. I also adjusted the menu, to show a burger menu in case the window will be made smaller and the icons overflow, to ensure, these are simply grouped underneath the burger menu. This fixes current odd behavior on smaller screens Signed-off-by: Kai Wagner --- .../stylesheets/components/navigation.css | 106 +++++++++++++++++- .../controllers/nav_overflow_controller.js | 85 ++++++++++++++ app/views/layouts/application.html.slim | 74 +++++++----- 3 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 app/javascript/controllers/nav_overflow_controller.js diff --git a/app/assets/stylesheets/components/navigation.css b/app/assets/stylesheets/components/navigation.css index 11eecc3..b07cade 100644 --- a/app/assets/stylesheets/components/navigation.css +++ b/app/assets/stylesheets/components/navigation.css @@ -47,7 +47,7 @@ display: flex; align-items: center; gap: var(--spacing-3); - flex: 1 1 auto; + flex: 0 0 auto; } .brand-link { @@ -59,6 +59,14 @@ display: inline-flex; align-items: center; gap: var(--spacing-2); + min-width: 0; + flex-shrink: 0; +} + +.brand-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .brand-icon { @@ -66,12 +74,22 @@ width: auto; display: block; object-fit: contain; + flex-shrink: 0; } .tagline { font-size: var(--font-size-xs); font-weight: var(--font-weight-normal); color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 1550px) { + .tagline { + display: none; + } } @media (max-width: 1500px) { @@ -83,12 +101,22 @@ .nav-links { display: flex; gap: var(--spacing-6); + flex: 0 0 auto; } .nav-right { display: flex; align-items: center; gap: var(--spacing-3); + flex: 0 0 auto; +} + +.nav-menu { + display: flex; + align-items: center; + margin-left: auto; + gap: var(--spacing-4); + flex: 0 0 auto; } .nav-auth { @@ -134,6 +162,10 @@ display: none; } +.nav-mobile-star { + display: none; +} + .nav-link-activity i { font-size: 1.05em; } @@ -164,6 +196,67 @@ } } +.nav-overflow-dropdown { + display: none; + position: relative; +} + +.nav-overflow-dropdown.is-visible { + display: inline-flex; +} + +.nav-overflow-toggle { + list-style: none; +} + +.nav-overflow-toggle::marker, +.nav-overflow-toggle::-webkit-details-marker { + display: none; +} + +.nav-overflow-toggle { + padding: var(--spacing-2); + width: auto; + height: auto; +} + +.nav-overflow-menu { + position: absolute; + right: 0; + top: calc(100% + var(--spacing-2)); + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + padding: var(--spacing-2); + min-width: 220px; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + z-index: 200; +} + +.nav-overflow-menu .nav-link { + width: 100%; + justify-content: space-between; +} + +.nav-overflow-menu form { + width: 100%; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .mobile-nav-dropdown { display: none; position: relative; @@ -233,7 +326,8 @@ } .nav-links, - .nav-right { + .nav-right, + .nav-menu { display: none; } @@ -242,6 +336,14 @@ margin-left: auto; } + .nav-mobile-star { + display: inline-flex; + } + + .nav-mobile-star + .nav-mobile-bell { + margin-left: 0; + } + body.has-sidebar .nav-burger { display: inline-flex; } diff --git a/app/javascript/controllers/nav_overflow_controller.js b/app/javascript/controllers/nav_overflow_controller.js new file mode 100644 index 0000000..d5117fb --- /dev/null +++ b/app/javascript/controllers/nav_overflow_controller.js @@ -0,0 +1,85 @@ +import { Controller } from "@hotwired/stimulus" + +const MOBILE_BREAKPOINT = "(max-width: 900px)" + +export default class extends Controller { + static targets = ["container", "menu", "overflow", "overflowMenu", "item"] + + connect() { + this.mediaQuery = window.matchMedia(MOBILE_BREAKPOINT) + this._resizeHandler = this.layout.bind(this) + window.addEventListener("resize", this._resizeHandler) + this.storePositions() + this.layout() + } + + disconnect() { + window.removeEventListener("resize", this._resizeHandler) + } + + storePositions() { + this.positions = new Map() + this.orderedItems = [...this.itemTargets] + this.orderedItems.forEach((item) => { + const parent = item.parentElement + const index = Array.from(parent.children).indexOf(item) + this.positions.set(item, { parent, index }) + }) + } + + restoreItems() { + const byParent = new Map() + this.positions.forEach((position, item) => { + if (!byParent.has(position.parent)) { + byParent.set(position.parent, []) + } + byParent.get(position.parent).push({ item, index: position.index }) + }) + + byParent.forEach((items, parent) => { + items + .sort((a, b) => a.index - b.index) + .forEach(({ item, index }) => { + const ref = parent.children[index] || null + parent.insertBefore(item, ref) + }) + }) + } + + hideOverflow() { + this.overflowTarget.classList.remove("is-visible") + this.overflowTarget.open = false + } + + showOverflow() { + this.overflowTarget.classList.add("is-visible") + } + + layout() { + if (!this.hasContainerTarget || !this.hasOverflowMenuTarget) return + + this.restoreItems() + this.overflowMenuTarget.innerHTML = "" + this.hideOverflow() + + if (this.mediaQuery.matches) { + return + } + + const fits = this.containerTarget.scrollWidth <= this.containerTarget.clientWidth + if (fits) return + + this.showOverflow() + + for (let i = this.orderedItems.length - 1; i >= 0; i -= 1) { + if (this.containerTarget.scrollWidth <= this.containerTarget.clientWidth) break + const item = this.orderedItems[i] + if (this.overflowMenuTarget.contains(item)) continue + this.overflowMenuTarget.insertBefore(item, this.overflowMenuTarget.firstChild) + } + + if (!this.overflowMenuTarget.children.length) { + this.hideOverflow() + } + } +} diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 21cc599..fd778c9 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -41,8 +41,9 @@ html data-theme="light" - if user_signed_in? && current_user.username.blank? .global-warning span Please set a username in Settings. + - starred_active = controller_name == "topics" && action_name == "index" && params[:filter].to_s == "starred_by_me" nav.main-navigation - .nav-container + .nav-container data-controller="nav-overflow" data-nav-overflow-target="container" .nav-brand - if content_for?(:sidebar) button.nav-burger type="button" aria-label="Toggle sidebar" data-action="click->sidebar#toggleMobile" @@ -56,6 +57,13 @@ html data-theme="light" i.fa-solid.fa-caret-down .mobile-nav-menu data-action="click->sidebar#closeMenuOnNavigate" = link_to "Topics", topics_path, class: "nav-link" + - if user_signed_in? + - icon_class = starred_active ? "fa-solid fa-star" : "fa-regular fa-star" + - link_classes = ["nav-link"] + - link_classes << "is-active" if starred_active + = link_to topics_path(filter: "starred_by_me"), class: link_classes.join(" "), title: "Starred by me", aria: { label: "Starred by me" } do + i class=icon_class aria-hidden="true" + span.sr-only Starred = link_to "Search", topics_path(anchor: "search"), class: "nav-link" = link_to "Statistics", stats_path, class: "nav-link" = link_to "Reports", reports_path, class: "nav-link" @@ -76,37 +84,47 @@ html data-theme="light" span.tagline PostgreSQL Hackers Archive - if user_signed_in? - unread = activity_unread_count + - starred_href = starred_active ? topics_path : topics_path(filter: "starred_by_me") + - starred_title = starred_active ? "All topics" : "Starred by me" + = link_to starred_href, class: "nav-link nav-mobile-star#{' is-active' if starred_active}", title: starred_title, aria: { label: starred_title } do + i class=(starred_active ? "fa-solid fa-star" : "fa-regular fa-star") aria-hidden="true" + span.sr-only Starred = link_to activities_path, class: "nav-link nav-link-activity nav-mobile-bell", title: "Activity" do i.fa-regular.fa-bell - if unread.positive? span.nav-badge = unread - .nav-links - = link_to "Topics", topics_path, class: "nav-link" - - search_link = content_for?(:search_sidebar) ? "#search" : topics_path(anchor: "search") - = link_to "Search", search_link, class: "nav-link" - = link_to "Statistics", stats_path, class: "nav-link" - = link_to "Reports", reports_path, class: "nav-link" - = link_to "Help", help_index_path, class: "nav-link" - .nav-right - button.nav-link.theme-toggle type="button" aria-label="Toggle theme" data-controller="theme" data-action="click->theme#toggle" - i.fas.fa-moon data-theme-target="icon" - span data-theme-target="label" Theme - .nav-auth - - if user_signed_in? - - if current_user&.person&.default_alias - = link_to current_user.person.default_alias.name, person_path(current_user.person.default_alias.email), class: "nav-link nav-user" - - unread = activity_unread_count - = link_to activities_path, class: "nav-link nav-link-activity", title: "Activity" do - i.fa-regular.fa-bell - - if unread.positive? - span.nav-badge = unread - = link_to "Settings", settings_root_path, class: "nav-link" - - if current_admin? - = link_to "Admin", admin_root_path, class: "nav-link" - = button_to "Sign out", session_path, method: :delete, class: "nav-link", form: { style: 'display:inline' }, data: { turbo: false } - - else - = link_to "Sign in", new_session_path, class: "nav-link" - = link_to "Register", new_registration_path, class: "nav-link" + .nav-menu data-nav-overflow-target="menu" + .nav-links + = link_to "Topics", topics_path, class: "nav-link", data: { "nav-overflow-target": "item" } + - search_link = content_for?(:search_sidebar) ? "#search" : topics_path(anchor: "search") + = link_to "Search", search_link, class: "nav-link", data: { "nav-overflow-target": "item" } + = link_to "Statistics", stats_path, class: "nav-link", data: { "nav-overflow-target": "item" } + = link_to "Reports", reports_path, class: "nav-link", data: { "nav-overflow-target": "item" } + = link_to "Help", help_index_path, class: "nav-link", data: { "nav-overflow-target": "item" } + .nav-right + button.nav-link.theme-toggle type="button" aria-label="Toggle theme" data-controller="theme" data-action="click->theme#toggle" data-nav-overflow-target="item" + i.fas.fa-moon data-theme-target="icon" + span data-theme-target="label" Theme + .nav-auth + - if user_signed_in? + - if current_user&.person&.default_alias + = link_to current_user.person.default_alias.name, person_path(current_user.person.default_alias.email), class: "nav-link nav-user", data: { "nav-overflow-target": "item" } + - unread = activity_unread_count + = link_to activities_path, class: "nav-link nav-link-activity", title: "Activity", data: { "nav-overflow-target": "item" } do + i.fa-regular.fa-bell + - if unread.positive? + span.nav-badge = unread + = link_to "Settings", settings_root_path, class: "nav-link", data: { "nav-overflow-target": "item" } + - if current_admin? + = link_to "Admin", admin_root_path, class: "nav-link", data: { "nav-overflow-target": "item" } + = button_to "Sign out", session_path, method: :delete, class: "nav-link", form: { style: 'display:inline', data: { "nav-overflow-target": "item" } }, data: { turbo: false } + - else + = link_to "Sign in", new_session_path, class: "nav-link", data: { "nav-overflow-target": "item" } + = link_to "Register", new_registration_path, class: "nav-link", data: { "nav-overflow-target": "item" } + details.nav-overflow-dropdown data-nav-overflow-target="overflow" + summary.nav-link.nav-overflow-toggle aria-label="More" data-action="click->sidebar#closeMenuOnNavigate" + i.fa-solid.fa-bars + .nav-overflow-menu data-nav-overflow-target="overflowMenu" - if content_for?(:sidebar) .page-layout.with-sidebar data-sidebar-target="layout"